@oxy.so/services 1.0.3 → 1.2.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 (163) hide show
  1. package/lib/commonjs/index.js.map +1 -1
  2. package/lib/commonjs/ui/components/LanguageBridge.js +51 -0
  3. package/lib/commonjs/ui/components/LanguageBridge.js.map +1 -0
  4. package/lib/commonjs/ui/components/OxyAuthChooser.js +95 -140
  5. package/lib/commonjs/ui/components/OxyAuthChooser.js.map +1 -1
  6. package/lib/commonjs/ui/components/OxyConsentScreen.js +132 -2
  7. package/lib/commonjs/ui/components/OxyConsentScreen.js.map +1 -1
  8. package/lib/commonjs/ui/components/OxyProvider.js +19 -1
  9. package/lib/commonjs/ui/components/OxyProvider.js.map +1 -1
  10. package/lib/commonjs/ui/components/authChooser/AccountsMenuView.js +8 -1
  11. package/lib/commonjs/ui/components/authChooser/AccountsMenuView.js.map +1 -1
  12. package/lib/commonjs/ui/components/authChooser/SignInEntryView.js +35 -25
  13. package/lib/commonjs/ui/components/authChooser/SignInEntryView.js.map +1 -1
  14. package/lib/commonjs/ui/components/authChooser/SignInRequestView.js +2 -3
  15. package/lib/commonjs/ui/components/authChooser/SignInRequestView.js.map +1 -1
  16. package/lib/commonjs/ui/components/authChooser/SignUpView.js +12 -127
  17. package/lib/commonjs/ui/components/authChooser/SignUpView.js.map +1 -1
  18. package/lib/commonjs/ui/components/authChooser/signInFailureMessage.js +40 -0
  19. package/lib/commonjs/ui/components/authChooser/signInFailureMessage.js.map +1 -0
  20. package/lib/commonjs/ui/components/authChooser/types.js +4 -6
  21. package/lib/commonjs/ui/components/authChooser/types.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/hooks/accountDialogSnapshot.js +3 -1
  25. package/lib/commonjs/ui/hooks/accountDialogSnapshot.js.map +1 -1
  26. package/lib/commonjs/ui/hooks/useProfileEditing.js +14 -0
  27. package/lib/commonjs/ui/hooks/useProfileEditing.js.map +1 -1
  28. package/lib/commonjs/ui/screens/EditProfileFieldScreen.js +225 -9
  29. package/lib/commonjs/ui/screens/EditProfileFieldScreen.js.map +1 -1
  30. package/lib/commonjs/ui/screens/ManageAccountScreen.js +10 -0
  31. package/lib/commonjs/ui/screens/ManageAccountScreen.js.map +1 -1
  32. package/lib/module/index.js.map +1 -1
  33. package/lib/module/ui/components/LanguageBridge.js +47 -0
  34. package/lib/module/ui/components/LanguageBridge.js.map +1 -0
  35. package/lib/module/ui/components/OxyAuthChooser.js +97 -142
  36. package/lib/module/ui/components/OxyAuthChooser.js.map +1 -1
  37. package/lib/module/ui/components/OxyConsentScreen.js +132 -2
  38. package/lib/module/ui/components/OxyConsentScreen.js.map +1 -1
  39. package/lib/module/ui/components/OxyProvider.js +19 -1
  40. package/lib/module/ui/components/OxyProvider.js.map +1 -1
  41. package/lib/module/ui/components/authChooser/AccountsMenuView.js +8 -1
  42. package/lib/module/ui/components/authChooser/AccountsMenuView.js.map +1 -1
  43. package/lib/module/ui/components/authChooser/SignInEntryView.js +35 -25
  44. package/lib/module/ui/components/authChooser/SignInEntryView.js.map +1 -1
  45. package/lib/module/ui/components/authChooser/SignInRequestView.js +2 -3
  46. package/lib/module/ui/components/authChooser/SignInRequestView.js.map +1 -1
  47. package/lib/module/ui/components/authChooser/SignUpView.js +14 -129
  48. package/lib/module/ui/components/authChooser/SignUpView.js.map +1 -1
  49. package/lib/module/ui/components/authChooser/signInFailureMessage.js +36 -0
  50. package/lib/module/ui/components/authChooser/signInFailureMessage.js.map +1 -0
  51. package/lib/module/ui/components/authChooser/types.js +4 -6
  52. package/lib/module/ui/components/authChooser/types.js.map +1 -1
  53. package/lib/module/ui/context/hooks/useAuthOperations.js +15 -2
  54. package/lib/module/ui/context/hooks/useAuthOperations.js.map +1 -1
  55. package/lib/module/ui/hooks/accountDialogSnapshot.js +3 -1
  56. package/lib/module/ui/hooks/accountDialogSnapshot.js.map +1 -1
  57. package/lib/module/ui/hooks/useProfileEditing.js +14 -0
  58. package/lib/module/ui/hooks/useProfileEditing.js.map +1 -1
  59. package/lib/module/ui/screens/EditProfileFieldScreen.js +225 -9
  60. package/lib/module/ui/screens/EditProfileFieldScreen.js.map +1 -1
  61. package/lib/module/ui/screens/ManageAccountScreen.js +12 -2
  62. package/lib/module/ui/screens/ManageAccountScreen.js.map +1 -1
  63. package/lib/typescript/commonjs/index.d.ts +2 -2
  64. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  65. package/lib/typescript/commonjs/ui/components/LanguageBridge.d.ts +19 -0
  66. package/lib/typescript/commonjs/ui/components/LanguageBridge.d.ts.map +1 -0
  67. package/lib/typescript/commonjs/ui/components/OxyAuthChooser.d.ts +3 -4
  68. package/lib/typescript/commonjs/ui/components/OxyAuthChooser.d.ts.map +1 -1
  69. package/lib/typescript/commonjs/ui/components/OxyConsentScreen.d.ts +17 -1
  70. package/lib/typescript/commonjs/ui/components/OxyConsentScreen.d.ts.map +1 -1
  71. package/lib/typescript/commonjs/ui/components/OxyProvider.d.ts +14 -0
  72. package/lib/typescript/commonjs/ui/components/OxyProvider.d.ts.map +1 -1
  73. package/lib/typescript/commonjs/ui/components/authChooser/AccountsMenuView.d.ts.map +1 -1
  74. package/lib/typescript/commonjs/ui/components/authChooser/SignInEntryView.d.ts +11 -12
  75. package/lib/typescript/commonjs/ui/components/authChooser/SignInEntryView.d.ts.map +1 -1
  76. package/lib/typescript/commonjs/ui/components/authChooser/SignInRequestView.d.ts.map +1 -1
  77. package/lib/typescript/commonjs/ui/components/authChooser/SignUpView.d.ts +10 -13
  78. package/lib/typescript/commonjs/ui/components/authChooser/SignUpView.d.ts.map +1 -1
  79. package/lib/typescript/commonjs/ui/components/authChooser/signInFailureMessage.d.ts +16 -0
  80. package/lib/typescript/commonjs/ui/components/authChooser/signInFailureMessage.d.ts.map +1 -0
  81. package/lib/typescript/commonjs/ui/components/authChooser/types.d.ts +11 -13
  82. package/lib/typescript/commonjs/ui/components/authChooser/types.d.ts.map +1 -1
  83. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts +1 -1
  84. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  85. package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts +3 -2
  86. package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
  87. package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts +20 -2
  88. package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts.map +1 -1
  89. package/lib/typescript/commonjs/ui/hooks/accountDialogSnapshot.d.ts.map +1 -1
  90. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +3 -0
  91. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  92. package/lib/typescript/commonjs/ui/hooks/useProfileEditing.d.ts +1 -0
  93. package/lib/typescript/commonjs/ui/hooks/useProfileEditing.d.ts.map +1 -1
  94. package/lib/typescript/commonjs/ui/screens/EditProfileFieldScreen.d.ts.map +1 -1
  95. package/lib/typescript/commonjs/ui/screens/ManageAccountScreen.d.ts.map +1 -1
  96. package/lib/typescript/commonjs/ui/types/navigation.d.ts +29 -0
  97. package/lib/typescript/commonjs/ui/types/navigation.d.ts.map +1 -1
  98. package/lib/typescript/module/index.d.ts +2 -2
  99. package/lib/typescript/module/index.d.ts.map +1 -1
  100. package/lib/typescript/module/ui/components/LanguageBridge.d.ts +19 -0
  101. package/lib/typescript/module/ui/components/LanguageBridge.d.ts.map +1 -0
  102. package/lib/typescript/module/ui/components/OxyAuthChooser.d.ts +3 -4
  103. package/lib/typescript/module/ui/components/OxyAuthChooser.d.ts.map +1 -1
  104. package/lib/typescript/module/ui/components/OxyConsentScreen.d.ts +17 -1
  105. package/lib/typescript/module/ui/components/OxyConsentScreen.d.ts.map +1 -1
  106. package/lib/typescript/module/ui/components/OxyProvider.d.ts +14 -0
  107. package/lib/typescript/module/ui/components/OxyProvider.d.ts.map +1 -1
  108. package/lib/typescript/module/ui/components/authChooser/AccountsMenuView.d.ts.map +1 -1
  109. package/lib/typescript/module/ui/components/authChooser/SignInEntryView.d.ts +11 -12
  110. package/lib/typescript/module/ui/components/authChooser/SignInEntryView.d.ts.map +1 -1
  111. package/lib/typescript/module/ui/components/authChooser/SignInRequestView.d.ts.map +1 -1
  112. package/lib/typescript/module/ui/components/authChooser/SignUpView.d.ts +10 -13
  113. package/lib/typescript/module/ui/components/authChooser/SignUpView.d.ts.map +1 -1
  114. package/lib/typescript/module/ui/components/authChooser/signInFailureMessage.d.ts +16 -0
  115. package/lib/typescript/module/ui/components/authChooser/signInFailureMessage.d.ts.map +1 -0
  116. package/lib/typescript/module/ui/components/authChooser/types.d.ts +11 -13
  117. package/lib/typescript/module/ui/components/authChooser/types.d.ts.map +1 -1
  118. package/lib/typescript/module/ui/context/OxyContext.d.ts +1 -1
  119. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  120. package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts +3 -2
  121. package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
  122. package/lib/typescript/module/ui/context/oxyContextTypes.d.ts +20 -2
  123. package/lib/typescript/module/ui/context/oxyContextTypes.d.ts.map +1 -1
  124. package/lib/typescript/module/ui/hooks/accountDialogSnapshot.d.ts.map +1 -1
  125. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +3 -0
  126. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  127. package/lib/typescript/module/ui/hooks/useProfileEditing.d.ts +1 -0
  128. package/lib/typescript/module/ui/hooks/useProfileEditing.d.ts.map +1 -1
  129. package/lib/typescript/module/ui/screens/EditProfileFieldScreen.d.ts.map +1 -1
  130. package/lib/typescript/module/ui/screens/ManageAccountScreen.d.ts.map +1 -1
  131. package/lib/typescript/module/ui/types/navigation.d.ts +29 -0
  132. package/lib/typescript/module/ui/types/navigation.d.ts.map +1 -1
  133. package/package.json +5 -4
  134. package/src/index.ts +2 -1
  135. package/src/ui/components/LanguageBridge.tsx +44 -0
  136. package/src/ui/components/OxyAuthChooser.tsx +106 -156
  137. package/src/ui/components/OxyConsentScreen.tsx +127 -0
  138. package/src/ui/components/OxyProvider.tsx +17 -0
  139. package/src/ui/components/__tests__/LanguageBridge.test.tsx +81 -0
  140. package/src/ui/components/__tests__/OxyConsentScreen.test.tsx +32 -0
  141. package/src/ui/components/authChooser/AccountsMenuView.tsx +7 -0
  142. package/src/ui/components/authChooser/SignInEntryView.tsx +41 -44
  143. package/src/ui/components/authChooser/SignInRequestView.tsx +1 -4
  144. package/src/ui/components/authChooser/SignUpView.tsx +16 -114
  145. package/src/ui/components/authChooser/signInFailureMessage.ts +39 -0
  146. package/src/ui/components/authChooser/types.ts +11 -13
  147. package/src/ui/context/OxyContext.tsx +1 -1
  148. package/src/ui/context/hooks/useAuthOperations.ts +10 -5
  149. package/src/ui/context/oxyContextTypes.ts +16 -2
  150. package/src/ui/hooks/accountDialogSnapshot.ts +2 -0
  151. package/src/ui/hooks/useProfileEditing.ts +15 -0
  152. package/src/ui/screens/EditProfileFieldScreen.tsx +202 -7
  153. package/src/ui/screens/ManageAccountScreen.tsx +12 -0
  154. package/src/ui/types/navigation.ts +30 -0
  155. package/lib/commonjs/ui/components/authChooser/useUsernameAvailability.js +0 -50
  156. package/lib/commonjs/ui/components/authChooser/useUsernameAvailability.js.map +0 -1
  157. package/lib/module/ui/components/authChooser/useUsernameAvailability.js +0 -46
  158. package/lib/module/ui/components/authChooser/useUsernameAvailability.js.map +0 -1
  159. package/lib/typescript/commonjs/ui/components/authChooser/useUsernameAvailability.d.ts +0 -16
  160. package/lib/typescript/commonjs/ui/components/authChooser/useUsernameAvailability.d.ts.map +0 -1
  161. package/lib/typescript/module/ui/components/authChooser/useUsernameAvailability.d.ts +0 -16
  162. package/lib/typescript/module/ui/components/authChooser/useUsernameAvailability.d.ts.map +0 -1
  163. package/src/ui/components/authChooser/useUsernameAvailability.ts +0 -60
