@oxyhq/services 10.4.0 → 11.0.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 (39) 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/mutations/useAccountMutations.d.ts.map +1 -1
  22. package/lib/typescript/commonjs/ui/hooks/useOxyAuthSession.d.ts +83 -0
  23. package/lib/typescript/commonjs/ui/hooks/useOxyAuthSession.d.ts.map +1 -0
  24. package/lib/typescript/commonjs/ui/screens/OxyAuthScreen.d.ts +12 -8
  25. package/lib/typescript/commonjs/ui/screens/OxyAuthScreen.d.ts.map +1 -1
  26. package/lib/typescript/module/ui/components/AnotherDeviceQR.d.ts +24 -0
  27. package/lib/typescript/module/ui/components/AnotherDeviceQR.d.ts.map +1 -0
  28. package/lib/typescript/module/ui/components/SignInModal.d.ts +10 -5
  29. package/lib/typescript/module/ui/components/SignInModal.d.ts.map +1 -1
  30. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  31. package/lib/typescript/module/ui/hooks/useOxyAuthSession.d.ts +83 -0
  32. package/lib/typescript/module/ui/hooks/useOxyAuthSession.d.ts.map +1 -0
  33. package/lib/typescript/module/ui/screens/OxyAuthScreen.d.ts +12 -8
  34. package/lib/typescript/module/ui/screens/OxyAuthScreen.d.ts.map +1 -1
  35. package/package.json +21 -33
  36. package/src/ui/components/AnotherDeviceQR.tsx +111 -0
  37. package/src/ui/components/SignInModal.tsx +142 -381
  38. package/src/ui/hooks/useOxyAuthSession.ts +556 -0
  39. package/src/ui/screens/OxyAuthScreen.tsx +61 -506
@@ -1,64 +1,35 @@
1
1
  /**
2
- * SignInModal - Full screen sign-in modal with QR code
2
+ * SignInModal - Web-first centered sign-in modal (Continue with Oxy)
3
3
  *
4
- * A semi-transparent full-screen modal that displays:
5
- * - QR code for scanning with Oxy Accounts app
6
- * - Button to open the Oxy Auth approval page
4
+ * A semi-transparent full-screen modal whose primary action is the one-tap
5
+ * "Continue with Oxy" approval flow. The QR code is demoted to a collapsed
6
+ * "Sign in on another device" disclosure (you can't scan your own screen).
7
7
  *
8
- * Animates with fade-in effect.
8
+ * ALL of the auth-session machinery (session-token creation, QR data, socket +
9
+ * polling, waiting/error/retry state, the open-auth handler, deep-link return,
10
+ * and cleanup) lives in the shared `useOxyAuthSession` hook, which the native
11
+ * `OxyAuthScreen` also consumes — neither container re-implements the transport.
12
+ *
13
+ * Animates with a fade + scale effect.
9
14
  */
10
15
 
11
16
  import type React from 'react';
12
- import { useState, useEffect, useCallback, useRef } from 'react';
13
- import {
14
- View,
15
- Text,
16
- TouchableOpacity,
17
- StyleSheet,
18
- Modal,
19
- Dimensions,
20
- Platform,
21
- Linking,
22
- } from 'react-native';
17
+ import { useState, useEffect, useCallback } from 'react';
18
+ import { View, Text, TouchableOpacity, StyleSheet, Modal } from 'react-native';
23
19
  import Animated, {
24
20
  useSharedValue,
25
21
  useAnimatedStyle,
26
22
  withTiming,
27
- runOnJS,
28
23
  } from 'react-native-reanimated';
29
24
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
30
- import io, { type Socket } from 'socket.io-client';
31
- import QRCode from 'react-native-qrcode-svg';
32
25
  import { useTheme } from '@oxyhq/bloom/theme';
33
26
  import { Button } from '@oxyhq/bloom/button';
34
27
  import { Loading } from '@oxyhq/bloom/loading';
28
+ import { Linking } from 'react-native';
35
29
  import { useOxy } from '../context/OxyContext';
