From c6afc28a729ee6b5be2112814b27d57f341bc8e1 Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Thu, 28 May 2026 17:55:10 -0300 Subject: [PATCH 1/7] feat(pos-app): unified update-keys screen with QR scanner for customer key Co-Authored-By: Claude Opus 4.8 --- dapps/pos-app/app.json | 8 +- dapps/pos-app/app/_layout.tsx | 5 + dapps/pos-app/app/scan-customer-key.tsx | 155 ++++++++++++ dapps/pos-app/app/settings.tsx | 236 +----------------- dapps/pos-app/app/update-keys.tsx | 292 +++++++++++++++++++++++ dapps/pos-app/hooks/use-merchant-flow.ts | 167 ++++++------- dapps/pos-app/package-lock.json | 76 ++++++ dapps/pos-app/package.json | 1 + dapps/pos-app/store/useSettingsStore.ts | 12 + 9 files changed, 633 insertions(+), 319 deletions(-) create mode 100644 dapps/pos-app/app/scan-customer-key.tsx create mode 100644 dapps/pos-app/app/update-keys.tsx diff --git a/dapps/pos-app/app.json b/dapps/pos-app/app.json index de3d949d9..9c7696480 100644 --- a/dapps/pos-app/app.json +++ b/dapps/pos-app/app.json @@ -41,7 +41,7 @@ "android.permission.BLUETOOTH_ADVERTISE", "android.permission.USB_PERMISSION" ], - "versionCode": 24 + "versionCode": 25 }, "web": { "output": "static", @@ -128,6 +128,12 @@ "faceIDPermission": "Allow $(PRODUCT_NAME) to access your Face ID biometric data." } ], + [ + "expo-camera", + { + "cameraPermission": "$(PRODUCT_NAME) needs camera access to scan QR codes." + } + ], "@sentry/react-native", "expo-image" ], diff --git a/dapps/pos-app/app/_layout.tsx b/dapps/pos-app/app/_layout.tsx index c6b2935b9..2e462f727 100644 --- a/dapps/pos-app/app/_layout.tsx +++ b/dapps/pos-app/app/_layout.tsx @@ -185,6 +185,11 @@ export default Sentry.wrap(function RootLayout() { }} /> + + diff --git a/dapps/pos-app/app/scan-customer-key.tsx b/dapps/pos-app/app/scan-customer-key.tsx new file mode 100644 index 000000000..4e4169de5 --- /dev/null +++ b/dapps/pos-app/app/scan-customer-key.tsx @@ -0,0 +1,155 @@ +import { CloseButton } from "@/components/close-button"; +import { BorderRadius, Spacing } from "@/constants/spacing"; +import { useSettingsStore } from "@/store/useSettingsStore"; +import { + BarcodeScanningResult, + CameraView, + useCameraPermissions, +} from "expo-camera"; +import { router } from "expo-router"; +import { useCallback, useEffect, useRef } from "react"; +import { Dimensions, StyleSheet, Text, View } from "react-native"; +import { + SafeAreaView, + useSafeAreaInsets, +} from "react-native-safe-area-context"; + +const { width, height } = Dimensions.get("window"); +const SCAN_AREA_SIZE = 280; +const scanAreaLeft = (width - SCAN_AREA_SIZE) / 2; +const scanAreaTop = (height - SCAN_AREA_SIZE) / 3; + +export default function ScanCustomerKeyScreen() { + const { top } = useSafeAreaInsets(); + const [permission, requestPermission] = useCameraPermissions(); + const setScannedCustomerKey = useSettingsStore( + (state) => state.setScannedCustomerKey, + ); + // onBarcodeScanned fires for every frame; guard so we navigate back once. + const hasScannedRef = useRef(false); + + useEffect(() => { + if (!permission?.granted) { + requestPermission(); + } + }, [permission, requestPermission]); + + const onBarcodeScanned = useCallback( + (result: BarcodeScanningResult) => { + if (hasScannedRef.current) return; + const value = result.data?.trim(); + if (!value) return; + hasScannedRef.current = true; + setScannedCustomerKey(value); + router.back(); + }, + [setScannedCustomerKey], + ); + + const goBack = () => router.back(); + + return ( + + {permission?.granted && ( + + )} + + {/* Dimmed surround (plain views — no blur/svg deps) */} + + + + + + {/* Scan-area frame */} + + + + + + + {permission?.granted + ? "Scan a QR code containing the customer API key" + : "Camera not available"} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + ...StyleSheet.absoluteFillObject, + backgroundColor: "black", + }, + dim: { + position: "absolute", + backgroundColor: "rgba(0, 0, 0, 0.6)", + }, + frame: { + position: "absolute", + width: SCAN_AREA_SIZE, + height: SCAN_AREA_SIZE, + borderWidth: 4, + borderColor: "white", + borderRadius: BorderRadius["5"], + }, + closeButton: { + position: "absolute", + right: Spacing["spacing-5"], + }, + instruction: { + position: "absolute", + left: 0, + right: 0, + alignItems: "center", + paddingHorizontal: Spacing["spacing-6"], + paddingTop: Spacing["spacing-6"], + }, + instructionText: { + color: "white", + fontSize: 16, + lineHeight: 20, + fontFamily: "KH Teka", + textAlign: "center", + }, +}); diff --git a/dapps/pos-app/app/settings.tsx b/dapps/pos-app/app/settings.tsx index ea211a6f0..fe9c00574 100644 --- a/dapps/pos-app/app/settings.tsx +++ b/dapps/pos-app/app/settings.tsx @@ -1,16 +1,13 @@ -import { Button } from "@/components/button"; import { Card } from "@/components/card"; import { CloseButton } from "@/components/close-button"; -import { PinModal } from "@/components/pin-modal"; import { RadioList, RadioOption } from "@/components/radio-list"; import { SettingsBottomSheet } from "@/components/settings-bottom-sheet"; import { SettingsItem } from "@/components/settings-item"; import { Switch } from "@/components/switch"; import { ThemedText } from "@/components/themed-text"; -import { BorderRadius, Spacing } from "@/constants/spacing"; +import { Spacing } from "@/constants/spacing"; import { VariantList, VariantName } from "@/constants/variants"; import { useBiometricAuth } from "@/hooks/use-biometric-auth"; -import { useMerchantFlow } from "@/hooks/use-merchant-flow"; import { useNfcCapabilities } from "@/hooks/use-nfc-capabilities"; import { useTheme } from "@/hooks/use-theme-color"; import { useLogsStore } from "@/store/useLogsStore"; @@ -29,17 +26,11 @@ import * as Application from "expo-application"; import Constants from "expo-constants"; import { LinearGradient } from "expo-linear-gradient"; import { router } from "expo-router"; -import { useCallback, useMemo, useState } from "react"; -import { Platform, StyleSheet, TextInput, View } from "react-native"; +import { useMemo, useState } from "react"; +import { Platform, StyleSheet, View } from "react-native"; import { ScrollView } from "react-native-gesture-handler"; -type ActiveSheet = - | "theme" - | "walletTheme" - | "currency" - | "merchantId" - | "customerApiKey" - | null; +type ActiveSheet = "theme" | "walletTheme" | "currency" | null; const THEME_OPTIONS: RadioOption[] = [ { @@ -79,39 +70,15 @@ export default function SettingsScreen() { const theme = useTheme(); const [activeSheet, setActiveSheet] = useState(null); - const [isEditingCustomerKey, setIsEditingCustomerKey] = useState(false); - // Custom hooks for biometrics and merchant flow + // Custom hook for biometrics const { biometricStatus, biometricEnabled, - biometricLabel, - canUseBiometric, shouldShowBiometricOption, handleBiometricToggle, - authenticate, } = useBiometricAuth(); - const { - merchantIdInput, - customerApiKeyInput, - activeModal, - pinError, - isMerchantIdConfirmDisabled, - isCustomerApiKeyConfirmDisabled, - hasStoredCustomerApiKey, - handleMerchantIdInputChange, - handleCustomerApiKeyInputChange, - resetCustomerApiKeyInput, - handleMerchantIdConfirm, - handleCustomerApiKeyConfirm, - handlePinVerifyComplete, - handleBiometricAuthSuccess, - handleBiometricAuthFailure, - handlePinSetupComplete, - handleCancelSecurityFlow, - } = useMerchantFlow(); - const variantOptions: RadioOption[] = useMemo( () => VariantList.map((v) => ({ @@ -143,11 +110,7 @@ export default function SettingsScreen() { const isCustomVariant = variant !== "default"; const closeSheet = () => { - if (activeSheet === "customerApiKey") { - resetCustomerApiKeyInput(); - } setActiveSheet(null); - setIsEditingCustomerKey(false); }; const handleThemeModeChange = (value: ThemeMode) => { @@ -165,23 +128,6 @@ export default function SettingsScreen() { closeSheet(); }; - const handleMerchantIdSave = () => { - closeSheet(); - handleMerchantIdConfirm(); - }; - - const handleCustomerApiKeySave = () => { - closeSheet(); - handleCustomerApiKeyConfirm(); - }; - - const handleCustomerKeyChange = (value: string) => { - if (!isEditingCustomerKey) { - setIsEditingCustomerKey(true); - } - handleCustomerApiKeyInputChange(value); - }; - const showNfcToggle = Platform.OS === "android" && nfcCapabilities.isHceSupported; @@ -228,23 +174,6 @@ export default function SettingsScreen() { } }; - const handleBiometricAuth = useCallback(async () => { - const success = await authenticate( - `Use ${biometricLabel} to change merchant settings`, - ); - - if (success) { - handleBiometricAuthSuccess(); - } else { - handleBiometricAuthFailure(); - } - }, [ - authenticate, - biometricLabel, - handleBiometricAuthSuccess, - handleBiometricAuthFailure, - ]); - return ( setActiveSheet("merchantId")} - /> - - setActiveSheet("customerApiKey")} + title="Update keys" + onPress={() => router.push("/update-keys")} + showCaret /> {showNfcToggle && ( @@ -388,127 +311,6 @@ export default function SettingsScreen() { onChange={handleCurrencyChange} /> - - {/* Merchant ID Bottom Sheet */} - - - - - - - - {/* Customer API Key Bottom Sheet */} - - - - - - - - {/* PIN Modal */} - ); } @@ -556,26 +358,4 @@ const styles = StyleSheet.create({ biometricLabel: { gap: Spacing["spacing-1"], }, - inputContent: { - gap: Spacing["spacing-3"], - }, - sheetInput: { - borderWidth: 1, - borderRadius: BorderRadius["4"], - paddingHorizontal: Spacing["spacing-5"], - paddingVertical: Spacing["spacing-4"], - fontSize: 16, - lineHeight: 18, - fontFamily: "KH Teka", - height: 60, - }, - saveButton: { - borderRadius: BorderRadius["4"], - paddingVertical: Spacing["spacing-4"], - justifyContent: "center", - alignItems: "center", - }, - saveButtonLabel: { - textAlign: "center", - }, }); diff --git a/dapps/pos-app/app/update-keys.tsx b/dapps/pos-app/app/update-keys.tsx new file mode 100644 index 000000000..f1bf73b5b --- /dev/null +++ b/dapps/pos-app/app/update-keys.tsx @@ -0,0 +1,292 @@ +import { Button } from "@/components/button"; +import { CloseButton } from "@/components/close-button"; +import { PinModal } from "@/components/pin-modal"; +import { ThemedText } from "@/components/themed-text"; +import { BorderRadius, Spacing } from "@/constants/spacing"; +import { useBiometricAuth } from "@/hooks/use-biometric-auth"; +import { useMerchantFlow } from "@/hooks/use-merchant-flow"; +import { useTheme } from "@/hooks/use-theme-color"; +import { useSettingsStore } from "@/store/useSettingsStore"; +import { MerchantConfig } from "@/utils/merchant-config"; +import { resetNavigation } from "@/utils/navigation"; +import { useAssets } from "expo-asset"; +import { Image } from "expo-image"; +import { router, useFocusEffect } from "expo-router"; +import { useCallback } from "react"; +import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native"; +import { ScrollView } from "react-native-gesture-handler"; + +const isNative = Platform.OS !== "web"; + +export default function UpdateKeysScreen() { + const theme = useTheme(); + const [assets] = useAssets([require("@/assets/images/scan.png")]); + + const scannedCustomerKey = useSettingsStore( + (state) => state.scannedCustomerKey, + ); + const setScannedCustomerKey = useSettingsStore( + (state) => state.setScannedCustomerKey, + ); + + const { + merchantIdInput, + customerApiKeyInput, + activeModal, + pinError, + isUpdateKeysConfirmDisabled, + hasStoredCustomerApiKey, + handleMerchantIdInputChange, + handleCustomerApiKeyInputChange, + handleUpdateKeysConfirm, + handlePinVerifyComplete, + handleBiometricAuthSuccess, + handleBiometricAuthFailure, + handlePinSetupComplete, + handleCancelSecurityFlow, + } = useMerchantFlow(); + + const { biometricLabel, canUseBiometric, authenticate } = useBiometricAuth(); + + // Apply a value handed back by the QR scanner, then clear the hand-off. + useFocusEffect( + useCallback(() => { + if (scannedCustomerKey) { + handleCustomerApiKeyInputChange(scannedCustomerKey); + setScannedCustomerKey(null); + } + }, [ + scannedCustomerKey, + handleCustomerApiKeyInputChange, + setScannedCustomerKey, + ]), + ); + + const handleBiometricAuth = useCallback(async () => { + const success = await authenticate(`Use ${biometricLabel} to update keys`); + if (success) { + handleBiometricAuthSuccess(); + } else { + handleBiometricAuthFailure(); + } + }, [ + authenticate, + biometricLabel, + handleBiometricAuthSuccess, + handleBiometricAuthFailure, + ]); + + const handleResetToDefault = useCallback(() => { + const defaultMerchantId = MerchantConfig.getDefaultMerchantId(); + const defaultApiKey = MerchantConfig.getDefaultCustomerApiKey(); + if (defaultMerchantId) { + handleMerchantIdInputChange(defaultMerchantId); + } + if (defaultApiKey) { + handleCustomerApiKeyInputChange(defaultApiKey); + } + }, [handleMerchantIdInputChange, handleCustomerApiKeyInputChange]); + + const inputStyle = [ + styles.input, + { + borderColor: theme["border-primary"], + color: theme["text-primary"], + backgroundColor: theme["foreground-primary"], + }, + ]; + + return ( + + + {/* Merchant ID */} + + + Merchant ID + + + + + {/* Customer API Key */} + + + Customer API key + + + + {isNative && ( + router.push("/scan-customer-key")} + style={styles.scanButton} + hitSlop={8} + > + + + )} + + + + + + {MerchantConfig.hasEnvDefaults() && ( + + + Reset to default + + + )} + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingHorizontal: Spacing["spacing-5"], + }, + content: { + paddingTop: Spacing["spacing-5"], + paddingBottom: Spacing["extra-spacing-2"], + gap: Spacing["spacing-5"], + }, + field: { + gap: Spacing["spacing-2"], + }, + inputRow: { + position: "relative", + justifyContent: "center", + }, + input: { + borderWidth: 1, + borderRadius: BorderRadius["4"], + paddingHorizontal: Spacing["spacing-5"], + paddingVertical: Spacing["spacing-4"], + fontSize: 16, + lineHeight: 18, + fontFamily: "KH Teka", + height: 60, + }, + inputWithAction: { + paddingRight: Spacing["spacing-12"], + }, + scanButton: { + position: "absolute", + right: Spacing["spacing-4"], + height: 60, + justifyContent: "center", + alignItems: "center", + }, + scanIcon: { + width: 24, + height: 24, + }, + saveButton: { + borderRadius: BorderRadius["4"], + paddingVertical: Spacing["spacing-4"], + justifyContent: "center", + alignItems: "center", + }, + saveButtonLabel: { + textAlign: "center", + }, + resetLink: { + alignSelf: "center", + paddingVertical: Spacing["spacing-3"], + marginTop: Spacing["spacing-2"], + }, + resetLabel: { + textAlign: "center", + textDecorationLine: "underline", + }, + closeButton: { + position: "absolute", + alignSelf: "center", + }, +}); diff --git a/dapps/pos-app/hooks/use-merchant-flow.ts b/dapps/pos-app/hooks/use-merchant-flow.ts index ce88c1592..8b937aabb 100644 --- a/dapps/pos-app/hooks/use-merchant-flow.ts +++ b/dapps/pos-app/hooks/use-merchant-flow.ts @@ -1,18 +1,24 @@ -import { useLogsStore } from "@/store/useLogsStore"; import { useSettingsStore } from "@/store/useSettingsStore"; +import { useLogsStore } from "@/store/useLogsStore"; import { showErrorToast, showSuccessToast } from "@/utils/toast"; import { useCallback, useEffect, useState } from "react"; type ModalType = "none" | "pin-verify" | "pin-setup"; -type PendingAction = "merchant-id" | "customer-api-key" | null; + +// Describes what a single PIN-gated Save should write. Only the fields that +// actually changed are present, so merchant ID and customer API key can be +// updated together or independently in one flow. +type PendingSave = { + merchantId?: string; + customerApiKey?: string; +} | null; interface MerchantFlowState { merchantIdInput: string; customerApiKeyInput: string; activeModal: ModalType; pinError: string | null; - pendingValue: string | null; - pendingAction: PendingAction; + pendingSave: PendingSave; } const initialState: MerchantFlowState = { @@ -20,14 +26,12 @@ const initialState: MerchantFlowState = { customerApiKeyInput: "", activeModal: "none", pinError: null, - pendingValue: null, - pendingAction: null, + pendingSave: null, }; export function useMerchantFlow() { const storedMerchantId = useSettingsStore((state) => state.merchantId); const setMerchantId = useSettingsStore((state) => state.setMerchantId); - const clearMerchantId = useSettingsStore((state) => state.clearMerchantId); const setCustomerApiKey = useSettingsStore( (state) => state.setCustomerApiKey, ); @@ -51,7 +55,7 @@ export function useMerchantFlow() { merchantIdInput: storedMerchantId ?? "", }); - // Sync merchant ID input with stored value + // Sync merchant ID input with stored value (e.g. after a save or reset). useEffect(() => { setState((prev) => ({ ...prev, @@ -80,15 +84,8 @@ export function useMerchantFlow() { })); }, []); - const resetCustomerApiKeyInput = useCallback(() => { - setState((prev) => ({ - ...prev, - customerApiKeyInput: "", - })); - }, []); - const initiateSave = useCallback( - (value: string, action: PendingAction) => { + (pendingSave: NonNullable) => { // Check if locked out if (isLockedOut()) { showErrorToast(formatLockoutMessage()); @@ -99,97 +96,94 @@ export function useMerchantFlow() { setState((prev) => ({ ...prev, - pendingValue: value, - pendingAction: action, + pendingSave, activeModal: pinExists ? "pin-verify" : "pin-setup", })); }, [isLockedOut, formatLockoutMessage, isPinSet], ); - const handleMerchantIdConfirm = useCallback(() => { - const trimmedMerchantId = state.merchantIdInput.trim(); + const trimmedMerchantId = state.merchantIdInput.trim(); + const trimmedApiKey = state.customerApiKeyInput.trim(); + // Merchant ID must be non-empty; resetting to defaults is an explicit action + // (the Reset link pre-fills the input), never an empty Save. + const merchantIdChanged = + trimmedMerchantId.length > 0 && + trimmedMerchantId !== (storedMerchantId ?? ""); + const customerKeyProvided = trimmedApiKey.length > 0; - // Check if value changed - if (trimmedMerchantId === storedMerchantId) { - return; + // Build the PendingSave from the current inputs and start the PIN flow. + const handleUpdateKeysConfirm = useCallback(() => { + const pending: NonNullable = {}; + if (merchantIdChanged) { + pending.merchantId = trimmedMerchantId; + } + if (customerKeyProvided) { + pending.customerApiKey = trimmedApiKey; } - // Pass empty string to indicate clearing (will reset to default) - initiateSave(trimmedMerchantId || "", "merchant-id"); - }, [state.merchantIdInput, storedMerchantId, initiateSave]); - - const handleCustomerApiKeyConfirm = useCallback(() => { - const trimmedApiKey = state.customerApiKeyInput.trim(); - if (!trimmedApiKey) { + if ( + pending.merchantId === undefined && + pending.customerApiKey === undefined + ) { return; } - initiateSave(trimmedApiKey, "customer-api-key"); - }, [state.customerApiKeyInput, initiateSave]); + initiateSave(pending); + }, [ + merchantIdChanged, + customerKeyProvided, + trimmedMerchantId, + trimmedApiKey, + initiateSave, + ]); const completeSave = useCallback(async () => { - if (state.pendingValue === null || !state.pendingAction) { + const pending = state.pendingSave; + if (!pending) { return; } try { - if (state.pendingAction === "merchant-id") { - if (state.pendingValue === "") { - // Clear merchant ID and API key (resets both to env defaults) - const newMerchantId = await clearMerchantId(); - // Sync local input with the new default value - setState((prev) => ({ - ...prev, - merchantIdInput: newMerchantId ?? "", - })); - showSuccessToast("Merchant credentials reset to default"); - addLog( - "info", - "Merchant credentials reset to default", - "settings", - "completeSave", - ); - } else { - setMerchantId(state.pendingValue); - showSuccessToast("Merchant ID saved successfully"); - addLog( - "info", - `Merchant ID updated to: ${state.pendingValue}`, - "settings", - "completeSave", - ); - } - } else if (state.pendingAction === "customer-api-key") { - await setCustomerApiKey(state.pendingValue); - setState((prev) => ({ - ...prev, - customerApiKeyInput: "", // Clear input after saving - })); - showSuccessToast("Customer API key saved successfully"); + const saved: string[] = []; + + if (pending.merchantId !== undefined) { + setMerchantId(pending.merchantId); + addLog( + "info", + `Merchant ID updated to: ${pending.merchantId}`, + "settings", + "completeSave", + ); + saved.push("Merchant ID"); + } + + if (pending.customerApiKey !== undefined) { + await setCustomerApiKey(pending.customerApiKey); addLog("info", "Customer API key updated", "settings", "completeSave"); + saved.push("Customer API key"); } setState((prev) => ({ ...prev, - pendingValue: null, - pendingAction: null, + customerApiKeyInput: "", + pendingSave: null, activeModal: "none", + pinError: null, })); + + showSuccessToast( + saved.length > 1 + ? "Keys saved successfully" + : `${saved[0]} saved successfully`, + ); } catch (error) { const errorMessage = error instanceof Error ? error.message : "Failed to save"; showErrorToast(errorMessage); addLog("error", errorMessage, "settings", "completeSave"); } - }, [ - state.pendingValue, - state.pendingAction, - setMerchantId, - clearMerchantId, - setCustomerApiKey, - addLog, - ]); + }, [state.pendingSave, setMerchantId, setCustomerApiKey, addLog]); const handlePinVerifyComplete = useCallback( async (pin: string) => { @@ -251,19 +245,15 @@ export function useMerchantFlow() { ...prev, activeModal: "none", pinError: null, - pendingValue: null, - pendingAction: null, + pendingSave: null, merchantIdInput: storedMerchantId ?? "", - customerApiKeyInput: "", // Clear input on cancel + customerApiKeyInput: "", })); }, [storedMerchantId]); - // Enable save when value has changed (including clearing to reset to default) - const isMerchantIdConfirmDisabled = - state.merchantIdInput.trim() === (storedMerchantId ?? ""); - - const isCustomerApiKeyConfirmDisabled = - state.customerApiKeyInput.trim().length === 0; + // Save is enabled when there is at least one valid change to commit. + const isUpdateKeysConfirmDisabled = + !merchantIdChanged && !customerKeyProvided; const hasStoredCustomerApiKey = isCustomerApiKeySet; @@ -274,16 +264,13 @@ export function useMerchantFlow() { activeModal: state.activeModal, pinError: state.pinError, storedMerchantId, - isMerchantIdConfirmDisabled, - isCustomerApiKeyConfirmDisabled, + isUpdateKeysConfirmDisabled, hasStoredCustomerApiKey, // Handlers handleMerchantIdInputChange, handleCustomerApiKeyInputChange, - resetCustomerApiKeyInput, - handleMerchantIdConfirm, - handleCustomerApiKeyConfirm, + handleUpdateKeysConfirm, handlePinVerifyComplete, handleBiometricAuthSuccess, handleBiometricAuthFailure, diff --git a/dapps/pos-app/package-lock.json b/dapps/pos-app/package-lock.json index 70d0c9edc..8c9d60c8e 100644 --- a/dapps/pos-app/package-lock.json +++ b/dapps/pos-app/package-lock.json @@ -19,6 +19,7 @@ "expo": "55.0.4", "expo-application": "55.0.8", "expo-asset": "55.0.8", + "expo-camera": "~55.0.19", "expo-clipboard": "55.0.8", "expo-constants": "55.0.7", "expo-crypto": "55.0.8", @@ -4857,6 +4858,12 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/emscripten": { + "version": "1.41.5", + "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz", + "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -6357,6 +6364,15 @@ "@babel/core": "^7.0.0" } }, + "node_modules/barcode-detector": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-3.1.3.tgz", + "integrity": "sha512-omL3/x26oU9jlR0gUQcGdXIjQtMlrUGKF7xRFO1RwrQkRkRU7WLz0mgQEsdUtYBm2uX3JH+HQLrKlyTS/BxZRw==", + "license": "MIT", + "dependencies": { + "zxing-wasm": "3.0.3" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -8372,6 +8388,26 @@ "react-native": "*" } }, + "node_modules/expo-camera": { + "version": "55.0.19", + "resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-55.0.19.tgz", + "integrity": "sha512-EUGEo7m/cY6u8XyLKzavYWs1XP53vvg2LCCBM4nkY8hhUzQ3DeClYr9G3ew0JV2d8WOI15Yyj1Xoe8CjD3ySbg==", + "license": "MIT", + "dependencies": { + "barcode-detector": "^3.0.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, "node_modules/expo-clipboard": { "version": "55.0.8", "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-55.0.8.tgz", @@ -15581,6 +15617,18 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tar": { "version": "7.5.11", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz", @@ -17018,6 +17066,34 @@ "optional": true } } + }, + "node_modules/zxing-wasm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-3.0.3.tgz", + "integrity": "sha512-DdOn/G5F+qvZELWeO5ZFFwcN611TfMybxPV0LUUoutUmiH2t47MZSB7gLV9O9YLhvudBdnzQNAoFOu4Xz8eOrQ==", + "license": "MIT", + "dependencies": { + "@types/emscripten": "^1.41.5", + "type-fest": "^5.6.0" + }, + "peerDependencies": { + "@types/emscripten": ">=1.39.6" + } + }, + "node_modules/zxing-wasm/node_modules/type-fest": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", + "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/dapps/pos-app/package.json b/dapps/pos-app/package.json index c26b15b91..2d9b4f105 100644 --- a/dapps/pos-app/package.json +++ b/dapps/pos-app/package.json @@ -31,6 +31,7 @@ "expo": "55.0.4", "expo-application": "55.0.8", "expo-asset": "55.0.8", + "expo-camera": "~55.0.19", "expo-clipboard": "55.0.8", "expo-constants": "55.0.7", "expo-crypto": "55.0.8", diff --git a/dapps/pos-app/store/useSettingsStore.ts b/dapps/pos-app/store/useSettingsStore.ts index 2ccb520d1..52f714a3d 100644 --- a/dapps/pos-app/store/useSettingsStore.ts +++ b/dapps/pos-app/store/useSettingsStore.ts @@ -62,6 +62,10 @@ interface SettingsStore { merchantId: string | null; isCustomerApiKeySet: boolean; + // Transient hand-off from the QR scanner to the Update keys screen. + // Never persisted — it holds a raw customer API key (see partialize). + scannedCustomerKey: string | null; + // Transaction filters transactionFilter: TransactionFilterType; dateRangeFilter: DateRangeFilterType; @@ -82,6 +86,7 @@ interface SettingsStore { setVariant: (variant: VariantName) => void; setCurrency: (currency: CurrencyCode) => void; setMerchantId: (merchantId: string | null) => void; + setScannedCustomerKey: (value: string | null) => void; clearMerchantId: () => Promise; setCustomerApiKey: (apiKey: string | null) => Promise; clearCustomerApiKey: () => Promise; @@ -112,6 +117,7 @@ export const useSettingsStore = create()( _hasHydrated: false, merchantId: null, isCustomerApiKeySet: false, + scannedCustomerKey: null, transactionFilter: "all", dateRangeFilter: "today", isPinHashSet: false, @@ -130,6 +136,8 @@ export const useSettingsStore = create()( } }, setCurrency: (currency: CurrencyCode) => set({ currency }), + setScannedCustomerKey: (value: string | null) => + set({ scannedCustomerKey: value }), setMerchantId: (merchantId: string | null) => { // If clearing, reset to env default (unless embedded — parent provides credentials) if (!merchantId || merchantId.trim() === "") { @@ -277,6 +285,10 @@ export const useSettingsStore = create()( name: "settings", version: 16, storage, + // Never write the raw scanned customer API key to MMKV; it is a + // transient hand-off that lives only in memory. + partialize: ({ scannedCustomerKey: _scannedCustomerKey, ...rest }) => + rest, migrate: (persistedState: any, version: number) => { if (!persistedState || typeof persistedState !== "object") { return { variant: "default" }; From 4a33b2b3ad49008d6504ba236bcead6f15254f32 Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Fri, 29 May 2026 16:03:26 -0300 Subject: [PATCH 2/7] feat(pos-app): QR import/export for keys, PIN gate on Update keys entry - Gate the Update keys screen with a PIN on entry (usePinGate); saving no longer prompts separately - Add web-only Export keys screen (QR of merchant ID + customer key) and a native Import keys scanner, with a shared device-setup QR codec + tests - Apply UX-writing audit fixes to the new copy and minor UI polish (equal button heights, web focus-ring padding, back after save) Co-Authored-By: Claude Opus 4.8 --- dapps/pos-app/app/_layout.tsx | 1 + dapps/pos-app/app/export-keys.tsx | 211 ++++++++++++ dapps/pos-app/app/scan-customer-key.tsx | 33 +- dapps/pos-app/app/update-keys.tsx | 357 ++++++++++++-------- dapps/pos-app/hooks/use-merchant-flow.ts | 262 +++----------- dapps/pos-app/hooks/use-pin-gate.ts | 120 +++++++ dapps/pos-app/store/useSettingsStore.ts | 21 +- dapps/pos-app/utils/device-setup-qr.test.ts | 52 +++ dapps/pos-app/utils/device-setup-qr.ts | 70 ++++ package-lock.json | 2 +- 10 files changed, 758 insertions(+), 371 deletions(-) create mode 100644 dapps/pos-app/app/export-keys.tsx create mode 100644 dapps/pos-app/hooks/use-pin-gate.ts create mode 100644 dapps/pos-app/utils/device-setup-qr.test.ts create mode 100644 dapps/pos-app/utils/device-setup-qr.ts diff --git a/dapps/pos-app/app/_layout.tsx b/dapps/pos-app/app/_layout.tsx index 40cdb29a0..85ca01eee 100644 --- a/dapps/pos-app/app/_layout.tsx +++ b/dapps/pos-app/app/_layout.tsx @@ -186,6 +186,7 @@ export default Sentry.wrap(function RootLayout() { /> + state.merchantId); + const getCustomerApiKey = useSettingsStore( + (state) => state.getCustomerApiKey, + ); + + const { + activeModal, + pinError, + requireAuth, + handlePinVerifyComplete, + handlePinSetupComplete, + handleBiometricSuccess, + handleBiometricFailure, + cancel, + } = usePinGate(); + const { biometricLabel, canUseBiometric, authenticate } = useBiometricAuth(); + + const [unlocked, setUnlocked] = useState(false); + const [qrValue, setQrValue] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const startedRef = useRef(false); + + // Require a PIN once, before anything sensitive is read or shown. + useEffect(() => { + if (startedRef.current) return; + startedRef.current = true; + requireAuth(() => setUnlocked(true)); + }, [requireAuth]); + + // Read the customer key only after the user has unlocked. + useEffect(() => { + if (!unlocked) return; + let active = true; + (async () => { + const customerApiKey = await getCustomerApiKey(); + if (!active) return; + if (storedMerchantId && customerApiKey) { + setQrValue( + encodeDeviceSetupQr({ + merchantId: storedMerchantId, + customerApiKey, + }), + ); + } else { + setQrValue(null); + } + setIsLoading(false); + })(); + return () => { + active = false; + }; + }, [unlocked, storedMerchantId, getCustomerApiKey]); + + const handleBiometricAuth = useCallback(async () => { + const success = await authenticate(`Use ${biometricLabel} to export keys`); + if (success) { + handleBiometricSuccess(); + } else { + handleBiometricFailure(); + } + }, [ + authenticate, + biometricLabel, + handleBiometricSuccess, + handleBiometricFailure, + ]); + + // Cancelling the PIN prompt leaves the screen entirely. + const handleCancel = useCallback(() => { + cancel(); + router.back(); + }, [cancel]); + + const goToUpdateKeys = useCallback(() => { + if (router.canGoBack()) { + router.back(); + } else { + router.replace("/update-keys"); + } + }, []); + + return ( + + {unlocked && ( + + {!isLoading && !qrValue ? ( + <> + + Set a merchant ID and customer API key first. + + + + ) : ( + <> + + On the new device, open Settings → Update keys → Import keys, + then scan this setup code. + + + + + + + Anyone who scans this gets full access to your merchant + account. Only show it to devices you trust, and don't + share a photo of it. + + + + )} + + )} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + content: { + flexGrow: 1, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: Spacing["spacing-5"], + paddingVertical: Spacing["spacing-6"], + gap: Spacing["spacing-6"], + }, + centerText: { + textAlign: "center", + }, + cta: { + height: 56, + borderRadius: BorderRadius["4"], + paddingHorizontal: Spacing["spacing-6"], + justifyContent: "center", + alignItems: "center", + }, + warning: { + borderWidth: 1, + borderRadius: 16, + padding: Spacing["spacing-4"], + }, +}); diff --git a/dapps/pos-app/app/scan-customer-key.tsx b/dapps/pos-app/app/scan-customer-key.tsx index f9ffc9362..d4e52ab4a 100644 --- a/dapps/pos-app/app/scan-customer-key.tsx +++ b/dapps/pos-app/app/scan-customer-key.tsx @@ -3,6 +3,8 @@ import { ThemedText } from "@/components/themed-text"; import { BorderRadius, Spacing } from "@/constants/spacing"; import { useTheme } from "@/hooks/use-theme-color"; import { useSettingsStore } from "@/store/useSettingsStore"; +import { decodeDeviceSetupQr } from "@/utils/device-setup-qr"; +import { showErrorToast } from "@/utils/toast"; import { BarcodeScanningResult, CameraView, @@ -16,6 +18,9 @@ import { useSafeAreaInsets, } from "react-native-safe-area-context"; +// Don't spam the "invalid QR" toast for every camera frame. +const INVALID_QR_TOAST_COOLDOWN_MS = 2500; + const { width, height } = Dimensions.get("window"); const SCAN_AREA_SIZE = 280; const scanAreaLeft = (width - SCAN_AREA_SIZE) / 2; @@ -25,11 +30,10 @@ export default function ScanCustomerKeyScreen() { const { bottom } = useSafeAreaInsets(); const theme = useTheme(); const [permission, requestPermission] = useCameraPermissions(); - const setScannedCustomerKey = useSettingsStore( - (state) => state.setScannedCustomerKey, - ); + const setScannedSetup = useSettingsStore((state) => state.setScannedSetup); // onBarcodeScanned fires for every frame; guard so we navigate back once. const hasScannedRef = useRef(false); + const lastInvalidToastRef = useRef(0); useEffect(() => { if (!permission?.granted) { @@ -42,11 +46,26 @@ export default function ScanCustomerKeyScreen() { if (hasScannedRef.current) return; const value = result.data?.trim(); if (!value) return; + + const payload = decodeDeviceSetupQr(value); + if (!payload) { + // Not one of our setup QRs — keep scanning, nudge the user at intervals. + const now = Date.now(); + if (now - lastInvalidToastRef.current > INVALID_QR_TOAST_COOLDOWN_MS) { + lastInvalidToastRef.current = now; + showErrorToast("That QR code isn't a valid setup code"); + } + return; + } + hasScannedRef.current = true; - setScannedCustomerKey(value); + setScannedSetup({ + merchantId: payload.merchantId, + customerApiKey: payload.customerApiKey, + }); router.back(); }, - [setScannedCustomerKey], + [setScannedSetup], ); const goBack = () => router.back(); @@ -105,8 +124,8 @@ export default function ScanCustomerKeyScreen() { {permission?.granted - ? "Scan a QR code containing the customer API key" - : "Camera not available"} + ? "Scan a setup code from another device" + : "Camera access is off. Allow it in your device settings to scan."} diff --git a/dapps/pos-app/app/update-keys.tsx b/dapps/pos-app/app/update-keys.tsx index 1e08802d2..9d3e01fd0 100644 --- a/dapps/pos-app/app/update-keys.tsx +++ b/dapps/pos-app/app/update-keys.tsx @@ -4,76 +4,114 @@ import { ThemedText } from "@/components/themed-text"; import { BorderRadius, Spacing } from "@/constants/spacing"; import { useBiometricAuth } from "@/hooks/use-biometric-auth"; import { useMerchantFlow } from "@/hooks/use-merchant-flow"; +import { usePinGate } from "@/hooks/use-pin-gate"; import { useTheme } from "@/hooks/use-theme-color"; import { useSettingsStore } from "@/store/useSettingsStore"; import { MerchantConfig } from "@/utils/merchant-config"; import { useAssets } from "expo-asset"; import { Image } from "expo-image"; import { router, useFocusEffect } from "expo-router"; -import { useCallback } from "react"; -import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + KeyboardAvoidingView, + Platform, + Pressable, + StyleSheet, + TextInput, + View, +} from "react-native"; import { ScrollView } from "react-native-gesture-handler"; const isNative = Platform.OS !== "web"; +const isWeb = Platform.OS === "web"; export default function UpdateKeysScreen() { const theme = useTheme(); const [assets] = useAssets([require("@/assets/images/scan.png")]); - const scannedCustomerKey = useSettingsStore( - (state) => state.scannedCustomerKey, - ); - const setScannedCustomerKey = useSettingsStore( - (state) => state.setScannedCustomerKey, - ); + const scannedSetup = useSettingsStore((state) => state.scannedSetup); + const setScannedSetup = useSettingsStore((state) => state.setScannedSetup); const { - merchantIdInput, - customerApiKeyInput, activeModal, pinError, + requireAuth, + handlePinVerifyComplete, + handlePinSetupComplete, + handleBiometricSuccess, + handleBiometricFailure, + cancel, + } = usePinGate(); + const { biometricLabel, canUseBiometric, authenticate } = useBiometricAuth(); + + // Require a PIN once, on entry. The form stays hidden until it's unlocked. + const [unlocked, setUnlocked] = useState(false); + const startedRef = useRef(false); + useEffect(() => { + if (startedRef.current) return; + startedRef.current = true; + requireAuth(() => setUnlocked(true)); + }, [requireAuth]); + + // Return to the previous screen once the keys are saved. + const handleSaved = useCallback(() => { + if (router.canGoBack()) { + router.back(); + } + }, []); + + const { + merchantIdInput, + customerApiKeyInput, + storedMerchantId, isUpdateKeysConfirmDisabled, hasStoredCustomerApiKey, handleMerchantIdInputChange, handleCustomerApiKeyInputChange, handleUpdateKeysConfirm, - handlePinVerifyComplete, - handleBiometricAuthSuccess, - handleBiometricAuthFailure, - handlePinSetupComplete, - handleCancelSecurityFlow, - } = useMerchantFlow(); + } = useMerchantFlow(handleSaved); - const { biometricLabel, canUseBiometric, authenticate } = useBiometricAuth(); - - // Apply a value handed back by the QR scanner, then clear the hand-off. + // Apply the credentials handed back by the QR scanner, then clear the + // hand-off. A setup QR carries both merchant ID and customer key. useFocusEffect( useCallback(() => { - if (scannedCustomerKey) { - handleCustomerApiKeyInputChange(scannedCustomerKey); - setScannedCustomerKey(null); + if (scannedSetup) { + if (scannedSetup.merchantId) { + handleMerchantIdInputChange(scannedSetup.merchantId); + } + if (scannedSetup.customerApiKey) { + handleCustomerApiKeyInputChange(scannedSetup.customerApiKey); + } + setScannedSetup(null); } }, [ - scannedCustomerKey, + scannedSetup, + handleMerchantIdInputChange, handleCustomerApiKeyInputChange, - setScannedCustomerKey, + setScannedSetup, ]), ); const handleBiometricAuth = useCallback(async () => { const success = await authenticate(`Use ${biometricLabel} to update keys`); if (success) { - handleBiometricAuthSuccess(); + handleBiometricSuccess(); } else { - handleBiometricAuthFailure(); + handleBiometricFailure(); } }, [ authenticate, biometricLabel, - handleBiometricAuthSuccess, - handleBiometricAuthFailure, + handleBiometricSuccess, + handleBiometricFailure, ]); + // Cancelling the PIN prompt leaves the screen entirely. + const handleCancel = useCallback(() => { + cancel(); + router.back(); + }, [cancel]); + const handleResetToDefault = useCallback(() => { const defaultMerchantId = MerchantConfig.getDefaultMerchantId(); const defaultApiKey = MerchantConfig.getDefaultCustomerApiKey(); @@ -85,6 +123,10 @@ export default function UpdateKeysScreen() { } }, [handleMerchantIdInputChange, handleCustomerApiKeyInputChange]); + // Export shares the device's currently active credentials, so it requires + // both to already be saved. + const canExport = !!storedMerchantId && hasStoredCustomerApiKey; + const inputStyle = [ styles.input, { @@ -95,117 +137,149 @@ export default function UpdateKeysScreen() { ]; return ( - - - {/* Merchant ID */} - - - Merchant ID - - - + + {unlocked && ( + <> + + {/* Merchant ID */} + + + Merchant ID + + + + + {/* Customer API Key */} + + + Customer API key + + + + - {/* Customer API Key */} - - - Customer API key - - - + + {/* Provision this device by scanning another's export QR. */} {isNative && ( + + )} + + {/* Share this device's keys so another can be set up (web only). */} + {isWeb && ( + + )} + + + + {MerchantConfig.hasEnvDefaults() && ( router.push("/scan-customer-key")} - style={styles.scanButton} + onPress={handleResetToDefault} + style={styles.resetLink} hitSlop={8} > - + + Reset to default + )} - - - - - {MerchantConfig.hasEnvDefaults() && ( - - - Reset to default - - - )} - + + )} - + ); } @@ -229,16 +303,13 @@ const styles = StyleSheet.create({ }, content: { paddingTop: Spacing["spacing-5"], - paddingBottom: Spacing["extra-spacing-2"], gap: Spacing["spacing-5"], + // Leave room for the input focus ring so the ScrollView doesn't clip it. + ...(isWeb && { paddingHorizontal: Spacing["spacing-2"] }), }, field: { gap: Spacing["spacing-2"], }, - inputRow: { - position: "relative", - justifyContent: "center", - }, input: { borderWidth: 1, borderRadius: BorderRadius["4"], @@ -249,23 +320,30 @@ const styles = StyleSheet.create({ fontFamily: "KH Teka", height: 60, }, - inputWithAction: { - paddingRight: Spacing["spacing-12"], + footer: { + paddingTop: Spacing["spacing-4"], + gap: Spacing["spacing-3"], + ...(isWeb && { paddingHorizontal: Spacing["spacing-2"] }), }, - scanButton: { - position: "absolute", - right: Spacing["spacing-4"], - height: 60, + secondaryButton: { + height: 56, + borderWidth: 1, + borderRadius: BorderRadius["4"], justifyContent: "center", alignItems: "center", }, + secondaryButtonContent: { + flexDirection: "row", + alignItems: "center", + gap: Spacing["spacing-2"], + }, scanIcon: { width: 24, height: 24, }, saveButton: { + height: 56, borderRadius: BorderRadius["4"], - paddingVertical: Spacing["spacing-4"], justifyContent: "center", alignItems: "center", }, @@ -274,8 +352,7 @@ const styles = StyleSheet.create({ }, resetLink: { alignSelf: "center", - paddingVertical: Spacing["spacing-3"], - marginTop: Spacing["spacing-2"], + paddingVertical: Spacing["spacing-2"], }, resetLabel: { textAlign: "center", diff --git a/dapps/pos-app/hooks/use-merchant-flow.ts b/dapps/pos-app/hooks/use-merchant-flow.ts index fe2368b59..9c4026db8 100644 --- a/dapps/pos-app/hooks/use-merchant-flow.ts +++ b/dapps/pos-app/hooks/use-merchant-flow.ts @@ -1,35 +1,12 @@ -import { useSettingsStore } from "@/store/useSettingsStore"; import { useLogsStore } from "@/store/useLogsStore"; +import { useSettingsStore } from "@/store/useSettingsStore"; import { showErrorToast, showSuccessToast } from "@/utils/toast"; import { useCallback, useEffect, useState } from "react"; -type ModalType = "none" | "pin-verify" | "pin-setup"; +// The Update keys screen gates access with a PIN on entry (see usePinGate), so +// saving here writes directly — no second PIN prompt. -// Describes what a single PIN-gated Save should write. Only the fields that -// actually changed are present, so merchant ID and customer API key can be -// updated together or independently in one flow. -type PendingSave = { - merchantId?: string; - customerApiKey?: string; -} | null; - -interface MerchantFlowState { - merchantIdInput: string; - customerApiKeyInput: string; - activeModal: ModalType; - pinError: string | null; - pendingSave: PendingSave; -} - -const initialState: MerchantFlowState = { - merchantIdInput: "", - customerApiKeyInput: "", - activeModal: "none", - pinError: null, - pendingSave: null, -}; - -export function useMerchantFlow() { +export function useMerchantFlow(onSaved?: () => void) { const storedMerchantId = useSettingsStore((state) => state.merchantId); const setMerchantId = useSettingsStore((state) => state.setMerchantId); const setCustomerApiKey = useSettingsStore( @@ -38,73 +15,28 @@ export function useMerchantFlow() { const isCustomerApiKeySet = useSettingsStore( (state) => state.isCustomerApiKeySet, ); - const isPinSet = useSettingsStore((state) => state.isPinSet); - const verifyPin = useSettingsStore((state) => state.verifyPin); - const setPin = useSettingsStore((state) => state.setPin); - const isLockedOut = useSettingsStore((state) => state.isLockedOut); - const getLockoutRemainingSeconds = useSettingsStore( - (state) => state.getLockoutRemainingSeconds, - ); - const pinFailedAttempts = useSettingsStore( - (state) => state.pinFailedAttempts, - ); const addLog = useLogsStore((state) => state.addLog); - const [state, setState] = useState({ - ...initialState, - merchantIdInput: storedMerchantId ?? "", - }); + const [merchantIdInput, setMerchantIdInput] = useState( + storedMerchantId ?? "", + ); + const [customerApiKeyInput, setCustomerApiKeyInput] = useState(""); // Sync merchant ID input with stored value (e.g. after a save or reset). useEffect(() => { - setState((prev) => ({ - ...prev, - merchantIdInput: storedMerchantId ?? "", - })); + setMerchantIdInput(storedMerchantId ?? ""); }, [storedMerchantId]); - const formatLockoutMessage = useCallback(() => { - const remaining = getLockoutRemainingSeconds(); - const minutes = Math.floor(remaining / 60); - const seconds = remaining % 60; - return `Too many failed attempts. Try again in ${minutes}:${seconds.toString().padStart(2, "0")}`; - }, [getLockoutRemainingSeconds]); - const handleMerchantIdInputChange = useCallback((value: string) => { - setState((prev) => ({ - ...prev, - merchantIdInput: value, - })); + setMerchantIdInput(value); }, []); const handleCustomerApiKeyInputChange = useCallback((value: string) => { - setState((prev) => ({ - ...prev, - customerApiKeyInput: value, - })); + setCustomerApiKeyInput(value); }, []); - const initiateSave = useCallback( - (pendingSave: NonNullable) => { - // Check if locked out - if (isLockedOut()) { - showErrorToast(formatLockoutMessage()); - return; - } - - const pinExists = isPinSet(); - - setState((prev) => ({ - ...prev, - pendingSave, - activeModal: pinExists ? "pin-verify" : "pin-setup", - })); - }, - [isLockedOut, formatLockoutMessage, isPinSet], - ); - - const trimmedMerchantId = state.merchantIdInput.trim(); - const trimmedApiKey = state.customerApiKeyInput.trim(); + const trimmedMerchantId = merchantIdInput.trim(); + const trimmedApiKey = customerApiKeyInput.trim(); // Merchant ID must be non-empty; resetting to defaults is an explicit action // (the Reset link pre-fills the input), never an empty Save. const merchantIdChanged = @@ -112,169 +44,73 @@ export function useMerchantFlow() { trimmedMerchantId !== (storedMerchantId ?? ""); const customerKeyProvided = trimmedApiKey.length > 0; - // Build the PendingSave from the current inputs and start the PIN flow. - const handleUpdateKeysConfirm = useCallback(() => { - const pending: NonNullable = {}; - if (merchantIdChanged) { - pending.merchantId = trimmedMerchantId; - } - if (customerKeyProvided) { - pending.customerApiKey = trimmedApiKey; - } - - if ( - pending.merchantId === undefined && - pending.customerApiKey === undefined - ) { - return; - } - - initiateSave(pending); - }, [ - merchantIdChanged, - customerKeyProvided, - trimmedMerchantId, - trimmedApiKey, - initiateSave, - ]); - - const completeSave = useCallback(async () => { - const pending = state.pendingSave; - if (!pending) { + // Write whichever of merchant ID / customer key actually changed. + const handleUpdateKeysConfirm = useCallback(async () => { + if (!merchantIdChanged && !customerKeyProvided) { return; } try { const saved: string[] = []; - if (pending.merchantId !== undefined) { - setMerchantId(pending.merchantId); + if (merchantIdChanged) { + setMerchantId(trimmedMerchantId); addLog( "info", - `Merchant ID updated to: ${pending.merchantId}`, + `Merchant ID updated to: ${trimmedMerchantId}`, "settings", - "completeSave", + "handleUpdateKeysConfirm", ); saved.push("Merchant ID"); } - if (pending.customerApiKey !== undefined) { - await setCustomerApiKey(pending.customerApiKey); - addLog("info", "Customer API key updated", "settings", "completeSave"); + if (customerKeyProvided) { + await setCustomerApiKey(trimmedApiKey); + addLog( + "info", + "Customer API key updated", + "settings", + "handleUpdateKeysConfirm", + ); saved.push("Customer API key"); } - setState((prev) => ({ - ...prev, - customerApiKeyInput: "", - pendingSave: null, - activeModal: "none", - pinError: null, - })); + setCustomerApiKeyInput(""); + + showSuccessToast(saved.length > 1 ? "Keys saved" : `${saved[0]} saved`); - showSuccessToast( - saved.length > 1 - ? "Keys saved successfully" - : `${saved[0]} saved successfully`, - ); + onSaved?.(); } catch (error) { const errorMessage = - error instanceof Error ? error.message : "Failed to save"; + error instanceof Error + ? error.message + : "Couldn't save your changes. Try again."; showErrorToast(errorMessage); - addLog("error", errorMessage, "settings", "completeSave"); + addLog("error", errorMessage, "settings", "handleUpdateKeysConfirm"); } - }, [state.pendingSave, setMerchantId, setCustomerApiKey, addLog]); - - const handlePinVerifyComplete = useCallback( - async (pin: string) => { - const isValid = await verifyPin(pin); - if (isValid) { - setState((prev) => ({ - ...prev, - pinError: null, - })); - await completeSave(); - } else { - if (isLockedOut()) { - setState((prev) => ({ ...prev, activeModal: "none" })); - showErrorToast(formatLockoutMessage()); - } else { - const attemptsLeft = 3 - pinFailedAttempts; - setState((prev) => ({ - ...prev, - pinError: `Incorrect PIN. ${attemptsLeft} attempt${attemptsLeft !== 1 ? "s" : ""} remaining.`, - })); - } - } - }, - [ - verifyPin, - isLockedOut, - formatLockoutMessage, - pinFailedAttempts, - completeSave, - ], - ); - - const handleBiometricAuthSuccess = useCallback(async () => { - setState((prev) => ({ - ...prev, - pinError: null, - })); - await completeSave(); - }, [completeSave]); - - const handleBiometricAuthFailure = useCallback(() => { - setState((prev) => ({ - ...prev, - pinError: "Biometric check failed. Use your PIN instead.", - })); - }, []); - - const handlePinSetupComplete = useCallback( - async (pin: string) => { - await setPin(pin); - showSuccessToast("PIN set successfully"); - await completeSave(); - }, - [setPin, completeSave], - ); - - const handleCancelSecurityFlow = useCallback(() => { - setState((prev) => ({ - ...prev, - activeModal: "none", - pinError: null, - pendingSave: null, - merchantIdInput: storedMerchantId ?? "", - customerApiKeyInput: "", - })); - }, [storedMerchantId]); + }, [ + merchantIdChanged, + customerKeyProvided, + trimmedMerchantId, + trimmedApiKey, + setMerchantId, + setCustomerApiKey, + addLog, + onSaved, + ]); // Save is enabled when there is at least one valid change to commit. const isUpdateKeysConfirmDisabled = !merchantIdChanged && !customerKeyProvided; - const hasStoredCustomerApiKey = isCustomerApiKeySet; - return { - // State - merchantIdInput: state.merchantIdInput, - customerApiKeyInput: state.customerApiKeyInput, - activeModal: state.activeModal, - pinError: state.pinError, + merchantIdInput, + customerApiKeyInput, storedMerchantId, isUpdateKeysConfirmDisabled, - hasStoredCustomerApiKey, - - // Handlers + hasStoredCustomerApiKey: isCustomerApiKeySet, handleMerchantIdInputChange, handleCustomerApiKeyInputChange, handleUpdateKeysConfirm, - handlePinVerifyComplete, - handleBiometricAuthSuccess, - handleBiometricAuthFailure, - handlePinSetupComplete, - handleCancelSecurityFlow, }; } diff --git a/dapps/pos-app/hooks/use-pin-gate.ts b/dapps/pos-app/hooks/use-pin-gate.ts new file mode 100644 index 000000000..9e3712593 --- /dev/null +++ b/dapps/pos-app/hooks/use-pin-gate.ts @@ -0,0 +1,120 @@ +import { useSettingsStore } from "@/store/useSettingsStore"; +import { showErrorToast } from "@/utils/toast"; +import { useCallback, useRef, useState } from "react"; + +type GateModal = "none" | "pin-verify" | "pin-setup"; + +const MAX_PIN_ATTEMPTS = 3; + +/** + * Reusable PIN gate for sensitive actions (saving credentials, exporting them). + * + * Call `requireAuth(onSuccess)` to open the PIN modal; `onSuccess` runs once the + * user verifies an existing PIN, sets a new one, or passes biometrics. The + * lockout / attempt logic lives here so every gated action shares one + * implementation (the actual lockout state is owned by the store). + */ +export function usePinGate() { + const isPinSet = useSettingsStore((state) => state.isPinSet); + const verifyPin = useSettingsStore((state) => state.verifyPin); + const setPin = useSettingsStore((state) => state.setPin); + const isLockedOut = useSettingsStore((state) => state.isLockedOut); + const getLockoutRemainingSeconds = useSettingsStore( + (state) => state.getLockoutRemainingSeconds, + ); + const pinFailedAttempts = useSettingsStore( + (state) => state.pinFailedAttempts, + ); + + const [activeModal, setActiveModal] = useState("none"); + const [pinError, setPinError] = useState(null); + const onSuccessRef = useRef<(() => void | Promise) | null>(null); + + const formatLockoutMessage = useCallback(() => { + const remaining = getLockoutRemainingSeconds(); + const minutes = Math.floor(remaining / 60); + const seconds = remaining % 60; + return `Too many failed attempts. Try again in ${minutes}:${seconds.toString().padStart(2, "0")}`; + }, [getLockoutRemainingSeconds]); + + const requireAuth = useCallback( + (onSuccess: () => void | Promise) => { + if (isLockedOut()) { + showErrorToast(formatLockoutMessage()); + return; + } + onSuccessRef.current = onSuccess; + setPinError(null); + setActiveModal(isPinSet() ? "pin-verify" : "pin-setup"); + }, + [isLockedOut, formatLockoutMessage, isPinSet], + ); + + const runSuccess = useCallback(async () => { + const onSuccess = onSuccessRef.current; + onSuccessRef.current = null; + setActiveModal("none"); + setPinError(null); + if (onSuccess) { + await onSuccess(); + } + }, []); + + const handlePinVerifyComplete = useCallback( + async (pin: string) => { + const isValid = await verifyPin(pin); + if (isValid) { + await runSuccess(); + } else if (isLockedOut()) { + setActiveModal("none"); + showErrorToast(formatLockoutMessage()); + } else { + const attemptsLeft = MAX_PIN_ATTEMPTS - pinFailedAttempts; + setPinError( + `Incorrect PIN. ${attemptsLeft} attempt${attemptsLeft !== 1 ? "s" : ""} remaining.`, + ); + } + }, + [ + verifyPin, + isLockedOut, + formatLockoutMessage, + pinFailedAttempts, + runSuccess, + ], + ); + + const handlePinSetupComplete = useCallback( + async (pin: string) => { + await setPin(pin); + await runSuccess(); + }, + [setPin, runSuccess], + ); + + const handleBiometricSuccess = useCallback(async () => { + setPinError(null); + await runSuccess(); + }, [runSuccess]); + + const handleBiometricFailure = useCallback(() => { + setPinError("Biometric check failed. Use your PIN instead."); + }, []); + + const cancel = useCallback(() => { + onSuccessRef.current = null; + setActiveModal("none"); + setPinError(null); + }, []); + + return { + activeModal, + pinError, + requireAuth, + handlePinVerifyComplete, + handlePinSetupComplete, + handleBiometricSuccess, + handleBiometricFailure, + cancel, + }; +} diff --git a/dapps/pos-app/store/useSettingsStore.ts b/dapps/pos-app/store/useSettingsStore.ts index 52f714a3d..5b34d446f 100644 --- a/dapps/pos-app/store/useSettingsStore.ts +++ b/dapps/pos-app/store/useSettingsStore.ts @@ -63,8 +63,9 @@ interface SettingsStore { isCustomerApiKeySet: boolean; // Transient hand-off from the QR scanner to the Update keys screen. - // Never persisted — it holds a raw customer API key (see partialize). - scannedCustomerKey: string | null; + // Never persisted — it holds raw credentials scanned from a setup QR + // (see partialize). + scannedSetup: { merchantId?: string; customerApiKey?: string } | null; // Transaction filters transactionFilter: TransactionFilterType; @@ -86,7 +87,9 @@ interface SettingsStore { setVariant: (variant: VariantName) => void; setCurrency: (currency: CurrencyCode) => void; setMerchantId: (merchantId: string | null) => void; - setScannedCustomerKey: (value: string | null) => void; + setScannedSetup: ( + value: { merchantId?: string; customerApiKey?: string } | null, + ) => void; clearMerchantId: () => Promise; setCustomerApiKey: (apiKey: string | null) => Promise; clearCustomerApiKey: () => Promise; @@ -117,7 +120,7 @@ export const useSettingsStore = create()( _hasHydrated: false, merchantId: null, isCustomerApiKeySet: false, - scannedCustomerKey: null, + scannedSetup: null, transactionFilter: "all", dateRangeFilter: "today", isPinHashSet: false, @@ -136,8 +139,7 @@ export const useSettingsStore = create()( } }, setCurrency: (currency: CurrencyCode) => set({ currency }), - setScannedCustomerKey: (value: string | null) => - set({ scannedCustomerKey: value }), + setScannedSetup: (value) => set({ scannedSetup: value }), setMerchantId: (merchantId: string | null) => { // If clearing, reset to env default (unless embedded — parent provides credentials) if (!merchantId || merchantId.trim() === "") { @@ -285,10 +287,9 @@ export const useSettingsStore = create()( name: "settings", version: 16, storage, - // Never write the raw scanned customer API key to MMKV; it is a - // transient hand-off that lives only in memory. - partialize: ({ scannedCustomerKey: _scannedCustomerKey, ...rest }) => - rest, + // Never write the raw scanned credentials to MMKV; they are a transient + // hand-off that lives only in memory. + partialize: ({ scannedSetup: _scannedSetup, ...rest }) => rest, migrate: (persistedState: any, version: number) => { if (!persistedState || typeof persistedState !== "object") { return { variant: "default" }; diff --git a/dapps/pos-app/utils/device-setup-qr.test.ts b/dapps/pos-app/utils/device-setup-qr.test.ts new file mode 100644 index 000000000..fbc1cc97f --- /dev/null +++ b/dapps/pos-app/utils/device-setup-qr.test.ts @@ -0,0 +1,52 @@ +import { decodeDeviceSetupQr, encodeDeviceSetupQr } from "./device-setup-qr"; + +describe("device-setup-qr", () => { + it("round-trips a payload", () => { + const payload = { merchantId: "merchant_123", customerApiKey: "key_abc" }; + const decoded = decodeDeviceSetupQr(encodeDeviceSetupQr(payload)); + expect(decoded).toEqual(payload); + }); + + it("trims surrounding whitespace on decode", () => { + const raw = JSON.stringify({ + v: 1, + t: "wpay-keys", + m: " merchant_123 ", + k: " key_abc ", + }); + expect(decodeDeviceSetupQr(raw)).toEqual({ + merchantId: "merchant_123", + customerApiKey: "key_abc", + }); + }); + + it("returns null for non-JSON input", () => { + expect(decodeDeviceSetupQr("not a qr we made")).toBeNull(); + expect(decodeDeviceSetupQr("wc:1234@2?relay=...")).toBeNull(); + }); + + it("returns null for JSON without our type tag", () => { + expect( + decodeDeviceSetupQr(JSON.stringify({ m: "merchant", k: "key" })), + ).toBeNull(); + }); + + it("returns null for an unsupported version", () => { + expect( + decodeDeviceSetupQr( + JSON.stringify({ v: 99, t: "wpay-keys", m: "merchant", k: "key" }), + ), + ).toBeNull(); + }); + + it("returns null when a field is missing or empty", () => { + expect( + decodeDeviceSetupQr(JSON.stringify({ v: 1, t: "wpay-keys", m: "x" })), + ).toBeNull(); + expect( + decodeDeviceSetupQr( + JSON.stringify({ v: 1, t: "wpay-keys", m: "", k: "key" }), + ), + ).toBeNull(); + }); +}); diff --git a/dapps/pos-app/utils/device-setup-qr.ts b/dapps/pos-app/utils/device-setup-qr.ts new file mode 100644 index 000000000..c9394aff5 --- /dev/null +++ b/dapps/pos-app/utils/device-setup-qr.ts @@ -0,0 +1,70 @@ +/** + * Codec for the "device setup" QR code used to provision a new POS device. + * + * The web build exports the merchant's credentials as a QR; a POS device scans + * it to fill both fields in one go. Keys only ever flow web -> POS: native + * builds never produce this payload, so a POS device can't leak credentials. + * + * Keep encode/decode together so producer (export) and consumer (scanner) + * can't drift. + */ + +export interface DeviceSetupPayload { + merchantId: string; + customerApiKey: string; +} + +// Marker so the scanner can tell our QR apart from any other QR the camera +// happens to see. Bump VERSION if the shape ever changes. +const TYPE = "wpay-keys"; +const VERSION = 1; + +interface RawPayload { + v: number; + t: string; + m: string; + k: string; +} + +export function encodeDeviceSetupQr(payload: DeviceSetupPayload): string { + const raw: RawPayload = { + v: VERSION, + t: TYPE, + m: payload.merchantId, + k: payload.customerApiKey, + }; + return JSON.stringify(raw); +} + +/** + * Parses a scanned QR string. Returns the credentials only when the payload is + * a valid, current device-setup QR; returns null for anything else (foreign + * QRs, malformed JSON, wrong version) so callers can show an error. + */ +export function decodeDeviceSetupQr(raw: string): DeviceSetupPayload | null { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + + if (typeof parsed !== "object" || parsed === null) { + return null; + } + + const candidate = parsed as Partial; + if (candidate.t !== TYPE || candidate.v !== VERSION) { + return null; + } + + const merchantId = typeof candidate.m === "string" ? candidate.m.trim() : ""; + const customerApiKey = + typeof candidate.k === "string" ? candidate.k.trim() : ""; + + if (!merchantId || !customerApiKey) { + return null; + } + + return { merchantId, customerApiKey }; +} diff --git a/package-lock.json b/package-lock.json index d75fd541b..454c858db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "medan-v3", + "name": "react-native-examples", "lockfileVersion": 3, "requires": true, "packages": {} From 951994d47f019759541f55a0b1108867977dfb4e Mon Sep 17 00:00:00 2001 From: ignaciosantise <25931366+ignaciosantise@users.noreply.github.com> Date: Fri, 29 May 2026 16:28:45 -0300 Subject: [PATCH 3/7] fix(pos-app): address Copilot review on key-management PR - Guard export-keys to web-only (native navigates away, never reveals the QR) - Fix off-by-one in PIN "attempts remaining" (read post-increment count) - Only auto-request camera when permission is undetermined (no re-prompt loop) - On lockout, send the user back instead of leaving a blank gated screen Co-Authored-By: Claude Opus 4.8 --- dapps/pos-app/app/export-keys.tsx | 38 +++++++++++++++---------- dapps/pos-app/app/scan-customer-key.tsx | 7 ++++- dapps/pos-app/app/settings.tsx | 2 +- dapps/pos-app/app/update-keys.tsx | 27 +++++++++--------- dapps/pos-app/hooks/use-pin-gate.ts | 28 ++++++++++-------- 5 files changed, 60 insertions(+), 42 deletions(-) diff --git a/dapps/pos-app/app/export-keys.tsx b/dapps/pos-app/app/export-keys.tsx index b356cb21b..7bb2b9223 100644 --- a/dapps/pos-app/app/export-keys.tsx +++ b/dapps/pos-app/app/export-keys.tsx @@ -10,9 +10,11 @@ import { useSettingsStore } from "@/store/useSettingsStore"; import { encodeDeviceSetupQr } from "@/utils/device-setup-qr"; import { router } from "expo-router"; import { useCallback, useEffect, useRef, useState } from "react"; -import { StyleSheet, View } from "react-native"; +import { Platform, StyleSheet, View } from "react-native"; import { ScrollView } from "react-native-gesture-handler"; +const isWeb = Platform.OS === "web"; + export default function ExportKeysScreen() { const theme = useTheme(); const storedMerchantId = useSettingsStore((state) => state.merchantId); @@ -37,12 +39,26 @@ export default function ExportKeysScreen() { const [isLoading, setIsLoading] = useState(true); const startedRef = useRef(false); - // Require a PIN once, before anything sensitive is read or shown. + const leaveScreen = useCallback(() => { + if (router.canGoBack()) { + router.back(); + } else { + router.replace("/update-keys"); + } + }, []); + + // Export is web-only: native builds must have no path to reveal credentials. + // Require a PIN once, before anything sensitive is read or shown; a + // locked-out user is sent back rather than left on a blank screen. useEffect(() => { if (startedRef.current) return; startedRef.current = true; - requireAuth(() => setUnlocked(true)); - }, [requireAuth]); + if (!isWeb) { + leaveScreen(); + return; + } + requireAuth(() => setUnlocked(true), leaveScreen); + }, [requireAuth, leaveScreen]); // Read the customer key only after the user has unlocked. useEffect(() => { @@ -85,16 +101,8 @@ export default function ExportKeysScreen() { // Cancelling the PIN prompt leaves the screen entirely. const handleCancel = useCallback(() => { cancel(); - router.back(); - }, [cancel]); - - const goToUpdateKeys = useCallback(() => { - if (router.canGoBack()) { - router.back(); - } else { - router.replace("/update-keys"); - } - }, []); + leaveScreen(); + }, [cancel, leaveScreen]); return ( @@ -114,7 +122,7 @@ export default function ExportKeysScreen() { Set a merchant ID and customer API key first. )} - {/* Share this device's keys so another can be set up (web only). */} - {isWeb && ( + {/* Share this device's keys so another can be set up (web only, + and never the bundled default keys). */} + {isWeb && canExport && (