@oxyhq/services 10.4.0 → 10.5.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 (37) hide show
  1. package/lib/commonjs/ui/components/AnotherDeviceQR.js +121 -0
  2. package/lib/commonjs/ui/components/AnotherDeviceQR.js.map +1 -0
  3. package/lib/commonjs/ui/components/SignInModal.js +145 -356
  4. package/lib/commonjs/ui/components/SignInModal.js.map +1 -1
  5. package/lib/commonjs/ui/hooks/useOxyAuthSession.js +458 -0
  6. package/lib/commonjs/ui/hooks/useOxyAuthSession.js.map +1 -0
  7. package/lib/commonjs/ui/screens/OxyAuthScreen.js +62 -460
  8. package/lib/commonjs/ui/screens/OxyAuthScreen.js.map +1 -1
  9. package/lib/module/ui/components/AnotherDeviceQR.js +116 -0
  10. package/lib/module/ui/components/AnotherDeviceQR.js.map +1 -0
  11. package/lib/module/ui/components/SignInModal.js +149 -358
  12. package/lib/module/ui/components/SignInModal.js.map +1 -1
  13. package/lib/module/ui/hooks/useOxyAuthSession.js +452 -0
  14. package/lib/module/ui/hooks/useOxyAuthSession.js.map +1 -0
  15. package/lib/module/ui/screens/OxyAuthScreen.js +62 -460
  16. package/lib/module/ui/screens/OxyAuthScreen.js.map +1 -1
  17. package/lib/typescript/commonjs/ui/components/AnotherDeviceQR.d.ts +24 -0
  18. package/lib/typescript/commonjs/ui/components/AnotherDeviceQR.d.ts.map +1 -0
  19. package/lib/typescript/commonjs/ui/components/SignInModal.d.ts +10 -5
  20. package/lib/typescript/commonjs/ui/components/SignInModal.d.ts.map +1 -1
  21. package/lib/typescript/commonjs/ui/hooks/useOxyAuthSession.d.ts +83 -0
  22. package/lib/typescript/commonjs/ui/hooks/useOxyAuthSession.d.ts.map +1 -0
  23. package/lib/typescript/commonjs/ui/screens/OxyAuthScreen.d.ts +12 -8
  24. package/lib/typescript/commonjs/ui/screens/OxyAuthScreen.d.ts.map +1 -1
  25. package/lib/typescript/module/ui/components/AnotherDeviceQR.d.ts +24 -0
  26. package/lib/typescript/module/ui/components/AnotherDeviceQR.d.ts.map +1 -0
  27. package/lib/typescript/module/ui/components/SignInModal.d.ts +10 -5
  28. package/lib/typescript/module/ui/components/SignInModal.d.ts.map +1 -1
  29. package/lib/typescript/module/ui/hooks/useOxyAuthSession.d.ts +83 -0
  30. package/lib/typescript/module/ui/hooks/useOxyAuthSession.d.ts.map +1 -0
  31. package/lib/typescript/module/ui/screens/OxyAuthScreen.d.ts +12 -8
  32. package/lib/typescript/module/ui/screens/OxyAuthScreen.d.ts.map +1 -1
  33. package/package.json +1 -1
  34. package/src/ui/components/AnotherDeviceQR.tsx +111 -0
  35. package/src/ui/components/SignInModal.tsx +142 -381
  36. package/src/ui/hooks/useOxyAuthSession.ts +556 -0
  37. package/src/ui/screens/OxyAuthScreen.tsx +61 -506
