@oxyhq/services 22.0.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.
Files changed (78) hide show
  1. package/lib/commonjs/index.js +12 -0
  2. package/lib/commonjs/index.js.map +1 -1
  3. package/lib/commonjs/ui/components/OxyAccountDialog.js +27 -529
  4. package/lib/commonjs/ui/components/OxyAccountDialog.js.map +1 -1
  5. package/lib/commonjs/ui/components/OxyAuthChooser.js +902 -0
  6. package/lib/commonjs/ui/components/OxyAuthChooser.js.map +1 -0
  7. package/lib/commonjs/ui/components/oxyAuthChooserStyles.js +158 -0
  8. package/lib/commonjs/ui/components/oxyAuthChooserStyles.js.map +1 -0
  9. package/lib/commonjs/ui/components/passkeyHubPopup.js +33 -0
  10. package/lib/commonjs/ui/components/passkeyHubPopup.js.map +1 -0
  11. package/lib/commonjs/ui/context/OxyContext.js +35 -4
  12. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  13. package/lib/commonjs/ui/context/useOxyAccountGraph.js +6 -2
  14. package/lib/commonjs/ui/context/useOxyAccountGraph.js.map +1 -1
  15. package/lib/commonjs/ui/hooks/useSwitchableAccounts.js +2 -1
  16. package/lib/commonjs/ui/hooks/useSwitchableAccounts.js.map +1 -1
  17. package/lib/commonjs/ui/utils/commonsStoreLinks.js +42 -0
  18. package/lib/commonjs/ui/utils/commonsStoreLinks.js.map +1 -0
  19. package/lib/module/index.js +5 -0
  20. package/lib/module/index.js.map +1 -1
  21. package/lib/module/ui/components/OxyAccountDialog.js +29 -533
  22. package/lib/module/ui/components/OxyAccountDialog.js.map +1 -1
  23. package/lib/module/ui/components/OxyAuthChooser.js +899 -0
  24. package/lib/module/ui/components/OxyAuthChooser.js.map +1 -0
  25. package/lib/module/ui/components/oxyAuthChooserStyles.js +155 -0
  26. package/lib/module/ui/components/oxyAuthChooserStyles.js.map +1 -0
  27. package/lib/module/ui/components/passkeyHubPopup.js +29 -0
  28. package/lib/module/ui/components/passkeyHubPopup.js.map +1 -0
  29. package/lib/module/ui/context/OxyContext.js +35 -4
  30. package/lib/module/ui/context/OxyContext.js.map +1 -1
  31. package/lib/module/ui/context/useOxyAccountGraph.js +6 -2
  32. package/lib/module/ui/context/useOxyAccountGraph.js.map +1 -1
  33. package/lib/module/ui/hooks/useSwitchableAccounts.js +2 -1
  34. package/lib/module/ui/hooks/useSwitchableAccounts.js.map +1 -1
  35. package/lib/module/ui/utils/commonsStoreLinks.js +37 -0
  36. package/lib/module/ui/utils/commonsStoreLinks.js.map +1 -0
  37. package/lib/typescript/commonjs/index.d.ts +2 -0
  38. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  39. package/lib/typescript/commonjs/ui/components/OxyAccountDialog.d.ts +10 -20
  40. package/lib/typescript/commonjs/ui/components/OxyAccountDialog.d.ts.map +1 -1
  41. package/lib/typescript/commonjs/ui/components/OxyAuthChooser.d.ts +60 -0
  42. package/lib/typescript/commonjs/ui/components/OxyAuthChooser.d.ts.map +1 -0
  43. package/lib/typescript/commonjs/ui/components/oxyAuthChooserStyles.d.ts +148 -0
  44. package/lib/typescript/commonjs/ui/components/oxyAuthChooserStyles.d.ts.map +1 -0
  45. package/lib/typescript/commonjs/ui/components/passkeyHubPopup.d.ts +23 -0
  46. package/lib/typescript/commonjs/ui/components/passkeyHubPopup.d.ts.map +1 -0
  47. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  48. package/lib/typescript/commonjs/ui/context/useOxyAccountGraph.d.ts +1 -0
  49. package/lib/typescript/commonjs/ui/context/useOxyAccountGraph.d.ts.map +1 -1
  50. package/lib/typescript/commonjs/ui/hooks/useSwitchableAccounts.d.ts.map +1 -1
  51. package/lib/typescript/commonjs/ui/utils/commonsStoreLinks.d.ts +24 -0
  52. package/lib/typescript/commonjs/ui/utils/commonsStoreLinks.d.ts.map +1 -0
  53. package/lib/typescript/module/index.d.ts +2 -0
  54. package/lib/typescript/module/index.d.ts.map +1 -1
  55. package/lib/typescript/module/ui/components/OxyAccountDialog.d.ts +10 -20
  56. package/lib/typescript/module/ui/components/OxyAccountDialog.d.ts.map +1 -1
  57. package/lib/typescript/module/ui/components/OxyAuthChooser.d.ts +60 -0
  58. package/lib/typescript/module/ui/components/OxyAuthChooser.d.ts.map +1 -0
  59. package/lib/typescript/module/ui/components/oxyAuthChooserStyles.d.ts +148 -0
  60. package/lib/typescript/module/ui/components/oxyAuthChooserStyles.d.ts.map +1 -0
  61. package/lib/typescript/module/ui/components/passkeyHubPopup.d.ts +23 -0
  62. package/lib/typescript/module/ui/components/passkeyHubPopup.d.ts.map +1 -0
  63. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  64. package/lib/typescript/module/ui/context/useOxyAccountGraph.d.ts +1 -0
  65. package/lib/typescript/module/ui/context/useOxyAccountGraph.d.ts.map +1 -1
  66. package/lib/typescript/module/ui/hooks/useSwitchableAccounts.d.ts.map +1 -1
  67. package/lib/typescript/module/ui/utils/commonsStoreLinks.d.ts +24 -0
  68. package/lib/typescript/module/ui/utils/commonsStoreLinks.d.ts.map +1 -0
  69. package/package.json +11 -11
  70. package/src/index.ts +7 -0
  71. package/src/ui/components/OxyAccountDialog.tsx +33 -557
  72. package/src/ui/components/OxyAuthChooser.tsx +907 -0
  73. package/src/ui/components/oxyAuthChooserStyles.ts +152 -0
  74. package/src/ui/components/passkeyHubPopup.ts +28 -0
  75. package/src/ui/context/OxyContext.tsx +37 -3
  76. package/src/ui/context/useOxyAccountGraph.ts +4 -2
  77. package/src/ui/hooks/useSwitchableAccounts.ts +1 -0
  78. package/src/ui/utils/commonsStoreLinks.ts +38 -0
