@oxy.so/services 1.1.0 → 2.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 (176) hide show
  1. package/README.md +1 -1
  2. package/lib/commonjs/ui/components/BenefitList.js +93 -0
  3. package/lib/commonjs/ui/components/BenefitList.js.map +1 -0
  4. package/lib/commonjs/ui/components/FollowTargetButton.js +1 -1
  5. package/lib/commonjs/ui/components/FollowTargetButton.js.map +1 -1
  6. package/lib/commonjs/ui/components/OxyAuthChooser.js +95 -140
  7. package/lib/commonjs/ui/components/OxyAuthChooser.js.map +1 -1
  8. package/lib/commonjs/ui/components/authChooser/AccountsMenuView.js +8 -1
  9. package/lib/commonjs/ui/components/authChooser/AccountsMenuView.js.map +1 -1
  10. package/lib/commonjs/ui/components/authChooser/SignInEntryView.js +35 -25
  11. package/lib/commonjs/ui/components/authChooser/SignInEntryView.js.map +1 -1
  12. package/lib/commonjs/ui/components/authChooser/SignInRequestView.js +2 -3
  13. package/lib/commonjs/ui/components/authChooser/SignInRequestView.js.map +1 -1
  14. package/lib/commonjs/ui/components/authChooser/SignUpView.js +12 -127
  15. package/lib/commonjs/ui/components/authChooser/SignUpView.js.map +1 -1
  16. package/lib/commonjs/ui/components/authChooser/signInFailureMessage.js +40 -0
  17. package/lib/commonjs/ui/components/authChooser/signInFailureMessage.js.map +1 -0
  18. package/lib/commonjs/ui/components/authChooser/types.js +4 -6
  19. package/lib/commonjs/ui/components/authChooser/types.js.map +1 -1
  20. package/lib/commonjs/ui/context/OxyContext.js +1 -17
  21. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  22. package/lib/commonjs/ui/context/hooks/useAuthOperations.js +15 -2
  23. package/lib/commonjs/ui/context/hooks/useAuthOperations.js.map +1 -1
  24. package/lib/commonjs/ui/context/passkeyFlow.js +2 -28
  25. package/lib/commonjs/ui/context/passkeyFlow.js.map +1 -1
  26. package/lib/commonjs/ui/hooks/accountDialogSnapshot.js +3 -1
  27. package/lib/commonjs/ui/hooks/accountDialogSnapshot.js.map +1 -1
  28. package/lib/commonjs/ui/hooks/useProfileEditing.js +14 -0
  29. package/lib/commonjs/ui/hooks/useProfileEditing.js.map +1 -1
  30. package/lib/commonjs/ui/screens/AccountVerificationScreen.js +10 -10
  31. package/lib/commonjs/ui/screens/AccountVerificationScreen.js.map +1 -1
  32. package/lib/commonjs/ui/screens/EditProfileFieldScreen.js +225 -9
  33. package/lib/commonjs/ui/screens/EditProfileFieldScreen.js.map +1 -1
  34. package/lib/commonjs/ui/screens/LearnMoreUsernamesScreen.js +6 -6
  35. package/lib/commonjs/ui/screens/LearnMoreUsernamesScreen.js.map +1 -1
  36. package/lib/commonjs/ui/screens/ManageAccountScreen.js +10 -0
  37. package/lib/commonjs/ui/screens/ManageAccountScreen.js.map +1 -1
  38. package/lib/commonjs/ui/screens/PremiumSubscriptionScreen.js +5 -5
  39. package/lib/commonjs/ui/screens/PremiumSubscriptionScreen.js.map +1 -1
  40. package/lib/commonjs/ui/screens/trust/TrustAboutScreen.js +13 -13
  41. package/lib/commonjs/ui/screens/trust/TrustAboutScreen.js.map +1 -1
  42. package/lib/module/ui/components/BenefitList.js +89 -0
  43. package/lib/module/ui/components/BenefitList.js.map +1 -0
  44. package/lib/module/ui/components/FollowTargetButton.js +1 -1
  45. package/lib/module/ui/components/FollowTargetButton.js.map +1 -1
  46. package/lib/module/ui/components/OxyAuthChooser.js +97 -142
  47. package/lib/module/ui/components/OxyAuthChooser.js.map +1 -1
  48. package/lib/module/ui/components/authChooser/AccountsMenuView.js +8 -1
  49. package/lib/module/ui/components/authChooser/AccountsMenuView.js.map +1 -1
  50. package/lib/module/ui/components/authChooser/SignInEntryView.js +35 -25
  51. package/lib/module/ui/components/authChooser/SignInEntryView.js.map +1 -1
  52. package/lib/module/ui/components/authChooser/SignInRequestView.js +2 -3
  53. package/lib/module/ui/components/authChooser/SignInRequestView.js.map +1 -1
  54. package/lib/module/ui/components/authChooser/SignUpView.js +14 -129
  55. package/lib/module/ui/components/authChooser/SignUpView.js.map +1 -1
  56. package/lib/module/ui/components/authChooser/signInFailureMessage.js +36 -0
  57. package/lib/module/ui/components/authChooser/signInFailureMessage.js.map +1 -0
  58. package/lib/module/ui/components/authChooser/types.js +4 -6
  59. package/lib/module/ui/components/authChooser/types.js.map +1 -1
  60. package/lib/module/ui/context/OxyContext.js +2 -18
  61. package/lib/module/ui/context/OxyContext.js.map +1 -1
  62. package/lib/module/ui/context/hooks/useAuthOperations.js +15 -2
  63. package/lib/module/ui/context/hooks/useAuthOperations.js.map +1 -1
  64. package/lib/module/ui/context/passkeyFlow.js +2 -27
  65. package/lib/module/ui/context/passkeyFlow.js.map +1 -1
  66. package/lib/module/ui/hooks/accountDialogSnapshot.js +3 -1
  67. package/lib/module/ui/hooks/accountDialogSnapshot.js.map +1 -1
  68. package/lib/module/ui/hooks/useProfileEditing.js +14 -0
  69. package/lib/module/ui/hooks/useProfileEditing.js.map +1 -1
  70. package/lib/module/ui/screens/AccountVerificationScreen.js +6 -6
  71. package/lib/module/ui/screens/AccountVerificationScreen.js.map +1 -1
  72. package/lib/module/ui/screens/EditProfileFieldScreen.js +225 -9
  73. package/lib/module/ui/screens/EditProfileFieldScreen.js.map +1 -1
  74. package/lib/module/ui/screens/LearnMoreUsernamesScreen.js +6 -6
  75. package/lib/module/ui/screens/LearnMoreUsernamesScreen.js.map +1 -1
  76. package/lib/module/ui/screens/ManageAccountScreen.js +12 -2
  77. package/lib/module/ui/screens/ManageAccountScreen.js.map +1 -1
  78. package/lib/module/ui/screens/PremiumSubscriptionScreen.js +1 -1
  79. package/lib/module/ui/screens/PremiumSubscriptionScreen.js.map +1 -1
  80. package/lib/module/ui/screens/trust/TrustAboutScreen.js +7 -7
  81. package/lib/module/ui/screens/trust/TrustAboutScreen.js.map +1 -1
  82. package/lib/typescript/commonjs/index.d.ts +1 -1
  83. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  84. package/lib/typescript/commonjs/ui/components/BenefitList.d.ts +25 -0
  85. package/lib/typescript/commonjs/ui/components/BenefitList.d.ts.map +1 -0
  86. package/lib/typescript/commonjs/ui/components/OxyAuthChooser.d.ts +3 -4
  87. package/lib/typescript/commonjs/ui/components/OxyAuthChooser.d.ts.map +1 -1
  88. package/lib/typescript/commonjs/ui/components/authChooser/AccountsMenuView.d.ts.map +1 -1
  89. package/lib/typescript/commonjs/ui/components/authChooser/SignInEntryView.d.ts +11 -12
  90. package/lib/typescript/commonjs/ui/components/authChooser/SignInEntryView.d.ts.map +1 -1
  91. package/lib/typescript/commonjs/ui/components/authChooser/SignInRequestView.d.ts.map +1 -1
  92. package/lib/typescript/commonjs/ui/components/authChooser/SignUpView.d.ts +10 -13
  93. package/lib/typescript/commonjs/ui/components/authChooser/SignUpView.d.ts.map +1 -1
  94. package/lib/typescript/commonjs/ui/components/authChooser/signInFailureMessage.d.ts +16 -0
  95. package/lib/typescript/commonjs/ui/components/authChooser/signInFailureMessage.d.ts.map +1 -0
  96. package/lib/typescript/commonjs/ui/components/authChooser/types.d.ts +11 -13
  97. package/lib/typescript/commonjs/ui/components/authChooser/types.d.ts.map +1 -1
  98. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts +1 -1
  99. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  100. package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts +3 -2
  101. package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
  102. package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts +20 -10
  103. package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts.map +1 -1
  104. package/lib/typescript/commonjs/ui/context/passkeyFlow.d.ts +2 -21
  105. package/lib/typescript/commonjs/ui/context/passkeyFlow.d.ts.map +1 -1
  106. package/lib/typescript/commonjs/ui/hooks/accountDialogSnapshot.d.ts.map +1 -1
  107. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +3 -0
  108. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  109. package/lib/typescript/commonjs/ui/hooks/useProfileEditing.d.ts +1 -0
  110. package/lib/typescript/commonjs/ui/hooks/useProfileEditing.d.ts.map +1 -1
  111. package/lib/typescript/commonjs/ui/screens/EditProfileFieldScreen.d.ts.map +1 -1
  112. package/lib/typescript/commonjs/ui/screens/ManageAccountScreen.d.ts.map +1 -1
  113. package/lib/typescript/module/index.d.ts +1 -1
  114. package/lib/typescript/module/index.d.ts.map +1 -1
  115. package/lib/typescript/module/ui/components/BenefitList.d.ts +25 -0
  116. package/lib/typescript/module/ui/components/BenefitList.d.ts.map +1 -0
  117. package/lib/typescript/module/ui/components/OxyAuthChooser.d.ts +3 -4
  118. package/lib/typescript/module/ui/components/OxyAuthChooser.d.ts.map +1 -1
  119. package/lib/typescript/module/ui/components/authChooser/AccountsMenuView.d.ts.map +1 -1
  120. package/lib/typescript/module/ui/components/authChooser/SignInEntryView.d.ts +11 -12
  121. package/lib/typescript/module/ui/components/authChooser/SignInEntryView.d.ts.map +1 -1
  122. package/lib/typescript/module/ui/components/authChooser/SignInRequestView.d.ts.map +1 -1
  123. package/lib/typescript/module/ui/components/authChooser/SignUpView.d.ts +10 -13
  124. package/lib/typescript/module/ui/components/authChooser/SignUpView.d.ts.map +1 -1
  125. package/lib/typescript/module/ui/components/authChooser/signInFailureMessage.d.ts +16 -0
  126. package/lib/typescript/module/ui/components/authChooser/signInFailureMessage.d.ts.map +1 -0
  127. package/lib/typescript/module/ui/components/authChooser/types.d.ts +11 -13
  128. package/lib/typescript/module/ui/components/authChooser/types.d.ts.map +1 -1
  129. package/lib/typescript/module/ui/context/OxyContext.d.ts +1 -1
  130. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  131. package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts +3 -2
  132. package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
  133. package/lib/typescript/module/ui/context/oxyContextTypes.d.ts +20 -10
  134. package/lib/typescript/module/ui/context/oxyContextTypes.d.ts.map +1 -1
  135. package/lib/typescript/module/ui/context/passkeyFlow.d.ts +2 -21
  136. package/lib/typescript/module/ui/context/passkeyFlow.d.ts.map +1 -1
  137. package/lib/typescript/module/ui/hooks/accountDialogSnapshot.d.ts.map +1 -1
  138. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +3 -0
  139. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  140. package/lib/typescript/module/ui/hooks/useProfileEditing.d.ts +1 -0
  141. package/lib/typescript/module/ui/hooks/useProfileEditing.d.ts.map +1 -1
  142. package/lib/typescript/module/ui/screens/EditProfileFieldScreen.d.ts.map +1 -1
  143. package/lib/typescript/module/ui/screens/ManageAccountScreen.d.ts.map +1 -1
  144. package/package.json +6 -6
  145. package/src/index.ts +1 -1
  146. package/src/ui/components/BenefitList.tsx +111 -0
  147. package/src/ui/components/FollowTargetButton.tsx +1 -1
  148. package/src/ui/components/OxyAuthChooser.tsx +106 -156
  149. package/src/ui/components/authChooser/AccountsMenuView.tsx +7 -0
  150. package/src/ui/components/authChooser/SignInEntryView.tsx +41 -44
  151. package/src/ui/components/authChooser/SignInRequestView.tsx +1 -4
  152. package/src/ui/components/authChooser/SignUpView.tsx +16 -114
  153. package/src/ui/components/authChooser/signInFailureMessage.ts +39 -0
  154. package/src/ui/components/authChooser/types.ts +11 -13
  155. package/src/ui/context/OxyContext.tsx +2 -20
  156. package/src/ui/context/__tests__/passkeyFlow.test.ts +2 -65
  157. package/src/ui/context/hooks/useAuthOperations.ts +10 -5
  158. package/src/ui/context/oxyContextTypes.ts +16 -8
  159. package/src/ui/context/passkeyFlow.ts +2 -39
  160. package/src/ui/hooks/accountDialogSnapshot.ts +2 -0
  161. package/src/ui/hooks/useProfileEditing.ts +15 -0
  162. package/src/ui/screens/AccountVerificationScreen.tsx +6 -6
  163. package/src/ui/screens/EditProfileFieldScreen.tsx +202 -7
  164. package/src/ui/screens/LearnMoreUsernamesScreen.tsx +6 -6
  165. package/src/ui/screens/ManageAccountScreen.tsx +12 -0
  166. package/src/ui/screens/PremiumSubscriptionScreen.tsx +1 -1
  167. package/src/ui/screens/trust/TrustAboutScreen.tsx +7 -7
  168. package/lib/commonjs/ui/components/authChooser/useUsernameAvailability.js +0 -50
  169. package/lib/commonjs/ui/components/authChooser/useUsernameAvailability.js.map +0 -1
  170. package/lib/module/ui/components/authChooser/useUsernameAvailability.js +0 -46
  171. package/lib/module/ui/components/authChooser/useUsernameAvailability.js.map +0 -1
  172. package/lib/typescript/commonjs/ui/components/authChooser/useUsernameAvailability.d.ts +0 -16
  173. package/lib/typescript/commonjs/ui/components/authChooser/useUsernameAvailability.d.ts.map +0 -1
  174. package/lib/typescript/module/ui/components/authChooser/useUsernameAvailability.d.ts +0 -16
  175. package/lib/typescript/module/ui/components/authChooser/useUsernameAvailability.d.ts.map +0 -1
  176. package/src/ui/components/authChooser/useUsernameAvailability.ts +0 -60
