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,
};