@@ -0,0 +1,899 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * OxyAuthChooser — the account switcher + sign-in/sign-up surface, WITHOUT
5
+ * any dialog chrome.
6
+ *
7
+ * A thin React Native binding over the headless `AccountDialogController` in
8
+ * `@oxyhq/core` (bound via `useSyncExternalStore`) — the same data/state
9
+ * machine {@link OxyAccountDialog} renders, extracted so it can be mounted in
10
+ * TWO places: wrapped in Bloom's `<Dialog>` by `OxyAccountDialog` (the normal
11
+ * in-app surface), and mounted bare by a future auth.oxy.so hub page for the
12
+ * cross-origin passkey popup (b2) — same chooser, two hosts, two completion
13
+ * strategies via the `onComplete` prop. Neither host duplicates view logic.
14
+ *
15
+ * Views (from `snapshot.view`):
16
+ * - `accounts` — the unified `SwitchableAccount[]` list. Tapping a row
17
+ * switches through `controller.switchTo(accountId)`, the active row is
18
+ * flagged, and a "+ Add account" row opens the sign-in entry.
19
+ * - `add` / `signin` — the sign-in entry. On WEB this auto-starts "Sign in
20
+ * with Oxy" (native shared-keychain silently, else the QR handoff) the
21
+ * instant the view is entered — the QR is the PRIMARY web surface, not a
22
+ * button behind a click. On NATIVE it stays button-driven (unchanged):
23
+ * primary "Sign in with Oxy", secondary "Scan a QR from another device".
24
+ * Both platforms get a footer entry into `signup`.
25
+ * - `signup` — account creation. Commons is ALWAYS the priority path (owner
26
+ * mandate) and leads everywhere it appears: native shows only "Create your
27
+ * identity in Commons" (or "Get Commons" first, if not installed); web on
28
+ * a first-party Oxy origin leads with the SAME "Get Commons" CTA, with
29
+ * inline passkey creation (username + `registerWithPasskey`) offered
30
+ * UNDERNEATH as the de-emphasized "don't want to install anything"
31
+ * alternative — never co-equal, never a competing button. Anywhere else (a
32
+ * non-Oxy web origin, before the b2 hub-relay ships) shows an honest "not
33
+ * available here yet" message instead of a broken button.
34
+ * - `qr` — the cross-device Commons handoff, and the PRIMARY web sign-in
35
+ * surface. On a first-party Oxy origin this ALSO carries a "No Commons?"
36
+ * passkey link — identity is ONE thing (Commons key or browser passkey,
37
+ * same Oxy ID), so there is no separate "sign in with a passkey" button
38
+ * anywhere in this file, but passkey is explicitly the LIGHTWEIGHT
39
+ * ALTERNATIVE for someone who doesn't want to install Commons, never
40
+ * co-equal: same small-text-link subordination, same framing, wherever it
41
+ * appears. Native additionally branches on `commonsAvailability`: when
42
+ * Commons isn't installed, this leads with a "Get Commons" CTA instead of
43
+ * a same-device dead-end QR.
44
+ *
45
+ * Per-account color re-theming uses Bloom's `APP_COLOR_PRESETS` + `BloomColorScope`
46
+ * (same visual language auth.oxy.so uses). Base theming is `useTheme()` + a
47
+ * `StyleSheet`, so this renders correctly in EVERY consumer — including apps
48
+ * that do not use NativeWind (e.g. the accounts app).
49
+ */
50
+
51
+ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
52
+ import { Linking, Platform, Pressable, TextInput, View } from 'react-native';
53
+ import { MaterialCommunityIcons } from '@expo/vector-icons';
54
+ import QRCode from 'react-native-qrcode-svg';
55
+ import { Avatar } from '@oxyhq/bloom/avatar';
56
+ import { Button } from '@oxyhq/bloom/button';
57
+ import { Text } from '@oxyhq/bloom/typography';
58
+ import { useTheme, BloomColorScope, APP_COLOR_NAMES, APP_COLOR_PRESETS } from '@oxyhq/bloom/theme';
59
+ import { isOxyRpOrigin } from '@oxyhq/core';
60
+ import { useQueryClient } from '@tanstack/react-query';
61
+ import { useOxy } from "../context/OxyContext.js";
62
+ import { useI18n } from "../hooks/useI18n.js";
63
+ import { isWebBrowser } from "../utils/isWebBrowser.js";
64
+ import { getCommonsAcquisitionUrl } from "../utils/commonsStoreLinks.js";
65
+ import { authChooserStyles as styles } from "./oxyAuthChooserStyles.js";
66
+
67
+ /** Diameter of a row avatar. */
68
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
69
+ const ROW_AVATAR_SIZE = 40;
70
+ /** High-contrast QR colors — intentionally fixed (NOT themed) for scan reliability. */
71
+ const QR_PLATE_BG = '#FFFFFF';
72
+ const QR_FOREGROUND = '#000000';
73
+ const QR_SIZE = 196;
74
+ /** Commons' own identity-creation deep link (mirrors the `approve`/`attest`/`card` intents). */
75
+ const COMMONS_CREATE_IDENTITY_URL = 'oxycommons://create-identity';
76
+ /**
77
+ * Resolve an account's stored color (a named Bloom preset, e.g. `'purple'`) to
78
+ * a concrete brand hex for the row accent. Falls back to the theme primary when
79
+ * the account has no color or the value is not a recognized preset, so the accent
80
+ * renders in EVERY consumer regardless of NativeWind availability.
81
+ */
82
+ function resolveAccentHex(color, fallback) {
83
+ const preset = toPreset(color);
84
+ return preset ? APP_COLOR_PRESETS[preset].hex : fallback;
85
+ }
86
+
87
+ /** Narrow a stored color string to a known `AppColorName`, or `undefined`. */
88
+ function toPreset(color) {
89
+ if (!color) return undefined;
90
+ return APP_COLOR_NAMES.includes(color) ? color : undefined;
91
+ }
92
+ /**
93
+ * The account switcher + sign-in/sign-up chooser. Mounted by `OxyAccountDialog`
94
+ * (wrapped in Bloom's `<Dialog>`) today; mountable bare by any future host that
95
+ * supplies its own `onComplete`.
96
+ */
97
+ const OxyAuthChooser = ({
98
+ onComplete
99
+ }) => {
100
+ const {
101
+ accountDialogController: controller,
102
+ showBottomSheet,
103
+ refreshAccounts,
104
+ signInWithPasskey,
105
+ registerWithPasskey,
106
+ oxyServices
107
+ } = useOxy();
108
+ const theme = useTheme();
109
+ const {
110
+ t
111
+ } = useI18n();
112
+ const queryClient = useQueryClient();
113
+
114
+ // A credential minted for `oxy.so` can only be ASSERTED there (or a loopback
115
+ // dev host) — a hard WebAuthn RP-ID boundary the browser enforces, not
116
+ // feature detection. On a first-party Oxy web origin the ceremony runs
117
+ // directly ('direct'). On any OTHER web origin (b2) it can't run locally, so
118
+ // the passkey action instead opens a popup at the auth.oxy.so passkey hub —
119
+ // where the SAME ceremony IS first-party — and relays the resulting session
120
+ // back via `AccountDialogController.startPasskeyHubSignIn` ('hub'). Native
121
+ // has neither: Commons owns identity there ('none'). `isOxyRpOrigin()` reads
122
+ // `location` once and is stable for the component's lifetime.
123
+ const passkeyMode = useMemo(() => {
124
+ if (!isWebBrowser()) return 'none';
125
+ return isOxyRpOrigin() ? 'direct' : 'hub';
126
+ }, []);
127
+ const passkeyAvailable = passkeyMode !== 'none';
128
+ const [signInPasskeyPending, setSignInPasskeyPending] = useState(false);
129
+ const [signInPasskeyError, setSignInPasskeyError] = useState(null);
130
+ const handleSignInWithPasskey = useCallback(async () => {
131
+ if (signInPasskeyPending) return;
132
+ setSignInPasskeyPending(true);
133
+ setSignInPasskeyError(null);
134
+ try {
135
+ await signInWithPasskey();
136
+ onComplete?.();
137
+ } catch (error) {
138
+ // A cancelled/failed ceremony keeps the view open so the user can retry
139
+ // or fall back to the QR — surface a concise message, never swallow.
140
+ setSignInPasskeyError(error instanceof Error && error.message ? error.message : 'Passkey sign-in failed.');
141
+ } finally {
142
+ setSignInPasskeyPending(false);
143
+ }
144
+ }, [signInPasskeyPending, signInWithPasskey, onComplete]);
145
+ const [createPasskeyPending, setCreatePasskeyPending] = useState(false);
146
+ const [createPasskeyError, setCreatePasskeyError] = useState(null);
147
+ const handleCreateWithPasskey = useCallback(async username => {
148
+ if (createPasskeyPending) return;
149
+ setCreatePasskeyPending(true);
150
+ setCreatePasskeyError(null);
151
+ try {
152
+ await registerWithPasskey({
153
+ username
154
+ });
155
+ onComplete?.();
156
+ } catch (error) {
157
+ setCreatePasskeyError(error instanceof Error && error.message ? error.message : 'Account creation failed.');
158
+ } finally {
159
+ setCreatePasskeyPending(false);
160
+ }
161
+ }, [createPasskeyPending, registerWithPasskey, onComplete]);
162
+
163
+ // Bind the headless controller. `getSnapshot` returns a stable reference
164
+ // between changes, so it is `useSyncExternalStore`-safe. Guard the no-provider
165
+ // loading state (`controller` is `null`) with an inert store.
166
+ const subscribe = useCallback(listener => controller ? controller.subscribe(listener) : () => undefined, [controller]);
167
+ const getSnapshot = useCallback(() => controller ? controller.getSnapshot() : EMPTY_SNAPSHOT, [controller]);
168
+ const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
169
+ const {
170
+ view
171
+ } = snapshot;
172
+
173
+ // Web: "Sign in with Oxy" auto-starts the instant the sign-in entry is
174
+ // reached — no button tap needed. `signInWithOxy()` tries the shared
175
+ // keychain first (silently, native-only) then falls to the QR handoff,
176
+ // which is what actually renders: the QR is the primary web surface, not a
177
+ // peer button among several. Gated on `phase === 'idle'` so navigating back
178
+ // to this view while a flow is already active (or re-entering it) never
179
+ // restarts/duplicates it. Native stays fully button-driven (unchanged) —
180
+ // this effect is a no-op there.
181
+ useEffect(() => {
182
+ if (!controller || !isWebBrowser()) return;
183
+ if (view !== 'signin' && view !== 'add' || snapshot.signIn.phase !== 'idle') return;
184
+ void controller.signInWithOxy();
185
+ }, [controller, view, snapshot.signIn.phase]);
186
+ const [switching, setSwitching] = useState(false);
187
+ const handleSwitch = useCallback(async accountId => {
188
+ if (!controller || switching) return;
189
+ if (accountId === snapshot.activeAccountId) {
190
+ onComplete?.();
191
+ return;
192
+ }
193
+ setSwitching(true);
194
+ try {
195
+ await controller.switchTo(accountId);
196
+ // A switch succeeds unless the controller recorded an error. On success
197
+ // reload the app's account graph and drop cached account-scoped data so
198
+ // the new active identity re-fetches — the same side effects the
199
+ // context's own `switchToAccount` performs.
200
+ if (!controller.getSnapshot().error) {
201
+ void refreshAccounts();
202
+ queryClient.invalidateQueries();
203
+ onComplete?.();
204
+ }
205
+ } finally {
206
+ setSwitching(false);
207
+ }
208
+ }, [controller, switching, snapshot.activeAccountId, onComplete, refreshAccounts, queryClient]);
209
+ const handleManage = useCallback(() => {
210
+ onComplete?.();
211
+ showBottomSheet?.('ManageAccount');
212
+ }, [onComplete, showBottomSheet]);
213
+ const handlers = useMemo(() => ({
214
+ onSwitch: accountId => {
215
+ void handleSwitch(accountId);
216
+ },
217
+ onAdd: () => controller?.add(),
218
+ onManage: handleManage
219
+ }), [handleSwitch, controller, handleManage]);
220
+ if (!controller) {
221
+ return null;
222
+ }
223
+ if (view === 'accounts') {
224
+ return /*#__PURE__*/_jsx(AccountsView, {
225
+ snapshot: snapshot,
226
+ theme: theme,
227
+ t: t,
228
+ handlers: handlers
229
+ });
230
+ }
231
+ if (view === 'qr') {
232
+ return /*#__PURE__*/_jsx(QrView, {
233
+ snapshot: snapshot,
234
+ theme: theme,
235
+ t: t,
236
+ onRetry: () => void controller.showQr(),
237
+ passkeyAvailable: passkeyAvailable,
238
+ onSignInWithPasskey: passkeyMode === 'direct' ? () => void handleSignInWithPasskey() : () => void controller.startPasskeyHubSignIn()
239
+ // The hub-popup flow's pending/error state is `snapshot.signIn`
240
+ // (already rendered elsewhere in this view) — only the DIRECT ceremony
241
+ // uses this component-local pair.
242
+ ,
243
+ passkeyPending: passkeyMode === 'direct' ? signInPasskeyPending : false,
244
+ passkeyError: passkeyMode === 'direct' ? signInPasskeyError : null,
245
+ onCreateAccount: () => controller.startSignup()
246
+ });
247
+ }
248
+ if (view === 'signup') {
249
+ return /*#__PURE__*/_jsx(SignUpView, {
250
+ snapshot: snapshot,
251
+ theme: theme,
252
+ t: t,
253
+ oxyServices: oxyServices,
254
+ passkeyMode: passkeyMode,
255
+ onCreateWithPasskey: handleCreateWithPasskey,
256
+ createPending: createPasskeyPending,
257
+ createError: createPasskeyError,
258
+ onOpenHub: () => void controller.startPasskeyHubSignIn(),
259
+ onBackToSignIn: () => controller.setView('signin')
260
+ });
261
+ }
262
+ return /*#__PURE__*/_jsx(SignInView, {
263
+ snapshot: snapshot,
264
+ theme: theme,
265
+ t: t,
266
+ handlers: handlers,
267
+ onSignInWithOxy: () => void controller.signInWithOxy(),
268
+ onScanQr: () => void controller.showQr(),
269
+ onCreateAccount: () => controller.startSignup()
270
+ });
271
+ };
272
+
273
+ // ---------------------------------------------------------------------------
274
+ // Accounts view
275
+ // ---------------------------------------------------------------------------
276
+
277
+ /**
278
+ * Inline error banner for `snapshot.error` (the account-list / switch-failure
279
+ * error — distinct from `snapshot.signIn.error`, which `QrView` already
280
+ * renders). `AccountsView` and `SignInView` previously swallowed it: a
281
+ * blocked/failed switch left the user with no feedback outside the QR view.
282
+ */
283
+ const ErrorBanner = ({
284
+ error,
285
+ theme
286
+ }) => {
287
+ if (!error) return null;
288
+ return /*#__PURE__*/_jsxs(View, {
289
+ style: [styles.errorBanner, {
290
+ backgroundColor: theme.colors.negativeSubtle
291
+ }],
292
+ children: [/*#__PURE__*/_jsx(MaterialCommunityIcons, {
293
+ name: "alert-circle-outline",
294
+ size: 18,
295
+ color: theme.colors.error
296
+ }), /*#__PURE__*/_jsx(Text, {
297
+ style: [styles.errorBannerText, {
298
+ color: theme.colors.error
299
+ }],
300
+ children: error
301
+ })]
302
+ });
303
+ };
304
+ const AccountsView = ({
305
+ snapshot,
306
+ theme,
307
+ t,
308
+ handlers
309
+ }) => {
310
+ if (snapshot.loading && snapshot.accounts.length === 0) {
311
+ return /*#__PURE__*/_jsxs(View, {
312
+ style: styles.centeredBlock,
313
+ children: [/*#__PURE__*/_jsx(MaterialCommunityIcons, {
314
+ name: "loading",
315
+ size: 24,
316
+ color: theme.colors.textSecondary
317
+ }), /*#__PURE__*/_jsx(Text, {
318
+ style: [styles.mutedText, {
319
+ color: theme.colors.textSecondary
320
+ }],
321
+ children: t('accountSwitcher.loading') || 'Loading accounts…'
322
+ })]
323
+ });
324
+ }
325
+ return /*#__PURE__*/_jsxs(View, {
326
+ style: styles.rows,
327
+ children: [/*#__PURE__*/_jsx(ErrorBanner, {
328
+ error: snapshot.error,
329
+ theme: theme
330
+ }), snapshot.accounts.map(account => /*#__PURE__*/_jsx(AccountRow, {
331
+ account: account,
332
+ theme: theme,
333
+ switching: snapshot.switchingAccountId === account.accountId,
334
+ disabled: snapshot.switchingAccountId !== null,
335
+ onPress: () => handlers.onSwitch(account.accountId)
336
+ }, account.accountId)), /*#__PURE__*/_jsxs(Pressable, {
337
+ style: [styles.addRow, {
338
+ borderColor: theme.colors.border
339
+ }],
340
+ onPress: handlers.onAdd,
341
+ accessibilityRole: "button",
342
+ accessibilityLabel: t('signin.addAccountTitle') || 'Add another account',
343
+ children: [/*#__PURE__*/_jsx(View, {
344
+ style: [styles.addBadge, {
345
+ borderColor: theme.colors.border
346
+ }],
347
+ children: /*#__PURE__*/_jsx(MaterialCommunityIcons, {
348
+ name: "plus",
349
+ size: 20,
350
+ color: theme.colors.textSecondary
351
+ })
352
+ }), /*#__PURE__*/_jsx(Text, {
353
+ style: [styles.rowName, {
354
+ color: theme.colors.textSecondary
355
+ }],
356
+ children: t('signin.addAccountTitle') || 'Add another account'
357
+ })]
358
+ }), /*#__PURE__*/_jsx(View, {
359
+ style: styles.footerLinks,
360
+ children: /*#__PURE__*/_jsx(Pressable, {
361
+ onPress: handlers.onManage,
362
+ accessibilityRole: "button",
363
+ children: /*#__PURE__*/_jsx(Text, {
364
+ style: [styles.linkText, {
365
+ color: theme.colors.primary
366
+ }],
367
+ children: t('accountMenu.manage') || 'Manage accounts'
368
+ })
369
+ })
370
+ })]
371
+ });
372
+ };
373
+ const AccountRow = ({
374
+ account,
375
+ theme,
376
+ switching,
377
+ disabled,
378
+ onPress
379
+ }) => {
380
+ const accent = resolveAccentHex(account.color, theme.colors.primary);
381
+ const rowStyle = [styles.accountRow, {
382
+ borderColor: account.isCurrent ? accent : theme.colors.border,
383
+ backgroundColor: theme.colors.card
384
+ }, disabled && !switching ? styles.rowDisabled : null];
385
+ return /*#__PURE__*/_jsx(BloomColorScope, {
386
+ colorPreset: toPreset(account.color),
387
+ asChild: true,
388
+ children: /*#__PURE__*/_jsxs(Pressable, {
389
+ style: rowStyle,
390
+ onPress: onPress,
391
+ disabled: disabled,
392
+ accessibilityRole: "button",
393
+ accessibilityState: {
394
+ selected: account.isCurrent,
395
+ disabled
396
+ },
397
+ accessibilityLabel: account.displayName,
398
+ children: [/*#__PURE__*/_jsx(View, {
399
+ style: [styles.avatarRing, {
400
+ borderColor: account.isCurrent ? accent : 'transparent'
401
+ }],
402
+ children: /*#__PURE__*/_jsx(Avatar, {
403
+ source: account.avatarUrl ?? undefined,
404
+ variant: "thumb",
405
+ name: account.displayName,
406
+ size: ROW_AVATAR_SIZE
407
+ })
408
+ }), /*#__PURE__*/_jsxs(View, {
409
+ style: styles.rowMeta,
410
+ children: [/*#__PURE__*/_jsx(Text, {
411
+ style: [styles.rowName, {
412
+ color: theme.colors.text
413
+ }],
414
+ numberOfLines: 1,
415
+ children: account.displayName
416
+ }), account.email ? /*#__PURE__*/_jsx(Text, {
417
+ style: [styles.rowHandle, {
418
+ color: theme.colors.textSecondary
419
+ }],
420
+ numberOfLines: 1,
421
+ children: account.email
422
+ }) : null]
423
+ }), switching ? /*#__PURE__*/_jsx(MaterialCommunityIcons, {
424
+ name: "loading",
425
+ size: 20,
426
+ color: accent
427
+ }) : account.isCurrent ? /*#__PURE__*/_jsx(MaterialCommunityIcons, {
428
+ name: "check-circle",
429
+ size: 20,
430
+ color: accent
431
+ }) : /*#__PURE__*/_jsx(MaterialCommunityIcons, {
432
+ name: "chevron-right",
433
+ size: 20,
434
+ color: theme.colors.textSecondary
435
+ })]
436
+ })
437
+ });
438
+ };
439
+
440
+ // ---------------------------------------------------------------------------
441
+ // Sign-in view (add / signin) — NATIVE-facing today: on web the auto-start
442
+ // effect above moves the view to `qr` almost immediately, so this renders
443
+ // only for a brief instant there.
444
+ // ---------------------------------------------------------------------------
445
+
446
+ const SignInView = ({
447
+ snapshot,
448
+ theme,
449
+ t,
450
+ handlers,
451
+ onSignInWithOxy,
452
+ onScanQr,
453
+ onCreateAccount
454
+ }) => /*#__PURE__*/_jsxs(View, {
455
+ style: styles.signInBlock,
456
+ children: [/*#__PURE__*/_jsx(ErrorBanner, {
457
+ error: snapshot.error,
458
+ theme: theme
459
+ }), snapshot.accounts.length > 0 ? /*#__PURE__*/_jsxs(View, {
460
+ style: styles.rows,
461
+ children: [snapshot.accounts.map(account => /*#__PURE__*/_jsx(AccountRow, {
462
+ account: account,
463
+ theme: theme,
464
+ switching: snapshot.switchingAccountId === account.accountId,
465
+ disabled: snapshot.switchingAccountId !== null,
466
+ onPress: () => handlers.onSwitch(account.accountId)
467
+ }, account.accountId)), /*#__PURE__*/_jsx(Dividerish, {
468
+ theme: theme,
469
+ label: t('signin.or') || 'or'
470
+ })]
471
+ }) : null, /*#__PURE__*/_jsx(Button, {
472
+ variant: "primary",
473
+ onPress: onSignInWithOxy,
474
+ style: styles.primaryButton,
475
+ children: t('accountSwitcher.signInWithOxy') || 'Sign in with Oxy'
476
+ }), /*#__PURE__*/_jsx(Button, {
477
+ variant: "secondary",
478
+ onPress: onScanQr,
479
+ style: styles.secondaryButton,
480
+ children: t('accountSwitcher.scanQr') || 'Scan a QR from another device'
481
+ }), /*#__PURE__*/_jsx(SignUpFooterLink, {
482
+ theme: theme,
483
+ t: t,
484
+ onPress: onCreateAccount
485
+ })]
486
+ });
487
+ const Dividerish = ({
488
+ theme,
489
+ label
490
+ }) => /*#__PURE__*/_jsxs(View, {
491
+ style: styles.dividerRow,
492
+ children: [/*#__PURE__*/_jsx(View, {
493
+ style: [styles.dividerLine, {
494
+ backgroundColor: theme.colors.border
495
+ }]
496
+ }), /*#__PURE__*/_jsx(Text, {
497
+ style: [styles.dividerText, {
498
+ color: theme.colors.textSecondary
499
+ }],
500
+ children: label
501
+ }), /*#__PURE__*/_jsx(View, {
502
+ style: [styles.dividerLine, {
503
+ backgroundColor: theme.colors.border
504
+ }]
505
+ })]
506
+ });
507
+
508
+ /** "New to Oxy? Create one" — shared by `SignInView` and `QrView`. */
509
+ const SignUpFooterLink = ({
510
+ theme,
511
+ t,
512
+ onPress
513
+ }) => /*#__PURE__*/_jsx(Pressable, {
514
+ onPress: onPress,
515
+ accessibilityRole: "button",
516
+ style: styles.footerLink,
517
+ children: /*#__PURE__*/_jsx(Text, {
518
+ style: [styles.linkText, {
519
+ color: theme.colors.textSecondary
520
+ }],
521
+ children: t('signin.createAccountLink') || 'New to Oxy? Create one'
522
+ })
523
+ });
524
+
525
+ /** "Already have an account? Sign in" — used by `SignUpView`. */
526
+ const SignInFooterLink = ({
527
+ theme,
528
+ t,
529
+ onPress
530
+ }) => /*#__PURE__*/_jsx(Pressable, {
531
+ onPress: onPress,
532
+ accessibilityRole: "button",
533
+ style: styles.footerLink,
534
+ children: /*#__PURE__*/_jsx(Text, {
535
+ style: [styles.linkText, {
536
+ color: theme.colors.textSecondary
537
+ }],
538
+ children: t('signup.backToSignInLink') || 'Already have an account? Sign in'
539
+ })
540
+ });
541
+
542
+ // ---------------------------------------------------------------------------
543
+ // QR view — the PRIMARY web sign-in surface, and the ONLY sign-in surface on
544
+ // native. Commons is ALWAYS the priority path (owner mandate): the QR here
545
+ // and the "Get Commons" CTA below are the leading content everywhere they
546
+ // appear. The passkey link is explicitly the lightweight, de-emphasized
547
+ // alternative for someone who doesn't want to install Commons — same visual
548
+ // subordination (a small text link, never a button) and the same "No
549
+ // Commons?" framing in its copy wherever it's offered.
550
+ // ---------------------------------------------------------------------------
551
+
552
+ const QrView = ({
553
+ snapshot,
554
+ theme,
555
+ t,
556
+ onRetry,
557
+ passkeyAvailable,
558
+ onSignInWithPasskey,
559
+ passkeyPending,
560
+ passkeyError,
561
+ onCreateAccount
562
+ }) => {
563
+ const {
564
+ signIn,
565
+ commonsAvailability
566
+ } = snapshot;
567
+ const [showQrAnyway, setShowQrAnyway] = useState(false);
568
+ const passkeyLink = passkeyAvailable && signIn.phase !== 'authorized' ? /*#__PURE__*/_jsxs(_Fragment, {
569
+ children: [/*#__PURE__*/_jsx(Pressable, {
570
+ onPress: onSignInWithPasskey,
571
+ disabled: passkeyPending,
572
+ accessibilityRole: "button",
573
+ style: styles.footerLink,
574
+ testID: "passkey-signin-link",
575
+ children: /*#__PURE__*/_jsx(Text, {
576
+ style: [styles.linkText, {
577
+ color: theme.colors.textSecondary
578
+ }],
579
+ children: passkeyPending ? t('accountSwitcher.passkeySigningIn') || 'Signing in…' : t('accountSwitcher.useIdentityOnDevice') || 'No Commons? Use a passkey on this device instead'
580
+ })
581
+ }), passkeyError ? /*#__PURE__*/_jsx(Text, {
582
+ style: [styles.errorText, {
583
+ color: theme.colors.error
584
+ }],
585
+ children: passkeyError
586
+ }) : null]
587
+ }) : null;
588
+
589
+ // Native, Commons confirmed NOT installed: a same-device QR is a dead
590
+ // end (the user can't scan their own screen) — lead with "Get Commons"
591
+ // instead, and demote the QR to an explicit "I have it elsewhere" reveal.
592
+ if (commonsAvailability === 'unavailable' && !showQrAnyway) {
593
+ return /*#__PURE__*/_jsxs(View, {
594
+ style: styles.centeredBlock,
595
+ children: [/*#__PURE__*/_jsx(Text, {
596
+ style: [styles.mutedText, {
597
+ color: theme.colors.textSecondary
598
+ }],
599
+ children: t('accountSwitcher.commonsNotInstalled') || "Don't have Commons? Get the app to sign in with your Oxy ID."
600
+ }), /*#__PURE__*/_jsx(Button, {
601
+ variant: "primary",
602
+ onPress: () => void Linking.openURL(getCommonsAcquisitionUrl(Platform.OS)),
603
+ style: styles.primaryButton,
604
+ children: t('accountSwitcher.getCommons') || 'Get Commons'
605
+ }), /*#__PURE__*/_jsx(Pressable, {
606
+ onPress: () => setShowQrAnyway(true),
607
+ accessibilityRole: "button",
608
+ style: styles.footerLink,
609
+ children: /*#__PURE__*/_jsx(Text, {
610
+ style: [styles.linkText, {
611
+ color: theme.colors.textSecondary
612
+ }],
613
+ children: t('accountSwitcher.showQrAnyway') || 'I have Commons on another device'
614
+ })
615
+ }), passkeyLink, /*#__PURE__*/_jsx(SignUpFooterLink, {
616
+ theme: theme,
617
+ t: t,
618
+ onPress: onCreateAccount
619
+ })]
620
+ });
621
+ }
622
+ if (signIn.phase === 'error') {
623
+ return /*#__PURE__*/_jsxs(View, {
624
+ style: styles.centeredBlock,
625
+ children: [/*#__PURE__*/_jsx(Text, {
626
+ style: [styles.errorText, {
627
+ color: theme.colors.error
628
+ }],
629
+ children: signIn.error || 'Sign-in failed. Please try again.'
630
+ }), /*#__PURE__*/_jsx(Button, {
631
+ variant: "secondary",
632
+ onPress: onRetry,
633
+ style: styles.secondaryButton,
634
+ children: t('common.actions.tryAgain') || 'Try again'
635
+ }), passkeyLink]
636
+ });
637
+ }
638
+ if (signIn.phase === 'starting' || signIn.phase === 'authorized' || !signIn.qrPayload) {
639
+ const label = signIn.phase === 'authorized' ? t('signin.status.signingIn') || 'Signing in…' : t('accountSwitcher.loading') || 'Preparing sign-in…';
640
+ return /*#__PURE__*/_jsxs(View, {
641
+ style: styles.centeredBlock,
642
+ children: [/*#__PURE__*/_jsx(MaterialCommunityIcons, {
643
+ name: "loading",
644
+ size: 26,
645
+ color: theme.colors.primary
646
+ }), /*#__PURE__*/_jsx(Text, {
647
+ style: [styles.mutedText, {
648
+ color: theme.colors.textSecondary
649
+ }],
650
+ children: label
651
+ })]
652
+ });
653
+ }
654
+ return /*#__PURE__*/_jsxs(View, {
655
+ style: styles.centeredBlock,
656
+ children: [/*#__PURE__*/_jsx(Text, {
657
+ style: [styles.qrHeadline, {
658
+ color: theme.colors.text
659
+ }],
660
+ children: t('accountSwitcher.qrHeadline') || 'Sign in with your Oxy identity'
661
+ }), /*#__PURE__*/_jsx(View, {
662
+ style: styles.qrPlate,
663
+ children: /*#__PURE__*/_jsx(QRCode, {
664
+ value: signIn.qrPayload,
665
+ size: QR_SIZE,
666
+ backgroundColor: QR_PLATE_BG,
667
+ color: QR_FOREGROUND
668
+ })
669
+ }), /*#__PURE__*/_jsx(Text, {
670
+ style: [styles.mutedText, {
671
+ color: theme.colors.textSecondary
672
+ }],
673
+ children: t('accountSwitcher.scanWithOxy') || 'Scan with Commons on your phone to continue.'
674
+ }), passkeyLink, /*#__PURE__*/_jsx(SignUpFooterLink, {
675
+ theme: theme,
676
+ t: t,
677
+ onPress: onCreateAccount
678
+ })]
679
+ });
680
+ };
681
+
682
+ // ---------------------------------------------------------------------------
683
+ // Sign-up view
684
+ // ---------------------------------------------------------------------------
685
+
686
+ /** Debounced username-availability check over the existing SDK method. */
687
+ function useUsernameAvailability(oxyServices) {
688
+ const [status, setStatus] = useState('idle');
689
+ const timerRef = useRef(null);
690
+ const requestSeqRef = useRef(0);
691
+ const check = useCallback(value => {
692
+ if (timerRef.current) clearTimeout(timerRef.current);
693
+ const seq = ++requestSeqRef.current;
694
+ if (!value || value.length < 3) {
695
+ setStatus('idle');
696
+ return;
697
+ }
698
+ setStatus('checking');
699
+ timerRef.current = setTimeout(() => {
700
+ void oxyServices.checkUsernameAvailability(value).then(result => {
701
+ if (seq !== requestSeqRef.current) return; // superseded by a newer check
702
+ setStatus(result.available ? 'available' : 'taken');
703
+ }).catch(() => {
704
+ if (seq !== requestSeqRef.current) return;
705
+ setStatus('idle'); // network/error — don't block on a stale verdict
706
+ });
707
+ }, 400);
708
+ }, [oxyServices]);
709
+ return {
710
+ status,
711
+ check
712
+ };
713
+ }
714
+ const UsernameStatusText = ({
715
+ status,
716
+ theme,
717
+ t
718
+ }) => {
719
+ if (status === 'idle') return null;
720
+ if (status === 'checking') {
721
+ return /*#__PURE__*/_jsx(Text, {
722
+ style: [styles.mutedText, {
723
+ color: theme.colors.textSecondary
724
+ }],
725
+ children: t('signup.usernameChecking') || 'Checking…'
726
+ });
727
+ }
728
+ if (status === 'available') {
729
+ return /*#__PURE__*/_jsx(Text, {
730
+ style: [styles.mutedText, {
731
+ color: theme.colors.success
732
+ }],
733
+ children: t('signup.usernameAvailable') || 'Available'
734
+ });
735
+ }
736
+ return /*#__PURE__*/_jsx(Text, {
737
+ style: [styles.mutedText, {
738
+ color: theme.colors.error
739
+ }],
740
+ children: t('signup.usernameTaken') || 'This username is taken'
741
+ });
742
+ };
743
+ const SignUpView = ({
744
+ snapshot,
745
+ theme,
746
+ t,
747
+ oxyServices,
748
+ passkeyMode,
749
+ onCreateWithPasskey,
750
+ createPending,
751
+ createError,
752
+ onOpenHub,
753
+ onBackToSignIn
754
+ }) => {
755
+ const [username, setUsername] = useState('');
756
+ const {
757
+ status: usernameStatus,
758
+ check: checkUsername
759
+ } = useUsernameAvailability(oxyServices);
760
+ const canSubmit = username.trim().length >= 3 && usernameStatus === 'available' && !createPending;
761
+
762
+ // Native — Commons owns identity creation. Mirrors the QR view's own
763
+ // commonsAvailability branch: deep-link straight in when installed, else
764
+ // lead with the same "Get Commons" fallback.
765
+ if (!isWebBrowser()) {
766
+ const commonsInstalled = snapshot.commonsAvailability === 'available';
767
+ return /*#__PURE__*/_jsxs(View, {
768
+ style: styles.centeredBlock,
769
+ children: [/*#__PURE__*/_jsx(Button, {
770
+ variant: "primary",
771
+ onPress: () => void Linking.openURL(commonsInstalled ? COMMONS_CREATE_IDENTITY_URL : getCommonsAcquisitionUrl(Platform.OS)),
772
+ style: styles.primaryButton,
773
+ children: commonsInstalled ? t('signup.createInCommons') || 'Create your identity in Commons' : t('accountSwitcher.getCommons') || 'Get Commons'
774
+ }), /*#__PURE__*/_jsx(SignInFooterLink, {
775
+ theme: theme,
776
+ t: t,
777
+ onPress: onBackToSignIn
778
+ })]
779
+ });
780
+ }
781
+
782
+ // Web, first-party Oxy origin. Commons leads (owner mandate: it's ALWAYS
783
+ // the priority path, everywhere the two appear) with a "Get Commons" CTA;
784
+ // passkey creation is the de-emphasized alternative underneath for someone
785
+ // who doesn't want to install anything — secondary button, introduced by
786
+ // its own "No Commons?" framing, same subordination as the QR view's link.
787
+ if (passkeyMode === 'direct') {
788
+ return /*#__PURE__*/_jsxs(View, {
789
+ style: styles.signInBlock,
790
+ children: [/*#__PURE__*/_jsx(Text, {
791
+ style: [styles.qrHeadline, {
792
+ color: theme.colors.text
793
+ }],
794
+ children: t('signup.commonsHeadline') || 'Create your identity in Commons'
795
+ }), /*#__PURE__*/_jsx(Text, {
796
+ style: [styles.mutedText, {
797
+ color: theme.colors.textSecondary
798
+ }],
799
+ children: t('signup.commonsExplainer') || 'Commons is your full self-custody Oxy identity — one app, works everywhere.'
800
+ }), /*#__PURE__*/_jsx(Button, {
801
+ variant: "primary",
802
+ onPress: () => void Linking.openURL(getCommonsAcquisitionUrl(Platform.OS)),
803
+ style: styles.primaryButton,
804
+ children: t('accountSwitcher.getCommons') || 'Get Commons'
805
+ }), /*#__PURE__*/_jsx(Dividerish, {
806
+ theme: theme,
807
+ label: t('signin.or') || 'or'
808
+ }), /*#__PURE__*/_jsx(Text, {
809
+ style: [styles.mutedText, {
810
+ color: theme.colors.textSecondary
811
+ }],
812
+ children: t('signup.passkeyAlternative') || "No Commons? Create a passkey on this device instead"
813
+ }), /*#__PURE__*/_jsx(TextInput, {
814
+ style: [styles.usernameInput, {
815
+ borderColor: theme.colors.border,
816
+ color: theme.colors.text
817
+ }],
818
+ placeholder: t('signup.usernamePlaceholder') || 'Choose a username',
819
+ placeholderTextColor: theme.colors.textSecondary,
820
+ value: username,
821
+ autoCapitalize: "none",
822
+ autoCorrect: false,
823
+ autoComplete: "username",
824
+ onChangeText: value => {
825
+ setUsername(value);
826
+ checkUsername(value.trim());
827
+ },
828
+ testID: "signup-username-input"
829
+ }), /*#__PURE__*/_jsx(UsernameStatusText, {
830
+ status: usernameStatus,
831
+ theme: theme,
832
+ t: t
833
+ }), createError ? /*#__PURE__*/_jsx(Text, {
834
+ style: [styles.errorText, {
835
+ color: theme.colors.error
836
+ }],
837
+ children: createError
838
+ }) : null, /*#__PURE__*/_jsx(Button, {
839
+ variant: "secondary",
840
+ onPress: () => void onCreateWithPasskey(username.trim()),
841
+ disabled: !canSubmit,
842
+ style: styles.secondaryButton,
843
+ testID: "signup-create-button",
844
+ children: createPending ? t('signup.creating') || 'Creating…' : t('signup.createAccount') || 'Create account'
845
+ }), /*#__PURE__*/_jsx(SignInFooterLink, {
846
+ theme: theme,
847
+ t: t,
848
+ onPress: onBackToSignIn
849
+ })]
850
+ });
851
+ }
852
+
853
+ // Web, non-Oxy origin (b2): passkey creation can't run locally here — the
854
+ // same WebAuthn RP-ID boundary as sign-in — so this opens the SAME
855
+ // auth.oxy.so passkey hub popup `QrView`'s "No Commons?" link uses.
856
+ // Completing EITHER a sign-in OR a fresh sign-up there relays the resulting
857
+ // session back via the SAME poll/socket engine `showQr` drives.
858
+ return /*#__PURE__*/_jsxs(View, {
859
+ style: styles.centeredBlock,
860
+ children: [/*#__PURE__*/_jsx(Text, {
861
+ style: [styles.mutedText, {
862
+ color: theme.colors.textSecondary
863
+ }],
864
+ children: t('signup.hubExplainer') || 'Create your Oxy ID in a secure window, then come right back.'
865
+ }), /*#__PURE__*/_jsx(Button, {
866
+ variant: "primary",
867
+ onPress: onOpenHub,
868
+ style: styles.primaryButton,
869
+ children: t('signup.continueInWindow') || 'Continue in a new window'
870
+ }), /*#__PURE__*/_jsx(SignInFooterLink, {
871
+ theme: theme,
872
+ t: t,
873
+ onPress: onBackToSignIn
874
+ })]
875
+ });
876
+ };
877
+
878
+ // ---------------------------------------------------------------------------
879
+ // Empty snapshot (no-provider loading state)
880
+ // ---------------------------------------------------------------------------
881
+
882
+ const EMPTY_SNAPSHOT = {
883
+ view: 'accounts',
884
+ accounts: [],
885
+ activeAccountId: null,
886
+ loading: false,
887
+ error: null,
888
+ switchingAccountId: null,
889
+ signIn: {
890
+ phase: 'idle',
891
+ authorizeCode: null,
892
+ qrPayload: null,
893
+ expiresAt: null,
894
+ error: null
895
+ },
896
+ commonsAvailability: 'unknown'
897
+ };
898
+ export default OxyAuthChooser;
899
+ //# sourceMappingURL=OxyAuthChooser.js.map