@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
@@ -1,24 +1,13 @@
1
1
  /**
2
2
  * OxyAccountDialog — the ONE unified account dialog for `@oxyhq/services`.
3
3
  *
4
- * A thin React Native binding over the headless `AccountDialogController` in
5
- * `@oxyhq/core` (bound via `useSyncExternalStore`), presented as a Bloom `<Dialog>`.
6
- * One surface for account switching and sign-in data and state machine live in core.
7
- *
8
- * Views (from `snapshot.view`):
9
- * - `accounts` the unified `SwitchableAccount[]` list (device sign-ins graph
10
- * accounts). Tapping a row switches through `controller.switchTo(accountId)`
11
- * (the uniform switch), the active row is flagged, and a "+ Add account" row
12
- * opens the sign-in entry. This is what `ProfileButton` opens.
13
- * - `add` / `signin` — the sign-in entry: primary "Sign in with Oxy" (device
14
- * flow), "Scan a QR", and a secondary "Use a password" hand-off that opens
15
- * auth.oxy.so (password + 2FA are NOT in the SDK).
16
- * - `qr` — the cross-device QR handoff + its waiting state.
17
- *
18
- * Per-account color re-theming uses Bloom's `APP_COLOR_PRESETS` + `BloomColorScope`
19
- * (same visual language auth.oxy.so uses). Base theming is `useTheme()` + a
20
- * `StyleSheet`, so the dialog renders correctly in EVERY consumer — including apps
21
- * that do not use NativeWind (e.g. the accounts app).
4
+ * Bloom `<Dialog>` chrome (header, back button, placement, scroll body) around
5
+ * the headless chooser logic, which lives in `OxyAuthChooser` — the account
6
+ * switcher, sign-in, sign-up, and QR views are ALL there, extracted so the
7
+ * same chooser can be mounted bare by a future host with no Dialog chrome
8
+ * (e.g. an auth.oxy.so hub page driving the cross-origin passkey popup, b2).
9
+ * This file owns ONLY the chrome: the title/subtitle per view, the back
10
+ * button, and the Bloom `<Dialog>` surface itself.
22
11
  *
23
12
  * The surface is Bloom's `<Dialog>` (`@oxyhq/bloom/dialog`) with a responsive
24
13
  * `placement` — a bottom sheet on narrow viewports, a centered card on wide ones.
@@ -34,121 +23,39 @@
34
23
  * Backdrop / swipe-to-dismiss is disabled (`dismissOnBackdrop={false}`) on purpose:
35
24
  * Bloom's controlled `bottom` placement does not fire `onClose` on a gesture or
36
25
  * backdrop dismissal, so allowing it would desync `isAccountDialogOpen` from the
37
- * sheet and block reopening. The header close button (and a successful switch)
38
- * drive `closeAccountDialog`, which flips `open` and runs the exit animation.
26
+ * sheet and block reopening. The header close button (and `OxyAuthChooser`'s
27
+ * `onComplete`) drive `closeAccountDialog`, which flips `open` and runs the exit
28
+ * animation.
39
29
  */
40
30
 
41
31
  import type React from 'react';
42
- import { useCallback, useMemo, useState, useSyncExternalStore } from 'react';
43
- import {
44
- Pressable,
45
- ScrollView,
46
- StyleSheet,
47
- View,
48
- type StyleProp,
49
- type ViewStyle,
50
- } from 'react-native';
32
+ import { useCallback, useSyncExternalStore } from 'react';
33
+ import { Pressable, ScrollView, StyleSheet, View } from 'react-native';
51
34
  import { MaterialCommunityIcons } from '@expo/vector-icons';
52
- import QRCode from 'react-native-qrcode-svg';
53
- import { Avatar } from '@oxyhq/bloom/avatar';
54
- import { Button } from '@oxyhq/bloom/button';
55
35
  import { Dialog } from '@oxyhq/bloom/dialog';
56
36
  import { Text } from '@oxyhq/bloom/typography';
