@entropy-softworks/ui 2026.9.9 → 2026.9.10

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.
@@ -1,8 +1,6 @@
1
1
  import React, { useEffect, useRef, useState } from "react";
2
- import { Pressable, ScrollView, View } from "react-native";
3
- import { SafeAreaView } from "react-native-safe-area-context";
2
+ import { Pressable, View } from "react-native";
4
3
  import { useNavigation } from "../../context/NavigationContext";
5
- import { StatusBar } from "expo-status-bar";
6
4
  import { Ionicons } from "@expo/vector-icons";
7
5
  import { useColorScheme } from "nativewind";
8
6
  import * as Haptics from "expo-haptics";
@@ -11,14 +9,6 @@ import QRCode from "react-native-qrcode-svg";
11
9
  import Toast from "react-native-toast-message";
12
10
  import { MonoText, MutedText } from "../../components/text";
13
11
  import { Button } from "../../components/button";
14
- import {
15
- Card,
16
- CardContent,
17
- CardDescription,
18
- CardFooter,
19
- CardHeader,
20
- CardTitle,
21
- } from "../../components/card";
22
12
  import { withClickTracking } from "../../services/telemetry";
23
13
  import { Input } from "../../components/input";
24
14
  import { Separator } from "../../components/separator";
@@ -26,6 +16,7 @@ import AuthService from "../../services/auth.service";
26
16
  import { useAuth } from "../../hooks/useAuth";
27
17
  import { useAppConfig } from "../../context/AppConfigContext";
28
18
  import { getErrorMessage } from "../../utils/error";
19
+ import { AuthPage, type AuthPageLayout } from "./AuthPage";
29
20
 
30
21
  type Step = "prompt" | "setup" | "verify" | "recovery" | "done";
31
22
 
@@ -36,9 +27,11 @@ export interface MfaEnrollScreenProps {
36
27
  * nothing in that slot.
37
28
  */
38
29
  authenticatorAppPromo?: React.ReactNode;
30
+ /** See `AuthPageLayout`. Defaults to the card, as it always was. */
31
+ layout?: AuthPageLayout;
39
32
  }
40
33
 
