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