package/src/index.ts CHANGED
@@ -56,7 +56,7 @@ export type {
56
56
  OxyRuntimeStatus,
57
57
  OxyTokenStatus,
58
58
  } from './ui/runtime';
59
- export type { OxyContextState } from './ui/context/OxyContext';
59
+ export type { LogoutResult, OxyContextState } from './ui/context/OxyContext';
60
60
  export { useAuth } from './ui/hooks/useAuth';
61
61
  export type { AuthState, AuthActions, SignInOutcome, UseAuthReturn } from './ui/hooks/useAuth';
62
62
  export { createDeferredProductAnalytics } from './ui/analytics/productAnalytics';
@@ -296,6 +296,7 @@ export type {
296
296
  OxyConsentScreenProps,
297
297
  OxyConsentApplication,
298
298
  OxyConsentUser,
299
+ OxyConsentResource,
299
300
  } from './ui/components/OxyConsentScreen';
300
301
 
301
302
  // Optional signed-out gate primitive. Wrap any subtree (or the whole app via
@@ -0,0 +1,44 @@
1
+ import { useEffect, useMemo, useRef } from 'react';
2
+ import { coerceToSupportedLocale } from '@oxy.so/core';
3
+ import { useOxy } from '../context/OxyContext';
4
+ import type { OxyLanguageConfig } from '../types/navigation';
5
+
6
+ /**
7
+ * `OxyProvider`'s language config prop, realized. Oxy decides which
8
+ * language an account (or, signed out, the device/guest locale) should see;
9
+ * this is the ONE place that decision reaches the host app's own i18n library
10
+ * — so an app never re-derives or imperatively tracks a language of its own,
11
+ * and never mounts anything extra to get it. It owns only its translation
12
+ * catalogs.
13
+ *
14
+ * `currentLanguage` may name a locale the host app never shipped a catalog
15
+ * for — {@link coerceToSupportedLocale} narrows it to the closest one the app
16
+ * actually has (exact match, then same base language, then `fallbackLocale`).
17
+ *
18
+ * Renders nothing. Mounted by `OxyProvider` INSIDE `OxyRuntimeProvider` only
19
+ * when this config is supplied, so an app with no i18n of its own pays
20
+ * nothing.
21
+ */
22
+ export function LanguageBridge({ supportedLocales, fallbackLocale, onChange, onError }: OxyLanguageConfig): null {
23
+ const { currentLanguage } = useOxy();
24
+ const resolvedLocale = useMemo(
25
+ () => coerceToSupportedLocale(currentLanguage, supportedLocales, fallbackLocale),
26
+ [currentLanguage, supportedLocales, fallbackLocale],
27
+ );
28
+
29
+ // Refs, not effect dependencies: an inline `onChange`/`onError` (the common
30
+ // case) must not re-fire the effect on every render — only a genuine
31
+ // change in the RESOLVED locale should call it again.
32
+ const onChangeRef = useRef(onChange);
33
+ onChangeRef.current = onChange;
34
+ const onErrorRef = useRef(onError);
35
+ onErrorRef.current = onError;
36
+
37
+ useEffect(() => {
38
+ Promise.resolve(onChangeRef.current(resolvedLocale)).catch((error: unknown) => {
39
+ onErrorRef.current?.(error, resolvedLocale);
40
+ });
41
+ }, [resolvedLocale]);
42
+
43
+ return null;
44
+ }
@@ -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
  }