36
30
  import OxyLogo from './OxyLogo';
37
- import { createDebugLogger } from '@oxyhq/core';
38
- import { completeDeviceFlowSignIn } from '../../utils/deviceFlowSignIn';
39
-
40
- const debug = createDebugLogger('SignInModal');
41
-
42
- const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
43
-
44
- // Auth session expiration (5 minutes)
45
- const AUTH_SESSION_EXPIRY_MS = 5 * 60 * 1000;
46
-
47
- // Polling interval (fallback if socket fails)
48
- const POLLING_INTERVAL_MS = 3000;
49
-
50
- interface AuthSession {
51
- sessionToken: string;
52
- expiresAt: number;
53
- }
54
-
55
- interface AuthUpdatePayload {
56
- status: 'authorized' | 'cancelled' | 'expired';
57
- sessionId?: string;
58
- publicKey?: string;
59
- userId?: string;
60
- username?: string;
61
- }
31
+ import AnotherDeviceQR from './AnotherDeviceQR';
32
+ import { useOxyAuthSession, OXY_ACCOUNTS_WEB_URL } from '../hooks/useOxyAuthSession';
62
33
 
63
34
  // Store for modal visibility with subscription support
64
35
  let modalVisible = false;
@@ -87,24 +58,12 @@ export const subscribeToSignInModal = (listener: (visible: boolean) => void): ((
87
58
 
88
59
  const SignInModal: React.FC = () => {
89
60
  const [visible, setVisible] = useState(false);
90
- const [authSession, setAuthSession] = useState<AuthSession | null>(null);
91
- const [isLoading, setIsLoading] = useState(false);
92
- const [error, setError] = useState<string | null>(null);
93
- const [isWaiting, setIsWaiting] = useState(false);
94
61
 
95
62
  const insets = useSafeAreaInsets();
96
63
  const theme = useTheme();
97
64
  const { oxyServices, switchSession, clientId } = useOxy();
98
65
 
99
- const socketRef = useRef<Socket | null>(null);
100
- const pollingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
101
- const isProcessingRef = useRef(false);
102
-
103
- // Animation values
104
- const opacity = useSharedValue(0);
105
- const scale = useSharedValue(0.9);
106
-
107
- // Register callback
66
+ // Register the imperative visibility callback.
108
67
  useEffect(() => {
109
68
  setModalVisibleCallback = setVisible;
110
69
  return () => {
@@ -112,18 +71,54 @@ const SignInModal: React.FC = () => {
112
71
  };
113
72
  }, []);
114
73
 
115
- // Animate in/out
116
- // biome-ignore lint/correctness/useExhaustiveDependencies: opacity and scale are Reanimated SharedValues (stable refs) that should not be listed as dependencies
74
+ if (!visible) return null;
75
+
76
+ // The hook owns the whole auth-session lifecycle. Mounting it only while the
77
+ // modal is visible matches the previous behavior (session created on open,
78
+ // cleaned up on close) — the inner component is unmounted when `visible`
79
+ // flips false, which runs the hook's unmount cleanup.
80
+ return (
81
+ <SignInModalContent
82
+ theme={theme}
83
+ insets={insets}
84
+ oxyServices={oxyServices}
85
+ switchSession={switchSession}
86
+ clientId={clientId}
87
+ />
88
+ );
89
+ };
90
+
91
+ interface SignInModalContentProps {
92
+ theme: ReturnType<typeof useTheme>;
93
+ insets: ReturnType<typeof useSafeAreaInsets>;
94
+ oxyServices: ReturnType<typeof useOxy>['oxyServices'];
95
+ switchSession: ReturnType<typeof useOxy>['switchSession'];
96
+ clientId: ReturnType<typeof useOxy>['clientId'];
97
+ }
98
+
99
+ const SignInModalContent: React.FC<SignInModalContentProps> = ({
100
+ theme,
101
+ insets,
102
+ oxyServices,
103
+ switchSession,
104
+ clientId,
105
+ }) => {
106
+ const { qrData, isLoading, error, isWaiting, openAuthApproval, retry } = useOxyAuthSession(
107
+ oxyServices,
108
+ clientId,
109
+ switchSession,
110
+ { onSignedIn: hideSignInModal },
111
+ );
112
+
113
+ // Entrance animation.
114
+ const opacity = useSharedValue(0);
115
+ const scale = useSharedValue(0.96);
116
+
117
+ // biome-ignore lint/correctness/useExhaustiveDependencies: opacity/scale are Reanimated SharedValues (stable refs) and must not be listed as deps; this runs once on mount to play the entrance.
117
118
  useEffect(() => {
118
- if (visible) {
119
- opacity.value = withTiming(1, { duration: 250 });
120
- scale.value = withTiming(1, { duration: 250 });
121
- generateAuthSession();
122
- } else {
123
- opacity.value = withTiming(0, { duration: 200 });
124
- scale.value = withTiming(0.9, { duration: 200 });
125
- }
126
- }, [visible]);
119
+ opacity.value = withTiming(1, { duration: 250 });
120
+ scale.value = withTiming(1, { duration: 250 });
121
+ }, []);
127
122
 
128
123
  const backdropStyle = useAnimatedStyle(() => ({
129
124
  opacity: opacity.value,
@@ -134,282 +129,42 @@ const SignInModal: React.FC = () => {
134
129
  transform: [{ scale: scale.value }],
135
130
  }));
136
131
 
137
- // Handle successful authorization.
138
- //
139
- // The auth-session socket (or fallback poll) hands us the new
140
- // `sessionId`. Before any session-management code can touch it we
141
- // MUST first exchange the secret `sessionToken` (held only by this
142
- // client) for the first access token via `claimSessionByToken` —
143
- // this is the device-flow equivalent of OAuth's code-for-token
144
- // exchange (RFC 8628 §3.4).
145
- //
146
- // Without that exchange the SDK has no bearer token and the app never
147
- // becomes authenticated even though the session is authorized server-side.
148
- // Once `claimSessionByToken` plants the tokens in the HttpService, the rest
149
- // of the session wiring (state, persistence, language preference) flows
150
- // through the normal `switchSession` path.
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. Shared with the native `OxyAuthScreen` via
158
- // `completeDeviceFlowSignIn` so the two paths cannot drift.
159
- if (!switchSession) {
160
- throw new Error('Session management unavailable');
161
- }
162
- await completeDeviceFlowSignIn({
163
- oxyServices,
164
- sessionId,
165
- sessionToken,
166
- switchSession,
167
- });
168
-
169
- hideSignInModal();
170
- } catch (err) {
171
- debug.error('Error completing auth:', err);
172
- setError('Authorization successful but failed to complete sign in. Please try again.');
173
- isProcessingRef.current = false;
174
- }
175
- }, [oxyServices, switchSession]);
176
-
177
- // Cleanup socket and polling
178
- const cleanup = useCallback(() => {
179
- setIsWaiting(false);
180
-
181
- if (socketRef.current) {
182
- socketRef.current.disconnect();
183
- socketRef.current = null;
184
- }
185
-
186
- if (pollingIntervalRef.current) {
187
- clearInterval(pollingIntervalRef.current);
188
- pollingIntervalRef.current = null;
189
- }
190
- }, []);
191
-
192
- // Connect to socket for real-time updates
193
- const connectSocket = useCallback((sessionToken: string) => {
194
- const baseURL = oxyServices.getBaseURL();
195
-
196
- const socket = io(`${baseURL}/auth-session`, {
197
- transports: ['websocket', 'polling'],
198
- reconnection: true,
199
- reconnectionAttempts: 3,
200
- reconnectionDelay: 1000,
201
- });
202
-
203
- socketRef.current = socket;
204
-
205
- socket.on('connect', () => {
206
- debug.log('Auth socket connected');
207
- socket.emit('join', sessionToken);
208
- });
209
-
210
- socket.on('auth_update', (payload: AuthUpdatePayload) => {
211
- debug.log('Auth update received:', payload);
212
-
213
- if (payload.status === 'authorized' && payload.sessionId) {
214
- cleanup();
215
- // `sessionToken` was generated by this client and is in scope
216
- // via the `sessionToken` arg — pass it through so the claim
217
- // call can exchange it for the first access token.
218
- handleAuthSuccess(payload.sessionId, sessionToken);
219
- } else if (payload.status === 'cancelled') {
220
- cleanup();
221
- setError('Authorization was denied.');
222
- } else if (payload.status === 'expired') {
223
- cleanup();
224
- setError('Session expired. Please try again.');
225
- }
226
- });
227
-
228
- socket.on('connect_error', (err) => {
229
- debug.log('Socket connection error, falling back to polling:', (err instanceof Error ? err.message : null));
230
- socket.disconnect();
231
- startPolling(sessionToken);
232
- });
233
- }, [oxyServices, handleAuthSuccess, cleanup]);
234
-
235
- // Start polling for authorization.
236
- //
237
- // Idempotent: if a poll interval is already running this is a no-op, so the
238
- // `connect_error` path (which also calls this) cannot stack a second
239
- // interval on top of the always-on poll started in `generateAuthSession`.
240
- const startPolling = useCallback((sessionToken: string) => {
241
- if (pollingIntervalRef.current) return;
242
-
243
- pollingIntervalRef.current = setInterval(async () => {
244
- if (isProcessingRef.current) return;
245
-
246
- try {
247
- const response: {
248
- authorized: boolean;
249
- sessionId?: string;
250
- status?: string;
251
- } = await oxyServices.makeRequest('GET', `/auth/session/status/${sessionToken}`, undefined, { cache: false });
252
-
253
- if (response.authorized && response.sessionId) {
254
- cleanup();
255
- // Pass the original sessionToken through; the claim
256
- // exchange needs it to mint the first access token.
257
- handleAuthSuccess(response.sessionId, sessionToken);
258
- } else if (response.status === 'cancelled') {
259
- cleanup();
260
- setError('Authorization was denied.');
261
- } else if (response.status === 'expired') {
262
- cleanup();
263
- setError('Session expired. Please try again.');
264
- }
265
- } catch (err) {
266
- debug.log('Auth polling error:', err);
267
- }
268
- }, POLLING_INTERVAL_MS);
269
- }, [oxyServices, handleAuthSuccess, cleanup]);
270
-
271
- // Generate a new auth session
272
- const generateAuthSession = useCallback(async () => {
273
- setIsLoading(true);
274
- setError(null);
275
- isProcessingRef.current = false;
276
-
277
- // The cross-app device sign-in flow identifies the requesting app by its
278
- // real registered OAuth client id (ApplicationCredential publicKey).
279
- // Without it the API cannot resolve the consent identity, so we fail
280
- // fast with a clear configuration error rather than creating a session
281
- // the server would reject.
282
- if (!clientId) {
283
- setError('This app is not configured for sign-in (missing clientId).');
284
- setIsLoading(false);
285
- return;
286
- }
287
-
288
- try {
289
- const sessionToken = generateSessionToken();
290
- const expiresAt = Date.now() + AUTH_SESSION_EXPIRY_MS;
291
-
292
- await oxyServices.makeRequest('POST', '/auth/session/create', {
293
- sessionToken,
294
- expiresAt,
295
- clientId,
296
- }, { cache: false });
297
-
298
- setAuthSession({ sessionToken, expiresAt });
299
- setIsWaiting(true);
300
- // Socket is the fast path; the poll is a transport-independent
301
- // backstop that guarantees completion even if the socket connects
302
- // but silently never delivers auth_update (RN transport /
303
- // idle-timeout).
304
- connectSocket(sessionToken);
305
- startPolling(sessionToken);
306
- } catch (err: unknown) {
307
- setError((err instanceof Error ? err.message : null) || 'Failed to create auth session');
308
- } finally {
309
- setIsLoading(false);
310
- }
311
- }, [oxyServices, connectSocket, startPolling, clientId]);
312
-
313
- // Generate a cryptographically random session token.
314
- // 16 random bytes -> 32 hex chars (128 bits of entropy) — unguessable.
315
- // crypto.getRandomValues is guaranteed available because importing
316
- // @oxyhq/core installs a polyfill via expo-crypto on React Native.
317
- const generateSessionToken = (): string => {
318
- const bytes = new Uint8Array(16);
319
- crypto.getRandomValues(bytes);
320
- return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
321
- };
322
-
323
- // Build the QR code data
324
- const getQRData = (): string => {
325
- if (!authSession) return '';
326
- return `oxyauth://${authSession.sessionToken}`;
327
- };
328
-
329
- // Open Oxy Auth approval page for this device-flow session.
330
- const handleOpenAuthApproval = useCallback(async () => {
331
- if (!authSession) return;
332
-
333
- const baseURL = oxyServices.getBaseURL();
334
- // Resolve auth web URL
335
- let authWebUrl = oxyServices.config?.authWebUrl;
336
- if (!authWebUrl) {
337
- try {
338
- const url = new URL(baseURL);
339
- if (url.port === '3001') {
340
- url.port = '3002';
341
- authWebUrl = url.origin;
342
- } else if (url.hostname.startsWith('api.')) {
343
- url.hostname = `auth.${url.hostname.slice(4)}`;
344
- authWebUrl = url.origin;
345
- }
346
- } catch {
347
- authWebUrl = 'https://auth.oxy.so';
348
- }
349
- }
350
- authWebUrl = authWebUrl || 'https://auth.oxy.so';
351
-
352
- const webUrl = new URL('/authorize', authWebUrl);
353
- webUrl.searchParams.set('token', authSession.sessionToken);
354
-
355
- if (Platform.OS === 'web') {
356
- // Open a separate approval window on web for the device-flow token.
357
- const width = 500;
358
- const height = 650;
359
- const screenWidth = window.screen?.width ?? width;
360
- const screenHeight = window.screen?.height ?? height;
361
- const left = (screenWidth - width) / 2;
362
- const top = (screenHeight - height) / 2;
363
-
364
- window.open(
365
- webUrl.toString(),
366
- 'oxy-auth-approval',
367
- `width=${width},height=${height},left=${left},top=${top}`
368
- );
369
- } else {
370
- // Open in browser on native
371
- Linking.openURL(webUrl.toString());
372
- }
373
- }, [authSession, oxyServices]);
374
-
375
- // Refresh session
376
- const handleRefresh = useCallback(() => {
377
- cleanup();
378
- generateAuthSession();
379
- }, [generateAuthSession, cleanup]);
380
-
381
- // Handle close
382
132
  const handleClose = useCallback(() => {
383
- cleanup();
384
133
  hideSignInModal();
385
- }, [cleanup]);
386
-
387
- // Clean up on unmount
388
- useEffect(() => {
389
- return () => {
390
- cleanup();
391
- };
392
- }, [cleanup]);
134
+ }, []);
393
135
 
