Skip to content

feat(pos-app): unified Update keys screen with QR import/export + PIN gate#517

Draft
ignaciosantise wants to merge 5 commits into
mainfrom
chore/pos-app-key-management-ux
Draft

feat(pos-app): unified Update keys screen with QR import/export + PIN gate#517
ignaciosantise wants to merge 5 commits into
mainfrom
chore/pos-app-key-management-ux

Conversation

@ignaciosantise
Copy link
Copy Markdown
Collaborator

Summary

Reworks merchant credential management in pos-app into a single, PIN-protected Update keys screen, and adds QR-based device provisioning so keys flow web → POS without manual typing.

  • Unified screen — replaces the two separate Merchant ID / Customer API key bottom sheets (and their double-modal PIN flow) with one update-keys screen. The merchant ID and customer key are edited together and saved in one step.
  • PIN gate on entry — the screen is gated by a PIN/biometric on open via a reusable usePinGate hook; the form stays hidden until unlocked, and saving no longer re-prompts. useMerchantFlow is slimmed to form + save only.
  • QR import (native) — an Import keys button opens an expo-camera scanner that reads a setup QR and fills both fields; rejects non-setup QRs.
  • QR export (web) — an Export keys screen renders a QR of the device's stored credentials (PIN-gated), so another device can be provisioned in one scan. A shared device-setup-qr codec (with unit tests) keeps producer and scanner in sync.
  • Security — native builds have no export path, so a POS device can't leak credentials; the scanned key is held in a non-persisted store field (never written to MMKV).
  • Polish — UX-writing audit fixes on all new copy, equal button heights, web focus-ring padding, and return-to-previous-screen after save.

Provisioning flow

flowchart LR
  subgraph Web["Web (admin)"]
    UKw["Update keys (PIN)"] --> EXP["Export keys\nQR of merchant ID + customer key"]
  end
  subgraph POS["POS device (native)"]
    UKn["Update keys (PIN)"] --> IMP["Import keys\nscan QR"]
    IMP --> FILL["Both fields filled"] --> SAVE["Save keys"]
  end
  EXP -. "scan" .-> IMP
Loading

Testing

  • npx tsc --noEmit, eslint, prettier clean (only 2 pre-existing unrelated tsc errors).
  • New device-setup-qr codec tests + store tests pass. Full jest run is OOM-limited in the sandbox; per-suite runs pass.
  • expo.android.versionCode bumped; expo-camera config plugin added.

🤖 Generated with Claude Code

ignaciosantise and others added 3 commits May 28, 2026 17:55
…r key

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…agement-ux

# Conflicts:
#	dapps/pos-app/app/settings.tsx
- Gate the Update keys screen with a PIN on entry (usePinGate); saving no
  longer prompts separately
- Add web-only Export keys screen (QR of merchant ID + customer key) and a
  native Import keys scanner, with a shared device-setup QR codec + tests
- Apply UX-writing audit fixes to the new copy and minor UI polish
  (equal button heights, web focus-ring padding, back after save)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 29, 2026 19:04
@vercel
Copy link
Copy Markdown

vercel Bot commented May 29, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
pos-demo Ready Ready Preview, Comment May 29, 2026 7:30pm

Request Review

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR refactors merchant credential management in pos-app into a single PIN-gated “Update keys” flow and adds QR-based provisioning (web export → native import) backed by a shared encode/decode codec.

Changes:

  • Added a shared device-setup-qr codec (+ unit tests) for exporting/importing merchant ID + customer API key via QR payloads.
  • Introduced a reusable usePinGate hook and reworked the merchant flow + settings navigation to use a dedicated update-keys screen instead of bottom sheets.
  • Added native QR scanning via expo-camera, plus a web QR export screen (PIN-gated) and related routing/config updates.

Reviewed changes