57
- import {
58
- useTheme,
59
- BloomColorScope,
60
- APP_COLOR_NAMES,
61
- APP_COLOR_PRESETS,
62
- type AppColorName,
63
- } from '@oxyhq/bloom/theme';
64
- import type { SwitchableAccount, AccountDialogSnapshot } from '@oxyhq/core';
65
- import { isOxyRpOrigin } from '@oxyhq/core';
66
- import { useQueryClient } from '@tanstack/react-query';
37
+ import { useTheme } from '@oxyhq/bloom/theme';
38
+ import type { AccountDialogSnapshot } from '@oxyhq/core';
67
39
  import { useOxy } from '../context/OxyContext';
68
40
  import { useI18n } from '../hooks/useI18n';
69
- import { isWebBrowser } from '../utils/isWebBrowser';
41
+ import OxyAuthChooser from './OxyAuthChooser';
70
42
  import { LogoIcon } from './logo/LogoIcon';
71
43
 
72
- /** Diameter of a row avatar. */
73
- const ROW_AVATAR_SIZE = 40;
74
- /** High-contrast QR colors — intentionally fixed (NOT themed) for scan reliability. */
75
- const QR_PLATE_BG = '#FFFFFF';
76
- const QR_FOREGROUND = '#000000';
77
- const QR_SIZE = 196;
78
-
79
- /**
80
- * Resolve an account's stored color (a named Bloom preset, e.g. `'purple'`) to
81
- * a concrete brand hex for the row accent. Falls back to the theme primary when
82
- * the account has no color or the value is not a recognized preset, so the accent
83
- * renders in EVERY consumer regardless of NativeWind availability.
84
- */
85
- function resolveAccentHex(color: string | null, fallback: string): string {
86
- const preset = toPreset(color);
87
- return preset ? APP_COLOR_PRESETS[preset].hex : fallback;
88
- }
89
-
90
- /** Narrow a stored color string to a known `AppColorName`, or `undefined`. */
91
- function toPreset(color: string | null): AppColorName | undefined {
92
- if (!color) return undefined;
93
- return (APP_COLOR_NAMES as readonly string[]).includes(color)
94
- ? (color as AppColorName)
95
- : undefined;
96
- }
97
-
98
- interface OxyAccountDialogHandlers {
99
- onSwitch: (accountId: string) => void;
100
- onAdd: () => void;
101
- onManage: () => void;
102
- onClose: () => void;
103
- }
44
+ type Theme = ReturnType<typeof useTheme>;
45
+ type Translate = ReturnType<typeof useI18n>['t'];
104
46
 
105
47
  /**
106
48
  * The unified account dialog. Mounted once by `OxyProvider`; opened imperatively
107
49
  * via `useOxy().openAccountDialog(view?)` or imperative `openAccountDialog('signin')`.
108
50
  */