41
- export function MfaEnrollScreen({ authenticatorAppPromo }: MfaEnrollScreenProps = {}) {
34
+ export function MfaEnrollScreen({ authenticatorAppPromo, layout }: MfaEnrollScreenProps = {}) {
42
35
  const { refreshUser } = useAuth();
43
36
  const { colorScheme } = useColorScheme();
44
37
  const isDark = colorScheme === "dark";
@@ -159,236 +152,202 @@ export function MfaEnrollScreen({ authenticatorAppPromo }: MfaEnrollScreenProps
159
152
  }
160
153
  };
161
154
 
162
- return (
163
- <SafeAreaView className="flex-1 bg-background dark:bg-black">
164
- <StatusBar style={isDark ? "light" : "dark"} />
165
- <ScrollView
166
- className="flex-1"
167
- contentContainerClassName="flex-1 items-center justify-center p-6"
168
- keyboardShouldPersistTaps="handled"
169
- >
170
- <Card className="w-full max-w-[400px]">
171
- {step === "prompt" ? (
172
- <>
173
- <CardHeader className="items-center">
174
- <View className="mb-3 h-16 w-16 items-center justify-center rounded-full bg-secondary dark:bg-neutral-800">
175
- <Ionicons
176
- name="shield-checkmark-outline"
177
- size={32}
178
- color={isDark ? "#FFFFFF" : "#000000"}
179
- />
180
- </View>
181
- <CardTitle className="text-center">Secure your account</CardTitle>
182
- <CardDescription className="text-center">
183
- Add an extra layer of security with two-factor authentication (MFA). You&apos;ll
184
- need an authenticator app to generate verification codes.
185
- </CardDescription>
186
- </CardHeader>
187
-
188
- {authenticatorAppPromo ? (
189
- <CardContent className="gap-3">{authenticatorAppPromo}</CardContent>
190
- ) : null}
191
-
192
- <CardFooter className="flex-col items-stretch gap-3 border-t-0">
193
- <Button
194
- className="w-full"
195
- telemetryId="auth.mfa_enroll.start_setup"
196
- onPress={handleStartSetup}
197
- isLoading={isLoading}
198
- accessibilityLabel="Enable MFA"
199
- accessibilityRole="button"
200
- >
201
- Enable MFA
202
- </Button>
203
- <Button
204
- variant="ghost"
205
- className="w-full"
206
- telemetryId="auth.mfa_enroll.skip"
207
- onPress={handleSkip}
208
- accessibilityLabel="Skip for now"
209
- accessibilityRole="button"
210
- >
211
- Skip for now
212
- </Button>
213
- </CardFooter>
214
- </>
215
- ) : null}
216
-
217
- {step === "setup" && setupData ? (
218
- <>
219
- <CardHeader className="items-center">
220
- <CardTitle className="text-center">Scan QR code</CardTitle>
221
- <CardDescription className="text-center">
222
- Open your authenticator app and scan this QR code, or enter the secret key
223
- manually.
224
- </CardDescription>
225
- </CardHeader>
226
-
227
- <CardContent className="gap-4">
228
- <View className="items-center">
229
- <View className="rounded-xl bg-white p-4">
230
- <QRCode
231
- value={setupData.uri}
232
- size={200}
233
- backgroundColor="#FFFFFF"
234
- color="#000000"
235
- />
236
- </View>
237
- <MutedText className="mt-2 text-center text-xs">
238
- Scan with your authenticator app
239
- </MutedText>
240
- </View>
241
-
242
- <Separator />
155
+ /**
156
+ * What the page says, per step.
157
+ *
158
+ * Held here rather than inline because the frame below takes one title and
159
+ * one subtitle, and three branches each rendering their own header is what
160
+ * made this screen three screens wearing the same card.
161
+ */
162
+ const page =
163
+ step === "prompt"
164
+ ? {
165
+ title: "Secure your account",
166
+ subtitle:
167
+ "Two-factor authentication asks for a code from an authenticator app as well as your password.",
168
+ }
169
+ : step === "setup"
170
+ ? {
171
+ title: "Scan the QR code",
172
+ subtitle: "Open your authenticator app and scan this, or enter the key by hand.",
173
+ }
174
+ : { title: "MFA is on", subtitle: "Save these recovery codes somewhere safe." };
243
175
 
244
- <View>
245
- <MutedText className="mb-2 text-center text-xs font-medium">
246
- Can&apos;t scan? Enter the secret key manually
247
- </MutedText>
248
- <Pressable
249
- onPress={withClickTracking("auth.mfa_enroll.copy_to_clipboard", () => copyToClipboard(setupData.secret, "Secret"))}
250
- className="rounded-lg bg-secondary p-3 active:opacity-70 dark:bg-neutral-800"
251
- accessibilityRole="button"
252
- accessibilityLabel="Copy secret key"
253
- accessibilityHint="Double tap to copy the secret key to clipboard"
254
- >
255
- <MonoText className="text-center text-base font-bold tracking-widest">
256
- {setupData.secret}
257
- </MonoText>
258
- </Pressable>
259
- <MutedText className="mt-1 text-center text-xs">Tap to copy</MutedText>
260
- </View>
176
+ return (
177
+ <AuthPage
178
+ layout={layout}
179
+ title={page.title}
180
+ subtitle={page.subtitle}
181
+ icon={
182
+ step === "setup" ? null : (
183
+ <View className="mb-3 h-16 w-16 items-center justify-center rounded-full bg-secondary dark:bg-neutral-800">
184
+ <Ionicons
185
+ name={step === "prompt" ? "shield-checkmark-outline" : "shield-checkmark"}
186
+ size={32}
187
+ color={isDark ? "#FFFFFF" : "#000000"}
188
+ />
189
+ </View>
190
+ )
191
+ }
192
+ maxWidth={400}
193
+ footer={
194
+ step === "prompt" ? (
195
+ <>
196
+ <Button
197
+ className="w-full"
198
+ telemetryId="auth.mfa_enroll.start_setup"
199
+ onPress={handleStartSetup}
200
+ isLoading={isLoading}
201
+ accessibilityLabel="Enable MFA"
202
+ accessibilityRole="button"
203
+ >
204
+ Enable MFA
205
+ </Button>
206
+ <Button
207
+ variant="ghost"
208
+ className="w-full"
209
+ telemetryId="auth.mfa_enroll.skip"
210
+ onPress={handleSkip}
211
+ accessibilityLabel="Skip for now"
212
+ accessibilityRole="button"
213
+ >
214
+ Skip for now
215
+ </Button>
216
+ </>
217
+ ) : step === "setup" ? (
218
+ <>
219
+ <Button
220
+ className="w-full"
221
+ telemetryId="auth.mfa_enroll.verify_code"
222
+ onPress={handleVerifyCode}
223
+ isLoading={isLoading}
224
+ disabled={code.length !== 6}
225
+ accessibilityLabel="Verify and enable MFA"
226
+ accessibilityRole="button"
227
+ >
228
+ Verify &amp; Enable
229
+ </Button>
230
+ <Button
231
+ variant="ghost"
232
+ className="w-full"
233
+ telemetryId="auth.mfa_enroll.skip_2"
234
+ onPress={handleSkip}
235
+ accessibilityLabel="Skip for now"
236
+ accessibilityRole="button"
237
+ >
238
+ Skip for now
239
+ </Button>
240
+ </>
241
+ ) : (
242
+ <Button
243
+ className="w-full"
244
+ telemetryId="auth.mfa_enroll.finish"
245
+ onPress={handleFinish}
246
+ accessibilityLabel={`Continue to ${appName}`}
247
+ accessibilityRole="button"
248
+ >
249
+ Continue to {appName}
250
+ </Button>
251
+ )
252
+ }
253
+ >
254
+ {step === "prompt" ? authenticatorAppPromo : null}
261
255
 
262
- <Pressable
263
- onPress={withClickTracking("auth.mfa_enroll.copy_to_clipboard_2", () => copyToClipboard(setupData.uri, "URI"))}
264
- className="rounded-lg bg-secondary px-3 py-2 active:opacity-70 dark:bg-neutral-800"
265
- accessibilityRole="button"
266
- accessibilityLabel="Copy full OTP URI"
267
- >
268
- <MutedText className="text-center text-xs">Copy full OTP URI</MutedText>
269
- </Pressable>
256
+ {step === "setup" && setupData ? (
257
+ <>
258
+ <View className="items-center">
259
+ <View className="rounded-xl bg-white p-4">
260
+ <QRCode value={setupData.uri} size={200} backgroundColor="#FFFFFF" color="#000000" />
261
+ </View>
262
+ </View>
270
263
 
271
- <Separator />
264
+ <Separator />
272
265
 
273
- <View>
274
- <MutedText className="mb-1.5 text-center text-xs font-medium">
275
- Enter the 6-digit code from your authenticator
276
- </MutedText>
277
- <Input
278
- value={code}
279
- onChangeText={(text) => {
280
- setCode(text.replace(/[^0-9]/g, "").slice(0, 6));
281
- if (error) {
282
- setError("");
283
- }
284
- }}
285
- error={error}
286
- placeholder="000000"
287
- keyboardType="number-pad"
288
- className="text-center font-mono text-xl tracking-widest"
289
- returnKeyType="done"
290
- onSubmitEditing={handleVerifyCode}
291
- accessibilityLabel="Authenticator code"
292
- />
293
- </View>
294
- </CardContent>
266
+ <View>
267
+ <MutedText className="mb-2 text-center text-xs font-medium">
268
+ Can&apos;t scan? Enter the key by hand
269
+ </MutedText>
270
+ <Pressable
271
+ onPress={withClickTracking("auth.mfa_enroll.copy_to_clipboard", () =>
272
+ copyToClipboard(setupData.secret, "Secret")
273
+ )}
274
+ className="rounded-lg bg-secondary p-3 active:opacity-70 dark:bg-neutral-800"
275
+ accessibilityRole="button"
276
+ accessibilityLabel="Copy secret key"
277
+ accessibilityHint="Double tap to copy the secret key to clipboard"
278
+ >
279
+ <MonoText className="text-center text-base font-bold tracking-widest">
280
+ {setupData.secret}
281
+ </MonoText>
282
+ </Pressable>
283
+ <MutedText className="mt-1 text-center text-xs">Tap to copy</MutedText>
284
+ </View>
295
285
 
296
- <CardFooter className="flex-col items-stretch gap-3 border-t-0">
297
- <Button
298
- className="w-full"
299
- telemetryId="auth.mfa_enroll.verify_code"
300
- onPress={handleVerifyCode}
301
- isLoading={isLoading}
302
- disabled={code.length !== 6}
303
- accessibilityLabel="Verify and enable MFA"
304
- accessibilityRole="button"
305
- >
306
- Verify & Enable
307
- </Button>
308
- <Button
309
- variant="ghost"
310
- className="w-full"
311
- telemetryId="auth.mfa_enroll.skip_2"
312
- onPress={handleSkip}
313
- accessibilityLabel="Skip for now"
314
- accessibilityRole="button"
315
- >
316
- Skip for now
317
- </Button>
318
- </CardFooter>
319
- </>
320
- ) : null}
286
+ <Pressable
287
+ onPress={withClickTracking("auth.mfa_enroll.copy_to_clipboard_2", () =>
288
+ copyToClipboard(setupData.uri, "URI")
289
+ )}
290
+ className="rounded-lg bg-secondary px-3 py-2 active:opacity-70 dark:bg-neutral-800"
291
+ accessibilityRole="button"
292
+ accessibilityLabel="Copy full OTP URI"
293
+ >
294
+ <MutedText className="text-center text-xs">Copy full OTP URI</MutedText>
295
+ </Pressable>
321
296
 
322
- {step === "recovery" && setupData ? (
323
- <>
324
- <CardHeader className="items-center">
325
- <View className="mb-3 h-16 w-16 items-center justify-center rounded-full bg-secondary dark:bg-neutral-800">
326
- <Ionicons
327
- name="shield-checkmark"
328
- size={32}
329
- color={isDark ? "#FFFFFF" : "#000000"}
330
- />
331
- </View>
332
- <CardTitle className="text-center">MFA enabled!</CardTitle>
333
- <CardDescription className="text-center">
334
- Save these recovery codes in a safe place. You&apos;ll need them to access your
335
- account if you lose your authenticator device.
336
- </CardDescription>
337
- </CardHeader>
297
+ <Separator />
338
298
 
339
- <CardContent className="gap-3">
340
- <View className="rounded-lg border border-border bg-secondary p-4 dark:border-neutral-700 dark:bg-neutral-800">
341
- {setupData.recovery_codes.map((rc) => (
342
- <MonoText key={rc} className="text-center text-sm leading-6">
343
- {rc}
344
- </MonoText>
345
- ))}
346
- </View>
299
+ <View>
300
+ <MutedText className="mb-1.5 text-center text-xs font-medium">
301
+ Enter the 6-digit code from your authenticator
302
+ </MutedText>
303
+ <Input
304
+ value={code}
305
+ onChangeText={(text) => {
306
+ setCode(text.replace(/[^0-9]/g, "").slice(0, 6));
307
+ if (error) {
308
+ setError("");
309
+ }
310
+ }}
311
+ error={error}
312
+ placeholder="000000"
313
+ keyboardType="number-pad"
314
+ className="text-center font-mono text-xl tracking-widest"
315
+ returnKeyType="done"
316
+ onSubmitEditing={handleVerifyCode}
317
+ accessibilityLabel="Authenticator code"
318
+ />
319
+ </View>
320
+ </>
321
+ ) : null}
347
322
 
348
- <Button
349
- variant="outline"
350
- className="w-full"
351
- telemetryId="auth.mfa_enroll.copy_to_clipboard_3"
352
- onPress={() =>
353
- copyToClipboard(setupData.recovery_codes.join("\n"), "Recovery codes")
354
- }
355
- accessibilityLabel="Copy all recovery codes"
356
- accessibilityRole="button"
357
- >
358
- {copied === "Recovery codes" ? "Copied!" : "Copy all codes"}
359
- </Button>
323
+ {step === "recovery" && setupData ? (
324
+ <>
325
+ <View className="rounded-lg bg-secondary p-4 dark:bg-neutral-800">
326
+ {setupData.recovery_codes.map((rc) => (
327
+ <MonoText key={rc} className="text-center text-sm leading-6">
328
+ {rc}
329
+ </MonoText>
330
+ ))}
331
+ </View>
360
332
 
361
- <View className="rounded-lg bg-secondary p-3 dark:bg-neutral-800">
362
- <View className="flex-row items-start gap-2">
363
- <Ionicons
364
- name="warning-outline"
365
- size={16}
366
- color={isDark ? "#F59E0B" : "#D97706"}
367
- />
368
- <MutedText className="flex-1 text-xs leading-4">
369
- Each code can only be used once. Store them securely — they cannot be
370
- recovered if lost.
371
- </MutedText>
372
- </View>
373
- </View>
374
- </CardContent>
333
+ <Button
334
+ variant="outline"
335
+ className="w-full"
336
+ telemetryId="auth.mfa_enroll.copy_to_clipboard_3"
337
+ onPress={() => copyToClipboard(setupData.recovery_codes.join("\n"), "Recovery codes")}
338
+ accessibilityLabel="Copy all recovery codes"
339
+ accessibilityRole="button"
340
+ >
341
+ {copied === "Recovery codes" ? "Copied!" : "Copy all codes"}
342
+ </Button>
375
343
 
376
- <CardFooter className="border-t-0">
377
- <Button
378
- className="w-full"
379
- telemetryId="auth.mfa_enroll.finish"
380
- onPress={handleFinish}
381
- accessibilityLabel={`Continue to ${appName}`}
382
- accessibilityRole="button"
383
- >
384
- Continue to {appName}
385
- </Button>
386
- </CardFooter>
387
- </>
388
- ) : null}
389
- </Card>
390
- </ScrollView>
391
- </SafeAreaView>
344
+ <View className="flex-row items-start gap-2 rounded-lg bg-secondary p-3 dark:bg-neutral-800">
345
+ <Ionicons name="warning-outline" size={16} color={isDark ? "#F59E0B" : "#D97706"} />
346
+ <MutedText className="flex-1 text-xs leading-4">Each code works once.</MutedText>
347
+ </View>
348
+ </>
349
+ ) : null}
350
+ </AuthPage>
392
351
  );
393
352
  }
394
353