Copilot reviewed 12 out of 13 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
dapps/pos-app/utils/device-setup-qr.ts Implements encode/decode for device provisioning QR payloads.
dapps/pos-app/utils/device-setup-qr.test.ts Adds tests to keep QR producer/consumer in sync.
dapps/pos-app/store/useSettingsStore.ts Adds transient scannedSetup handoff and ensures it’s not persisted.
dapps/pos-app/package.json Adds expo-camera dependency for QR scanning.
dapps/pos-app/package-lock.json Locks new expo-camera and transitive dependencies.
dapps/pos-app/hooks/use-pin-gate.ts New shared PIN gate logic for sensitive screens/actions.
dapps/pos-app/hooks/use-merchant-flow.ts Simplifies to “form + save” (PIN gate moved to screen entry).
dapps/pos-app/app/update-keys.tsx New unified Update keys screen with PIN gate, import, export entry points.
dapps/pos-app/app/settings.tsx Replaces separate merchant/key sheets with a single “Update keys” entry.
dapps/pos-app/app/scan-customer-key.tsx New QR scanner screen (native provisioning) using expo-camera.
dapps/pos-app/app/export-keys.tsx New QR export screen (web provisioning) that renders the setup QR.
dapps/pos-app/app/_layout.tsx Registers new routes/screens for update/export/scan flows.
dapps/pos-app/app.json Bumps Android versionCode and adds expo-camera permission config.
Files not reviewed (1)
  • dapps/pos-app/package-lock.json: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +68 to +75
} else if (isLockedOut()) {
setActiveModal("none");
showErrorToast(formatLockoutMessage());
} else {
const attemptsLeft = MAX_PIN_ATTEMPTS - pinFailedAttempts;
setPinError(
`Incorrect PIN. ${attemptsLeft} attempt${attemptsLeft !== 1 ? "s" : ""} remaining.`,
);
Comment on lines +40 to +49
const requireAuth = useCallback(
(onSuccess: () => void | Promise<void>) => {
if (isLockedOut()) {
showErrorToast(formatLockoutMessage());
return;
}
onSuccessRef.current = onSuccess;
setPinError(null);
setActiveModal(isPinSet() ? "pin-verify" : "pin-setup");
},
Comment thread dapps/pos-app/app/export-keys.tsx Outdated
import { encodeDeviceSetupQr } from "@/utils/device-setup-qr";
import { router } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { StyleSheet, View } from "react-native";
Comment thread dapps/pos-app/app/export-keys.tsx Outdated
Comment on lines +41 to +45
useEffect(() => {
if (startedRef.current) return;
startedRef.current = true;
requireAuth(() => setUnlocked(true));
}, [requireAuth]);
Comment on lines +38 to +42
useEffect(() => {
if (!permission?.granted) {
requestPermission();
}
}, [permission, requestPermission]);
@ignaciosantise ignaciosantise marked this pull request as draft May 29, 2026 19:17
@ignaciosantise
Copy link
Copy Markdown
Collaborator Author

@claude review

@claude
Copy link
Copy Markdown

claude Bot commented May 29, 2026

Claude finished @ignaciosantise's task in 1m 34s —— View job


PR Review

  • Gather context and read changed files
  • Review core logic (QR codec, PIN gate, merchant flow)
  • Review new screens (update-keys, export-keys, scan-customer-key)
  • Review store changes and security model
  • Merge subagent checks (license, breaking changes, data classification)
  • Post final review

License compliance: ✅ All new deps (expo-camera + transitive) are MIT — no issues.
Breaking changes: ✅ Self-contained — useMerchantFlow API changed but the only consumer (settings.tsx) is updated in this same PR.


Found 8 issue(s)

Issue 1: export-keys screen accessible on native via deep link — credentials can leak

ID: export-keys-platform-guard-missing-a3f1
File: dapps/pos-app/app/export-keys.tsx:16
Severity: HIGH
Category: security

Context:

  • Pattern: ExportKeysScreen renders unconditionally on any platform; no Platform.OS guard exists. The update-keys.tsx correctly hides the "Export keys" button with {isWeb && ...}, but the route itself is registered and reachable on native via router.push('/export-keys') or a deep link.
  • Risk: The PR's stated security model ("native builds have no export path") is only one navigational hop away from being violated. Any deep link or programmatic navigation on a native device reaches the export screen and shows the credentials QR after PIN.
  • Impact: Credentials exfiltration from a native POS device.
  • Trigger: Any call to router.push('/export-keys') on a native build.

Recommendation:

export default function ExportKeysScreen() {
  // Web-only: native builds must never expose an export path.
  useEffect(() => {
    if (Platform.OS !== 'web') {
      router.replace('/update-keys');
    }
  }, []);

  if (Platform.OS !== 'web') return null;
  // ...rest of component

Fix this →


Issue 2: customerApiKey encoded as plaintext JSON in QR payload

ID: dcl-device-setup-qr-plaintext-api-key-in-qr-e3a7
File: dapps/pos-app/utils/device-setup-qr.ts:29-37
Severity: HIGH
Category: security / data_classification

Context:

  • Pattern: encodeDeviceSetupQr serializes customerApiKey directly into a plain JSON string (JSON.stringify(raw)). The resulting string is displayed as a QR code on screen with no timeout.
  • Risk: The customerApiKey grants full merchant account access. As plaintext JSON it is immediately usable by anyone who captures the QR — screenshot, photo, screen recording, shoulder surfing, or a security camera.
  • Impact: Full merchant account compromise from a single photo. The QR stays on screen indefinitely; there is no auto-dismiss or hide toggle.
  • Trigger: Any time the export screen is displayed (after PIN unlock on web).

Recommendation: At minimum, add an auto-dismiss timeout (e.g. 30–60 s) and a "tap to hide/show" toggle. Longer term, consider one-time-code symmetric encryption: importing device shows a short pairing code; exporting device encrypts the payload with it; the QR is useless without the matching code.


Issue 3: pinFailedAttempts off-by-one — wrong attempt count shown to user

ID: use-pin-gate-stale-attempts-count-7c2a
File: dapps/pos-app/hooks/use-pin-gate.ts:72
Severity: HIGH
Category: code_quality / security

Context:

  • Pattern: const attemptsLeft = MAX_PIN_ATTEMPTS - pinFailedAttempts uses the pre-increment snapshot of pinFailedAttempts captured from the store selector at render time. verifyPin() on line 65 increments pinFailedAttempts inside the store synchronously, but the closure still holds the old value.
  • Risk: After the first failure, pinFailedAttempts in the store is 1, but the closure sees 0, so the message reads "3 attempts remaining" instead of "2". After the second failure it shows "2" instead of "1". This makes the lockout warning misleading and could cause a user to expect one more attempt than they actually have.
  • Impact: User is surprised by lockout because the displayed count was wrong.
  • Trigger: Every failed PIN attempt.

Recommendation: Read the current count from the store after verifyPin() resolves:

const isValid = await verifyPin(pin);
if (isValid) {
  await runSuccess();
} else if (isLockedOut()) {
  setActiveModal("none");
  showErrorToast(formatLockoutMessage());
} else {
  // Read fresh value — verifyPin() already incremented it
  const currentAttempts = useSettingsStore.getState().pinFailedAttempts;
  const attemptsLeft = MAX_PIN_ATTEMPTS - currentAttempts;
  setPinError(`Incorrect PIN. ${attemptsLeft} attempt${attemptsLeft !== 1 ? "s" : ""} remaining.`);
}

Fix this →


Issue 4: Blank screen after lockout toast dismisses — no retry UI

ID: use-pin-gate-lockout-blank-screen-5d9b
File: dapps/pos-app/hooks/use-pin-gate.ts:42-50
Severity: MEDIUM
Category: code_quality

Context:

  • Pattern: When isLockedOut() is true, requireAuth() shows an error toast and returns early. Both update-keys and export-keys stay on the screen with unlocked = false, which hides the content behind the PIN modal. But no modal is shown and after the toast fades the user is left on a blank screen with no way to retry or navigate away.
  • Risk: Users on locked-out devices see an empty screen with no feedback or action.
  • Impact: Dead-end UX; user must background/foreground the app or force-quit.
  • Trigger: Any user who has been locked out navigating to update-keys or export-keys.

Recommendation: Return a boolean or expose a lockedOut state so callers can render a lockout message or navigate away:

// Option A: return false so screens can router.back()
const requireAuth = useCallback((onSuccess) => {
  if (isLockedOut()) {
    showErrorToast(formatLockoutMessage());
    return false;
  }
  // ...
  return true;
}, [...]);

// In update-keys.tsx / export-keys.tsx:
useEffect(() => {
  if (startedRef.current) return;
  startedRef.current = true;
  const granted = requireAuth(() => setUnlocked(true));
  if (!granted) router.back();
}, [requireAuth]);

Fix this →


Issue 5: Camera permission re-requested in denied state on every re-render

ID: scan-customer-key-permission-denied-loop-1e8c
File: dapps/pos-app/app/scan-customer-key.tsx:38-42
Severity: MEDIUM
Category: code_quality

Context:

  • Pattern: useEffect calls requestPermission() whenever !permission?.granted, which includes both undetermined and denied states. After a user explicitly denies the camera permission, re-renders (e.g. state updates) trigger repeated requestPermission() calls.
  • Risk: On Android, repeated requests after denial can permanently suppress the permission dialog (auto-deny). On iOS the second request is silently no-opped, but the effect still runs unnecessarily.
  • Impact: Users who deny permission may be unable to grant it later via a re-prompt; they'd need to go to device settings.
  • Trigger: Any state update that causes a re-render while camera permission is denied.

Recommendation:

useEffect(() => {
  if (permission?.status === 'undetermined') {
    requestPermission();
  }
}, [permission?.status, requestPermission]);

Fix this →


Issue 6: PIN stored as unsalted SHA-256 — rainbow table attack feasible

ID: use-settings-store-unsalted-pin-hash-c4f2
File: dapps/pos-app/store/useSettingsStore.ts:22-27
Severity: MEDIUM
Category: security

Context:

  • Pattern: hashPin hashes the 4-digit PIN with SHA256(pin) — no salt, no iterations. SHA-256 is a fast, general-purpose hash not designed for password storage.
  • Risk: A 4-digit PIN has exactly 10,000 possible values. A precomputed table of all 10,000 SHA-256(pin) values takes milliseconds to build and is ~640 KB. An attacker with access to secure storage (e.g. rooted device, ADB backup on an unencrypted device) can immediately reverse the hash.
  • Impact: PIN compromise on any device where secure storage is accessible.
  • Trigger: Any attacker with local device access who can read the secure storage value.

Recommendation: Use a slow KDF with a per-device salt stored separately. expo-crypto doesn't expose PBKDF2/Argon2 directly, but a simple improvement is to incorporate a stored random salt:

// On first setPin: generate and store a salt
const salt = Crypto.randomUUID();
await secureStorage.setItem(SECURE_STORAGE_KEYS.PIN_SALT, salt);
const hashedPin = await Crypto.digestStringAsync(
  Crypto.CryptoDigestAlgorithm.SHA256,
  salt + pin,
);

A proper KDF (PBKDF2) would be better but requires a native module.


Issue 7: QR renders with undefined uri during async key load

ID: export-keys-qr-undefined-uri-loading-8b3e
File: dapps/pos-app/app/export-keys.tsx:106-141
Severity: LOW
Category: code_quality

Context:

  • Pattern: The render logic is {!isLoading && !qrValue ? <error> : <QRCode uri={qrValue ?? undefined} />}. When isLoading is true, qrValue is null, so the QR component renders with uri={undefined} — the loading/error branch is not shown, the QR branch is shown with no data.
  • Risk: The QR component may render a broken/empty state before the key loads.
  • Trigger: Brief window between unlock and key retrieval.

Recommendation:

{isLoading ? (
  <ActivityIndicator />
) : !qrValue ? (
  // error state
) : (
  <QRCode size={280} uri={qrValue} arenaClear />
)}

Issue 8: merchantId logged verbatim — account identifier in log store

ID: dcl-use-merchant-flow-merchantid-in-logs-b9c2
File: dapps/pos-app/hooks/use-merchant-flow.ts:59
Severity: LOW
Category: data_classification

Context:

  • Pattern: addLog("info", \Merchant ID updated to: ${trimmedMerchantId}`, ...)` writes the full merchant ID into the log store on every save. Logs are viewable via "View logs" in settings and potentially exported in bug reports.
  • Impact: Merchant account identifier disclosed in any log export or support ticket.

Recommendation: Log the event without the value: addLog("info", "Merchant ID updated", "settings", "handleUpdateKeysConfirm").


Automated checks:

  • ✅ License: all new deps (expo-camera, barcode-detector, zxing-wasm, type-fest, tagged-tag) are MIT
  • ✅ Breaking changes: self-contained — only internal consumer (settings.tsx) updated in same PR
  • ✅ PR size: 13 files, ~1,600 lines — within threshold

…agement-ux

# Conflicts:
#	dapps/pos-app/app/settings.tsx
import { StyleSheet, View } from "react-native";
import { ScrollView } from "react-native-gesture-handler";

export default function ExportKeysScreen() {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Auto Review Issue: export-keys screen accessible on native via deep link — credentials can leak

Severity: HIGH
Category: security
Tool: Claude Auto Review

Context:

  • Pattern: ExportKeysScreen renders unconditionally on any platform; no Platform.OS guard exists. The update-keys.tsx correctly hides the "Export keys" button with {isWeb && ...}, but the route itself is registered and reachable on native via router.push('/export-keys') or a deep link.
  • Risk: The PR's stated security model ("native builds have no export path") is only one navigational hop away from being violated. Any deep link or programmatic navigation on a native device reaches the export screen and shows the credentials QR after PIN.
  • Impact: Credentials exfiltration from a native POS device.
  • Trigger: Any call to router.push('/export-keys') on a native build.

Recommendation:

export default function ExportKeysScreen() {
  // Web-only: native builds must never expose an export path.
  useEffect(() => {
    if (Platform.OS !== 'web') {
      router.replace('/update-keys');
    }
  }, []);

  if (Platform.OS !== 'web') return null;
  // ...rest of component

Fix this →

k: string;
}

export function encodeDeviceSetupQr(payload: DeviceSetupPayload): string {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Auto Review Issue: customerApiKey encoded as plaintext JSON in QR payload

Severity: HIGH
Category: security / data_classification
Tool: Claude Auto Review

Context:

  • Pattern: encodeDeviceSetupQr serializes customerApiKey directly into a plain JSON string (JSON.stringify(raw)). The resulting string is displayed as a QR code on screen with no timeout.
  • Risk: The customerApiKey grants full merchant account access. As plaintext JSON it is immediately usable by anyone who captures the QR — screenshot, photo, screen recording, shoulder surfing, or a security camera.
  • Impact: Full merchant account compromise from a single photo. The QR stays on screen indefinitely; there is no auto-dismiss or hide toggle.
  • Trigger: Any time the export screen is displayed (after PIN unlock on web).

Recommendation: At minimum, add an auto-dismiss timeout (e.g. 30–60 s) and a "tap to hide/show" toggle. Longer term, consider one-time-code symmetric encryption: importing device shows a short pairing code; exporting device encrypts the payload with it; the QR is useless without the matching code.

Comment thread dapps/pos-app/hooks/use-pin-gate.ts Outdated
setActiveModal("none");
showErrorToast(formatLockoutMessage());
} else {
const attemptsLeft = MAX_PIN_ATTEMPTS - pinFailedAttempts;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Auto Review Issue: pinFailedAttempts off-by-one — wrong attempt count shown to user

Severity: HIGH
Category: code_quality / security
Tool: Claude Auto Review

Context:

  • Pattern: const attemptsLeft = MAX_PIN_ATTEMPTS - pinFailedAttempts uses the pre-increment snapshot of pinFailedAttempts captured from the store selector at render time. verifyPin() on line 65 increments pinFailedAttempts inside the store synchronously, but the closure still holds the old value.
  • Risk: After the first failure, pinFailedAttempts in the store is 1, but the closure sees 0, so the message reads "3 attempts remaining" instead of "2". After the second failure it shows "2" instead of "1". This makes the lockout warning misleading and could cause a user to expect one more attempt than they actually have.
  • Impact: User is surprised by lockout because the displayed count was wrong.
  • Trigger: Every failed PIN attempt.

Recommendation: Read the current count from the store after verifyPin() resolves:

const isValid = await verifyPin(pin);
if (isValid) {
  await runSuccess();
} else if (isLockedOut()) {
  setActiveModal("none");
  showErrorToast(formatLockoutMessage());
} else {
  // Read fresh value — verifyPin() already incremented it
  const currentAttempts = useSettingsStore.getState().pinFailedAttempts;
  const attemptsLeft = MAX_PIN_ATTEMPTS - currentAttempts;
  setPinError(`Incorrect PIN. ${attemptsLeft} attempt${attemptsLeft !== 1 ? "s" : ""} remaining.`);
}

Fix this →


const requireAuth = useCallback(
(onSuccess: () => void | Promise<void>) => {
if (isLockedOut()) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Auto Review Issue: Blank screen after lockout toast dismisses — no retry UI

Severity: MEDIUM
Category: code_quality
Tool: Claude Auto Review

Context:

  • Pattern: When isLockedOut() is true, requireAuth() shows an error toast and returns early. Both update-keys and export-keys stay on the screen with unlocked = false, which hides the content behind the PIN modal. But no modal is shown and after the toast fades the user is left on a blank screen with no way to retry or navigate away.
  • Risk: Users on locked-out devices see an empty screen with no feedback or action.
  • Impact: Dead-end UX; user must background/foreground the app or force-quit.
  • Trigger: Any user who has been locked out navigating to update-keys or export-keys.

Recommendation: Return a boolean or expose a lockedOut state so callers can render a lockout message or navigate away:

// Option A: return false so screens can router.back()
const requireAuth = useCallback((onSuccess) => {
  if (isLockedOut()) {
    showErrorToast(formatLockoutMessage());
    return false;
  }
  // ...
  return true;
}, [...]);

// In update-keys.tsx / export-keys.tsx:
useEffect(() => {
  if (startedRef.current) return;
  startedRef.current = true;
  const granted = requireAuth(() => setUnlocked(true));
  if (!granted) router.back();
}, [requireAuth]);

Fix this →

const hasScannedRef = useRef(false);
const lastInvalidToastRef = useRef(0);

useEffect(() => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Auto Review Issue: Camera permission re-requested in denied state on every re-render

Severity: MEDIUM
Category: code_quality
Tool: Claude Auto Review

Context:

  • Pattern: useEffect calls requestPermission() whenever !permission?.granted, which includes both undetermined and denied states. After a user explicitly denies the camera permission, re-renders (e.g. state updates) trigger repeated requestPermission() calls.
  • Risk: On Android, repeated requests after denial can permanently suppress the permission dialog (auto-deny). On iOS the second request is silently no-opped, but the effect still runs unnecessarily.
  • Impact: Users who deny permission may be unable to grant it later via a re-prompt; they'd need to go to device settings.
  • Trigger: Any state update that causes a re-render while camera permission is denied.

Recommendation:

useEffect(() => {
  if (permission?.status === 'undetermined') {
    requestPermission();
  }
}, [permission?.status, requestPermission]);

Fix this →

contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
{!isLoading && !qrValue ? (
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Auto Review Issue: QR renders with undefined uri during async key load

Severity: LOW
Category: code_quality
Tool: Claude Auto Review

Context:

  • Pattern: The render logic is {!isLoading && !qrValue ? <error> : <QRCode uri={qrValue ?? undefined} />}. When isLoading is true, qrValue is null, so the QR component renders with uri={undefined} — the loading/error branch is not shown, the QR branch is shown with no data.
  • Risk: The QR component may render a broken/empty state before the key loads.
  • Trigger: Brief window between unlock and key retrieval.

Recommendation:

{isLoading ? (
  <ActivityIndicator />
) : !qrValue ? (
  // error state
) : (
  <QRCode size={280} uri={qrValue} arenaClear />
)}

if (merchantIdChanged) {
setMerchantId(trimmedMerchantId);
addLog(
"info",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Auto Review Issue: merchantId logged verbatim — account identifier in log store

Severity: LOW
Category: data_classification
Tool: Claude Auto Review

Context:

  • Pattern: addLog("info", \Merchant ID updated to: ${trimmedMerchantId}`, ...)` writes the full merchant ID into the log store on every save. Logs are viewable via "View logs" in settings and potentially exported in bug reports.
  • Impact: Merchant account identifier disclosed in any log export or support ticket.

Recommendation: Log the event without the value: addLog("info", "Merchant ID updated", "settings", "handleUpdateKeysConfirm").

- Guard export-keys to web-only (native navigates away, never reveals the QR)
- Fix off-by-one in PIN "attempts remaining" (read post-increment count)
- Only auto-request camera when permission is undetermined (no re-prompt loop)
- On lockout, send the user back instead of leaving a blank gated screen

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants