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