@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.
- package/lib/commonjs/index.js +12 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/ui/components/OxyAccountDialog.js +27 -529
- package/lib/commonjs/ui/components/OxyAccountDialog.js.map +1 -1
- package/lib/commonjs/ui/components/OxyAuthChooser.js +881 -0
- package/lib/commonjs/ui/components/OxyAuthChooser.js.map +1 -0
- package/lib/commonjs/ui/components/oxyAuthChooserStyles.js +158 -0
- package/lib/commonjs/ui/components/oxyAuthChooserStyles.js.map +1 -0
- package/lib/commonjs/ui/context/OxyContext.js +28 -3
- package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
- package/lib/commonjs/ui/context/useOxyAccountGraph.js +6 -2
- package/lib/commonjs/ui/context/useOxyAccountGraph.js.map +1 -1
- package/lib/commonjs/ui/hooks/useSwitchableAccounts.js +2 -1
- package/lib/commonjs/ui/hooks/useSwitchableAccounts.js.map +1 -1
- package/lib/commonjs/ui/utils/commonsStoreLinks.js +42 -0
- package/lib/commonjs/ui/utils/commonsStoreLinks.js.map +1 -0
- package/lib/module/index.js +5 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/ui/components/OxyAccountDialog.js +29 -533
- package/lib/module/ui/components/OxyAccountDialog.js.map +1 -1
- package/lib/module/ui/components/OxyAuthChooser.js +878 -0
- package/lib/module/ui/components/OxyAuthChooser.js.map +1 -0
- package/lib/module/ui/components/oxyAuthChooserStyles.js +155 -0
- package/lib/module/ui/components/oxyAuthChooserStyles.js.map +1 -0
- package/lib/module/ui/context/OxyContext.js +28 -3
- package/lib/module/ui/context/OxyContext.js.map +1 -1
- package/lib/module/ui/context/useOxyAccountGraph.js +6 -2
- package/lib/module/ui/context/useOxyAccountGraph.js.map +1 -1
- package/lib/module/ui/hooks/useSwitchableAccounts.js +2 -1
- package/lib/module/ui/hooks/useSwitchableAccounts.js.map +1 -1
- package/lib/module/ui/utils/commonsStoreLinks.js +37 -0
- package/lib/module/ui/utils/commonsStoreLinks.js.map +1 -0
- package/lib/typescript/commonjs/index.d.ts +2 -0
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/OxyAccountDialog.d.ts +10 -20
- package/lib/typescript/commonjs/ui/components/OxyAccountDialog.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/OxyAuthChooser.d.ts +60 -0
- package/lib/typescript/commonjs/ui/components/OxyAuthChooser.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/oxyAuthChooserStyles.d.ts +148 -0
- package/lib/typescript/commonjs/ui/components/oxyAuthChooserStyles.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/useOxyAccountGraph.d.ts +1 -0
- package/lib/typescript/commonjs/ui/context/useOxyAccountGraph.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/useSwitchableAccounts.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/utils/commonsStoreLinks.d.ts +24 -0
- package/lib/typescript/commonjs/ui/utils/commonsStoreLinks.d.ts.map +1 -0
- package/lib/typescript/module/index.d.ts +2 -0
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/OxyAccountDialog.d.ts +10 -20
- package/lib/typescript/module/ui/components/OxyAccountDialog.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/OxyAuthChooser.d.ts +60 -0
- package/lib/typescript/module/ui/components/OxyAuthChooser.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/oxyAuthChooserStyles.d.ts +148 -0
- package/lib/typescript/module/ui/components/oxyAuthChooserStyles.d.ts.map +1 -0
- package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/useOxyAccountGraph.d.ts +1 -0
- package/lib/typescript/module/ui/context/useOxyAccountGraph.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/useSwitchableAccounts.d.ts.map +1 -1
- package/lib/typescript/module/ui/utils/commonsStoreLinks.d.ts +24 -0
- package/lib/typescript/module/ui/utils/commonsStoreLinks.d.ts.map +1 -0
- package/package.json +4 -4
- package/src/index.ts +7 -0
- package/src/ui/components/OxyAccountDialog.tsx +33 -557
- package/src/ui/components/OxyAuthChooser.tsx +883 -0
- package/src/ui/components/oxyAuthChooserStyles.ts +152 -0
- package/src/ui/context/OxyContext.tsx +31 -3
- package/src/ui/context/useOxyAccountGraph.ts +4 -2
- package/src/ui/hooks/useSwitchableAccounts.ts +1 -0
- 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;
|