@@ -1,454 +1,41 @@
1
1
  /**
2
- * OxyAuthScreen - Sign in with Oxy
3
- *
4
- * This screen is used by OTHER apps in the Oxy ecosystem to authenticate users.
5
- * It presents two options:
6
- * 1. Scan QR code with Oxy Accounts app
7
- * 2. Open the Oxy Auth web flow
8
- *
9
- * Uses WebSocket for real-time authorization updates (with polling fallback).
10
- * The Oxy Accounts app is where users manage their cryptographic identity.
11
- * This screen should NOT be used within the Accounts app itself.
2
+ * OxyAuthScreen - Sign in with Oxy (native bottom-sheet container)
3
+ *
4
+ * Used by OTHER apps in the Oxy ecosystem to authenticate users. The primary
5
+ * action is the one-tap "Continue with Oxy" approval flow (opens the Oxy Auth
6
+ * web flow with a deep-link `redirect_uri` so the app→app return path can
7
+ * complete the sign-in). The QR code is demoted to a collapsed "Sign in on
8
+ * another device" disclosure — you can't scan your own screen.
9
+ *
10
+ * ALL of the auth-session machinery (session-token creation, QR data, socket +
11
+ * polling, the native deep-link return path, waiting/error/retry state, the
12
+ * open-auth handler, and cleanup) lives in the shared `useOxyAuthSession` hook,
13
+ * which the web `SignInModal` also consumes — neither container re-implements
14
+ * the transport. The Oxy Accounts app is where users manage their cryptographic
15
+ * identity; this screen should NOT be used within the Accounts app itself.
12
16
  */
13
17
 
14
18
  import type React from 'react';
15
- import { useState, useEffect, useCallback, useRef } from 'react';
16
- import {
17
- View,
18
- Text,
19
- TouchableOpacity,
20
- StyleSheet,
21
- Linking,
22
- Platform,
23
- } from 'react-native';
24
- import io, { type Socket } from 'socket.io-client';
19
+ import { View, Text, TouchableOpacity, StyleSheet, Linking } from 'react-native';
25
20
  import type { BaseScreenProps } from '../types/navigation';
26
21
  import { useTheme } from '@oxyhq/bloom/theme';
27
22
  import { Button } from '@oxyhq/bloom/button';
28
23
  import { Loading } from '@oxyhq/bloom/loading';
29
24
  import { useOxy } from '../context/OxyContext';
30
- import QRCode from 'react-native-qrcode-svg';
31
25
  import OxyLogo from '../components/OxyLogo';
32
- import { createDebugLogger } from '@oxyhq/core';
33
- import { completeDeviceFlowSignIn } from '../../utils/deviceFlowSignIn';
26
+ import AnotherDeviceQR from '../components/AnotherDeviceQR';
27
+ import { useOxyAuthSession, OXY_ACCOUNTS_WEB_URL } from '../hooks/useOxyAuthSession';
34
28
 
