Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions src/components/common/PermissionDeniedView.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View style={styles.container}>
<Text style={styles.title}>{permission} access is off</Text>
<Text style={styles.body}>
Turn on {permission.toLowerCase()} access in Settings to use this screen.
</Text>
<TouchableOpacity accessibilityRole="button" onPress={handlePress} style={styles.button}>
<Text style={styles.buttonLabel}>Go to Settings</Text>
</TouchableOpacity>
</View>
);
}

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;
50 changes: 50 additions & 0 deletions src/hooks/useAsyncEffectState.ts
Original file line number Diff line number Diff line change
@@ -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<void>,
deps: DependencyList,
onError?: (error: Error) => void
): AsyncEffectState {
const [error, setError] = useState<Error | null>(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 };
}
34 changes: 34 additions & 0 deletions src/utils/safeLog.ts
Original file line number Diff line number Diff line change
@@ -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<void>): Promise<void> {
try {
await log();
} catch (loggerError) {
safeLog(() => console.error('[safeLogAsync] logger threw and was suppressed:', loggerError));
}
}
33 changes: 33 additions & 0 deletions src/utils/sanitizeErrorResponse.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {};
for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
result[key] = SENSITIVE_KEY_PATTERN.test(key)
? REDACTED
: sanitizeErrorResponse(value, depth + 1);
}
return result;
}