@@ -34,12 +34,12 @@
34
34
  */
35
35
 
36
36
  import type React from 'react';
37
- import { useCallback, useMemo, useState, useSyncExternalStore } from 'react';
37
+ import { useCallback, useMemo, useSyncExternalStore } from 'react';
38
38
  import { Linking, Platform } from 'react-native';
39
39
  import { toast } from '@oxy.so/bloom/toast';
40
40
  import { surfaces } from '@oxy.so/bloom/surfaces';
41
41
  import { useTheme } from '@oxy.so/bloom/theme';
42
- import { getNormalizedUserHandle, isOxyRpOrigin, type User } from '@oxy.so/core';
42
+ import { IDENTITY_WEB_ORIGIN, getNormalizedUserHandle, type User } from '@oxy.so/core';
43
43
  import { useQueryClient } from '@tanstack/react-query';
44
44
  import { useOxy } from '../context/OxyContext';
45
45
  import { useDeviceSwitcher } from '../hooks/useDeviceSwitcher';
@@ -55,6 +55,7 @@ import AccountsMenuView from './authChooser/AccountsMenuView';
55
55
  import SignInEntryView from './authChooser/SignInEntryView';
56
56
  import SignInRequestView from './authChooser/SignInRequestView';
57
57
  import SignUpView from './authChooser/SignUpView';
