@sparkvault/sdk-mobile 0.1.3
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/README.md +186 -0
- package/dist/auth.d.ts +74 -0
- package/dist/auth.js +515 -0
- package/dist/auth.js.map +1 -0
- package/dist/billing.d.ts +21 -0
- package/dist/billing.js +10 -0
- package/dist/billing.js.map +1 -0
- package/dist/client.d.ts +27 -0
- package/dist/client.js +36 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +39 -0
- package/dist/config.js +69 -0
- package/dist/config.js.map +1 -0
- package/dist/encoding.d.ts +7 -0
- package/dist/encoding.js +113 -0
- package/dist/encoding.js.map +1 -0
- package/dist/entropy.d.ts +18 -0
- package/dist/entropy.js +35 -0
- package/dist/entropy.js.map +1 -0
- package/dist/errors.d.ts +46 -0
- package/dist/errors.js +72 -0
- package/dist/errors.js.map +1 -0
- package/dist/folders.d.ts +15 -0
- package/dist/folders.js +54 -0
- package/dist/folders.js.map +1 -0
- package/dist/health.d.ts +19 -0
- package/dist/health.js +60 -0
- package/dist/health.js.map +1 -0
- package/dist/http.d.ts +45 -0
- package/dist/http.js +351 -0
- package/dist/http.js.map +1 -0
- package/dist/identity-dialog.d.ts +19 -0
- package/dist/identity-dialog.js +656 -0
- package/dist/identity-dialog.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/ingots.d.ts +99 -0
- package/dist/ingots.js +365 -0
- package/dist/ingots.js.map +1 -0
- package/dist/mutex.d.ts +6 -0
- package/dist/mutex.js +20 -0
- package/dist/mutex.js.map +1 -0
- package/dist/push-tokens.d.ts +12 -0
- package/dist/push-tokens.js +15 -0
- package/dist/push-tokens.js.map +1 -0
- package/dist/sparks.d.ts +39 -0
- package/dist/sparks.js +32 -0
- package/dist/sparks.js.map +1 -0
- package/dist/tus.d.ts +24 -0
- package/dist/tus.js +202 -0
- package/dist/tus.js.map +1 -0
- package/dist/types.d.ts +406 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/validation.d.ts +5 -0
- package/dist/validation.js +45 -0
- package/dist/validation.js.map +1 -0
- package/dist/vaults.d.ts +94 -0
- package/dist/vaults.js +106 -0
- package/dist/vaults.js.map +1 -0
- package/package.json +58 -0
- package/src/auth.ts +707 -0
- package/src/billing.ts +30 -0
- package/src/client.ts +51 -0
- package/src/config.ts +123 -0
- package/src/encoding.ts +150 -0
- package/src/entropy.ts +56 -0
- package/src/errors.ts +110 -0
- package/src/folders.ts +76 -0
- package/src/health.ts +81 -0
- package/src/http.ts +429 -0
- package/src/identity-dialog.tsx +955 -0
- package/src/index.ts +103 -0
- package/src/ingots.ts +593 -0
- package/src/mutex.ts +26 -0
- package/src/push-tokens.ts +26 -0
- package/src/sparks.ts +73 -0
- package/src/tus.ts +280 -0
- package/src/types.ts +487 -0
- package/src/validation.ts +49 -0
- package/src/vaults.ts +271 -0
|
@@ -0,0 +1,955 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
ActivityIndicator,
|
|
4
|
+
Image,
|
|
5
|
+
KeyboardAvoidingView,
|
|
6
|
+
Modal,
|
|
7
|
+
Platform,
|
|
8
|
+
Pressable,
|
|
9
|
+
ScrollView,
|
|
10
|
+
StyleSheet,
|
|
11
|
+
Text,
|
|
12
|
+
TextInput,
|
|
13
|
+
View,
|
|
14
|
+
} from 'react-native';
|
|
15
|
+
import type { SparkVaultMobile } from './client.js';
|
|
16
|
+
import type {
|
|
17
|
+
IdentityConfig,
|
|
18
|
+
IdentityJwks,
|
|
19
|
+
IdentityMethodId,
|
|
20
|
+
IdentityType,
|
|
21
|
+
IdentityVerifyResult,
|
|
22
|
+
PasskeyAuthOptions,
|
|
23
|
+
PasskeyCredential,
|
|
24
|
+
} from './types.js';
|
|
25
|
+
|
|
26
|
+
type DialogStep = 'loading' | 'identity' | 'methods' | 'passkey' | 'totp' | 'error';
|
|
27
|
+
type TotpMethod = 'email' | 'sms' | 'voice';
|
|
28
|
+
type SupportedMethodId = 'passkey' | 'totp_email' | 'totp_sms' | 'totp_voice';
|
|
29
|
+
|
|
30
|
+
export interface MobilePasskeyProvider {
|
|
31
|
+
isSupported(): boolean | Promise<boolean>;
|
|
32
|
+
authenticate(options: PasskeyAuthOptions): Promise<PasskeyCredential | null>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SparkVaultIdentityDialogProps {
|
|
36
|
+
client: SparkVaultMobile;
|
|
37
|
+
visible: boolean;
|
|
38
|
+
initialIdentity?: string;
|
|
39
|
+
initialIdentityType?: IdentityType;
|
|
40
|
+
passkeyProvider?: MobilePasskeyProvider;
|
|
41
|
+
onSuccess(result: IdentityVerifyResult & { jwks: IdentityJwks }): void | Promise<void>;
|
|
42
|
+
onCancel(): void;
|
|
43
|
+
onError?(error: Error): void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const SUPPORTED_METHODS = new Set<IdentityMethodId>(['passkey', 'totp_email', 'totp_sms', 'totp_voice']);
|
|
47
|
+
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
48
|
+
const E164_PHONE_REGEX = /^\+[1-9]\d{7,14}$/;
|
|
49
|
+
|
|
50
|
+
export function SparkVaultIdentityDialog({
|
|
51
|
+
client,
|
|
52
|
+
visible,
|
|
53
|
+
initialIdentity,
|
|
54
|
+
initialIdentityType,
|
|
55
|
+
passkeyProvider,
|
|
56
|
+
onSuccess,
|
|
57
|
+
onCancel,
|
|
58
|
+
onError,
|
|
59
|
+
}: SparkVaultIdentityDialogProps) {
|
|
60
|
+
const [step, setStep] = useState<DialogStep>('loading');
|
|
61
|
+
const [config, setConfig] = useState<IdentityConfig | null>(null);
|
|
62
|
+
const [identity, setIdentity] = useState(initialIdentity ?? '');
|
|
63
|
+
const [identityType, setIdentityType] = useState<IdentityType>(initialIdentityType ?? 'email');
|
|
64
|
+
const [selectedMethod, setSelectedMethod] = useState<SupportedMethodId | null>(null);
|
|
65
|
+
const [kindling, setKindling] = useState<string | null>(null);
|
|
66
|
+
const [expiresAt, setExpiresAt] = useState<number | null>(null);
|
|
67
|
+
const [pin, setPin] = useState('');
|
|
68
|
+
const [countdown, setCountdown] = useState('');
|
|
69
|
+
const [isBusy, setIsBusy] = useState(false);
|
|
70
|
+
const [error, setError] = useState('');
|
|
71
|
+
const sessionRef = useRef(0);
|
|
72
|
+
const onSuccessRef = useRef(onSuccess);
|
|
73
|
+
const onCancelRef = useRef(onCancel);
|
|
74
|
+
const onErrorRef = useRef(onError);
|
|
75
|
+
|
|
76
|
+
const branding = config?.branding;
|
|
77
|
+
const effectiveTheme = branding?.themeMode ?? 'light';
|
|
78
|
+
const colors = effectiveTheme === 'dark' ? darkColors : lightColors;
|
|
79
|
+
const allowedTypes = useMemo(() => normalizeAllowedTypes(config), [config]);
|
|
80
|
+
const methodsForIdentity = useMemo(
|
|
81
|
+
() => getMethodsForIdentity(config, identityType, passkeyProvider),
|
|
82
|
+
[config, identityType, passkeyProvider]
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
onSuccessRef.current = onSuccess;
|
|
87
|
+
onCancelRef.current = onCancel;
|
|
88
|
+
onErrorRef.current = onError;
|
|
89
|
+
}, [onCancel, onError, onSuccess]);
|
|
90
|
+
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (!visible) {
|
|
93
|
+
sessionRef.current += 1;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let cancelled = false;
|
|
98
|
+
const sessionId = sessionRef.current + 1;
|
|
99
|
+
sessionRef.current = sessionId;
|
|
100
|
+
setStep('loading');
|
|
101
|
+
setError('');
|
|
102
|
+
setPin('');
|
|
103
|
+
setKindling(null);
|
|
104
|
+
setExpiresAt(null);
|
|
105
|
+
setSelectedMethod(null);
|
|
106
|
+
setIdentity(initialIdentity ?? '');
|
|
107
|
+
setIdentityType(initialIdentityType ?? 'email');
|
|
108
|
+
|
|
109
|
+
client.auth.getConfig()
|
|
110
|
+
.then((nextConfig) => {
|
|
111
|
+
if (cancelled || !isActiveSession(sessionRef, sessionId)) return;
|
|
112
|
+
setConfig(nextConfig);
|
|
113
|
+
setIdentityType(resolveInitialIdentityType(initialIdentity, initialIdentityType, normalizeAllowedTypes(nextConfig)));
|
|
114
|
+
setStep('identity');
|
|
115
|
+
})
|
|
116
|
+
.catch((err: unknown) => {
|
|
117
|
+
if (cancelled || !isActiveSession(sessionRef, sessionId)) return;
|
|
118
|
+
const nextError = asError(err);
|
|
119
|
+
setError(nextError.message);
|
|
120
|
+
setStep('error');
|
|
121
|
+
onErrorRef.current?.(nextError);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
return () => {
|
|
125
|
+
cancelled = true;
|
|
126
|
+
};
|
|
127
|
+
}, [client, initialIdentity, initialIdentityType, visible]);
|
|
128
|
+
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
if (!expiresAt || step !== 'totp') {
|
|
131
|
+
setCountdown('');
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const updateCountdown = () => {
|
|
136
|
+
const remaining = expiresAt - Math.floor(Date.now() / 1000);
|
|
137
|
+
setCountdown(remaining > 0 ? formatCountdown(remaining) : 'Expired');
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
updateCountdown();
|
|
141
|
+
const timer = setInterval(updateCountdown, 1000);
|
|
142
|
+
return () => clearInterval(timer);
|
|
143
|
+
}, [expiresAt, step]);
|
|
144
|
+
|
|
145
|
+
const finishWithToken = useCallback(async (result: IdentityVerifyResult, sessionId: number) => {
|
|
146
|
+
const jwks = await client.auth.getJwks();
|
|
147
|
+
if (!isActiveSession(sessionRef, sessionId)) return;
|
|
148
|
+
await onSuccessRef.current({ ...result, jwks });
|
|
149
|
+
}, [client]);
|
|
150
|
+
|
|
151
|
+
const reportError = useCallback((err: unknown, fallback: string) => {
|
|
152
|
+
const nextError = asError(err, fallback);
|
|
153
|
+
setError(nextError.message);
|
|
154
|
+
onErrorRef.current?.(nextError);
|
|
155
|
+
}, []);
|
|
156
|
+
|
|
157
|
+
const handleCancel = useCallback(() => {
|
|
158
|
+
sessionRef.current += 1;
|
|
159
|
+
setIsBusy(false);
|
|
160
|
+
onCancelRef.current();
|
|
161
|
+
}, []);
|
|
162
|
+
|
|
163
|
+
const sendTotp = useCallback(async (
|
|
164
|
+
method: SupportedMethodId,
|
|
165
|
+
targetIdentity = identity,
|
|
166
|
+
targetIdentityType = identityType
|
|
167
|
+
) => {
|
|
168
|
+
const totpMethod = toTotpMethod(method);
|
|
169
|
+
if (!totpMethod) return;
|
|
170
|
+
|
|
171
|
+
const sessionId = sessionRef.current;
|
|
172
|
+
setIsBusy(true);
|
|
173
|
+
setError('');
|
|
174
|
+
setSelectedMethod(method);
|
|
175
|
+
try {
|
|
176
|
+
const response = await client.auth.sendTotp({
|
|
177
|
+
identity: targetIdentity,
|
|
178
|
+
identityType: targetIdentityType,
|
|
179
|
+
method: totpMethod,
|
|
180
|
+
});
|
|
181
|
+
if (!isActiveSession(sessionRef, sessionId)) return;
|
|
182
|
+
setKindling(response.kindling);
|
|
183
|
+
setExpiresAt(response.expires_at);
|
|
184
|
+
setPin('');
|
|
185
|
+
setStep('totp');
|
|
186
|
+
} catch (err) {
|
|
187
|
+
if (!isActiveSession(sessionRef, sessionId)) return;
|
|
188
|
+
reportError(err, 'Failed to send verification code');
|
|
189
|
+
} finally {
|
|
190
|
+
if (isActiveSession(sessionRef, sessionId)) {
|
|
191
|
+
setIsBusy(false);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}, [client, identity, identityType, reportError]);
|
|
195
|
+
|
|
196
|
+
const runPasskey = useCallback(async (
|
|
197
|
+
targetIdentity = identity,
|
|
198
|
+
targetIdentityType = identityType
|
|
199
|
+
) => {
|
|
200
|
+
if (!passkeyProvider) {
|
|
201
|
+
setError('Passkeys are not available in this app.');
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const sessionId = sessionRef.current;
|
|
206
|
+
setIsBusy(true);
|
|
207
|
+
setError('');
|
|
208
|
+
setSelectedMethod('passkey');
|
|
209
|
+
setStep('passkey');
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
const supported = await passkeyProvider.isSupported();
|
|
213
|
+
if (!supported) {
|
|
214
|
+
throw new Error('Passkeys are not supported on this device.');
|
|
215
|
+
}
|
|
216
|
+
if (!isActiveSession(sessionRef, sessionId)) return;
|
|
217
|
+
|
|
218
|
+
const challenge = await client.auth.getPasskeyAuthOptions(targetIdentity, targetIdentityType);
|
|
219
|
+
if (!isActiveSession(sessionRef, sessionId)) return;
|
|
220
|
+
const credential = await passkeyProvider.authenticate(challenge.options);
|
|
221
|
+
if (!isActiveSession(sessionRef, sessionId)) return;
|
|
222
|
+
if (!credential) {
|
|
223
|
+
throw new Error('Passkey authentication was cancelled.');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const result = await client.auth.completePasskeyAuthentication(credential, challenge.session);
|
|
227
|
+
await finishWithToken(result, sessionId);
|
|
228
|
+
} catch (err) {
|
|
229
|
+
if (!isActiveSession(sessionRef, sessionId)) return;
|
|
230
|
+
reportError(err, 'Passkey authentication failed');
|
|
231
|
+
} finally {
|
|
232
|
+
if (isActiveSession(sessionRef, sessionId)) {
|
|
233
|
+
setIsBusy(false);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}, [client, finishWithToken, identity, identityType, passkeyProvider, reportError]);
|
|
237
|
+
|
|
238
|
+
const handleIdentitySubmit = useCallback(async () => {
|
|
239
|
+
if (isBusy) return;
|
|
240
|
+
|
|
241
|
+
const parsed = parseIdentity(identity, allowedTypes);
|
|
242
|
+
if (!parsed.ok) {
|
|
243
|
+
setError(parsed.error);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const normalizedIdentity = parsed.identity;
|
|
248
|
+
const nextIdentityType = parsed.identityType;
|
|
249
|
+
const availableMethods = getMethodsForIdentity(config, nextIdentityType, passkeyProvider);
|
|
250
|
+
if (availableMethods.length === 0) {
|
|
251
|
+
setError('No supported sign-in methods are enabled for this identity type.');
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
setIdentity(normalizedIdentity);
|
|
256
|
+
setIdentityType(nextIdentityType);
|
|
257
|
+
setError('');
|
|
258
|
+
setIsBusy(true);
|
|
259
|
+
const sessionId = sessionRef.current;
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
if (availableMethods.includes('passkey')) {
|
|
263
|
+
const { hasPasskey } = await client.auth.checkPasskeyStatus(normalizedIdentity, nextIdentityType);
|
|
264
|
+
if (!isActiveSession(sessionRef, sessionId)) return;
|
|
265
|
+
if (hasPasskey) {
|
|
266
|
+
await runPasskey(normalizedIdentity, nextIdentityType);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const totpMethods = availableMethods.filter((method) => method !== 'passkey');
|
|
272
|
+
if (totpMethods.length === 1) {
|
|
273
|
+
await sendTotp(totpMethods[0], normalizedIdentity, nextIdentityType);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
setStep('methods');
|
|
278
|
+
} catch (err) {
|
|
279
|
+
if (!isActiveSession(sessionRef, sessionId)) return;
|
|
280
|
+
reportError(err, 'Failed to load sign-in methods');
|
|
281
|
+
} finally {
|
|
282
|
+
if (isActiveSession(sessionRef, sessionId)) {
|
|
283
|
+
setIsBusy(false);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}, [allowedTypes, client, config, identity, isBusy, passkeyProvider, reportError, runPasskey, sendTotp]);
|
|
287
|
+
|
|
288
|
+
const handleMethodSelect = useCallback(async (method: SupportedMethodId) => {
|
|
289
|
+
if (method === 'passkey') {
|
|
290
|
+
await runPasskey();
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
await sendTotp(method);
|
|
294
|
+
}, [runPasskey, sendTotp]);
|
|
295
|
+
|
|
296
|
+
const handleVerifyTotp = useCallback(async () => {
|
|
297
|
+
if (!kindling || pin.length !== 6) return;
|
|
298
|
+
|
|
299
|
+
const sessionId = sessionRef.current;
|
|
300
|
+
setIsBusy(true);
|
|
301
|
+
setError('');
|
|
302
|
+
try {
|
|
303
|
+
const result = await client.auth.verifyTotp({
|
|
304
|
+
kindling,
|
|
305
|
+
pin,
|
|
306
|
+
recipient: identity,
|
|
307
|
+
});
|
|
308
|
+
await finishWithToken(result, sessionId);
|
|
309
|
+
} catch (err) {
|
|
310
|
+
if (!isActiveSession(sessionRef, sessionId)) return;
|
|
311
|
+
const apiError = err as { data?: { kindling?: string; expires_at?: number } };
|
|
312
|
+
if (apiError.data?.kindling) {
|
|
313
|
+
setKindling(apiError.data.kindling);
|
|
314
|
+
}
|
|
315
|
+
if (apiError.data?.expires_at) {
|
|
316
|
+
setExpiresAt(apiError.data.expires_at);
|
|
317
|
+
}
|
|
318
|
+
setPin('');
|
|
319
|
+
reportError(err, 'Verification failed');
|
|
320
|
+
} finally {
|
|
321
|
+
if (isActiveSession(sessionRef, sessionId)) {
|
|
322
|
+
setIsBusy(false);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}, [client, finishWithToken, identity, kindling, pin, reportError]);
|
|
326
|
+
|
|
327
|
+
const handleResend = useCallback(async () => {
|
|
328
|
+
if (!selectedMethod) return;
|
|
329
|
+
await sendTotp(selectedMethod);
|
|
330
|
+
}, [selectedMethod, sendTotp]);
|
|
331
|
+
|
|
332
|
+
const handleRetryConfig = useCallback(() => {
|
|
333
|
+
const sessionId = sessionRef.current + 1;
|
|
334
|
+
sessionRef.current = sessionId;
|
|
335
|
+
setIsBusy(true);
|
|
336
|
+
setConfig(null);
|
|
337
|
+
setError('');
|
|
338
|
+
setStep('loading');
|
|
339
|
+
|
|
340
|
+
client.auth.getConfig()
|
|
341
|
+
.then((nextConfig) => {
|
|
342
|
+
if (!isActiveSession(sessionRef, sessionId)) return;
|
|
343
|
+
setConfig(nextConfig);
|
|
344
|
+
setIdentityType(resolveInitialIdentityType(initialIdentity, initialIdentityType, normalizeAllowedTypes(nextConfig)));
|
|
345
|
+
setStep('identity');
|
|
346
|
+
})
|
|
347
|
+
.catch((err: unknown) => {
|
|
348
|
+
if (!isActiveSession(sessionRef, sessionId)) return;
|
|
349
|
+
reportError(err, 'Failed to load sign-in options');
|
|
350
|
+
setStep('error');
|
|
351
|
+
})
|
|
352
|
+
.finally(() => {
|
|
353
|
+
if (isActiveSession(sessionRef, sessionId)) {
|
|
354
|
+
setIsBusy(false);
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
}, [client, initialIdentity, initialIdentityType, reportError]);
|
|
358
|
+
|
|
359
|
+
const headerLogo = getHeaderLogo(branding, effectiveTheme);
|
|
360
|
+
const companyName = branding?.companyName || 'SparkVault';
|
|
361
|
+
|
|
362
|
+
return (
|
|
363
|
+
<Modal visible={visible} transparent animationType="fade" onRequestClose={handleCancel}>
|
|
364
|
+
<KeyboardAvoidingView
|
|
365
|
+
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
|
366
|
+
style={[styles.backdrop, { backgroundColor: colors.backdrop }]}
|
|
367
|
+
>
|
|
368
|
+
<ScrollView
|
|
369
|
+
contentContainerStyle={styles.scrollContent}
|
|
370
|
+
keyboardShouldPersistTaps="handled"
|
|
371
|
+
>
|
|
372
|
+
<View style={[styles.card, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
|
373
|
+
<View style={styles.header}>
|
|
374
|
+
<View style={styles.brand}>
|
|
375
|
+
{headerLogo ? (
|
|
376
|
+
<Image source={{ uri: headerLogo }} style={styles.logo} resizeMode="contain" />
|
|
377
|
+
) : (
|
|
378
|
+
<View style={[styles.logoFallback, { backgroundColor: colors.primarySoft }]}>
|
|
379
|
+
<Text style={[styles.logoFallbackText, { color: colors.primary }]}>
|
|
380
|
+
{companyName.slice(0, 1).toUpperCase()}
|
|
381
|
+
</Text>
|
|
382
|
+
</View>
|
|
383
|
+
)}
|
|
384
|
+
<Text style={[styles.companyName, { color: colors.text }]}>{companyName}</Text>
|
|
385
|
+
</View>
|
|
386
|
+
<Pressable
|
|
387
|
+
accessibilityRole="button"
|
|
388
|
+
accessibilityLabel="Close identity verification"
|
|
389
|
+
onPress={handleCancel}
|
|
390
|
+
hitSlop={12}
|
|
391
|
+
style={styles.closeButton}
|
|
392
|
+
>
|
|
393
|
+
<Text style={[styles.closeText, { color: colors.muted }]}>x</Text>
|
|
394
|
+
</Pressable>
|
|
395
|
+
</View>
|
|
396
|
+
|
|
397
|
+
{step === 'loading' && (
|
|
398
|
+
<View style={styles.centerContent}>
|
|
399
|
+
<ActivityIndicator color={colors.primary} />
|
|
400
|
+
<Text style={[styles.mutedText, { color: colors.muted }]}>Loading sign-in options...</Text>
|
|
401
|
+
</View>
|
|
402
|
+
)}
|
|
403
|
+
|
|
404
|
+
{step === 'identity' && (
|
|
405
|
+
<View style={styles.body}>
|
|
406
|
+
<Text style={[styles.title, { color: colors.text }]}>Sign in</Text>
|
|
407
|
+
<Text style={[styles.subtitle, { color: colors.muted }]}>
|
|
408
|
+
{identitySubtitle(allowedTypes)}
|
|
409
|
+
</Text>
|
|
410
|
+
<TextInput
|
|
411
|
+
value={identity}
|
|
412
|
+
onChangeText={(value) => {
|
|
413
|
+
setIdentity(value);
|
|
414
|
+
setError('');
|
|
415
|
+
if (allowedTypes.length > 1) {
|
|
416
|
+
setIdentityType(value.trim().startsWith('+') || /^\d/.test(value.trim()) ? 'phone' : 'email');
|
|
417
|
+
}
|
|
418
|
+
}}
|
|
419
|
+
placeholder={identityPlaceholder(allowedTypes)}
|
|
420
|
+
placeholderTextColor={colors.placeholder}
|
|
421
|
+
autoCapitalize="none"
|
|
422
|
+
autoCorrect={false}
|
|
423
|
+
autoComplete={identityType === 'phone' ? 'tel' : 'email'}
|
|
424
|
+
keyboardType={identityType === 'phone' ? 'phone-pad' : 'email-address'}
|
|
425
|
+
editable={!isBusy}
|
|
426
|
+
style={[
|
|
427
|
+
styles.input,
|
|
428
|
+
{
|
|
429
|
+
borderColor: error ? colors.error : colors.border,
|
|
430
|
+
color: colors.text,
|
|
431
|
+
backgroundColor: colors.input,
|
|
432
|
+
},
|
|
433
|
+
]}
|
|
434
|
+
/>
|
|
435
|
+
{error ? <Text style={[styles.error, { color: colors.error }]}>{error}</Text> : null}
|
|
436
|
+
<PrimaryButton
|
|
437
|
+
label="Continue"
|
|
438
|
+
colors={colors}
|
|
439
|
+
disabled={!identity.trim() || isBusy}
|
|
440
|
+
loading={isBusy}
|
|
441
|
+
onPress={handleIdentitySubmit}
|
|
442
|
+
/>
|
|
443
|
+
</View>
|
|
444
|
+
)}
|
|
445
|
+
|
|
446
|
+
{step === 'methods' && (
|
|
447
|
+
<View style={styles.body}>
|
|
448
|
+
<Text style={[styles.title, { color: colors.text }]}>Choose a method</Text>
|
|
449
|
+
<Text style={[styles.subtitle, { color: colors.muted }]}>{identity}</Text>
|
|
450
|
+
<View style={styles.methodList}>
|
|
451
|
+
{methodsForIdentity.map((method) => (
|
|
452
|
+
<Pressable
|
|
453
|
+
key={method}
|
|
454
|
+
accessibilityRole="button"
|
|
455
|
+
onPress={() => handleMethodSelect(method)}
|
|
456
|
+
disabled={isBusy}
|
|
457
|
+
style={({ pressed }) => [
|
|
458
|
+
styles.methodButton,
|
|
459
|
+
{
|
|
460
|
+
borderColor: colors.border,
|
|
461
|
+
backgroundColor: pressed ? colors.pressed : colors.input,
|
|
462
|
+
opacity: isBusy ? 0.6 : 1,
|
|
463
|
+
},
|
|
464
|
+
]}
|
|
465
|
+
>
|
|
466
|
+
<Text style={[styles.methodTitle, { color: colors.text }]}>{methodTitle(method)}</Text>
|
|
467
|
+
<Text style={[styles.methodDescription, { color: colors.muted }]}>
|
|
468
|
+
{methodDescription(method)}
|
|
469
|
+
</Text>
|
|
470
|
+
</Pressable>
|
|
471
|
+
))}
|
|
472
|
+
</View>
|
|
473
|
+
{error ? <Text style={[styles.error, { color: colors.error }]}>{error}</Text> : null}
|
|
474
|
+
<TextButton label="Use a different identity" colors={colors} onPress={() => setStep('identity')} />
|
|
475
|
+
</View>
|
|
476
|
+
)}
|
|
477
|
+
|
|
478
|
+
{step === 'passkey' && (
|
|
479
|
+
<View style={styles.body}>
|
|
480
|
+
<Text style={[styles.title, { color: colors.text }]}>Use your passkey</Text>
|
|
481
|
+
<Text style={[styles.subtitle, { color: colors.muted }]}>
|
|
482
|
+
Confirm this sign-in with your device passkey.
|
|
483
|
+
</Text>
|
|
484
|
+
<View style={[styles.passkeyIcon, { backgroundColor: colors.primarySoft }]}>
|
|
485
|
+
<Text style={[styles.passkeyIconText, { color: colors.primary }]}>key</Text>
|
|
486
|
+
</View>
|
|
487
|
+
{isBusy ? <ActivityIndicator color={colors.primary} /> : null}
|
|
488
|
+
{error ? <Text style={[styles.error, { color: colors.error }]}>{error}</Text> : null}
|
|
489
|
+
<PrimaryButton
|
|
490
|
+
label="Try Again"
|
|
491
|
+
colors={colors}
|
|
492
|
+
disabled={isBusy}
|
|
493
|
+
loading={isBusy}
|
|
494
|
+
onPress={runPasskey}
|
|
495
|
+
/>
|
|
496
|
+
{fallbackTotpMethod(methodsForIdentity) ? (
|
|
497
|
+
<TextButton
|
|
498
|
+
label={fallbackLabel(fallbackTotpMethod(methodsForIdentity)!)}
|
|
499
|
+
colors={colors}
|
|
500
|
+
onPress={() => sendTotp(fallbackTotpMethod(methodsForIdentity)!)}
|
|
501
|
+
disabled={isBusy}
|
|
502
|
+
/>
|
|
503
|
+
) : null}
|
|
504
|
+
</View>
|
|
505
|
+
)}
|
|
506
|
+
|
|
507
|
+
{step === 'totp' && (
|
|
508
|
+
<View style={styles.body}>
|
|
509
|
+
<Text style={[styles.title, { color: colors.text }]}>{totpTitle(selectedMethod)}</Text>
|
|
510
|
+
<Text style={[styles.subtitle, { color: colors.muted }]}>
|
|
511
|
+
Enter the 6-digit code sent to {'\n'}
|
|
512
|
+
<Text style={{ color: colors.text, fontWeight: '700' }}>{identity}</Text>
|
|
513
|
+
</Text>
|
|
514
|
+
<TextInput
|
|
515
|
+
value={pin}
|
|
516
|
+
onChangeText={(value) => {
|
|
517
|
+
setPin(value.replace(/\D/g, '').slice(0, 6));
|
|
518
|
+
setError('');
|
|
519
|
+
}}
|
|
520
|
+
placeholder="000000"
|
|
521
|
+
placeholderTextColor={colors.placeholder}
|
|
522
|
+
keyboardType="number-pad"
|
|
523
|
+
maxLength={6}
|
|
524
|
+
editable={!isBusy && countdown !== 'Expired'}
|
|
525
|
+
style={[
|
|
526
|
+
styles.pinInput,
|
|
527
|
+
{
|
|
528
|
+
borderColor: error ? colors.error : colors.border,
|
|
529
|
+
color: colors.text,
|
|
530
|
+
backgroundColor: colors.input,
|
|
531
|
+
},
|
|
532
|
+
]}
|
|
533
|
+
/>
|
|
534
|
+
<Text style={[styles.countdown, { color: countdown === 'Expired' ? colors.error : colors.muted }]}>
|
|
535
|
+
{countdown === 'Expired' ? 'Code expired' : `Expires in ${countdown}`}
|
|
536
|
+
</Text>
|
|
537
|
+
{error ? <Text style={[styles.error, { color: colors.error }]}>{error}</Text> : null}
|
|
538
|
+
<PrimaryButton
|
|
539
|
+
label="Verify"
|
|
540
|
+
colors={colors}
|
|
541
|
+
disabled={pin.length !== 6 || isBusy || countdown === 'Expired'}
|
|
542
|
+
loading={isBusy}
|
|
543
|
+
onPress={handleVerifyTotp}
|
|
544
|
+
/>
|
|
545
|
+
<TextButton label="Resend code" colors={colors} onPress={handleResend} disabled={isBusy} />
|
|
546
|
+
</View>
|
|
547
|
+
)}
|
|
548
|
+
|
|
549
|
+
{step === 'error' && (
|
|
550
|
+
<View style={styles.body}>
|
|
551
|
+
<Text style={[styles.title, { color: colors.text }]}>Sign-in unavailable</Text>
|
|
552
|
+
<Text style={[styles.error, { color: colors.error }]}>{error}</Text>
|
|
553
|
+
<PrimaryButton
|
|
554
|
+
label="Try Again"
|
|
555
|
+
colors={colors}
|
|
556
|
+
disabled={isBusy}
|
|
557
|
+
loading={isBusy}
|
|
558
|
+
onPress={handleRetryConfig}
|
|
559
|
+
/>
|
|
560
|
+
</View>
|
|
561
|
+
)}
|
|
562
|
+
</View>
|
|
563
|
+
</ScrollView>
|
|
564
|
+
</KeyboardAvoidingView>
|
|
565
|
+
</Modal>
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function isActiveSession(sessionRef: { current: number }, sessionId: number): boolean {
|
|
570
|
+
return sessionRef.current === sessionId;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
interface ButtonColors {
|
|
574
|
+
primary: string;
|
|
575
|
+
primaryText: string;
|
|
576
|
+
muted: string;
|
|
577
|
+
pressed: string;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function PrimaryButton({
|
|
581
|
+
label,
|
|
582
|
+
colors,
|
|
583
|
+
disabled,
|
|
584
|
+
loading,
|
|
585
|
+
onPress,
|
|
586
|
+
}: {
|
|
587
|
+
label: string;
|
|
588
|
+
colors: ButtonColors;
|
|
589
|
+
disabled?: boolean;
|
|
590
|
+
loading?: boolean;
|
|
591
|
+
onPress(): void;
|
|
592
|
+
}) {
|
|
593
|
+
return (
|
|
594
|
+
<Pressable
|
|
595
|
+
accessibilityRole="button"
|
|
596
|
+
onPress={onPress}
|
|
597
|
+
disabled={disabled}
|
|
598
|
+
style={({ pressed }) => [
|
|
599
|
+
styles.primaryButton,
|
|
600
|
+
{
|
|
601
|
+
backgroundColor: disabled ? colors.muted : colors.primary,
|
|
602
|
+
opacity: pressed && !disabled ? 0.9 : 1,
|
|
603
|
+
},
|
|
604
|
+
]}
|
|
605
|
+
>
|
|
606
|
+
{loading ? (
|
|
607
|
+
<ActivityIndicator color={colors.primaryText} />
|
|
608
|
+
) : (
|
|
609
|
+
<Text style={[styles.primaryButtonText, { color: colors.primaryText }]}>{label}</Text>
|
|
610
|
+
)}
|
|
611
|
+
</Pressable>
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function TextButton({
|
|
616
|
+
label,
|
|
617
|
+
colors,
|
|
618
|
+
disabled,
|
|
619
|
+
onPress,
|
|
620
|
+
}: {
|
|
621
|
+
label: string;
|
|
622
|
+
colors: ButtonColors;
|
|
623
|
+
disabled?: boolean;
|
|
624
|
+
onPress(): void;
|
|
625
|
+
}) {
|
|
626
|
+
return (
|
|
627
|
+
<Pressable accessibilityRole="button" onPress={onPress} disabled={disabled} style={styles.textButton}>
|
|
628
|
+
<Text style={[styles.textButtonLabel, { color: disabled ? colors.muted : colors.primary }]}>{label}</Text>
|
|
629
|
+
</Pressable>
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function normalizeAllowedTypes(config: IdentityConfig | null): IdentityType[] {
|
|
634
|
+
const allowed = config?.allowedIdentityTypes?.filter((type): type is IdentityType => type === 'email' || type === 'phone');
|
|
635
|
+
return allowed && allowed.length > 0 ? allowed : ['email'];
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function resolveInitialIdentityType(
|
|
639
|
+
identity: string | undefined,
|
|
640
|
+
explicitType: IdentityType | undefined,
|
|
641
|
+
allowedTypes: IdentityType[]
|
|
642
|
+
): IdentityType {
|
|
643
|
+
if (explicitType && allowedTypes.includes(explicitType)) return explicitType;
|
|
644
|
+
if (identity?.trim().startsWith('+') && allowedTypes.includes('phone')) return 'phone';
|
|
645
|
+
return allowedTypes.includes('email') ? 'email' : 'phone';
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function getMethodsForIdentity(
|
|
649
|
+
config: IdentityConfig | null,
|
|
650
|
+
identityType: IdentityType,
|
|
651
|
+
passkeyProvider?: MobilePasskeyProvider
|
|
652
|
+
): SupportedMethodId[] {
|
|
653
|
+
const configuredMethods = config?.methods ?? ['totp_email'];
|
|
654
|
+
return configuredMethods.filter((method): method is SupportedMethodId => {
|
|
655
|
+
if (!SUPPORTED_METHODS.has(method)) return false;
|
|
656
|
+
if (method === 'passkey') return identityType === 'email' && Boolean(passkeyProvider);
|
|
657
|
+
if (method === 'totp_email') return identityType === 'email';
|
|
658
|
+
return identityType === 'phone';
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function parseIdentity(
|
|
663
|
+
value: string,
|
|
664
|
+
allowedTypes: IdentityType[]
|
|
665
|
+
): { ok: true; identity: string; identityType: IdentityType } | { ok: false; error: string } {
|
|
666
|
+
const trimmed = value.trim();
|
|
667
|
+
if (!trimmed) {
|
|
668
|
+
return { ok: false, error: 'Enter an identity to continue.' };
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const inferredType: IdentityType = trimmed.startsWith('+') || /^\d/.test(trimmed) ? 'phone' : 'email';
|
|
672
|
+
if (!allowedTypes.includes(inferredType)) {
|
|
673
|
+
return {
|
|
674
|
+
ok: false,
|
|
675
|
+
error: inferredType === 'phone' ? 'Phone sign-in is not enabled for this app.' : 'Email sign-in is not enabled for this app.',
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (inferredType === 'email') {
|
|
680
|
+
const email = trimmed.toLowerCase();
|
|
681
|
+
if (!EMAIL_REGEX.test(email) || email.length > 254) {
|
|
682
|
+
return { ok: false, error: 'Enter a valid email address.' };
|
|
683
|
+
}
|
|
684
|
+
return { ok: true, identity: email, identityType: 'email' };
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const phone = trimmed.startsWith('+') ? trimmed.replace(/[^\d+]/g, '') : `+${trimmed.replace(/\D/g, '')}`;
|
|
688
|
+
if (!E164_PHONE_REGEX.test(phone)) {
|
|
689
|
+
return { ok: false, error: 'Enter a valid phone number in international format.' };
|
|
690
|
+
}
|
|
691
|
+
return { ok: true, identity: phone, identityType: 'phone' };
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function toTotpMethod(method: SupportedMethodId): TotpMethod | null {
|
|
695
|
+
if (method === 'totp_email') return 'email';
|
|
696
|
+
if (method === 'totp_sms') return 'sms';
|
|
697
|
+
if (method === 'totp_voice') return 'voice';
|
|
698
|
+
return null;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function fallbackTotpMethod(methods: SupportedMethodId[]): SupportedMethodId | null {
|
|
702
|
+
return methods.find((method) => method !== 'passkey') ?? null;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function fallbackLabel(method: SupportedMethodId): string {
|
|
706
|
+
if (method === 'totp_sms') return 'Use SMS code instead';
|
|
707
|
+
if (method === 'totp_voice') return 'Use voice call instead';
|
|
708
|
+
return 'Use email code instead';
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function methodTitle(method: SupportedMethodId): string {
|
|
712
|
+
if (method === 'passkey') return 'Passkey';
|
|
713
|
+
if (method === 'totp_sms') return 'Text message';
|
|
714
|
+
if (method === 'totp_voice') return 'Voice call';
|
|
715
|
+
return 'Email code';
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function methodDescription(method: SupportedMethodId): string {
|
|
719
|
+
if (method === 'passkey') return 'Use Face ID, Touch ID, or your device passcode.';
|
|
720
|
+
if (method === 'totp_sms') return 'Receive a one-time code by SMS.';
|
|
721
|
+
if (method === 'totp_voice') return 'Receive a one-time code by phone call.';
|
|
722
|
+
return 'Receive a one-time code by email.';
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function identitySubtitle(allowedTypes: IdentityType[]): string {
|
|
726
|
+
if (allowedTypes.includes('email') && allowedTypes.includes('phone')) return 'Enter your email or phone number to continue.';
|
|
727
|
+
if (allowedTypes.includes('phone')) return 'Enter your phone number to continue.';
|
|
728
|
+
return 'Enter your email address to continue.';
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function identityPlaceholder(allowedTypes: IdentityType[]): string {
|
|
732
|
+
if (allowedTypes.includes('email') && allowedTypes.includes('phone')) return 'name@company.com or +14155551234';
|
|
733
|
+
if (allowedTypes.includes('phone')) return '+14155551234';
|
|
734
|
+
return 'name@company.com';
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function totpTitle(method: SupportedMethodId | null): string {
|
|
738
|
+
if (method === 'totp_sms') return 'Check your messages';
|
|
739
|
+
if (method === 'totp_voice') return 'Check your phone';
|
|
740
|
+
return 'Check your email';
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function getHeaderLogo(branding: IdentityConfig['branding'], theme: 'light' | 'dark'): string | null {
|
|
744
|
+
if (!branding) return null;
|
|
745
|
+
return theme === 'dark'
|
|
746
|
+
? branding.logoDark || branding.logoLight
|
|
747
|
+
: branding.logoLight || branding.logoDark;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function formatCountdown(seconds: number): string {
|
|
751
|
+
const minutes = Math.floor(seconds / 60);
|
|
752
|
+
const remainingSeconds = seconds % 60;
|
|
753
|
+
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function asError(value: unknown, fallback = 'Something went wrong'): Error {
|
|
757
|
+
if (value instanceof Error) return value;
|
|
758
|
+
if (typeof value === 'string') return new Error(value);
|
|
759
|
+
return new Error(fallback);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const lightColors = {
|
|
763
|
+
backdrop: 'rgba(15, 23, 42, 0.42)',
|
|
764
|
+
card: '#ffffff',
|
|
765
|
+
input: '#ffffff',
|
|
766
|
+
pressed: '#f8fafc',
|
|
767
|
+
text: '#0f172a',
|
|
768
|
+
muted: '#64748b',
|
|
769
|
+
placeholder: '#94a3b8',
|
|
770
|
+
border: '#e2e8f0',
|
|
771
|
+
primary: '#2563eb',
|
|
772
|
+
primaryText: '#ffffff',
|
|
773
|
+
primarySoft: '#dbeafe',
|
|
774
|
+
error: '#dc2626',
|
|
775
|
+
};
|
|
776
|
+
|
|
777
|
+
const darkColors = {
|
|
778
|
+
backdrop: 'rgba(2, 6, 23, 0.72)',
|
|
779
|
+
card: '#0f172a',
|
|
780
|
+
input: '#111827',
|
|
781
|
+
pressed: '#1e293b',
|
|
782
|
+
text: '#f8fafc',
|
|
783
|
+
muted: '#94a3b8',
|
|
784
|
+
placeholder: '#64748b',
|
|
785
|
+
border: '#334155',
|
|
786
|
+
primary: '#60a5fa',
|
|
787
|
+
primaryText: '#0f172a',
|
|
788
|
+
primarySoft: '#172554',
|
|
789
|
+
error: '#f87171',
|
|
790
|
+
};
|
|
791
|
+
|
|
792
|
+
const styles = StyleSheet.create({
|
|
793
|
+
backdrop: {
|
|
794
|
+
flex: 1,
|
|
795
|
+
},
|
|
796
|
+
scrollContent: {
|
|
797
|
+
flexGrow: 1,
|
|
798
|
+
justifyContent: 'center',
|
|
799
|
+
padding: 20,
|
|
800
|
+
},
|
|
801
|
+
card: {
|
|
802
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
803
|
+
borderRadius: 8,
|
|
804
|
+
padding: 20,
|
|
805
|
+
width: '100%',
|
|
806
|
+
maxWidth: 420,
|
|
807
|
+
alignSelf: 'center',
|
|
808
|
+
shadowColor: '#000000',
|
|
809
|
+
shadowOffset: { width: 0, height: 16 },
|
|
810
|
+
shadowOpacity: 0.18,
|
|
811
|
+
shadowRadius: 24,
|
|
812
|
+
elevation: 12,
|
|
813
|
+
},
|
|
814
|
+
header: {
|
|
815
|
+
minHeight: 40,
|
|
816
|
+
flexDirection: 'row',
|
|
817
|
+
alignItems: 'center',
|
|
818
|
+
justifyContent: 'space-between',
|
|
819
|
+
marginBottom: 18,
|
|
820
|
+
},
|
|
821
|
+
brand: {
|
|
822
|
+
minWidth: 0,
|
|
823
|
+
flex: 1,
|
|
824
|
+
flexDirection: 'row',
|
|
825
|
+
alignItems: 'center',
|
|
826
|
+
},
|
|
827
|
+
logo: {
|
|
828
|
+
width: 32,
|
|
829
|
+
height: 32,
|
|
830
|
+
marginRight: 10,
|
|
831
|
+
},
|
|
832
|
+
logoFallback: {
|
|
833
|
+
width: 32,
|
|
834
|
+
height: 32,
|
|
835
|
+
borderRadius: 8,
|
|
836
|
+
alignItems: 'center',
|
|
837
|
+
justifyContent: 'center',
|
|
838
|
+
marginRight: 10,
|
|
839
|
+
},
|
|
840
|
+
logoFallbackText: {
|
|
841
|
+
fontSize: 15,
|
|
842
|
+
fontWeight: '800',
|
|
843
|
+
},
|
|
844
|
+
companyName: {
|
|
845
|
+
flex: 1,
|
|
846
|
+
fontSize: 16,
|
|
847
|
+
fontWeight: '700',
|
|
848
|
+
},
|
|
849
|
+
closeButton: {
|
|
850
|
+
width: 32,
|
|
851
|
+
height: 32,
|
|
852
|
+
alignItems: 'center',
|
|
853
|
+
justifyContent: 'center',
|
|
854
|
+
},
|
|
855
|
+
closeText: {
|
|
856
|
+
fontSize: 20,
|
|
857
|
+
fontWeight: '500',
|
|
858
|
+
lineHeight: 22,
|
|
859
|
+
},
|
|
860
|
+
centerContent: {
|
|
861
|
+
minHeight: 180,
|
|
862
|
+
alignItems: 'center',
|
|
863
|
+
justifyContent: 'center',
|
|
864
|
+
gap: 12,
|
|
865
|
+
},
|
|
866
|
+
body: {
|
|
867
|
+
gap: 14,
|
|
868
|
+
},
|
|
869
|
+
title: {
|
|
870
|
+
fontSize: 24,
|
|
871
|
+
fontWeight: '800',
|
|
872
|
+
letterSpacing: 0,
|
|
873
|
+
},
|
|
874
|
+
subtitle: {
|
|
875
|
+
fontSize: 14,
|
|
876
|
+
lineHeight: 20,
|
|
877
|
+
},
|
|
878
|
+
mutedText: {
|
|
879
|
+
fontSize: 14,
|
|
880
|
+
},
|
|
881
|
+
input: {
|
|
882
|
+
minHeight: 48,
|
|
883
|
+
borderWidth: 1,
|
|
884
|
+
borderRadius: 8,
|
|
885
|
+
paddingHorizontal: 14,
|
|
886
|
+
fontSize: 16,
|
|
887
|
+
},
|
|
888
|
+
pinInput: {
|
|
889
|
+
height: 56,
|
|
890
|
+
borderWidth: 1,
|
|
891
|
+
borderRadius: 8,
|
|
892
|
+
paddingHorizontal: 14,
|
|
893
|
+
fontSize: 26,
|
|
894
|
+
fontWeight: '700',
|
|
895
|
+
letterSpacing: 0,
|
|
896
|
+
textAlign: 'center',
|
|
897
|
+
},
|
|
898
|
+
countdown: {
|
|
899
|
+
fontSize: 13,
|
|
900
|
+
textAlign: 'center',
|
|
901
|
+
},
|
|
902
|
+
error: {
|
|
903
|
+
fontSize: 13,
|
|
904
|
+
lineHeight: 18,
|
|
905
|
+
},
|
|
906
|
+
primaryButton: {
|
|
907
|
+
minHeight: 48,
|
|
908
|
+
borderRadius: 8,
|
|
909
|
+
alignItems: 'center',
|
|
910
|
+
justifyContent: 'center',
|
|
911
|
+
paddingHorizontal: 16,
|
|
912
|
+
},
|
|
913
|
+
primaryButtonText: {
|
|
914
|
+
fontSize: 15,
|
|
915
|
+
fontWeight: '700',
|
|
916
|
+
},
|
|
917
|
+
textButton: {
|
|
918
|
+
minHeight: 40,
|
|
919
|
+
alignItems: 'center',
|
|
920
|
+
justifyContent: 'center',
|
|
921
|
+
},
|
|
922
|
+
textButtonLabel: {
|
|
923
|
+
fontSize: 14,
|
|
924
|
+
fontWeight: '700',
|
|
925
|
+
},
|
|
926
|
+
methodList: {
|
|
927
|
+
gap: 10,
|
|
928
|
+
},
|
|
929
|
+
methodButton: {
|
|
930
|
+
borderWidth: 1,
|
|
931
|
+
borderRadius: 8,
|
|
932
|
+
padding: 14,
|
|
933
|
+
},
|
|
934
|
+
methodTitle: {
|
|
935
|
+
fontSize: 15,
|
|
936
|
+
fontWeight: '800',
|
|
937
|
+
marginBottom: 4,
|
|
938
|
+
},
|
|
939
|
+
methodDescription: {
|
|
940
|
+
fontSize: 13,
|
|
941
|
+
lineHeight: 18,
|
|
942
|
+
},
|
|
943
|
+
passkeyIcon: {
|
|
944
|
+
width: 76,
|
|
945
|
+
height: 76,
|
|
946
|
+
borderRadius: 38,
|
|
947
|
+
alignSelf: 'center',
|
|
948
|
+
alignItems: 'center',
|
|
949
|
+
justifyContent: 'center',
|
|
950
|
+
},
|
|
951
|
+
passkeyIconText: {
|
|
952
|
+
fontSize: 18,
|
|
953
|
+
fontWeight: '800',
|
|
954
|
+
},
|
|
955
|
+
});
|