394
- if (!visible) return null;
136
+ const handleCreateAccount = useCallback(() => {
137
+ Linking.openURL(OXY_ACCOUNTS_WEB_URL);
138
+ }, []);
395
139
 
396
140
  return (
397
- <Modal visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={handleClose}>
141
+ <Modal visible transparent animationType="none" statusBarTranslucent onRequestClose={handleClose}>
398
142
  <Animated.View style={[styles.backdrop, { backgroundColor: theme.colors.overlay }, backdropStyle]}>
399
143
  <TouchableOpacity style={StyleSheet.absoluteFill} onPress={handleClose} activeOpacity={1} />
400
144
 
401
- <Animated.View style={[styles.content, contentStyle, { paddingTop: insets.top + 20, paddingBottom: insets.bottom + 20 }]}>
145
+ <Animated.View
146
+ style={[
147
+ styles.card,
148
+ { backgroundColor: theme.colors.card, paddingTop: insets.top + 24, paddingBottom: insets.bottom + 24 },
149
+ contentStyle,
150
+ ]}
151
+ >
402
152
  {/* Close button */}
403
- <TouchableOpacity style={styles.closeButton} onPress={handleClose}>
404
- <Text style={styles.closeButtonText}>×</Text>
153
+ <TouchableOpacity
154
+ style={[styles.closeButton, { backgroundColor: theme.colors.backgroundSecondary }]}
155
+ onPress={handleClose}
156
+ accessibilityRole="button"
157
+ accessibilityLabel="Close sign in"
158
+ >
159
+ <Text style={[styles.closeButtonText, { color: theme.colors.textSecondary }]}>×</Text>
405
160
  </TouchableOpacity>
406
161
 
407
- {/* Header */}
162
+ {/* Branded header */}
408
163
  <View style={styles.header}>
409
164
  <OxyLogo variant="icon" size={56} />
410
- <Text className="text-foreground" style={styles.title}>Sign in with Oxy</Text>
165
+ <Text className="text-foreground" style={styles.title}>Sign in to Oxy</Text>
411
166
  <Text className="text-muted-foreground" style={styles.subtitle}>
412
- Scan with Oxy Accounts app or use the button below
167
+ Continue with your Oxy identity to sign in securely
413
168
  </Text>
414
169
  </View>
415
170
 
@@ -423,50 +178,48 @@ const SignInModal: React.FC = () => {
423
178
  ) : error ? (
424
179
  <View style={styles.errorContainer}>
425
180
  <Text className="text-destructive" style={styles.errorText}>{error}</Text>
426
- <Button onPress={handleRefresh}>Try Again</Button>
181
+ <Button variant="primary" onPress={retry} style={styles.primaryButton}>Try Again</Button>
427
182
  </View>
428
183
  ) : (
429
184
  <>
430
- {/* QR Code */}
431
- <View style={[styles.qrContainer, { backgroundColor: 'white' }]}>
432
- {authSession ? (
433
- <QRCode
434
- value={getQRData()}
435
- size={200}
436
- backgroundColor="white"
437
- color="black"
438
- />
439
- ) : (
440
- <Loading size="large" />
441
- )}
442
- </View>
443
-
444
- {/* Divider */}
445
- <View style={styles.dividerContainer}>
446
- <View style={[styles.divider, { backgroundColor: 'rgba(255,255,255,0.3)' }]} />
447
- <Text style={[styles.dividerText, { color: 'rgba(255,255,255,0.7)' }]}>or</Text>
448
- <View style={[styles.divider, { backgroundColor: 'rgba(255,255,255,0.3)' }]} />
449
- </View>
450
-
451
- {/* Open approval window */}
185
+ {/* Primary action — Continue with Oxy */}
452
186
  <Button
453
- onPress={handleOpenAuthApproval}
454
- icon={<OxyLogo variant="icon" size={20} fillColor={theme.colors.card} />}
187
+ variant="primary"
188
+ onPress={openAuthApproval}
189
+ icon={<OxyLogo variant="icon" size={20} fillColor={theme.colors.primaryForeground} style={styles.buttonIcon} />}
190
+ style={styles.primaryButton}
455
191
  >
456
- Open Oxy Auth
192
+ Continue with Oxy
457
193
  </Button>
458
194
 
459
- {/* Status */}
195
+ {/* Waiting status */}
460
196
  {isWaiting && (
461
197
  <View style={styles.statusContainer}>
462
- <Loading size="small" />
463
- <Text style={styles.statusText}>
198
+ <Loading size="small" style={styles.statusSpinner} />
199
+ <Text style={[styles.statusText, { color: theme.colors.textSecondary }]}>
464
200
  Waiting for authorization...
465
201
  </Text>
466
202
  </View>
467
203
  )}
204
+
205
+ {/* Collapsed "sign in on another device" QR disclosure */}
206
+ <View style={styles.qrSection}>
207
+ <AnotherDeviceQR qrData={qrData} />
208
+ </View>
468
209
  </>
469
210
  )}
