diff --git a/dapps/pos-app/app.json b/dapps/pos-app/app.json index d7d79f67..9c769648 100644 --- a/dapps/pos-app/app.json +++ b/dapps/pos-app/app.json @@ -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 df15092d..85ca01ee 100644 --- a/dapps/pos-app/app/_layout.tsx +++ b/dapps/pos-app/app/_layout.tsx @@ -185,6 +185,12 @@ export default Sentry.wrap(function RootLayout() { }} /> + + + diff --git a/dapps/pos-app/app/export-keys.tsx b/dapps/pos-app/app/export-keys.tsx new file mode 100644 index 00000000..571624b6 --- /dev/null +++ b/dapps/pos-app/app/export-keys.tsx @@ -0,0 +1,221 @@ +import { Button } from "@/components/button"; +import { PinModal } from "@/components/pin-modal"; +import QRCode from "@/components/qr-code"; +import { ThemedText } from "@/components/themed-text"; +import { BorderRadius, Spacing } from "@/constants/spacing"; +import { useBiometricAuth } from "@/hooks/use-biometric-auth"; +import { usePinGate } from "@/hooks/use-pin-gate"; +import { useTheme } from "@/hooks/use-theme-color"; +import { useSettingsStore } from "@/store/useSettingsStore"; +import { encodeDeviceSetupQr } from "@/utils/device-setup-qr"; +import { MerchantConfig } from "@/utils/merchant-config"; +import { router } from "expo-router"; +import { useCallback, useEffect, useRef, useState } from "react"; +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); + 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); + + const leaveScreen = useCallback(() => { + if (router.canGoBack()) { + router.back(); + } else { + router.replace("/update-keys"); + } + }, []); + + // Export is web-only (native must have no path to reveal credentials) and + // never exposes the bundled default keys. Require a PIN once before anything + // sensitive is read; a locked-out user is sent back, not left on a blank + // screen. + useEffect(() => { + if (startedRef.current) return; + startedRef.current = true; + if (!isWeb || MerchantConfig.isUsingDefaultKeys(storedMerchantId)) { + leaveScreen(); + return; + } + requireAuth(() => setUnlocked(true), leaveScreen); + }, [requireAuth, leaveScreen, storedMerchantId]); + + // 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(); + leaveScreen(); + }, [cancel, leaveScreen]); + + 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 new file mode 100644 index 00000000..20f66006 --- /dev/null +++ b/dapps/pos-app/app/scan-customer-key.tsx @@ -0,0 +1,196 @@ +import { Button } from "@/components/button"; +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, + 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"; + +// 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; +const scanAreaTop = (height - SCAN_AREA_SIZE) / 3; + +export default function ScanCustomerKeyScreen() { + const { bottom } = useSafeAreaInsets(); + const theme = useTheme(); + const [permission, requestPermission] = useCameraPermissions(); + 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); + const requestedRef = useRef(false); + + // Ask for camera access only when it hasn't been decided yet. Re-requesting + // after a denial would re-prompt (or no-op loop) on every re-render. + useEffect(() => { + if (requestedRef.current || !permission) return; + if (permission.status === "undetermined") { + requestedRef.current = true; + requestPermission(); + } + }, [permission, requestPermission]); + + const onBarcodeScanned = useCallback( + (result: BarcodeScanningResult) => { + 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; + setScannedSetup({ + merchantId: payload.merchantId, + customerApiKey: payload.customerApiKey, + }); + router.back(); + }, + [setScannedSetup], + ); + + const goBack = () => router.back(); + + return ( + + {permission?.granted && ( + + )} + + {/* Dimmed surround (plain views — no blur/svg deps) */} + + + + + + {/* Scan-area frame */} + + + + + {permission?.granted + ? "Scan a setup code from another device" + : "Camera access is off. Allow it in your device settings to scan."} + + + + + + ); +} + +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"], + }, + cancelButton: { + position: "absolute", + left: Spacing["spacing-5"], + right: Spacing["spacing-5"], + height: 48, + borderRadius: BorderRadius["4"], + alignItems: "center", + justifyContent: "center", + }, + 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 36291354..d585af2d 100644 --- a/dapps/pos-app/app/settings.tsx +++ b/dapps/pos-app/app/settings.tsx @@ -1,17 +1,13 @@ -import { Button } from "@/components/button"; import { Card } from "@/components/card"; -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, Variants } 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"; import { useSettingsStore } from "@/store/useSettingsStore"; import { ThemeMode } from "@/utils/types"; @@ -27,17 +23,11 @@ import { showErrorToast } from "@/utils/toast"; import * as Application from "expo-application"; import Constants from "expo-constants"; 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[] = [ { @@ -77,42 +67,17 @@ export default function SettingsScreen() { const setNfcEnabled = useSettingsStore((state) => state.setNfcEnabled); const nfcCapabilities = useNfcCapabilities(); const addLog = useLogsStore((state) => state.addLog); - 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) => ({ @@ -146,11 +111,7 @@ export default function SettingsScreen() { variant !== "default" && !Variants[variant].allowThemeToggle; const closeSheet = () => { - if (activeSheet === "customerApiKey") { - resetCustomerApiKeyInput(); - } setActiveSheet(null); - setIsEditingCustomerKey(false); }; const handleThemeModeChange = (value: ThemeMode) => { @@ -168,23 +129,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; @@ -241,23 +185,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 +309,6 @@ export default function SettingsScreen() { onChange={handleCurrencyChange} /> - - {/* Merchant ID Bottom Sheet */} - - - - - - - - {/* Customer API Key Bottom Sheet */} - - - - - - - - {/* PIN Modal */} - ); } @@ -545,26 +345,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 00000000..4d8e0423 --- /dev/null +++ b/dapps/pos-app/app/update-keys.tsx @@ -0,0 +1,381 @@ +import { Button } from "@/components/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 { 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, 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 scannedSetup = useSettingsStore((state) => state.scannedSetup); + const setScannedSetup = useSettingsStore((state) => state.setScannedSetup); + + const { + activeModal, + pinError, + requireAuth, + handlePinVerifyComplete, + handlePinSetupComplete, + handleBiometricSuccess, + handleBiometricFailure, + cancel, + } = usePinGate(); + const { biometricLabel, canUseBiometric, authenticate } = useBiometricAuth(); + + // Leave the screen (after save, on cancel, or when locked out). + const leaveScreen = useCallback(() => { + if (router.canGoBack()) { + router.back(); + } + }, []); + + // Require a PIN once, on entry. The form stays hidden until it's unlocked; + // a locked-out user is sent back rather than left on a blank screen. + const [unlocked, setUnlocked] = useState(false); + const startedRef = useRef(false); + useEffect(() => { + if (startedRef.current) return; + startedRef.current = true; + requireAuth(() => setUnlocked(true), leaveScreen); + }, [requireAuth, leaveScreen]); + + const { + merchantIdInput, + customerApiKeyInput, + storedMerchantId, + merchantIdChanged, + customerKeyRequired, + isUpdateKeysConfirmDisabled, + hasStoredCustomerApiKey, + handleMerchantIdInputChange, + handleCustomerApiKeyInputChange, + handleUpdateKeysConfirm, + } = useMerchantFlow(leaveScreen); + + // Show a masked stand-in so the user knows a key is already saved, until they + // start editing. The masking drops once the merchant ID changes (the old key + // no longer applies) so the now-required field reads as empty. + const [keyEditing, setKeyEditing] = useState(false); + const showMaskedKey = + hasStoredCustomerApiKey && + !merchantIdChanged && + !keyEditing && + customerApiKeyInput.length === 0; + + // 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 (scannedSetup) { + if (scannedSetup.merchantId) { + handleMerchantIdInputChange(scannedSetup.merchantId); + } + if (scannedSetup.customerApiKey) { + handleCustomerApiKeyInputChange(scannedSetup.customerApiKey); + } + setScannedSetup(null); + } + }, [ + scannedSetup, + handleMerchantIdInputChange, + handleCustomerApiKeyInputChange, + setScannedSetup, + ]), + ); + + const handleBiometricAuth = useCallback(async () => { + const success = await authenticate(`Use ${biometricLabel} to update keys`); + if (success) { + handleBiometricSuccess(); + } else { + handleBiometricFailure(); + } + }, [ + authenticate, + biometricLabel, + handleBiometricSuccess, + handleBiometricFailure, + ]); + + // Cancelling the PIN prompt leaves the screen entirely. + const handleCancel = useCallback(() => { + cancel(); + leaveScreen(); + }, [cancel, leaveScreen]); + + const handleResetToDefault = useCallback(() => { + const defaultMerchantId = MerchantConfig.getDefaultMerchantId(); + const defaultApiKey = MerchantConfig.getDefaultCustomerApiKey(); + if (defaultMerchantId) { + handleMerchantIdInputChange(defaultMerchantId); + } + if (defaultApiKey) { + handleCustomerApiKeyInputChange(defaultApiKey); + } + }, [handleMerchantIdInputChange, handleCustomerApiKeyInputChange]); + + // Export shares the device's currently active credentials, so it requires + // both to be saved — and never the bundled default keys. + const canExport = + !!storedMerchantId && + hasStoredCustomerApiKey && + !MerchantConfig.isUsingDefaultKeys(storedMerchantId); + + const inputStyle = [ + styles.input, + { + borderColor: theme["border-primary"], + color: theme["text-primary"], + backgroundColor: theme["foreground-primary"], + }, + ]; + + return ( + + {unlocked && ( + <> + + {/* Merchant ID */} + + + Merchant ID + + + + + {/* Customer API Key */} + + + Customer API key + + { + if (!keyEditing) setKeyEditing(true); + handleCustomerApiKeyInputChange(value); + }} + onFocus={() => setKeyEditing(true)} + placeholder="wcp_…" + placeholderTextColor={theme["text-secondary"]} + autoCapitalize="none" + autoCorrect={false} + secureTextEntry={!showMaskedKey} + style={[ + inputStyle, + customerKeyRequired && { borderColor: theme["icon-error"] }, + ]} + /> + + + + + {/* Provision this device by scanning another's export QR. */} + {isNative && ( + + )} + + {/* Share this device's keys so another can be set up (web only, + and never the bundled default keys). */} + {isWeb && canExport && ( + + )} + + + + {MerchantConfig.hasEnvDefaults() && ( + + + Reset to default + + + )} + + + )} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingHorizontal: Spacing["spacing-5"], + }, + content: { + paddingTop: Spacing["spacing-5"], + 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"], + }, + input: { + borderWidth: 1, + borderRadius: BorderRadius["4"], + paddingHorizontal: Spacing["spacing-5"], + paddingVertical: Spacing["spacing-4"], + fontSize: 16, + lineHeight: 18, + fontFamily: "KH Teka", + height: 60, + }, + footer: { + paddingTop: Spacing["spacing-4"], + gap: Spacing["spacing-3"], + ...(isWeb && { paddingHorizontal: Spacing["spacing-2"] }), + }, + 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"], + justifyContent: "center", + alignItems: "center", + }, + saveButtonLabel: { + textAlign: "center", + }, + resetLink: { + alignSelf: "center", + paddingVertical: Spacing["spacing-2"], + }, + resetLabel: { + textAlign: "center", + textDecorationLine: "underline", + }, +}); diff --git a/dapps/pos-app/hooks/use-merchant-flow.ts b/dapps/pos-app/hooks/use-merchant-flow.ts index 3941792c..92e99933 100644 --- a/dapps/pos-app/hooks/use-merchant-flow.ts +++ b/dapps/pos-app/hooks/use-merchant-flow.ts @@ -3,291 +3,123 @@ import { useSettingsStore } from "@/store/useSettingsStore"; 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; +// The Update keys screen gates access with a PIN on entry (see usePinGate), so +// saving here writes directly — no second PIN prompt. -interface MerchantFlowState { - merchantIdInput: string; - customerApiKeyInput: string; - activeModal: ModalType; - pinError: string | null; - pendingValue: string | null; - pendingAction: PendingAction; -} - -const initialState: MerchantFlowState = { - merchantIdInput: "", - customerApiKeyInput: "", - activeModal: "none", - pinError: null, - pendingValue: null, - pendingAction: null, -}; - -export function useMerchantFlow() { +export function useMerchantFlow(onSaved?: () => void) { const storedMerchantId = useSettingsStore((state) => state.merchantId); const setMerchantId = useSettingsStore((state) => state.setMerchantId); - const clearMerchantId = useSettingsStore((state) => state.clearMerchantId); const setCustomerApiKey = useSettingsStore( (state) => state.setCustomerApiKey, ); 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 + // 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 resetCustomerApiKeyInput = useCallback(() => { - setState((prev) => ({ - ...prev, - customerApiKeyInput: "", - })); - }, []); - - const initiateSave = useCallback( - (value: string, action: PendingAction) => { - // Check if locked out - if (isLockedOut()) { - showErrorToast(formatLockoutMessage()); - return; - } - - const pinExists = isPinSet(); - - setState((prev) => ({ - ...prev, - pendingValue: value, - pendingAction: action, - activeModal: pinExists ? "pin-verify" : "pin-setup", - })); - }, - [isLockedOut, formatLockoutMessage, isPinSet], - ); - - const handleMerchantIdConfirm = useCallback(() => { - const trimmedMerchantId = state.merchantIdInput.trim(); - - // Check if value changed - if (trimmedMerchantId === storedMerchantId) { + 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 = + trimmedMerchantId.length > 0 && + trimmedMerchantId !== (storedMerchantId ?? ""); + const customerKeyProvided = trimmedApiKey.length > 0; + + // A new merchant ID invalidates the old key (it belongs to the old merchant), + // so changing it always requires entering a fresh customer key in the same + // save. This also blocks swapping the merchant ID to keep the previous key. + const customerKeyRequired = merchantIdChanged && !customerKeyProvided; + + // Enabled when there's a valid change to commit and no required field missing. + const isUpdateKeysConfirmDisabled = + (!merchantIdChanged && !customerKeyProvided) || customerKeyRequired; + + const handleUpdateKeysConfirm = useCallback(async () => { + if (isUpdateKeysConfirmDisabled) { return; } - // 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) { - return; - } + try { + const saved: string[] = []; + + if (merchantIdChanged) { + setMerchantId(trimmedMerchantId); + // Don't log the value — the merchant ID is an account identifier and + // logs are viewable/exportable from Settings. + addLog( + "info", + "Merchant ID updated", + "settings", + "handleUpdateKeysConfirm", + ); + saved.push("Merchant ID"); + } - initiateSave(trimmedApiKey, "customer-api-key"); - }, [state.customerApiKeyInput, initiateSave]); + if (customerKeyProvided) { + await setCustomerApiKey(trimmedApiKey); + addLog( + "info", + "Customer API key updated", + "settings", + "handleUpdateKeysConfirm", + ); + saved.push("Customer API key"); + } - const completeSave = useCallback(async () => { - if (state.pendingValue === null || !state.pendingAction) { - return; - } + setCustomerApiKeyInput(""); - 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"); - addLog("info", "Customer API key updated", "settings", "completeSave"); - } + showSuccessToast(saved.length > 1 ? "Keys saved" : `${saved[0]} saved`); - setState((prev) => ({ - ...prev, - pendingValue: null, - pendingAction: null, - activeModal: "none", - })); + 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.pendingValue, - state.pendingAction, + isUpdateKeysConfirmDisabled, + merchantIdChanged, + customerKeyProvided, + trimmedMerchantId, + trimmedApiKey, setMerchantId, - clearMerchantId, setCustomerApiKey, addLog, + onSaved, ]); - 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, - pendingValue: null, - pendingAction: null, - merchantIdInput: storedMerchantId ?? "", - customerApiKeyInput: "", // Clear input on cancel - })); - }, [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; - - const hasStoredCustomerApiKey = isCustomerApiKeySet; - return { - // State - merchantIdInput: state.merchantIdInput, - customerApiKeyInput: state.customerApiKeyInput, - activeModal: state.activeModal, - pinError: state.pinError, + merchantIdInput, + customerApiKeyInput, storedMerchantId, - isMerchantIdConfirmDisabled, - isCustomerApiKeyConfirmDisabled, - hasStoredCustomerApiKey, - - // Handlers + merchantIdChanged, + customerKeyRequired, + isUpdateKeysConfirmDisabled, + hasStoredCustomerApiKey: isCustomerApiKeySet, handleMerchantIdInputChange, handleCustomerApiKeyInputChange, - resetCustomerApiKeyInput, - handleMerchantIdConfirm, - handleCustomerApiKeyConfirm, - handlePinVerifyComplete, - handleBiometricAuthSuccess, - handleBiometricAuthFailure, - handlePinSetupComplete, - handleCancelSecurityFlow, + handleUpdateKeysConfirm, }; } 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 00000000..cf1c83c8 --- /dev/null +++ b/dapps/pos-app/hooks/use-pin-gate.ts @@ -0,0 +1,124 @@ +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 [activeModal, setActiveModal] = useState("none"); + const [pinError, setPinError] = useState(null); + const onSuccessRef = useRef<(() => void | Promise) | null>(null); + const onLockoutRef = useRef<(() => void) | 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]); + + // `onLockout` lets a caller react (e.g. navigate away) when the gate can't + // be opened — either locked out on entry, or tripped into lockout mid-verify. + const requireAuth = useCallback( + (onSuccess: () => void | Promise, onLockout?: () => void) => { + if (isLockedOut()) { + showErrorToast(formatLockoutMessage()); + onLockout?.(); + return; + } + onSuccessRef.current = onSuccess; + onLockoutRef.current = onLockout ?? null; + setPinError(null); + setActiveModal(isPinSet() ? "pin-verify" : "pin-setup"); + }, + [isLockedOut, formatLockoutMessage, isPinSet], + ); + + const runSuccess = useCallback(async () => { + const onSuccess = onSuccessRef.current; + onSuccessRef.current = null; + onLockoutRef.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()) { + const onLockout = onLockoutRef.current; + onSuccessRef.current = null; + onLockoutRef.current = null; + setActiveModal("none"); + showErrorToast(formatLockoutMessage()); + onLockout?.(); + } else { + // Read the post-increment count so "N attempts remaining" is accurate. + const attemptsLeft = + MAX_PIN_ATTEMPTS - useSettingsStore.getState().pinFailedAttempts; + setPinError( + `Incorrect PIN. ${attemptsLeft} attempt${attemptsLeft !== 1 ? "s" : ""} remaining.`, + ); + } + }, + [verifyPin, isLockedOut, formatLockoutMessage, 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; + onLockoutRef.current = null; + setActiveModal("none"); + setPinError(null); + }, []); + + return { + activeModal, + pinError, + requireAuth, + handlePinVerifyComplete, + handlePinSetupComplete, + handleBiometricSuccess, + handleBiometricFailure, + cancel, + }; +} diff --git a/dapps/pos-app/package-lock.json b/dapps/pos-app/package-lock.json index 70d0c9ed..8c9d60c8 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 c26b15b9..2d9b4f10 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 ac321de6..a73b357d 100644 --- a/dapps/pos-app/store/useSettingsStore.ts +++ b/dapps/pos-app/store/useSettingsStore.ts @@ -63,6 +63,11 @@ interface SettingsStore { merchantId: string | null; isCustomerApiKeySet: boolean; + // Transient hand-off from the QR scanner to the Update keys screen. + // Never persisted — it holds raw credentials scanned from a setup QR + // (see partialize). + scannedSetup: { merchantId?: string; customerApiKey?: string } | null; + // Transaction filters transactionFilter: TransactionFilterType; dateRangeFilter: DateRangeFilterType; @@ -84,6 +89,9 @@ interface SettingsStore { getVariantPrinterLogo: () => string; setCurrency: (currency: CurrencyCode) => void; setMerchantId: (merchantId: string | null) => void; + setScannedSetup: ( + value: { merchantId?: string; customerApiKey?: string } | null, + ) => void; clearMerchantId: () => Promise; setCustomerApiKey: (apiKey: string | null) => Promise; clearCustomerApiKey: () => Promise; @@ -114,6 +122,7 @@ export const useSettingsStore = create()( _hasHydrated: false, merchantId: null, isCustomerApiKeySet: false, + scannedSetup: null, transactionFilter: "all", dateRangeFilter: "today", isPinHashSet: false, @@ -134,6 +143,7 @@ export const useSettingsStore = create()( getVariantPrinterLogo: () => Variants[get().variant]?.printerLogo ?? DEFAULT_LOGO_BASE64, setCurrency: (currency: CurrencyCode) => set({ currency }), + setScannedSetup: (value) => set({ scannedSetup: value }), setMerchantId: (merchantId: string | null) => { // If clearing, reset to env default (unless embedded — parent provides credentials) if (!merchantId || merchantId.trim() === "") { @@ -281,6 +291,9 @@ export const useSettingsStore = create()( name: "settings", version: 16, storage, + // 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 00000000..fbc1cc97 --- /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 00000000..c9394aff --- /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/dapps/pos-app/utils/merchant-config.ts b/dapps/pos-app/utils/merchant-config.ts index 63ab49de..618a41f4 100644 --- a/dapps/pos-app/utils/merchant-config.ts +++ b/dapps/pos-app/utils/merchant-config.ts @@ -7,4 +7,8 @@ export const MerchantConfig = { getDefaultCustomerApiKey: (): string | null => DEFAULT_CUSTOMER_API_KEY, hasEnvDefaults: (): boolean => Boolean(DEFAULT_MERCHANT_ID && DEFAULT_CUSTOMER_API_KEY), + // True when the device is still on the bundled default credentials — there's + // nothing worth exporting to another device in that case. + isUsingDefaultKeys: (merchantId: string | null): boolean => + DEFAULT_MERCHANT_ID !== null && merchantId === DEFAULT_MERCHANT_ID, };