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