@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.
- 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/mutations/useAccountMutations.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/mutations/useAccountMutations.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 +21 -33
- 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,454 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* OxyAuthScreen - Sign in with Oxy
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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 {
|
|
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
|
|
33
|
-
import {
|
|
26
|
+
import AnotherDeviceQR from '../components/AnotherDeviceQR';
|
|
27
|
+
import { useOxyAuthSession, OXY_ACCOUNTS_WEB_URL } from '../hooks/useOxyAuthSession';
|
|
34
28
|
|
|
35
|
-
const
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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={
|
|
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
|
-
{/*
|
|
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
|
|
67
|
+
<Text style={styles.title} className="text-foreground">Sign in to Oxy</Text>
|
|
481
68
|
<Text style={styles.subtitle} className="text-muted-foreground">
|
|
482
|
-
|
|
69
|
+
Continue with your Oxy identity to sign in securely
|
|
483
70
|
</Text>
|
|
484
71
|
</View>
|
|
485
72
|
|
|
486
|
-
{/*
|
|
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={
|
|
512
|
-
icon={<OxyLogo variant="icon" size={20} fillColor={bloomTheme.colors.
|
|
513
|
-
style={
|
|
76
|
+
onPress={openAuthApproval}
|
|
77
|
+
icon={<OxyLogo variant="icon" size={20} fillColor={bloomTheme.colors.primaryForeground} style={styles.buttonIcon} />}
|
|
78
|
+
style={styles.primaryButton}
|
|
514
79
|
>
|
|
515
|
-
|
|
80
|
+
Continue with Oxy
|
|
516
81
|
</Button>
|
|
517
82
|
|
|
518
|
-
{/*
|
|
83
|
+
{/* Waiting status */}
|
|
519
84
|
{isWaiting && (
|
|
520
85
|
<View style={styles.statusContainer}>
|
|
521
|
-
<Loading size="small" style={
|
|
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
|
-
{/*
|
|
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
|
|
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
|
-
|
|
105
|
+
Create one
|
|
536
106
|
</Text>
|
|
537
107
|
</TouchableOpacity>
|
|
538
108
|
</View>
|
|
539
109
|
|
|
540
|
-
{/* Cancel
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
167
|
+
flexWrap: 'wrap',
|
|
168
|
+
justifyContent: 'center',
|
|
169
|
+
marginTop: 28,
|
|
615
170
|
},
|
|
616
171
|
footerText: {
|
|
617
172
|
fontSize: 14,
|