@@ -21,6 +21,7 @@
21
21
  import { useCallback } from 'react';
22
22
  import { Linking, Pressable, ScrollView, StyleSheet, View } from 'react-native';
23
23
  import { logger } from '@oxy.so/core';
24
+ import type { McpOAuthWriteAction } from '@oxy.so/contracts';
24
25
  import { Avatar } from '@oxy.so/bloom/avatar';
25
26
  import { Button } from '@oxy.so/bloom/button';
26
27
  import { Text } from '@oxy.so/bloom/typography';
@@ -48,6 +49,8 @@ export interface OxyConsentApplication {
48
49
 
49
50
  /** The account that will authorize the request (the currently signed-in user). */
50
51
  export interface OxyConsentUser {
52
+ /** Exact effective account bound to the grant. */
53
+ accountId?: string;
51
54
  /** Real display name, when the profile has one. */
52
55
  displayName?: string;
53
56
  /** Normalized handle — the sanctioned fallback when `displayName` is absent. */
@@ -56,6 +59,18 @@ export interface OxyConsentUser {
56
59
  avatarUri?: string;
57
60
  }
58
61
 
62
+ export interface OxyConsentResource {
63
+ /** Oxy application that owns the protected MCP resource. */
64
+ application: {
65
+ name: string;
66
+ iconUrl?: string;
67
+ };
68
+ /** Canonical protected-resource URI bound into the access token. */
69
+ uri: string;
70
+ /** Effectful tools enabled by the requested capabilities. */
71
+ writeActions: McpOAuthWriteAction[];
72
+ }
73
+
59
74
  export interface OxyConsentScreenProps {
60
75
  /** The resolved requesting application. */
61
76
  application: OxyConsentApplication;
@@ -63,6 +78,8 @@ export interface OxyConsentScreenProps {
63
78
  scopes: string[];
64
79
  /** The account that will authorize the request, when known. */
65
80
  user?: OxyConsentUser;
81
+ /** Exact protected resource for an external MCP connection. */
82
+ resource?: OxyConsentResource;
66
83
  /** Approve handler — the caller mints the code / completes the flow. */
67
84
  onAllow: () => void | Promise<void>;
68
85
  /** Deny handler — the caller cancels the flow. */
@@ -192,6 +209,7 @@ export function OxyConsentScreen({
192
209
  application,
193
210
  scopes,
194
211
  user,
212
+ resource,
195
213
  onAllow,
196
214
  onDeny,
197
215
  busy = false,
@@ -259,6 +277,40 @@ export function OxyConsentScreen({
259
277
  ) : null}
260
278
  </View>
261
279
 
280
+ {resource ? (
281
+ <View style={styles.section}>
282
+ <Text style={[styles.sectionLabel, { color: theme.colors.textSecondary }]}>
283
+ {t('consent.resource.title')}
284
+ </Text>
285
+ <View
286
+ testID="consent-resource"
287
+ style={[styles.resourceCard, { borderColor: theme.colors.border, backgroundColor: theme.colors.card }]}
288
+ >
289
+ <Avatar
290
+ source={resource.application.iconUrl}
291
+ name={resource.application.name}
292
+ size={40}
293
+ />
294
+ <View style={styles.resourceText}>
295
+ <Text
296
+ testID="consent-resource-app"
297
+ numberOfLines={1}
298
+ style={[styles.accountName, { color: theme.colors.text }]}
299
+ >
300
+ {resource.application.name}
301
+ </Text>
302
+ <Text
303
+ testID="consent-resource-uri"
304
+ numberOfLines={2}
305
+ style={[styles.resourceUri, { color: theme.colors.textSecondary }]}
306
+ >
307
+ {resource.uri}
308
+ </Text>
309
+ </View>
310
+ </View>
311
+ </View>
312
+ ) : null}
313
+
262
314
  {/* Requested permissions */}
263
315
  <View style={styles.section}>
264
316
  <Text style={[styles.sectionLabel, { color: theme.colors.textSecondary }]}>
@@ -285,6 +337,29 @@ export function OxyConsentScreen({
285
337
  </View>
286
338
  </View>
287
339
 
340
+ {resource && resource.writeActions.length > 0 ? (
341
+ <View style={styles.section}>
342
+ <Text style={[styles.sectionLabel, { color: theme.colors.textSecondary }]}>
343
+ {t('consent.writeActions.title')}
344
+ </Text>
345
+ <View style={[styles.card, { borderColor: theme.colors.border, backgroundColor: theme.colors.card }]}>
346
+ {resource.writeActions.map((action) => (
347
+ <View key={`${action.name}:${action.version}`} testID={`consent-write-action-${action.name}`} style={styles.actionRow}>
348
+ <View style={styles.actionCopy}>
349
+ <Text style={[styles.rowText, { color: theme.colors.text }]}>{action.description}</Text>
350
+ <Text style={[styles.actionName, { color: theme.colors.textSecondary }]}>{action.name}</Text>
351
+ </View>
352
+ <View style={[styles.effectBadge, { borderColor: theme.colors.border }]}>
353
+ <Text style={[styles.effectText, { color: theme.colors.textSecondary }]}>
354
+ {t(`consent.effects.${action.effect}`)}
355
+ </Text>
356
+ </View>
357
+ </View>
358
+ ))}
359
+ </View>
360
+ </View>
361
+ ) : null}
362
+
288
363
  {/* Authorizing account */}
289
364
  {user ? (
290
365
  <View
@@ -305,6 +380,15 @@ export function OxyConsentScreen({
305
380
  {user.handle}
306
381
  </Text>
307
382
  ) : null}
383
+ {user.accountId ? (
384
+ <Text
385
+ testID="consent-account-id"
386
+ numberOfLines={1}
387
+ style={[styles.accountId, { color: theme.colors.textSecondary }]}
388
+ >
389
+ {t('consent.account.id', { id: user.accountId })}
390
+ </Text>
391
+ ) : null}
308
392
  </View>
309
393
  </View>
310
394
  ) : null}
@@ -466,6 +550,49 @@ const styles = StyleSheet.create({
466
550
  accountHandle: {
467
551
  fontSize: 13,
468
552
  },
553
+ accountId: {
554
+ fontSize: 11,
555
+ },
556
+ resourceCard: {
557
+ flexDirection: 'row',
558
+ alignItems: 'center',
559
+ gap: 12,
560
+ borderWidth: StyleSheet.hairlineWidth,
561
+ borderRadius: 16,
562
+ padding: 12,
563
+ },
564
+ resourceText: {
565
+ flex: 1,
566
+ minWidth: 0,
567
+ gap: 2,
568
+ },
569
+ resourceUri: {
570
+ fontSize: 12,
571
+ lineHeight: 17,
572
+ },
573
+ actionRow: {
574
+ flexDirection: 'row',
575
+ alignItems: 'center',
576
+ gap: 10,
577
+ },
578
+ actionCopy: {
579
+ flex: 1,
580
+ minWidth: 0,
581
+ gap: 2,
582
+ },
583
+ actionName: {
584
+ fontSize: 11,
585
+ },
586
+ effectBadge: {
587
+ borderWidth: StyleSheet.hairlineWidth,
588
+ borderRadius: 999,
589
+ paddingHorizontal: 8,
590
+ paddingVertical: 4,
591
+ },
592
+ effectText: {
593
+ fontSize: 10,
594
+ fontWeight: '600',
595
+ },
469
596
  linksRow: {
470
597
  flexDirection: 'row',
471
598
  flexWrap: 'wrap',