diff --git a/src/components/common/PermissionDeniedView.tsx b/src/components/common/PermissionDeniedView.tsx
new file mode 100644
index 0000000..ed8134c
--- /dev/null
+++ b/src/components/common/PermissionDeniedView.tsx
@@ -0,0 +1,42 @@
+/**
+ * Permission denial UI. (#793)
+ *
+ * The QR scanner caught camera permission errors in a bare catch and rendered
+ * a blank screen, so a denied permission looked identical to a broken screen
+ * and offered no way forward. This states what is blocked and routes the user
+ * into system settings.
+ */
+import { Linking, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
+
+export interface PermissionDeniedViewProps {
+ /** Permission the screen needs, e.g. "Camera". */
+ permission: string;
+ /** Overrides the jump into system settings; used by tests. */
+ onOpenSettings?: () => void;
+}
+
+export function PermissionDeniedView({ permission, onOpenSettings }: PermissionDeniedViewProps) {
+ const handlePress = onOpenSettings ?? (() => void Linking.openSettings());
+
+ return (
+
+ {permission} access is off
+
+ Turn on {permission.toLowerCase()} access in Settings to use this screen.
+
+
+ Go to Settings
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ body: { fontSize: 14, opacity: 0.75, textAlign: 'center' },
+ button: { backgroundColor: '#2563eb', borderRadius: 8, paddingHorizontal: 20, paddingVertical: 12 },
+ buttonLabel: { color: '#ffffff', fontWeight: '600' },
+ container: { alignItems: 'center', flex: 1, gap: 12, justifyContent: 'center', padding: 24 },
+ title: { fontSize: 18, fontWeight: '600' },
+});
+
+export default PermissionDeniedView;
diff --git a/src/hooks/useAsyncEffectState.ts b/src/hooks/useAsyncEffectState.ts
new file mode 100644
index 0000000..5100b4d
--- /dev/null
+++ b/src/hooks/useAsyncEffectState.ts
@@ -0,0 +1,50 @@
+/**
+ * Async effect with captured rejection state. (#795)
+ *
+ * Form fields ran their cache load as `void loadCache()` inside useEffect,
+ * which discards the rejection — a failed read left the field looking healthy
+ * with the error recorded nowhere. This keeps the rejection as state, reports
+ * it, and skips updates after unmount.
+ */
+import { DependencyList, useEffect, useState } from 'react';
+
+export interface AsyncEffectState {
+ /** Rejection from the most recent run, or null when it succeeded. */
+ error: Error | null;
+ /** True while the effect is in flight. */
+ loading: boolean;
+}
+/**
+ * Runs `effect` on mount and whenever `deps` change, surfacing its failure
+ * instead of discarding it. `onError` receives the Error for reporting.
+ */
+export function useAsyncEffectState(
+ effect: () => Promise,
+ deps: DependencyList,
+ onError?: (error: Error) => void
+): AsyncEffectState {
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(true);
+ useEffect(() => {
+ let active = true;
+ setLoading(true);
+ effect()
+ .then(() => {
+ if (active) setError(null);
+ })
+ .catch((caught: unknown) => {
+ const failure = caught instanceof Error ? caught : new Error(String(caught));
+ onError?.(failure);
+ if (active) setError(failure);
+ })
+ .finally(() => {
+ if (active) setLoading(false);
+ });
+ return () => {
+ active = false;
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, deps);
+
+ return { error, loading };
+}
diff --git a/src/utils/safeLog.ts b/src/utils/safeLog.ts
new file mode 100644
index 0000000..e67cd5c
--- /dev/null
+++ b/src/utils/safeLog.ts
@@ -0,0 +1,34 @@
+/**
+ * Crash-proof logging wrapper. (#794)
+ *
+ * The response interceptor called the logger unguarded. When the logger
+ * itself threw — Sentry not initialised, a circular reference during
+ * serialisation — the interceptor unwound and the original request error was
+ * lost. Logging must never be able to do that.
+ */
+
+/**
+ * Runs `log`, swallowing any failure it raises so the caller's control flow
+ * is unaffected. Falls back to console.error so the logger's own failure
+ * stays visible rather than disappearing silently.
+ */
+export function safeLog(log: () => void): void {
+ try {
+ log();
+ } catch (loggerError) {
+ try {
+ console.error('[safeLog] logger threw and was suppressed:', loggerError);
+ } catch {
+ // Console is unavailable too — drop it rather than break the caller.
+ }
+ }
+}
+
+/** Promise-returning variant for async transports. Never rejects. */
+export async function safeLogAsync(log: () => Promise): Promise {
+ try {
+ await log();
+ } catch (loggerError) {
+ safeLog(() => console.error('[safeLogAsync] logger threw and was suppressed:', loggerError));
+ }
+}
diff --git a/src/utils/sanitizeErrorResponse.ts b/src/utils/sanitizeErrorResponse.ts
new file mode 100644
index 0000000..ba59036
--- /dev/null
+++ b/src/utils/sanitizeErrorResponse.ts
@@ -0,0 +1,33 @@
+/**
+ * Error payload sanitisation before logging. (#792)
+ *
+ * Error response bodies were logged verbatim to console and Sentry. Backends
+ * occasionally echo credentials and PII inside those bodies, which then
+ * persist in log storage in plaintext. Sensitive keys are redacted here
+ * before anything reaches a transport.
+ */
+
+const SENSITIVE_KEY_PATTERN = /(password|token|secret|card|cvv|ssn|authorization)/i;
+const REDACTED = '[REDACTED]';
+const MAX_DEPTH = 6;
+
+/**
+ * Returns a copy of `data` with sensitive values replaced by [REDACTED],
+ * recursing through nested objects and arrays.
+ *
+ * Depth-capped so a deeply nested or self-referencing body cannot hang the
+ * logger — beyond MAX_DEPTH the subtree is redacted wholesale.
+ */
+export function sanitizeErrorResponse(data: unknown, depth = 0): unknown {
+ if (depth >= MAX_DEPTH) return REDACTED;
+ if (Array.isArray(data)) return data.map((item) => sanitizeErrorResponse(item, depth + 1));
+ if (!data || typeof data !== 'object') return data;
+
+ const result: Record = {};
+ for (const [key, value] of Object.entries(data as Record)) {
+ result[key] = SENSITIVE_KEY_PATTERN.test(key)
+ ? REDACTED
+ : sanitizeErrorResponse(value, depth + 1);
+ }
+ return result;
+}