35
- const debug = createDebugLogger('OxyAuthScreen');
36
-
37
- const OXY_ACCOUNTS_WEB_URL = 'https://accounts.oxy.so';
38
- const OXY_AUTH_WEB_URL = 'https://auth.oxy.so';
39
-
40
- // Auth session expiration (5 minutes)
41
- const AUTH_SESSION_EXPIRY_MS = 5 * 60 * 1000;
42
-
43
- // Polling interval (fallback if socket fails)
44
- const POLLING_INTERVAL_MS = 3000;
45
-
46
- interface AuthSession {
47
- sessionToken: string;
48
- expiresAt: number;
49
- }
50
-
51
- interface AuthUpdatePayload {
52
- status: 'authorized' | 'cancelled' | 'expired';
53
- sessionId?: string;
54
- publicKey?: string;
55
- userId?: string;
56
- username?: string;
57
- }
58
-
59
- const resolveAuthWebBaseUrl = (baseURL: string, authWebUrl?: string): string => {
60
- if (authWebUrl) {
61
- return authWebUrl;
62
- }
63
-
64
- try {
65
- const url = new URL(baseURL);
66
- if (url.port === '3001') {
67
- url.port = '3002';
68
- return url.origin;
69
- }
70
- if (url.hostname.startsWith('api.')) {
71
- url.hostname = `auth.${url.hostname.slice(4)}`;
72
- return url.origin;
73
- }
74
- } catch {
75
- // Ignore parsing errors, fall back to default.
76
- }
77
- return OXY_AUTH_WEB_URL;
78
- };
79
-
80
- const resolveAuthRedirectUri = async (authRedirectUri?: string): Promise<string | null> => {
81
- if (authRedirectUri) {
82
- return authRedirectUri;
83
- }
84
-
85
- try {
86
- const initialUrl = await Linking.getInitialURL();
87
- if (!initialUrl) {
88
- return null;
89
- }
90
-
91
- const parsed = new URL(initialUrl);
92
- parsed.search = '';
93
- parsed.hash = '';
94
- return parsed.toString();
95
- } catch {
96
- return null;
97
- }
98
- };
99
-
100
- const getRedirectParams = (url: string): { sessionId?: string; error?: string } | null => {
101
- try {
102
- const parsed = new URL(url);
103
- const sessionId = parsed.searchParams.get('session_id') ?? undefined;
104
- const error = parsed.searchParams.get('error') ?? undefined;
105
-
106
- if (!sessionId && !error) {
107
- return null;
108
- }
109
-
110
- return { sessionId, error };
111
- } catch {
112
- return null;
113
- }
114
- };
115
-
116
- const OxyAuthScreen: React.FC<BaseScreenProps> = ({
117
- navigate,
118
- goBack,
119
- onAuthenticated,
120
- theme,
121
- }) => {
29
+ const OxyAuthScreen: React.FC<BaseScreenProps> = ({ goBack, onAuthenticated }) => {
122
30
  const bloomTheme = useTheme();
123
31
  const { oxyServices, switchSession, clientId } = useOxy();
124
32
 
125
- const [authSession, setAuthSession] = useState<AuthSession | null>(null);
126
- const [isLoading, setIsLoading] = useState(true);
127
- const [error, setError] = useState<string | null>(null);
128
- const [isWaiting, setIsWaiting] = useState(false);
129
- const [connectionType, setConnectionType] = useState<'socket' | 'polling'>('socket');
130
-
131
- const socketRef = useRef<Socket | null>(null);
132
- const pollingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
133
- const isProcessingRef = useRef(false);
134
- const linkingHandledRef = useRef(false);
135
-
136
- // Handle successful authorization.
137
- //
138
- // The auth-session socket / poll (or deep-link return) hands us the
139
- // authorized `sessionId`. Before any session-management code can touch it we
140
- // MUST first exchange the secret `sessionToken` (held only by this client,
141
- // generated for THIS flow) for the first access token via
142
- // `claimSessionByToken` — the device-flow equivalent of OAuth's
143
- // code-for-token exchange (RFC 8628 §3.4).
144
- //
145
- // Without that exchange the SDK has no bearer token — the session is
146
- // authorized server-side but the app never becomes authenticated and the
147
- // sheet sits "Waiting for authorization..." forever. Once
148
- // `claimSessionByToken` plants the tokens in the HttpService, the rest of the
149
- // session wiring flows through the normal `switchSession` path. This mirrors
150
- // `SignInModal`'s web flow exactly.
151
- const handleAuthSuccess = useCallback(async (sessionId: string, sessionToken: string) => {
152
- if (isProcessingRef.current) return;
153
- isProcessingRef.current = true;
154
-
155
- try {
156
- // Claim the first access token with the secret sessionToken, then
157
- // hydrate the session. The claim step is what the native screen was
158
- // previously missing — see `completeDeviceFlowSignIn`. `switchSession`
159
- // is always provided by the context here; the helper requires it.
160
- if (!switchSession) {
161
- throw new Error('Session management unavailable');
162
- }
163
- const user = await completeDeviceFlowSignIn({
164
- oxyServices,
165
- sessionId,
166
- sessionToken,
167
- switchSession,
168
- });
169
- if (onAuthenticated) {
170
- onAuthenticated(user);
171
- }
172
- } catch (err) {
173
- debug.error('Error completing auth:', err);
174
- setError('Authorization successful but failed to complete sign in. Please try again.');
175
- isProcessingRef.current = false;
176
- }
177
- }, [oxyServices, switchSession, onAuthenticated]);
178
-
179
- // Connect to socket for real-time updates
180
- const connectSocket = useCallback((sessionToken: string) => {
181
- const baseURL = oxyServices.getBaseURL();
182
-
183
- // Connect to the auth-session namespace (no authentication required)
184
- const socket = io(`${baseURL}/auth-session`, {
185
- transports: ['websocket', 'polling'],
186
- reconnection: true,
187
- reconnectionAttempts: 3,
188
- reconnectionDelay: 1000,
189
- });
190
-
191
- socketRef.current = socket;
192
-
193
- socket.on('connect', () => {
194
- debug.log('Auth socket connected');
195
- // Join the room for this session token
196
- socket.emit('join', sessionToken);
197
- setConnectionType('socket');
198
- });
199
-
200
- socket.on('joined', () => {
201
- debug.log('Joined auth session room');
202
- });
203
-
204
- socket.on('auth_update', (payload: AuthUpdatePayload) => {
205
- debug.log('Auth update received:', payload);
206
-
207
- if (payload.status === 'authorized' && payload.sessionId) {
208
- cleanup();
209
- // `sessionToken` is this flow's secret credential (in closure) — pass
210
- // it through so `handleAuthSuccess` can claim the first access token.
211
- handleAuthSuccess(payload.sessionId, sessionToken);
212
- } else if (payload.status === 'cancelled') {
213
- cleanup();
214
- setError('Authorization was denied.');
215
- } else if (payload.status === 'expired') {
216
- cleanup();
217
- setError('Session expired. Please try again.');
218
- }
219
- });
220
-
221
- socket.on('connect_error', (err) => {
222
- debug.log('Socket connection error, falling back to polling:', (err instanceof Error ? err.message : null));
223
- // Realtime transport errored — reflect the honest connection state. The
224
- // poll is already running (started unconditionally in
225
- // `generateAuthSession`), so `startPolling` here is a no-op backstop.
226
- socket.disconnect();
227
- setConnectionType('polling');
228
- startPolling(sessionToken);
229
- });
230
-
231
- socket.on('disconnect', () => {
232
- debug.log('Auth socket disconnected');
233
- });
234
- }, [oxyServices, handleAuthSuccess]);
235
-
236
- // Start polling for authorization.
237
- //
238
- // Idempotent: if a poll interval is already running this is a no-op, so the
239
- // `connect_error` path (which also calls this) cannot stack a second interval
240
- // on top of the always-on poll started in `generateAuthSession`.
241
- const startPolling = useCallback((sessionToken: string) => {
242
- if (pollingIntervalRef.current) return;
243
-
244
- pollingIntervalRef.current = setInterval(async () => {
245
- if (isProcessingRef.current) return;
246
-
247
- try {
248
- const response: {
249
- authorized: boolean;
250
- sessionId?: string;
251
- publicKey?: string;
252
- status?: string;
253
- } = await oxyServices.makeRequest('GET', `/auth/session/status/${sessionToken}`, undefined, { cache: false });
254
-
255
- if (response.authorized && response.sessionId) {
256
- cleanup();
257
- // Pass the original sessionToken (in closure) through; the claim
258
- // exchange needs it to mint the first access token.
259
- handleAuthSuccess(response.sessionId, sessionToken);
260
- } else if (response.status === 'cancelled') {
261
- cleanup();
262
- setError('Authorization was denied.');
263
- } else if (response.status === 'expired') {
264
- cleanup();
265
- setError('Session expired. Please try again.');
266
- }
267
- } catch (err) {
268
- // Silent fail for polling - will retry
269
- debug.log('Auth polling error:', err);
270
- }
271
- }, POLLING_INTERVAL_MS);
272
- }, [oxyServices, handleAuthSuccess]);
273
-
274
- // Cleanup socket and polling
275
- const cleanup = useCallback(() => {
276
- setIsWaiting(false);
277
-
278
- if (socketRef.current) {
279
- socketRef.current.disconnect();
280
- socketRef.current = null;
281
- }
282
-
283
- if (pollingIntervalRef.current) {
284
- clearInterval(pollingIntervalRef.current);
285
- pollingIntervalRef.current = null;
286
- }
287
- }, []);
288
-
289
- // Generate a new auth session
290
- const generateAuthSession = useCallback(async () => {
291
- setIsLoading(true);
292
- setError(null);
293
- isProcessingRef.current = false;
294
-
295
- // The cross-app device sign-in flow identifies the requesting app by its
296
- // real registered OAuth client id (ApplicationCredential publicKey).
297
- // Without it the API cannot resolve the consent identity, so we fail fast
298
- // with a clear configuration error rather than creating a session the
299
- // server would reject.
300
- if (!clientId) {
301
- setError('This app is not configured for sign-in (missing clientId).');
302
- setIsLoading(false);
303
- return;
304
- }
305
-
306
- try {
307
- // Generate a unique session token for this auth request
308
- const sessionToken = generateSessionToken();
309
- const expiresAt = Date.now() + AUTH_SESSION_EXPIRY_MS;
310
-
311
- // Register the auth session with the server
312
- await oxyServices.makeRequest('POST', '/auth/session/create', {
313
- sessionToken,
314
- expiresAt,
315
- clientId,
316
- }, { cache: false });
317
-
318
- setAuthSession({ sessionToken, expiresAt });
319
- setIsWaiting(true);
320
-
321
- // Socket is the fast path; the poll is a transport-independent backstop
322
- // that guarantees completion even if the socket connects but silently
323
- // never delivers auth_update (RN transport / idle-timeout).
324
- connectSocket(sessionToken);
325
- startPolling(sessionToken);
326
- } catch (err: unknown) {
327
- setError((err instanceof Error ? err.message : null) || 'Failed to create auth session');
328
- } finally {
329
- setIsLoading(false);
330
- }
331
- }, [oxyServices, connectSocket, startPolling, clientId]);
332
-
333
- // Generate a random session token
334
- const generateSessionToken = (): string => {
335
- const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
336
- let result = '';
337
- for (let i = 0; i < 32; i++) {
338
- result += chars.charAt(Math.floor(Math.random() * chars.length));
339
- }
340
- return result;
341
- };
342
-
343
- // Clean up on unmount
344
- useEffect(() => {
345
- return () => {
346
- cleanup();
347
- };
348
- }, [cleanup]);
349
-
350
- // Initialize auth session
351
- useEffect(() => {
352
- generateAuthSession();
353
- }, []);
354
-
355
- // Check if session expired
356
- useEffect(() => {
357
- if (authSession && Date.now() > authSession.expiresAt) {
358
- cleanup();
359
- setAuthSession(null);
360
- setError('Session expired. Please try again.');
361
- }
362
- }, [authSession, cleanup]);
363
-
364
- // Build the QR code data
365
- const getQRData = (): string => {
366
- if (!authSession) return '';
367
-
368
- // Format: oxyauth://{sessionToken}
369
- return `oxyauth://${authSession.sessionToken}`;
370
- };
371
-
372
- // Open Oxy Auth web flow
373
- const handleOpenAuth = useCallback(async () => {
374
- if (!authSession) return;
375
-
376
- const authBaseUrl = resolveAuthWebBaseUrl(
377
- oxyServices.getBaseURL(),
378
- oxyServices.config?.authWebUrl
379
- );
380
- const webUrl = new URL('/authorize', authBaseUrl);
381
- webUrl.searchParams.set('token', authSession.sessionToken);
382
- const redirectUri = await resolveAuthRedirectUri(oxyServices.config?.authRedirectUri);
383
- if (redirectUri) {
384
- webUrl.searchParams.set('redirect_uri', redirectUri);
385
- }
386
-
387
- try {
388
- await Linking.openURL(webUrl.toString());
389
- } catch (err) {
390
- setError('Unable to open Oxy Auth. Please try again or use the QR code.');
391
- }
392
- }, [authSession, oxyServices]);
393
-
394
- // Refresh session
395
- const handleRefresh = useCallback(() => {
396
- cleanup();
397
- generateAuthSession();
398
- }, [generateAuthSession, cleanup]);
399
-
400
- const handleAuthRedirect = useCallback((url: string) => {
401
- const params = getRedirectParams(url);
402
- if (!params) {
403
- return;
404
- }
405
-
406
- if (params.error) {
407
- cleanup();
408
- setError('Authorization was denied.');
409
- return;
410
- }
411
-
412
- if (params.sessionId) {
413
- // The deep-link return carries only `session_id` — the secret
414
- // `sessionToken` for this flow lives in component state (generated in
415
- // `generateAuthSession`). Without it we cannot claim the first access
416
- // token, so the flow would 401 in `handleAuthSuccess`. If it is somehow
417
- // unavailable, fall through to the socket/poll path (which carries the
418
- // token in closure) rather than attempting an unauthenticated claim.
419
- const flowSessionToken = authSession?.sessionToken;
420
- if (!flowSessionToken) {
421
- return;
422
- }
423
- cleanup();
424
- handleAuthSuccess(params.sessionId, flowSessionToken);
425
- }
426
- }, [authSession, cleanup, handleAuthSuccess]);
427
-
428
- useEffect(() => {
429
- if (Platform.OS === 'web') {
430
- return;
431
- }
432
-
433
- const subscription = Linking.addEventListener('url', ({ url }) => {
434
- linkingHandledRef.current = true;
435
- handleAuthRedirect(url);
436
- });
437
-
438
- Linking.getInitialURL()
439
- .then((url) => {
440
- if (url && !linkingHandledRef.current) {
441
- handleAuthRedirect(url);
442
- }
443
- })
444
- .catch(() => {
445
- // Ignore linking errors; auth will still resolve via socket/polling.
446
- });
447
-
448
- return () => {
449
- subscription.remove();
450
- };
451
- }, [handleAuthRedirect]);
33
+ const { qrData, isLoading, error, isWaiting, openAuthApproval, retry } = useOxyAuthSession(
34
+ oxyServices,
35
+ clientId,
36
+ switchSession,
37
+ { onSignedIn: onAuthenticated },
38
+ );
452
39
 
453
40
  if (isLoading) {
454
41
  return (
@@ -465,7 +52,7 @@ const OxyAuthScreen: React.FC<BaseScreenProps> = ({
465
52
  return (
466
53
  <View style={[styles.container, { backgroundColor: bloomTheme.colors.background }]}>
467
54
  <Text style={styles.errorText} className="text-destructive">{error}</Text>
468
- <Button variant="primary" onPress={handleRefresh} style={{ width: '100%', borderRadius: 12 }}>
55
+ <Button variant="primary" onPress={retry} style={styles.primaryButton}>
469
56
  Try Again
470
57
  </Button>
471
58
  </View>
@@ -474,72 +61,55 @@ const OxyAuthScreen: React.FC<BaseScreenProps> = ({
474
61
 
475
62
  return (
476
63
  <View style={[styles.container, { backgroundColor: bloomTheme.colors.background }]}>
477
- {/* Header */}
64
+ {/* Branded header */}
478
65
  <View style={styles.header}>
479
66
  <OxyLogo variant="icon" size={48} />
480
- <Text style={styles.title} className="text-foreground">Sign in with Oxy</Text>
67
+ <Text style={styles.title} className="text-foreground">Sign in to Oxy</Text>
481
68
  <Text style={styles.subtitle} className="text-muted-foreground">
482
- Use your Oxy identity to sign in securely
69
+ Continue with your Oxy identity to sign in securely
483
70
  </Text>
484
71
  </View>
485
72
 
486
- {/* QR Code */}
487
- <View style={styles.qrContainer} className="bg-secondary border-border">
488
- <View style={styles.qrWrapper}>
489
- <QRCode
490
- value={getQRData()}
491
- size={200}
492
- backgroundColor="white"
493
- color="black"
494
- />
495
- </View>
496
- <Text style={styles.qrHint} className="text-muted-foreground">
497
- Scan with Oxy Accounts app
498
- </Text>
499
- </View>
500
-
501
- {/* Divider */}
502
- <View style={styles.dividerContainer}>
503
- <View style={styles.divider} className="bg-border" />
504
- <Text style={styles.dividerText} className="text-muted-foreground">or</Text>
505
- <View style={styles.divider} className="bg-border" />
506
- </View>
507
-
508
- {/* Open Oxy Auth Button */}
73
+ {/* Primary action — Continue with Oxy */}
509
74
  <Button
510
75
  variant="primary"
511
- onPress={handleOpenAuth}
512
- icon={<OxyLogo variant="icon" size={20} fillColor={bloomTheme.colors.card} style={styles.buttonIcon} />}
513
- style={{ width: '100%', borderRadius: 12 }}
76
+ onPress={openAuthApproval}
77
+ icon={<OxyLogo variant="icon" size={20} fillColor={bloomTheme.colors.primaryForeground} style={styles.buttonIcon} />}
78
+ style={styles.primaryButton}
514
79
  >
515
- Open Oxy Auth
80
+ Continue with Oxy
516
81
  </Button>
517
82
 
518
- {/* Status */}
83
+ {/* Waiting status */}
519
84
  {isWaiting && (
520
85
  <View style={styles.statusContainer}>
521
- <Loading size="small" style={{ flex: undefined }} />
86
+ <Loading size="small" style={styles.statusSpinner} />
522
87
  <Text style={styles.statusText} className="text-muted-foreground">
523
88
  Waiting for authorization...
524
89
  </Text>
525
90
  </View>
526
91
  )}
527
92
 
528
- {/* Footer */}
93
+ {/* Collapsed "sign in on another device" QR disclosure */}
94
+ <View style={styles.qrSection}>
95
+ <AnotherDeviceQR qrData={qrData} />
96
+ </View>
97
+
98
+ {/* Footer — create an account */}
529
99
  <View style={styles.footer}>
530
100
  <Text style={styles.footerText} className="text-muted-foreground">
531
- Don't have Oxy Accounts?{' '}
101
+ Don't have an Oxy account?{' '}
532
102
  </Text>
533
- <TouchableOpacity onPress={() => Linking.openURL(OXY_ACCOUNTS_WEB_URL)}>
103
+ <TouchableOpacity onPress={() => Linking.openURL(OXY_ACCOUNTS_WEB_URL)} accessibilityRole="link">
534
104
  <Text style={styles.footerLink} className="text-primary">
535
- Get it here
105
+ Create one
536
106
  </Text>
537
107
  </TouchableOpacity>
538
108
  </View>
539
109
 
540
- {/* Cancel Button */}
110
+ {/* Cancel */}
541
111
  {goBack && (
542
- <TouchableOpacity style={styles.cancelButton} onPress={goBack}>
112
+ <TouchableOpacity style={styles.cancelButton} onPress={goBack} accessibilityRole="button">
543
113
  <Text style={styles.cancelText} className="text-muted-foreground">Cancel</Text>
544
114
  </TouchableOpacity>
545
115
  )}
@@ -556,46 +126,22 @@ const styles = StyleSheet.create({
556
126
  },
557
127
  header: {
558
128
  alignItems: 'center',
559
- marginBottom: 32,
129
+ marginBottom: 28,
560
130
  },
561
131
  title: {
562
132
  fontSize: 24,
563
133
  fontWeight: 'bold',
564
134
  marginTop: 16,
135
+ textAlign: 'center',
565
136
  },
566
137
  subtitle: {
567
138
  fontSize: 14,
568
139
  marginTop: 8,
569
140
  textAlign: 'center',
570
141
  },
571
- qrContainer: {
572
- padding: 24,
573
- borderRadius: 16,
574
- borderWidth: 1,
575
- alignItems: 'center',
576
- },
577
- qrWrapper: {
578
- padding: 16,
579
- backgroundColor: 'white',
580
- borderRadius: 12,
581
- },
582
- qrHint: {
583
- marginTop: 16,
584
- fontSize: 12,
585
- },
586
- dividerContainer: {
587
- flexDirection: 'row',
588
- alignItems: 'center',
589
- marginVertical: 24,
142
+ primaryButton: {
590
143
  width: '100%',
591
- },
592
- divider: {
593
- flex: 1,
594
- height: 1,
595
- },
596
- dividerText: {
597
- marginHorizontal: 16,
598
- fontSize: 14,
144
+ borderRadius: 12,
599
145
  },
600
146
  buttonIcon: {
601
147
  marginRight: 10,
@@ -603,15 +149,24 @@ const styles = StyleSheet.create({
603
149
  statusContainer: {
604
150
  flexDirection: 'row',
605
151
  alignItems: 'center',
606
- marginTop: 24,
152
+ marginTop: 20,
153
+ },
154
+ statusSpinner: {
155
+ flex: undefined,
607
156
  },
608
157
  statusText: {
609
158
  marginLeft: 8,
610
159
  fontSize: 14,
611
160
  },
161
+ qrSection: {
162
+ width: '100%',
163
+ marginTop: 24,
164
+ },
612
165
  footer: {
613
166
  flexDirection: 'row',
614
- marginTop: 32,
167
+ flexWrap: 'wrap',
168
+ justifyContent: 'center',
169
+ marginTop: 28,
615
170
  },
616
171
  footerText: {
617
172
  fontSize: 14,