211
+
212
+ {/* Footer — create an account */}
213
+ <View style={styles.footer}>
214
+ <Text style={styles.footerText} className="text-muted-foreground">
215
+ Don't have an Oxy account?{' '}
216
+ </Text>
217
+ <TouchableOpacity onPress={handleCreateAccount} accessibilityRole="link">
218
+ <Text style={styles.footerLink} className="text-primary">
219
+ Create one
220
+ </Text>
221
+ </TouchableOpacity>
222
+ </View>
470
223
  </Animated.View>
471
224
  </Animated.View>
472
225
  </Modal>
@@ -479,11 +232,12 @@ const styles = StyleSheet.create({
479
232
  justifyContent: 'center',
480
233
  alignItems: 'center',
481
234
  },
482
- content: {
235
+ card: {
483
236
  width: '100%',
484
237
  maxWidth: 400,
485
238
  alignItems: 'center',
486
239
  paddingHorizontal: 24,
240
+ borderRadius: 24,
487
241
  },
488
242
  closeButton: {
489
243
  position: 'absolute',
@@ -492,58 +246,52 @@ const styles = StyleSheet.create({
492
246
  width: 40,
493
247
  height: 40,
494
248
  borderRadius: 20,
495
- backgroundColor: 'rgba(255, 255, 255, 0.1)',
496
249
  justifyContent: 'center',
497
250
  alignItems: 'center',
498
251
  zIndex: 10,
499
252
  },
500
253
  closeButtonText: {
501
- color: 'white',
502
254
  fontSize: 28,
503
255
  fontWeight: '300',
504
256
  lineHeight: 32,
505
257
  },
506
258
  header: {
507
259
  alignItems: 'center',
508
- marginBottom: 32,
260
+ marginBottom: 28,
509
261
  },
510
262
  title: {
511
- fontSize: 28,
263
+ fontSize: 26,
512
264
  fontWeight: 'bold',
513
265
  marginTop: 16,
266
+ textAlign: 'center',
514
267
  },
515
268
  subtitle: {
516
269
  fontSize: 15,
517
270
  marginTop: 8,
518
271
  textAlign: 'center',
519
272
  },
520
- qrContainer: {
521
- padding: 20,
522
- borderRadius: 16,
523
- },
524
- dividerContainer: {
525
- flexDirection: 'row',
526
- alignItems: 'center',
527
- marginVertical: 24,
273
+ primaryButton: {
528
274
  width: '100%',
275
+ borderRadius: 12,
529
276
  },
530
- divider: {
531
- flex: 1,
532
- height: 1,
533
- },
534
- dividerText: {
535
- marginHorizontal: 16,
536
- fontSize: 14,
277
+ buttonIcon: {
278
+ marginRight: 10,
537
279
  },
538
280
  statusContainer: {
539
281
  flexDirection: 'row',
540
282
  alignItems: 'center',
541
- marginTop: 24,
283
+ marginTop: 20,
284
+ },
285
+ statusSpinner: {
286
+ flex: undefined,
542
287
  },
543
288
  statusText: {
544
289
  marginLeft: 8,
545
290
  fontSize: 14,
546
- color: 'rgba(255, 255, 255, 0.7)',
291
+ },
292
+ qrSection: {
293
+ width: '100%',
294
+ marginTop: 24,
547
295
  },
548
296
  loadingContainer: {
549
297
  alignItems: 'center',
@@ -563,6 +311,19 @@ const styles = StyleSheet.create({
563
311
  textAlign: 'center',
564
312
  marginBottom: 16,
565
313
  },
314
+ footer: {
315
+ flexDirection: 'row',
316
+ flexWrap: 'wrap',
317
+ justifyContent: 'center',
318
+ marginTop: 28,
319
+ },
320
+ footerText: {
321
+ fontSize: 14,
322
+ },
323
+ footerLink: {
324
+ fontSize: 14,
325
+ fontWeight: '600',
326
+ },
566
327
  });
567
328
 
568
329
  export default SignInModal;