58
+ import { signInFailureMessage } from './authChooser/signInFailureMessage';
58
59
  import {
59
60
  resolveAccentHex,
60
61
  type AccountHeroModel,
@@ -76,14 +77,23 @@ const COMMONS_CREATE_IDENTITY_URL = 'oxycommons://create-identity';
76
77
  */
77
78
  const ACCOUNTS_APP_URL = 'https://accounts.oxy.so';
78
79
 
80
+ /**
81
+ * The last sign-in attempt whose failure was toasted, per controller.
82
+ *
83
+ * Module-level, not per subscription: a failure is ONE event. Remounting the
84
+ * chooser (or re-subscribing to the same controller) must not report it again,
85
+ * while a NEW attempt that fails with the very same message must. The attempt
86
+ * identity is what tells those apart; the message text cannot.
87
+ */
88
+ const toastedFailureAttempt = new WeakMap<object, number>();
89
+
79
90
  export interface OxyAuthChooserProps {
80
91
  /** Called after a completed switch, sign-in, or sign-up. */
81
92
  onComplete?: () => void;
82
93
  /**
83
- * When false, the web sign-in entry does not auto-start a device-flow
84
- * session on mount. Use for hosts (e.g. the cross-origin passkey hub) that
85
- * already have a pending `authorizeCode` and must not spawn a second one.
86
- * @default true
94
+ * @deprecated No effect. The web sign-in entry no longer auto-starts a
95
+ * device-flow request: its primary action opens the identity origin
96
+ * (`id.oxy.so`) in a popup, which a browser only allows from a user gesture.
87
97
  */
88
98
  autoStartSignIn?: boolean;
89
99
  }
@@ -93,15 +103,10 @@ export interface OxyAuthChooserProps {
93
103
  * (wrapped in Bloom's `<Dialog>`) today; mountable bare by any future host that
94
104
  * supplies its own `onComplete`.
95
105
  */
96
- const OxyAuthChooser: React.FC<OxyAuthChooserProps> = ({
97
- onComplete,
98
- autoStartSignIn: autoStartSignInEnabled = true,
99
- }) => {
106
+ const OxyAuthChooser: React.FC<OxyAuthChooserProps> = ({ onComplete }) => {
100
107
  const {
101
108
  accountDialogController: controller,
102
109
  showBottomSheet,
103
- signInWithPasskey,
104
- registerWithPasskey,
105
110
  oxyServices,
106
111
  logout,
107
112
  openAvatarPicker,
@@ -115,114 +120,39 @@ const OxyAuthChooser: React.FC<OxyAuthChooserProps> = ({
115
120
  // so the two switchers cannot drift.
116
121
  const { principals } = useDeviceSwitcher();
117
122
 
118
- // A credential minted for `oxy.so` can only be ASSERTED there (or a loopback
119
- // dev host) — a hard WebAuthn RP-ID boundary the browser enforces, not
120
- // feature detection. On a first-party Oxy web origin the ceremony runs
121
- // directly ('direct'). On any OTHER web origin (b2) it can't run locally, so
122
- // the passkey action instead opens a popup at the auth.oxy.so passkey hub —
123
- // where the SAME ceremony IS first-party — and relays the resulting session
124
- // back via `AccountDialogController.startPasskeyHubSignIn` ('hub'). Native
125
- // has neither: Commons owns identity there ('none'). `isOxyRpOrigin()` reads
126
- // `location` once and is stable for the component's lifetime.
127
- const passkeyMode = useMemo<PasskeyMode>(() => {
128
- if (!isWebBrowser()) return 'none';
129
- return isOxyRpOrigin() ? 'direct' : 'hub';
130
- }, []);
131
-
132
- const [signInPasskeyPending, setSignInPasskeyPending] = useState(false);
133
- const handleSignInWithPasskey = useCallback(async () => {
134
- if (signInPasskeyPending) return;
135
- setSignInPasskeyPending(true);
136
- try {
137
- await signInWithPasskey();
138
- onComplete?.();
139
- } catch (error) {
140
- // A cancelled/failed ceremony keeps the view open so the user can retry
141
- // or fall back to the QR — surface the reason as a toast (owner mandate:
142
- // errors never render inline inside the dialog), never swallow.
143
- toast.error(
144
- error instanceof Error && error.message
145
- ? error.message
146
- : t('accountSwitcher.toasts.passkeySignInFailed'),
147
- );
148
- } finally {
149
- setSignInPasskeyPending(false);
150
- }
151
- }, [signInPasskeyPending, signInWithPasskey, onComplete, t]);
152
-
153
- const [createPasskeyPending, setCreatePasskeyPending] = useState(false);
154
- const handleCreateWithPasskey = useCallback(
155
- async (username: string) => {
156
- if (createPasskeyPending) return;
157
- setCreatePasskeyPending(true);
158
- try {
159
- await registerWithPasskey({ username });
160
- onComplete?.();
161
- } catch (error) {
162
- toast.error(
163
- error instanceof Error && error.message
164
- ? error.message
165
- : t('signup.toasts.passkeyCreateFailed'),
166
- );
167
- } finally {
168
- setCreatePasskeyPending(false);
169
- }
170
- },
171
- [createPasskeyPending, registerWithPasskey, onComplete, t],
172
- );
173
-
174
- // Web: auto-start "Sign in with Oxy" when the sign-in entry becomes the shown
175
- // view — the request surface is the PRIMARY web route, not something behind a
176
- // second tap (the user's ONE action was the sign-in button that opened this).
177
- // `signInWithOxy()` tries the shared keychain first (silently, native-only)
178
- // then falls to the device flow, whose route the controller picks. Driven from
179
- // the EVENTS that reach that view — the initial `subscribe` pass below (an
180
- // open-to-sign-in) and the add / back-to-sign-in handlers — never a watcher
181
- // effect. Guarded so it never restarts a live flow (phase must be idle) and is
182
- // a no-op on native (button-driven). Reads the live snapshot at call time (an
183
- // event callback, not render), so it is stable across renders.
184
- const autoStartSignIn = useCallback(() => {
185
- if (!autoStartSignInEnabled || !controller || !isWebBrowser()) return;
186
- const { view: currentView, signIn } = controller.getSnapshot();
187
- if ((currentView !== 'signin' && currentView !== 'add') || signIn.phase !== 'idle') return;
188
- void controller.signInWithOxy();
189
- }, [autoStartSignInEnabled, controller]);
123
+ // Web sign-in and sign-up run at the identity origin (`id.oxy.so`), never
124
+ // on this page, whatever this page's origin: that is where the passkey
125
+ // ceremony can run for any app AND where the account's identity is created
126
+ // and kept sealed under the passkey (one identity, two carriers). Native has
127
+ // no passkey path: Commons owns identity there ('none').
128
+ const passkeyMode = useMemo<PasskeyMode>(() => (isWebBrowser() ? 'hub' : 'none'), []);
190
129
 
191
130
  // Bind the headless controller. `getSnapshot` returns a stable reference
192
131
  // between changes, so it is `useSyncExternalStore`-safe. Guard the no-provider
193
132
  // loading state (`controller` is `null`) with an inert store.
194
133
  //
195
- // The subscribe callback ALSO carries two owner-mandated reactions to the
196
- // controller. These run in the subscribe / notification callbacks — EVENT
197
- // callbacks, not render/effect — so neither is a React effect hook:
198
- // 1. Sign-in device-flow failures (poll / socket / popup-cancel land on
199
- // `signIn` asynchronously) are toasted at the notification site, deduped
200
- // per subscription — NEVER an inline banner in the request view (owner
201
- // mandate, same contract as account-switch).
202
- // 2. On subscribe (mount / re-subscribe — a passive effect, the SAME timing
203
- // the removed effects had) the web sign-in flow auto-starts if the opening
204
- // view is the sign-in entry.
134
+ // The subscribe callback ALSO toasts sign-in device-flow failures (poll /
135
+ // socket / popup-cancel land on `signIn` asynchronously) at the notification
136
+ // site — an EVENT callback, not render/effect — deduped per attempt, NEVER an
137
+ // inline banner in the request view (owner mandate).
205
138
  const subscribe = useCallback(
206
139
  (listener: () => void) => {
207
140
  if (!controller) return () => undefined;
208
- let lastToastedSignInError: string | null = null;
209
141
  const maybeToastSignInError = () => {
210
142
  const { signIn } = controller.getSnapshot();
211
- if (signIn.phase === 'error' && signIn.error && signIn.error !== lastToastedSignInError) {
212
- lastToastedSignInError = signIn.error;
213
- toast.error(signIn.error);
214
- } else if (signIn.phase !== 'error') {
215
- lastToastedSignInError = null;
216
- }
143
+ if (signIn.phase !== 'error') return;
144
+ if (toastedFailureAttempt.get(controller) === signIn.attempt) return;
145
+ toastedFailureAttempt.set(controller, signIn.attempt);
146
+ const message = signInFailureMessage(signIn.failure, t);
147
+ if (message) toast.error(message);
217
148
  };
218
149
  maybeToastSignInError();
219
- autoStartSignIn();
220
150
  return controller.subscribe(() => {
221
151
  maybeToastSignInError();
222
152
  listener();
223
153
  });
224
154
  },
225
- [controller, autoStartSignIn],
155
+ [controller, t],
226
156
  );
227
157
  const getSnapshot = useCallback(
228
158
  () => (controller ? controller.getSnapshot() : EMPTY_ACCOUNT_DIALOG_SNAPSHOT),
@@ -239,31 +169,30 @@ const OxyAuthChooser: React.FC<OxyAuthChooserProps> = ({
239
169
  const handleActivate = useCallback(
240
170
  async (contextId: string) => {
241
171
  if (!controller) return;
242
- if (controller.getSnapshot().activatingContextId) return;
172
+ // Another switch or removal is already changing this device: the press is
173
+ // dropped, not queued — and it is not a failure to report.
174
+ if (controller.isDeviceMutationInFlight()) return;
243
175
  // Already live: activating it again bumps nothing server-side, so treat
244
176
  // the press as "yes, this one" and close.
245
177
  if (contextId === controller.getSnapshot().activeContext?.contextId) {
246
178
  onComplete?.();
247
179
  return;
248
180
  }
249
- try {
250
- await controller.activateContext(contextId);
251
- // Failures are recorded on the controller snapshot rather than thrown,
252
- // so the same path works with the real controller and with test doubles
253
- // that resolve void.
254
- if (controller.getSnapshot().error) {
255
- toast.error(t('accountSwitcher.toasts.activateFailed'));
256
- return;
257
- }
258
- // The subject changed, so every account-scoped query is now about
259
- // somebody else. The runtime resets its own caches between the bearer
260
- // commit and the notify; this drops the Query cache the same way the
261
- // account switch did.
262
- queryClient.invalidateQueries();
263
- onComplete?.();
264
- } catch {
181
+ // The controller's own verdict on the SWITCH — never `snapshot.error`,
182
+ // which the directory re-read after a successful switch can also set.
183
+ // Reading that as "the switch failed" left the dialog open on a false
184
+ // error with the previous account's queries still cached.
185
+ const switched = await controller.activateContext(contextId).catch(() => false);
186
+ if (!switched) {
265
187
  toast.error(t('accountSwitcher.toasts.activateFailed'));
188
+ return;
266
189
  }
190
+ // The subject changed, so every account-scoped query is now about
191
+ // somebody else. The runtime resets its own caches between the bearer
192
+ // commit and the notify; this drops the Query cache the same way the
193
+ // account switch did.
194
+ queryClient.invalidateQueries();
195
+ onComplete?.();
267
196
  },
268
197
  [controller, onComplete, queryClient, t],
269
198
  );
@@ -278,7 +207,7 @@ const OxyAuthChooser: React.FC<OxyAuthChooserProps> = ({
278
207
  */
279
208
  const handleRemoveContext = useCallback(
280
209
  async (contextId: string) => {
281
- if (!controller) return;
210
+ if (!controller || controller.isDeviceMutationInFlight()) return;
282
211
  const group = principals.find((principal) =>
283
212
  principal.contexts.some((context) => context.contextId === contextId),
284
213
  );
@@ -308,7 +237,7 @@ const OxyAuthChooser: React.FC<OxyAuthChooserProps> = ({
308
237
  /** Remove ONE PERSON and every account they reach here, and nobody else's. */
309
238
  const handleRemovePrincipal = useCallback(
310
239
  async (principalId: string) => {
311
- if (!controller) return;
240
+ if (!controller || controller.isDeviceMutationInFlight()) return;
312
241
  const group = principals.find((principal) => principal.principalId === principalId);
313
242
  if (!group) return;
314
243
  const confirmed = await surfaces.confirm({
@@ -329,6 +258,42 @@ const OxyAuthChooser: React.FC<OxyAuthChooserProps> = ({
329
258
  [controller, principals, t],
330
259
  );
331
260
 
261
+ /**
262
+ * Sign out of the current account, and close only once that is TRUE.
263
+ *
264
+ * Runs under the controller's device-mutation gate, so it can neither race a
265
+ * switch or removal nor be issued twice by a double press. A failed
266
+ * revocation keeps the dialog open and says so: closing it would read as
267
+ * "signed out" while this device still holds the session.
268
+ */
269
+ const handleSignOut = useCallback(async () => {
270
+ if (!controller) return;
271
+ const outcome = await controller.runDeviceMutation(() => logout());
272
+ if (!outcome.ran) return;
273
+ if (outcome.value.status === 'failed') {
274
+ toast.error(t('common.errors.signOutFailed'));
275
+ return;
276
+ }
277
+ onComplete?.();
278
+ }, [controller, logout, onComplete, t]);
279
+
280
+ /**
281
+ * Hand a URL to the OS. `Linking.openURL` REJECTS when nothing can open it
282
+ * (no browser, an unregistered scheme, a blocked navigation) — a press that
283
+ * then does nothing at all is exactly the dead end this reports instead.
284
+ */
285
+ const openExternal = useCallback(
286
+ (url: string) => {
287
+ // Wrapped so a synchronous throw lands in the same handler as a rejection.
288
+ Promise.resolve()
289
+ .then(() => Linking.openURL(url))
290
+ .catch(() => {
291
+ toast.error(t('accountSwitcher.linkOpenFailed'));
292
+ });
293
+ },
294
+ [t],
295
+ );
296
+
332
297
  const handleManage = useCallback(() => {
333
298
  onComplete?.();
334
299
  if (consumerHooks?.onNavigateManage) {
@@ -344,11 +309,9 @@ const OxyAuthChooser: React.FC<OxyAuthChooserProps> = ({
344
309
  consumerHooks.onAddAccount();
345
310
  return;
346
311
  }
347
- // No consumer override: enter the "add account" view and auto-start the web
348
- // sign-in flow, from this handler (the event that reaches the view).
312
+ // No consumer override: enter the "add account" view.
349
313
  controller?.add();
350
- autoStartSignIn();
351
- }, [consumerHooks, controller, autoStartSignIn, onComplete]);
314
+ }, [consumerHooks, controller, onComplete]);
352
315
 
353
316
  const handlers = useMemo<OxyAuthChooserHandlers>(
354
317
  () => ({
@@ -390,23 +353,18 @@ const OxyAuthChooser: React.FC<OxyAuthChooserProps> = ({
390
353
  const alternatives = useMemo<SignInAlternatives>(
391
354
  () => ({
392
355
  passkeyAvailable: passkeyMode !== 'none',
393
- // The hub-popup flow reports progress through `snapshot.signIn` (already
394
- // rendered by the request view) — only the DIRECT ceremony uses this
395
- // component-local pending flag. A ceremony FAILURE is toasted by
396
- // `handleSignInWithPasskey`, never rendered inline.
397
- passkeyPending: passkeyMode === 'direct' ? signInPasskeyPending : false,
398
- onSignInWithPasskey: () => {
399
- if (passkeyMode === 'direct') {
400
- void handleSignInWithPasskey();
401
- return;
402
- }
403
- void controller?.startPasskeyHubSignIn();
404
- },
356
+ // Called straight from the press: the popup opens before any await.
357
+ onSignInWithPasskey: () => void controller?.startPasskeyHubSignIn(),
405
358
  onShowQr: () => void controller?.showQr(),
406
- onGetCommons: () => void Linking.openURL(getCommonsAcquisitionUrl(Platform.OS)),
407
- onCreateAccount: () => controller?.startSignup(),
359
+ onGetCommons: () => openExternal(getCommonsAcquisitionUrl(Platform.OS)),
360
+ // Web: the identity origin creates the account and its identity in the
361
+ // same window a sign-in uses. Native: Commons creates the identity.
362
+ onCreateAccount: () => {
363
+ if (passkeyMode === 'hub') void controller?.startPasskeyHubSignIn();
364
+ else controller?.startSignup();
365
+ },
408
366
  }),
409
- [passkeyMode, signInPasskeyPending, handleSignInWithPasskey, controller],
367
+ [passkeyMode, controller, openExternal],
410
368
  );
411
369
 
412
370
  // Real storage usage for the account menu's "Oxy storage" block. Disabled
@@ -430,9 +388,7 @@ const OxyAuthChooser: React.FC<OxyAuthChooserProps> = ({
430
388
  // endpoints — the same handoff pattern `handleManage`/`getCommonsAcquisitionUrl`
431
389
  // already use.
432
390
  const accountMenu = useMemo<AccountsMenuActions>(() => {
433
- const openUrl = (url: string) => {
434
- void Linking.openURL(url);
435
- };
391
+ const openUrl = openExternal;
436
392
  const openSheet = (config: Parameters<NonNullable<typeof showBottomSheet>>[0]) => {
437
393
  onComplete?.();
438
394
  showBottomSheet?.(config);
@@ -446,9 +402,9 @@ const OxyAuthChooser: React.FC<OxyAuthChooserProps> = ({
446
402
  onPrivacy: () => openSheet({ screen: 'LegalDocuments', props: { initialStep: 1 } }),
447
403
  onTerms: () => openSheet({ screen: 'LegalDocuments', props: { initialStep: 2 } }),
448
404
  onSignOut: () => {
449
- void logout();
450
- onComplete?.();
405
+ void handleSignOut();
451
406
  },
407
+ onOpenIdentity: passkeyMode === 'hub' ? () => openUrl(`${IDENTITY_WEB_ORIGIN}/`) : undefined,
452
408
  customItems: (consumerHooks?.menuItems ?? []).map((item) => ({
453
409
  ...item,
454
410
  onPress: () => {
@@ -457,7 +413,7 @@ const OxyAuthChooser: React.FC<OxyAuthChooserProps> = ({
457
413
  },
458
414
  })),
459
415
  };
460
- }, [consumerHooks, onComplete, showBottomSheet, logout]);
416
+ }, [consumerHooks, onComplete, showBottomSheet, handleSignOut, openExternal, passkeyMode]);
461
417
 
462
418
  if (!controller) {
463
419
  return null;
@@ -483,7 +439,7 @@ const OxyAuthChooser: React.FC<OxyAuthChooserProps> = ({
483
439
  <SignInRequestView
484
440
  snapshot={snapshot}
485
441
  t={t}
486
- onRetry={() => void controller.showQr()}
442
+ onRetry={() => void controller.retrySignIn()}
487
443
  alternatives={alternatives}
488
444
  />
489
445
  );
@@ -495,17 +451,11 @@ const OxyAuthChooser: React.FC<OxyAuthChooserProps> = ({
495
451
  snapshot={snapshot}
496
452
  theme={theme}
497
453
  t={t}
498
- oxyServices={oxyServices}
499
454
  passkeyMode={passkeyMode}
500
- onCreateWithPasskey={(username) => void handleCreateWithPasskey(username)}
501
- createPending={createPasskeyPending}
502
455
  onOpenHub={() => void controller.startPasskeyHubSignIn()}
503
- onCreateIdentityInCommons={() => void Linking.openURL(COMMONS_CREATE_IDENTITY_URL)}
456
+ onCreateIdentityInCommons={() => openExternal(COMMONS_CREATE_IDENTITY_URL)}
504
457
  onGetCommons={alternatives.onGetCommons}
505
- onBackToSignIn={() => {
506
- controller.setView('signin');
507
- autoStartSignIn();
508
- }}
458
+ onBackToSignIn={() => controller.setView('signin')}
509
459
  />
510
460
  );
511
461
  }
@@ -477,6 +477,13 @@ const AccountsMenuView: React.FC<AccountsMenuViewProps> = ({
477
477
  title={t('accountMenu.settings')}
478
478
  onPress={menu.onOpenSettings}
479
479
  />
480
+ {menu.onOpenIdentity ? (
481
+ <SettingsListItem
482
+ icon={<MenuIcon name="shield-account-outline" theme={theme} />}
483
+ title={t('accountMenu.identity')}
484
+ onPress={menu.onOpenIdentity}
485
+ />
486
+ ) : null}
480
487
  <SettingsListItem
481
488
  icon={<MenuIcon name="help-circle-outline" theme={theme} />}
482
489
  title={t('accountMenu.help')}
@@ -1,24 +1,24 @@
1
1
  /**
2
2
  * The sign-in ENTRY (`signin` / `add` views) — ONE primary action.
3
3
  *
4
- * Issue #691, Phase 5: the normal experience presents no menu of authentication
5
- * methods. The user sees "Continue with Oxy"; Oxy — not the user — then picks
6
- * how the request reaches their Commons identity, and the request surface shows
7
- * honest progress. Everything else (a QR from another device, a passkey on this
8
- * device, getting Commons, creating an account) lives behind "Having trouble?".
4
+ * Web: "Continue" opens the identity origin (`id.oxy.so`) in a popup, where the
5
+ * person signs in — or creates an account — with a passkey, and the account's
6
+ * self-custody identity is created or unsealed (one identity, two carriers). No
7
+ * philosophy up front: fingerprint, face or device PIN, and they are in. An
8
+ * account that lives on another device is one subordinate link away (Commons
9
+ * QR). Nothing auto-starts: a browser only opens a popup from a user gesture.
9
10
  *
10
- * Existing accounts still render ABOVE the CTA: "continue as one of these" is a
11
- * choice of WHO, not of HOW, so it is not one of the competing method buttons
12
- * the issue removes.
11
+ * Native: "Continue with Oxy" — the Commons flow (shared keychain, else the
12
+ * request Oxy routes to the person's Commons identity).
13
13
  *
14
- * On web this view is transient — the container auto-starts the flow the instant
15
- * the view is reached, because the request surface (QR, or "Check Commons on
16
- * your phone") IS the primary route there, never something behind a tap.
14
+ * Existing accounts still render ABOVE the CTA: "continue as one of these" is a
15
+ * choice of WHO, not of HOW.
17
16
  */
18
17
 
19
18
  import type React from 'react';
20
19
  import { View } from 'react-native';
21
20
  import { Button } from '@oxy.so/bloom/button';
21
+ import { Text } from '@oxy.so/bloom/typography';
22
22
  import {
23
23
  showsPrincipalHeaders,
24
24
  type AccountDialogSnapshot,
@@ -42,7 +42,7 @@ interface SignInEntryViewProps {
42
42
  theme: Theme;
43
43
  t: Translate;
44
44
  handlers: OxyAuthChooserHandlers;
45
- /** The ONE primary action. Starts the flow; Oxy chooses the route from there. */
45
+ /** Native's primary action. Starts the Commons flow; Oxy chooses the route from there. */
46
46
  onContinueWithOxy: () => void;
47
47
  alternatives: SignInAlternatives;
48
48
  }
@@ -56,37 +56,20 @@ const SignInEntryView: React.FC<SignInEntryViewProps> = ({
56
56
  onContinueWithOxy,
57
57
  alternatives,
58
58
  }) => {
59
- // The entry has no chosen route yet, so nothing here can have failed: the
60
- // alternatives stay behind the disclosure unconditionally.
61
- const troubleActions: OxySignInSurfaceAction[] = [
62
- {
63
- key: 'scan-qr-link',
64
- label: t('accountSwitcher.scanQr'),
65
- onPress: alternatives.onShowQr,
66
- },
67
- ...(alternatives.passkeyAvailable
68
- ? [
69
- {
70
- key: 'passkey-signin-link',
71
- label: alternatives.passkeyPending
72
- ? t('accountSwitcher.passkeySigningIn')
73
- : t('accountSwitcher.useIdentityOnDevice'),
74
- onPress: alternatives.onSignInWithPasskey,
75
- disabled: alternatives.passkeyPending,
76
- },
77
- ]
78
- : []),
79
- {
80
- key: 'get-commons-link',
81
- label: t('accountSwitcher.getCommons'),
82
- onPress: alternatives.onGetCommons,
83
- },
84
- ];
59
+ const web = alternatives.passkeyAvailable;
60
+
61
+ // Web keeps Commons one link away; native keeps its QR/Commons alternatives
62
+ // behind the disclosure, as before.
63
+ const troubleActions: OxySignInSurfaceAction[] = web
64
+ ? [{ key: 'get-commons-link', label: t('accountSwitcher.getCommons'), onPress: alternatives.onGetCommons }]
65
+ : [
66
+ { key: 'scan-qr-link', label: t('accountSwitcher.scanQr'), onPress: alternatives.onShowQr },
67
+ { key: 'get-commons-link', label: t('accountSwitcher.getCommons'), onPress: alternatives.onGetCommons },
68
+ ];
85
69
 
86
70
  // Whose route this is only needs naming when the list holds more than one
87
71
  // person, or somebody with more than one account — the same rule the account
88
- // menu's group headers follow, so the two surfaces agree on when the actor is
89
- // worth stating.
72
+ // menu's group headers follow.
90
73
  const namesTheOperator = showsPrincipalHeaders(principals);
91
74
  const rows = principals.flatMap((principal) =>
92
75
  principal.contexts.map((context) => ({
@@ -117,18 +100,23 @@ const SignInEntryView: React.FC<SignInEntryViewProps> = ({
117
100
  </View>
118
101
  ) : null}
119
102
 
103
+ {web ? (
104
+ <Text style={[styles.mutedText, { color: theme.colors.textSecondary }]}>
105
+ {t('accountSwitcher.passkeyHint')}
106
+ </Text>
107
+ ) : null}
108
+
120
109
  <Button
121
110
  variant="primary"
122
- onPress={onContinueWithOxy}
111
+ onPress={web ? alternatives.onSignInWithPasskey : onContinueWithOxy}
123
112
  style={styles.primaryButton}
124
113
  testID="continue-with-oxy"
125
114
  >
126
- {t('accountSwitcher.continueWithOxy')}
115
+ {web ? t('accountSwitcher.continueWithPasskey') : t('accountSwitcher.continueWithOxy')}
127
116
  </Button>
128
117
 
129
118
  {/* Account CREATION is not an authentication method — it is the way in for
130
- someone who has no Oxy ID yet — so it keeps its own subordinate link
131
- rather than hiding behind a troubleshooting affordance. */}
119
+ someone who has no account yet — so it keeps its own subordinate link. */}
132
120
  <SubtleLink
133
121
  label={t('signin.createAccountLink')}
134
122
  theme={theme}
@@ -136,6 +124,15 @@ const SignInEntryView: React.FC<SignInEntryViewProps> = ({
136
124
  testID="create-account-link"
137
125
  />
138
126
 
127
+ {web ? (
128
+ <SubtleLink
129
+ label={t('accountSwitcher.otherDeviceCommons')}
130
+ theme={theme}
131
+ onPress={alternatives.onShowQr}
132
+ testID="scan-qr-link"
133
+ />
134
+ ) : null}
135
+
139
136
  <TroubleDisclosure actions={troubleActions} revealed={false} theme={theme} t={t} />
140
137
  </View>
141
138
  );
@@ -58,11 +58,8 @@ const SignInRequestView: React.FC<SignInRequestViewProps> = ({
58
58
  ? [
59
59
  {
60
60
  key: 'passkey-signin-link',
61
- label: alternatives.passkeyPending
62
- ? t('accountSwitcher.passkeySigningIn')
63
- : t('accountSwitcher.useIdentityOnDevice'),
61
+ label: t('accountSwitcher.useIdentityOnDevice'),
64
62
  onPress: alternatives.onSignInWithPasskey,
65
- disabled: alternatives.passkeyPending,
66
63
  },
67
64
  ]
68
65
  : [];