@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.
- package/lib/commonjs/ui/components/AnotherDeviceQR.js +121 -0
- package/lib/commonjs/ui/components/AnotherDeviceQR.js.map +1 -0
- package/lib/commonjs/ui/components/SignInModal.js +145 -356
- package/lib/commonjs/ui/components/SignInModal.js.map +1 -1
- package/lib/commonjs/ui/hooks/useOxyAuthSession.js +458 -0
- package/lib/commonjs/ui/hooks/useOxyAuthSession.js.map +1 -0
- package/lib/commonjs/ui/screens/OxyAuthScreen.js +62 -460
- package/lib/commonjs/ui/screens/OxyAuthScreen.js.map +1 -1
- package/lib/module/ui/components/AnotherDeviceQR.js +116 -0
- package/lib/module/ui/components/AnotherDeviceQR.js.map +1 -0
- package/lib/module/ui/components/SignInModal.js +149 -358
- package/lib/module/ui/components/SignInModal.js.map +1 -1
- package/lib/module/ui/hooks/useOxyAuthSession.js +452 -0
- package/lib/module/ui/hooks/useOxyAuthSession.js.map +1 -0
- package/lib/module/ui/screens/OxyAuthScreen.js +62 -460
- package/lib/module/ui/screens/OxyAuthScreen.js.map +1 -1
- package/lib/typescript/commonjs/ui/components/AnotherDeviceQR.d.ts +24 -0
- package/lib/typescript/commonjs/ui/components/AnotherDeviceQR.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/SignInModal.d.ts +10 -5
- package/lib/typescript/commonjs/ui/components/SignInModal.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/useOxyAuthSession.d.ts +83 -0
- package/lib/typescript/commonjs/ui/hooks/useOxyAuthSession.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/screens/OxyAuthScreen.d.ts +12 -8
- package/lib/typescript/commonjs/ui/screens/OxyAuthScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/AnotherDeviceQR.d.ts +24 -0
- package/lib/typescript/module/ui/components/AnotherDeviceQR.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/SignInModal.d.ts +10 -5
- package/lib/typescript/module/ui/components/SignInModal.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/useOxyAuthSession.d.ts +83 -0
- package/lib/typescript/module/ui/hooks/useOxyAuthSession.d.ts.map +1 -0
- package/lib/typescript/module/ui/screens/OxyAuthScreen.d.ts +12 -8
- package/lib/typescript/module/ui/screens/OxyAuthScreen.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/ui/components/AnotherDeviceQR.tsx +111 -0
- package/src/ui/components/SignInModal.tsx +142 -381
- package/src/ui/hooks/useOxyAuthSession.ts +556 -0
- package/src/ui/screens/OxyAuthScreen.tsx +61 -506
|
@@ -1,64 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SignInModal -
|
|
2
|
+
* SignInModal - Web-first centered sign-in modal (Continue with Oxy)
|
|
3
3
|
*
|
|
4
|
-
* A semi-transparent full-screen modal
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
|
38
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
}, [
|
|
386
|
-
|
|
387
|
-
// Clean up on unmount
|
|
388
|
-
useEffect(() => {
|
|
389
|
-
return () => {
|
|
390
|
-
cleanup();
|
|
391
|
-
};
|
|
392
|
-
}, [cleanup]);
|
|
134
|
+
}, []);
|
|
393
135
|
|
|
394
|
-
|
|
136
|
+
const handleCreateAccount = useCallback(() => {
|
|
137
|
+
Linking.openURL(OXY_ACCOUNTS_WEB_URL);
|
|
138
|
+
}, []);
|
|
395
139
|
|
|
396
140
|
return (
|
|
397
|
-
<Modal visible
|
|
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
|
|
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
|
|
404
|
-
|
|
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
|
-
{/*
|
|
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
|
|
165
|
+
<Text className="text-foreground" style={styles.title}>Sign in to Oxy</Text>
|
|
411
166
|
<Text className="text-muted-foreground" style={styles.subtitle}>
|
|
412
|
-
|
|
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={
|
|
181
|
+
<Button variant="primary" onPress={retry} style={styles.primaryButton}>Try Again</Button>
|
|
427
182
|
</View>
|
|
428
183
|
) : (
|
|
429
184
|
<>
|
|
430
|
-
{/*
|
|
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
|
-
|
|
454
|
-
|
|
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
|
-
|
|
192
|
+
Continue with Oxy
|
|
457
193
|
</Button>
|
|
458
194
|
|
|
459
|
-
{/*
|
|
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
|
-
|
|
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:
|
|
260
|
+
marginBottom: 28,
|
|
509
261
|
},
|
|
510
262
|
title: {
|
|
511
|
-
fontSize:
|
|
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
|
-
|
|
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
|
-
|
|
531
|
-
|
|
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:
|
|
283
|
+
marginTop: 20,
|
|
284
|
+
},
|
|
285
|
+
statusSpinner: {
|
|
286
|
+
flex: undefined,
|
|
542
287
|
},
|
|
543
288
|
statusText: {
|
|
544
289
|
marginLeft: 8,
|
|
545
290
|
fontSize: 14,
|
|
546
|
-
|
|
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;
|