@oxyhq/services 22.1.0 → 22.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.
@@ -138,12 +138,20 @@ const OxyAuthChooser: React.FC<OxyAuthChooserProps> = ({ onComplete }) => {
138
138
  const { t } = useI18n();
139
139
  const queryClient = useQueryClient();
140
140
 
141
- // A credential minted for `oxy.so` can only be asserted there (or a loopback
142
- // dev host) — this is a hard WebAuthn RP-ID boundary, not feature detection,
143
- // so it is the ONLY gate on the passkey affordances. `isOxyRpOrigin()` is
144
- // `false` on native by construction (no `location`), so no separate native
145
- // check is needed.
146
- const passkeyAvailable = useMemo(() => isOxyRpOrigin(), []);
141
+ // A credential minted for `oxy.so` can only be ASSERTED there (or a loopback
142
+ // dev host) — a hard WebAuthn RP-ID boundary the browser enforces, not
143
+ // feature detection. On a first-party Oxy web origin the ceremony runs
144
+ // directly ('direct'). On any OTHER web origin (b2) it can't run locally, so
145
+ // the passkey action instead opens a popup at the auth.oxy.so passkey hub —
146
+ // where the SAME ceremony IS first-party — and relays the resulting session
147
+ // back via `AccountDialogController.startPasskeyHubSignIn` ('hub'). Native
148
+ // has neither: Commons owns identity there ('none'). `isOxyRpOrigin()` reads
149
+ // `location` once and is stable for the component's lifetime.
150
+ const passkeyMode = useMemo<'direct' | 'hub' | 'none'>(() => {
151
+ if (!isWebBrowser()) return 'none';
152
+ return isOxyRpOrigin() ? 'direct' : 'hub';
153
+ }, []);
154
+ const passkeyAvailable = passkeyMode !== 'none';
147
155
 
148
156
  const [signInPasskeyPending, setSignInPasskeyPending] = useState(false);
149
157
  const [signInPasskeyError, setSignInPasskeyError] = useState<string | null>(null);
@@ -274,9 +282,16 @@ const OxyAuthChooser: React.FC<OxyAuthChooserProps> = ({ onComplete }) => {
274
282
  t={t}
275
283
  onRetry={() => void controller.showQr()}
276
284
  passkeyAvailable={passkeyAvailable}
277
- onSignInWithPasskey={() => void handleSignInWithPasskey()}
278
- passkeyPending={signInPasskeyPending}
279
- passkeyError={signInPasskeyError}
285
+ onSignInWithPasskey={
286
+ passkeyMode === 'direct'
287
+ ? () => void handleSignInWithPasskey()
288
+ : () => void controller.startPasskeyHubSignIn()
289
+ }
290
+ // The hub-popup flow's pending/error state is `snapshot.signIn`
291
+ // (already rendered elsewhere in this view) — only the DIRECT ceremony
292
+ // uses this component-local pair.
293
+ passkeyPending={passkeyMode === 'direct' ? signInPasskeyPending : false}
294
+ passkeyError={passkeyMode === 'direct' ? signInPasskeyError : null}
280
295
  onCreateAccount={() => controller.startSignup()}
281
296
  />
282
297
  );
@@ -289,10 +304,11 @@ const OxyAuthChooser: React.FC<OxyAuthChooserProps> = ({ onComplete }) => {
289
304
  theme={theme}
290
305
  t={t}
291
306
  oxyServices={oxyServices}
292
- passkeyAvailable={passkeyAvailable}
307
+ passkeyMode={passkeyMode}
293
308
  onCreateWithPasskey={handleCreateWithPasskey}
294
309
  createPending={createPasskeyPending}
295
310
  createError={createPasskeyError}
311
+ onOpenHub={() => void controller.startPasskeyHubSignIn()}
296
312
  onBackToSignIn={() => controller.setView('signin')}
297
313
  />
298
314
  );
@@ -742,10 +758,12 @@ interface SignUpViewProps {
742
758
  theme: Theme;
743
759
  t: Translate;
744
760
  oxyServices: OxyServices;
745
- passkeyAvailable: boolean;
761
+ passkeyMode: 'direct' | 'hub' | 'none';
746
762
  onCreateWithPasskey: (username: string) => Promise<void>;
747
763
  createPending: boolean;
748
764
  createError: string | null;
765
+ /** Open the auth.oxy.so hub popup (b2) — the 'hub' mode's only path. */
766
+ onOpenHub: () => void;
749
767
  onBackToSignIn: () => void;
750
768
  }
751
769
 
@@ -754,10 +772,11 @@ const SignUpView: React.FC<SignUpViewProps> = ({
754
772
  theme,
755
773
  t,
756
774
  oxyServices,
757
- passkeyAvailable,
775
+ passkeyMode,
758
776
  onCreateWithPasskey,
759
777
  createPending,
760
778
  createError,
779
+ onOpenHub,
761
780
  onBackToSignIn,
762
781
  }) => {
763
782
  const [username, setUsername] = useState('');
@@ -794,7 +813,7 @@ const SignUpView: React.FC<SignUpViewProps> = ({
794
813
  // passkey creation is the de-emphasized alternative underneath for someone
795
814
  // who doesn't want to install anything — secondary button, introduced by
796
815
  // its own "No Commons?" framing, same subordination as the QR view's link.
797
- if (passkeyAvailable) {
816
+ if (passkeyMode === 'direct') {
798
817
  return (
799
818
  <View style={styles.signInBlock}>
800
819
  <Text style={[styles.qrHeadline, { color: theme.colors.text }]}>
@@ -851,15 +870,20 @@ const SignUpView: React.FC<SignUpViewProps> = ({
851
870
  );
852
871
  }
853
872
 
854
- // Web, non-Oxy origin: passkey creation here requires the cross-origin hub
855
- // relay (b2), which has not shipped yet an honest message beats a button
856
- // that would fail.
873
+ // Web, non-Oxy origin (b2): passkey creation can't run locally here the
874
+ // same WebAuthn RP-ID boundary as sign-inso this opens the SAME
875
+ // auth.oxy.so passkey hub popup `QrView`'s "No Commons?" link uses.
876
+ // Completing EITHER a sign-in OR a fresh sign-up there relays the resulting
877
+ // session back via the SAME poll/socket engine `showQr` drives.
857
878
  return (
858
879
  <View style={styles.centeredBlock}>
859
880
  <Text style={[styles.mutedText, { color: theme.colors.textSecondary }]}>
860
- {t('signup.notAvailableHere') ||
861
- "Account creation isn't available in this app yet — create your identity with Commons, or visit oxy.so to sign up with a passkey."}
881
+ {t('signup.hubExplainer') ||
882
+ 'Create your Oxy ID in a secure window, then come right back.'}
862
883
  </Text>
884
+ <Button variant="primary" onPress={onOpenHub} style={styles.primaryButton}>
885
+ {t('signup.continueInWindow') || 'Continue in a new window'}
886
+ </Button>
863
887
  <SignInFooterLink theme={theme} t={t} onPress={onBackToSignIn} />
864
888
  </View>
865
889
  );
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Popup opener for the cross-origin passkey hub (b2).
3
+ *
4
+ * On a non-Oxy web origin (e.g. mention.earth) a WebAuthn ceremony can't run
5
+ * locally — the credential's RP ID is bound to `oxy.so`. Instead,
6
+ * `AccountDialogController.startPasskeyHubSignIn` opens a popup at the
7
+ * auth.oxy.so passkey hub, where the SAME ceremony IS a first-party operation.
8
+ *
9
+ * Kept in its own module (mirroring `oauthNavigation.ts`'s
10
+ * `redirectToAuthorize`) so `window.open` sits behind a plain function the
11
+ * controller wiring in `OxyContext` can inject without touching `window`
12
+ * itself, and so a test can substitute a fake opener.
13
+ */
14
+
15
+ import type { PopupWindowHandle } from '@oxyhq/core';
16
+
17
+ /**
18
+ * Open a new, EMPTY popup window. Must be called SYNCHRONOUSLY from within a
19
+ * user-gesture handler (before any `await`) — opening it after an async call
20
+ * loses the gesture attribution and the browser silently blocks it. Returns
21
+ * `null` when blocked, or when there is no DOM `window` (native/SSR — the
22
+ * hub-popup flow does not apply there).
23
+ */
24
+ export function openPasskeyHubPopup(): PopupWindowHandle | null {
25
+ const win = (globalThis as { window?: Window }).window;
26
+ if (!win) return null;
27
+ return win.open('', '_blank', 'width=420,height=640');
28
+ }
@@ -32,6 +32,7 @@ import {
32
32
  notifyAccountDialogVisibility,
33
33
  } from '../navigation/accountDialogManager';
34
34
  import { redirectToAuthorize } from '../components/oauthNavigation';
35
+ import { openPasskeyHubPopup } from '../components/passkeyHubPopup';
35
36
  import { isWebBrowser } from '../utils/isWebBrowser';
36
37
  import { runProviderColdBoot } from '../boot/runProviderColdBoot';
37
38
  import { loadPersistedDeviceCredential } from '../utils/deviceCredential';
@@ -668,6 +669,11 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
668
669
  // controller short-circuits, so `redirectToAuthorize` never runs for the
669
670
  // `oxycommons://` payload.
670
671
  canOpenApp: isWebBrowser() ? undefined : (url) => Linking.canOpenURL(url),
672
+ // Web-only: lets `startPasskeyHubSignIn` open the auth.oxy.so passkey hub
673
+ // popup (b2) for a non-Oxy origin. `undefined` on native — there is no
674
+ // popup concept there, and off-origin passkey sign-in isn't reachable
675
+ // (Commons owns the native flow).
676
+ openPopup: isWebBrowser() ? openPasskeyHubPopup : undefined,
671
677
  });
672
678
  }
673
679
  const accountDialogController = accountDialogControllerRef.current;