109
51
  const OxyAccountDialog: React.FC = () => {
110
- const {
111
- accountDialogController: controller,
112
- isAccountDialogOpen,
113
- closeAccountDialog,
114
- showBottomSheet,
115
- logoutAll,
116
- refreshAccounts,
117
- signInWithPasskey,
118
- } = useOxy();
52
+ const { accountDialogController: controller, isAccountDialogOpen, closeAccountDialog } = useOxy();
119
53
  const theme = useTheme();
120
54
  const { t } = useI18n();
121
- const queryClient = useQueryClient();
122
-
123
- // Passkey sign-in is offered ONLY on a first-party Oxy web origin (a credential
124
- // minted for `oxy.so` can only be asserted there or on a loopback dev host).
125
- // Off the web / on a non-Oxy origin the button is hidden entirely (a hub popup
126
- // for arbitrary web origins is a later phase). This is environment-static.
127
- const passkeyAvailable = useMemo(() => isWebBrowser() && isOxyRpOrigin(), []);
128
- const [passkeyPending, setPasskeyPending] = useState(false);
129
- const [passkeyError, setPasskeyError] = useState<string | null>(null);
130
-
131
- const handleSignInWithPasskey = useCallback(async () => {
132
- if (passkeyPending) return;
133
- setPasskeyPending(true);
134
- setPasskeyError(null);
135
- try {
136
- await signInWithPasskey();
137
- closeAccountDialog();
138
- } catch (error) {
139
- // A cancelled/failed ceremony keeps the dialog open so the user can retry
140
- // or fall back to another method — surface a concise message, never swallow.
141
- setPasskeyError(
142
- error instanceof Error && error.message ? error.message : 'Passkey sign-in failed.',
143
- );
144
- } finally {
145
- setPasskeyPending(false);
146
- }
147
- }, [passkeyPending, signInWithPasskey, closeAccountDialog]);
148
55
 
149
- // Bind the headless controller. `getSnapshot` returns a stable reference
150
- // between changes, so it is `useSyncExternalStore`-safe. Guard the no-provider
151
- // loading state (`controller` is `null`) with an inert store.
56
+ // A lightweight, header-only binding to the same controller `OxyAuthChooser`
57
+ // binds independently — cheap, and the established pattern here (
58
+ // `useSwitchableAccounts` also binds to this controller on its own).
152
59
  const subscribe = useCallback(
153
60
  (listener: () => void) => (controller ? controller.subscribe(listener) : () => undefined),
154
61
  [controller],
@@ -159,62 +66,13 @@ const OxyAccountDialog: React.FC = () => {
159
66
  );
160
67
  const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
161
68
 
162
- const [switching, setSwitching] = useState(false);
163
-
164
- const handleSwitch = useCallback(
165
- async (accountId: string) => {
166
- if (!controller || switching) return;
167
- if (accountId === snapshot.activeAccountId) {
168
- closeAccountDialog();
169
- return;
170
- }
171
- setSwitching(true);
172
- try {
173
- await controller.switchTo(accountId);
174
- // A switch succeeds unless the controller recorded an error. On success
175
- // reload the app's account graph and drop cached account-scoped data so
176
- // the new active identity re-fetches — the same side effects the
177
- // context's own `switchToAccount` performs.
178
- if (!controller.getSnapshot().error) {
179
- void refreshAccounts();
180
- queryClient.invalidateQueries();
181
- closeAccountDialog();
182
- }
183
- } finally {
184
- setSwitching(false);
185
- }
186
- },
187
- [controller, switching, snapshot.activeAccountId, closeAccountDialog, refreshAccounts, queryClient],
188
- );
189
-
190
- const handleManage = useCallback(() => {
191
- closeAccountDialog();
192
- showBottomSheet?.('ManageAccount');
193
- }, [closeAccountDialog, showBottomSheet]);
194
-
195
- const handleSignOutAll = useCallback(() => {
196
- closeAccountDialog();
197
- void logoutAll();
198
- }, [closeAccountDialog, logoutAll]);
199
-
200
- const handlers = useMemo<OxyAccountDialogHandlers>(
201
- () => ({
202
- onSwitch: (accountId) => {
203
- void handleSwitch(accountId);
204
- },
205
- onAdd: () => controller?.add(),
206
- onManage: handleManage,
207
- onClose: closeAccountDialog,
208
- }),
209
- [handleSwitch, controller, handleManage, closeAccountDialog],
210
- );
211
-
212
69
  if (!controller) {
213
70
  return null;
214
71
  }
215
72
 
216
73
  const { view } = snapshot;
217
- const showBack = view === 'qr' || (view === 'add' && snapshot.accounts.length > 0);
74
+ const showBack =
75
+ view === 'qr' || view === 'signup' || (view === 'add' && snapshot.accounts.length > 0);
218
76
 
219
77
  return (
220
78
  <Dialog
@@ -233,28 +91,8 @@ const OxyAccountDialog: React.FC = () => {
233
91
  onBack={() => controller.setView('accounts')}
234
92
  onClose={closeAccountDialog}
235
93
  />
236
- <ScrollView
237
- style={styles.body}
238
- contentContainerStyle={styles.bodyContent}
239
- showsVerticalScrollIndicator={false}
240
- >
241
- {view === 'accounts' ? (
242
- <AccountsView snapshot={snapshot} theme={theme} t={t} handlers={handlers} />
243
- ) : view === 'qr' ? (
244
- <QrView snapshot={snapshot} theme={theme} t={t} onRetry={() => void controller.showQr()} />
245
- ) : (
246
- <SignInView
247
- snapshot={snapshot}
248
- theme={theme}
249
- t={t}
250
- handlers={handlers}
251
- onSignInWithOxy={() => void controller.signInWithOxy()}
252
- onScanQr={() => void controller.showQr()}
253
- onSignInWithPasskey={passkeyAvailable ? () => void handleSignInWithPasskey() : undefined}
254
- passkeyPending={passkeyPending}
255
- passkeyError={passkeyError}
256
- />
257
- )}
94
+ <ScrollView style={styles.body} contentContainerStyle={styles.bodyContent} showsVerticalScrollIndicator={false}>
95
+ <OxyAuthChooser onComplete={closeAccountDialog} />
258
96
  </ScrollView>
259
97
  </Dialog>
260
98
  );
@@ -264,9 +102,6 @@ const OxyAccountDialog: React.FC = () => {
264
102
  // Header
265
103
  // ---------------------------------------------------------------------------
266
104
 
267
- type Theme = ReturnType<typeof useTheme>;
268
- type Translate = ReturnType<typeof useI18n>['t'];
269
-
270
105
  interface HeaderProps {
271
106
  snapshot: AccountDialogSnapshot;
272
107
  theme: Theme;
@@ -315,259 +150,6 @@ const DialogHeader: React.FC<HeaderProps> = ({ snapshot, theme, t, showBack, onB
315
150
  );
316
151
  };
317
152
 
318
- // ---------------------------------------------------------------------------
319
- // Accounts view
320
- // ---------------------------------------------------------------------------
321
-
322
- interface AccountsViewProps {
323
- snapshot: AccountDialogSnapshot;
324
- theme: Theme;
325
- t: Translate;
326
- handlers: OxyAccountDialogHandlers;
327
- }
328
-
329
- const AccountsView: React.FC<AccountsViewProps> = ({ snapshot, theme, t, handlers }) => {
330
- if (snapshot.loading && snapshot.accounts.length === 0) {
331
- return (
332
- <View style={styles.centeredBlock}>
333
- <MaterialCommunityIcons name="loading" size={24} color={theme.colors.textSecondary} />
334
- <Text style={[styles.mutedText, { color: theme.colors.textSecondary }]}>
335
- {t('accountSwitcher.loading') || 'Loading accounts…'}
336
- </Text>
337
- </View>
338
- );
339
- }
340
-
341
- return (
342
- <View style={styles.rows}>
343
- {snapshot.accounts.map((account) => (
344
- <AccountRow
345
- key={account.accountId}
346
- account={account}
347
- theme={theme}
348
- switching={snapshot.switchingAccountId === account.accountId}
349
- disabled={snapshot.switchingAccountId !== null}
350
- onPress={() => handlers.onSwitch(account.accountId)}
351
- />
352
- ))}
353
-
354
- <Pressable
355
- style={[styles.addRow, { borderColor: theme.colors.border }]}
356
- onPress={handlers.onAdd}
357
- accessibilityRole="button"
358
- accessibilityLabel={t('signin.addAccountTitle') || 'Add another account'}
359
- >
360
- <View style={[styles.addBadge, { borderColor: theme.colors.border }]}>
361
- <MaterialCommunityIcons name="plus" size={20} color={theme.colors.textSecondary} />
362
- </View>
363
- <Text style={[styles.rowName, { color: theme.colors.textSecondary }]}>
364
- {t('signin.addAccountTitle') || 'Add another account'}
365
- </Text>
366
- </Pressable>
367
-
368
- <View style={styles.footerLinks}>
369
- <Pressable onPress={handlers.onManage} accessibilityRole="button">
370
- <Text style={[styles.linkText, { color: theme.colors.primary }]}>
371
- {t('accountMenu.manage') || 'Manage accounts'}
372
- </Text>
373
- </Pressable>
374
- </View>
375
- </View>
376
- );
377
- };
378
-
379
- interface AccountRowProps {
380
- account: SwitchableAccount;
381
- theme: Theme;
382
- switching: boolean;
383
- disabled: boolean;
384
- onPress: () => void;
385
- }
386
-
387
- const AccountRow: React.FC<AccountRowProps> = ({ account, theme, switching, disabled, onPress }) => {
388
- const accent = resolveAccentHex(account.color, theme.colors.primary);
389
- const rowStyle: StyleProp<ViewStyle> = [
390
- styles.accountRow,
391
- {
392
- borderColor: account.isCurrent ? accent : theme.colors.border,
393
- backgroundColor: theme.colors.card,
394
- },
395
- disabled && !switching ? styles.rowDisabled : null,
396
- ];
397
-
398
- return (
399
- <BloomColorScope colorPreset={toPreset(account.color)} asChild>
400
- <Pressable
401
- style={rowStyle}
402
- onPress={onPress}
403
- disabled={disabled}
404
- accessibilityRole="button"
405
- accessibilityState={{ selected: account.isCurrent, disabled }}
406
- accessibilityLabel={account.displayName}
407
- >
408
- <View style={[styles.avatarRing, { borderColor: account.isCurrent ? accent : 'transparent' }]}>
409
- <Avatar
410
- source={account.avatarUrl ?? undefined}
411
- variant="thumb"
412
- name={account.displayName}
413
- size={ROW_AVATAR_SIZE}
414
- />
415
- </View>
416
- <View style={styles.rowMeta}>
417
- <Text style={[styles.rowName, { color: theme.colors.text }]} numberOfLines={1}>
418
- {account.displayName}
419
- </Text>
420
- {account.email ? (
421
- <Text style={[styles.rowHandle, { color: theme.colors.textSecondary }]} numberOfLines={1}>
422
- {account.email}
423
- </Text>
424
- ) : null}
425
- </View>
426
- {switching ? (
427
- <MaterialCommunityIcons name="loading" size={20} color={accent} />
428
- ) : account.isCurrent ? (
429
- <MaterialCommunityIcons name="check-circle" size={20} color={accent} />
430
- ) : (
431
- <MaterialCommunityIcons name="chevron-right" size={20} color={theme.colors.textSecondary} />
432
- )}
433
- </Pressable>
434
- </BloomColorScope>
435
- );
436
- };
437
-
438
- // ---------------------------------------------------------------------------
439
- // Sign-in view (add / signin)
440
- // ---------------------------------------------------------------------------
441
-
442
- interface SignInViewProps {
443
- snapshot: AccountDialogSnapshot;
444
- theme: Theme;
445
- t: Translate;
446
- handlers: OxyAccountDialogHandlers;
447
- onSignInWithOxy: () => void;
448
- onScanQr: () => void;
449
- /** When present, offer a "Sign in with a passkey" button (first-party Oxy web only). */
450
- onSignInWithPasskey?: () => void;
451
- passkeyPending: boolean;
452
- passkeyError: string | null;
453
- }
454
-
455
- const SignInView: React.FC<SignInViewProps> = ({
456
- snapshot,
457
- theme,
458
- t,
459
- handlers,
460
- onSignInWithOxy,
461
- onScanQr,
462
- onSignInWithPasskey,
463
- passkeyPending,
464
- passkeyError,
465
- }) => (
466
- <View style={styles.signInBlock}>
467
- {snapshot.accounts.length > 0 ? (
468
- <View style={styles.rows}>
469
- {snapshot.accounts.map((account) => (
470
- <AccountRow
471
- key={account.accountId}
472
- account={account}
473
- theme={theme}
474
- switching={snapshot.switchingAccountId === account.accountId}
475
- disabled={snapshot.switchingAccountId !== null}
476
- onPress={() => handlers.onSwitch(account.accountId)}
477
- />
478
- ))}
479
- <Dividerish theme={theme} label={t('signin.or') || 'or'} />
480
- </View>
481
- ) : null}
482
-
483
- <Button variant="primary" onPress={onSignInWithOxy} style={styles.primaryButton}>
484
- Sign in with Oxy
485
- </Button>
486
-
487
- {onSignInWithPasskey ? (
488
- <Button
489
- variant="secondary"
490
- onPress={onSignInWithPasskey}
491
- disabled={passkeyPending}
492
- style={styles.secondaryButton}
493
- testID="passkey-signin-button"
494
- >
495
- {passkeyPending
496
- ? t('accountSwitcher.passkeySigningIn') || 'Signing in…'
497
- : t('accountSwitcher.signInWithPasskey') || 'Sign in with a passkey'}
498
- </Button>
499
- ) : null}
500
-
501
- {passkeyError ? (
502
- <Text style={[styles.errorText, { color: theme.colors.error }]}>{passkeyError}</Text>
503
- ) : null}
504
-
505
- <Button variant="secondary" onPress={onScanQr} style={styles.secondaryButton}>
506
- {t('accountSwitcher.scanQr') || 'Scan a QR from another device'}
507
- </Button>
508
- </View>
509
- );
510
-
511
- const Dividerish: React.FC<{ theme: Theme; label: string }> = ({ theme, label }) => (
512
- <View style={styles.dividerRow}>
513
- <View style={[styles.dividerLine, { backgroundColor: theme.colors.border }]} />
514
- <Text style={[styles.dividerText, { color: theme.colors.textSecondary }]}>{label}</Text>
515
- <View style={[styles.dividerLine, { backgroundColor: theme.colors.border }]} />
516
- </View>
517
- );
518
-
519
- // ---------------------------------------------------------------------------
520
- // QR view
521
- // ---------------------------------------------------------------------------
522
-
523
- interface QrViewProps {
524
- snapshot: AccountDialogSnapshot;
525
- theme: Theme;
526
- t: Translate;
527
- onRetry: () => void;
528
- }
529
-
530
- const QrView: React.FC<QrViewProps> = ({ snapshot, theme, t, onRetry }) => {
531
- const { signIn } = snapshot;
532
-
533
- if (signIn.phase === 'error') {
534
- return (
535
- <View style={styles.centeredBlock}>
536
- <Text style={[styles.errorText, { color: theme.colors.error }]}>
537
- {signIn.error || 'Sign-in failed. Please try again.'}
538
- </Text>
539
- <Button variant="secondary" onPress={onRetry} style={styles.secondaryButton}>
540
- {t('common.actions.tryAgain') || 'Try again'}
541
- </Button>
542
- </View>
543
- );
544
- }
545
-
546
- if (signIn.phase === 'starting' || signIn.phase === 'authorized' || !signIn.qrPayload) {
547
- const label =
548
- signIn.phase === 'authorized'
549
- ? t('signin.status.signingIn') || 'Signing in…'
550
- : t('accountSwitcher.loading') || 'Preparing sign-in…';
551
- return (
552
- <View style={styles.centeredBlock}>
553
- <MaterialCommunityIcons name="loading" size={26} color={theme.colors.primary} />
554
- <Text style={[styles.mutedText, { color: theme.colors.textSecondary }]}>{label}</Text>
555
- </View>
556
- );
557
- }
558
-
559
- return (
560
- <View style={styles.centeredBlock}>
561
- <View style={styles.qrPlate}>
562
- <QRCode value={signIn.qrPayload} size={QR_SIZE} backgroundColor={QR_PLATE_BG} color={QR_FOREGROUND} />
563
- </View>
564
- <Text style={[styles.mutedText, { color: theme.colors.textSecondary }]}>
565
- {t('accountSwitcher.scanWithOxy') || 'Scan with any Oxy app and approve.'}
566
- </Text>
567
- </View>
568
- );
569
- };
570
-
571
153
  // ---------------------------------------------------------------------------
572
154
  // Copy + helpers
573
155
  // ---------------------------------------------------------------------------
@@ -585,8 +167,13 @@ function headerCopy(
585
167
  };
586
168
  case 'qr':
587
169
  return {
588
- title: t('accountSwitcher.scanTitle') || 'Scan with Oxy',
589
- subtitle: t('accountSwitcher.scanSubtitle') || 'Approve from your phone.',
170
+ title: t('accountSwitcher.scanTitle') || 'Sign in with Oxy',
171
+ subtitle: t('accountSwitcher.scanSubtitle') || 'Scan with Commons on your phone.',
172
+ };
173
+ case 'signup':
174
+ return {
175
+ title: t('signup.title') || 'Create your account',
176
+ subtitle: t('signup.subtitle') || 'One identity for the whole ecosystem.',
590
177
  };
591
178
  default:
592
179
  return accountCount > 0
@@ -601,12 +188,6 @@ function headerCopy(
601
188
  }
602
189
  }
603
190
 
604
- /** Current document URL on web; `undefined` where `location` is absent. */
605
- function currentHref(): string | undefined {
606
- const location = (globalThis as { location?: { href?: string } }).location;
607
- return typeof location?.href === 'string' ? location.href : undefined;
608
- }
609
-
610
191
  const EMPTY_SNAPSHOT: AccountDialogSnapshot = {
611
192
  view: 'accounts',
612
193
  accounts: [],
@@ -615,6 +196,7 @@ const EMPTY_SNAPSHOT: AccountDialogSnapshot = {
615
196
  error: null,
616
197
  switchingAccountId: null,
617
198
  signIn: { phase: 'idle', authorizeCode: null, qrPayload: null, expiresAt: null, error: null },
199
+ commonsAvailability: 'unknown',
618
200
  };
619
201
 
620
202
  const styles = StyleSheet.create({
@@ -656,112 +238,6 @@ const styles = StyleSheet.create({
656
238
  paddingTop: 4,
657
239
  paddingBottom: 4,
658
240
  },
659
- rows: {
660
- width: '100%',
661
- gap: 8,
662
- },
663
- accountRow: {
664
- flexDirection: 'row',
665
- alignItems: 'center',
666
- gap: 12,
667
- borderWidth: StyleSheet.hairlineWidth,
668
- borderRadius: 16,
669
- paddingVertical: 10,
670
- paddingHorizontal: 12,
671
- },
672
- rowDisabled: {
673
- opacity: 0.6,
674
- },
675
- avatarRing: {
676
- borderRadius: 9999,
677
- borderWidth: 2,
678
- padding: 1,
679
- },
680
- rowMeta: {
681
- flex: 1,
682
- minWidth: 0,
683
- },
684
- rowName: {
685
- fontSize: 15,
686
- fontWeight: '600',
687
- },
688
- rowHandle: {
689
- fontSize: 12.5,
690
- marginTop: 1,
691
- },
692
- addRow: {
693
- flexDirection: 'row',
694
- alignItems: 'center',
695
- gap: 12,
696
- borderWidth: StyleSheet.hairlineWidth,
697
- borderStyle: 'dashed',
698
- borderRadius: 16,
699
- paddingVertical: 10,
700
- paddingHorizontal: 12,
701
- marginTop: 8,
702
- },
703
- addBadge: {
704
- width: ROW_AVATAR_SIZE,
705
- height: ROW_AVATAR_SIZE,
706
- borderRadius: 9999,
707
- borderWidth: StyleSheet.hairlineWidth,
708
- alignItems: 'center',
709
- justifyContent: 'center',
710
- },
711
- footerLinks: {
712
- alignItems: 'center',
713
- marginTop: 16,
714
- },
715
- linkText: {
716
- fontSize: 14,
717
- fontWeight: '600',
718
- },
719
- signInBlock: {
720
- width: '100%',
721
- },
722
- primaryButton: {
723
- width: '100%',
724
- borderRadius: 14,
725
- marginTop: 8,
726
- },
727
- secondaryButton: {
728
- width: '100%',
729
- borderRadius: 14,
730
- marginTop: 10,
731
- },
732
- dividerRow: {
733
- flexDirection: 'row',
734
- alignItems: 'center',
735
- width: '100%',
736
- marginVertical: 14,
737
- },
738
- dividerLine: {
739
- flex: 1,
740
- height: StyleSheet.hairlineWidth,
741
- },
742
- dividerText: {
743
- marginHorizontal: 12,
744
- fontSize: 13,
745
- },
746
- centeredBlock: {
747
- alignItems: 'center',
748
- justifyContent: 'center',
749
- gap: 12,
750
- paddingVertical: 24,
751
- },
752
- mutedText: {
753
- fontSize: 14,
754
- textAlign: 'center',
755
- },
756
- errorText: {
757
- fontSize: 14,
758
- textAlign: 'center',
759
- },
760
- qrPlate: {
761
- padding: 16,
762
- borderRadius: 16,
763
- backgroundColor: QR_PLATE_BG,
764
- },
765
241
  });
766
242
 
767
243
  export default OxyAccountDialog;