diff --git a/src/services/biometricAvailability.ts b/src/services/biometricAvailability.ts new file mode 100644 index 0000000..7dbe32a --- /dev/null +++ b/src/services/biometricAvailability.ts @@ -0,0 +1,50 @@ +/** + * Typed biometric availability reporting. (#784) + * + * Biometric login surfaced every failure as the same generic "not available" + * Error, so callers could not tell unsupported hardware from an unenrolled + * device and always showed the same dead-end message. These helpers classify + * the blocking reason and carry it on a typed, `instanceof`-checkable error. + * The caller passes in probe results, keeping this testable without natives. + */ + +export type BiometricUnavailableReason = + | 'noHardware' + | 'notEnrolled' + | 'notEnabled' + | 'unknown'; + +const REASON_MESSAGES: Record = { + noHardware: 'This device has no biometric sensor.', + notEnrolled: 'No fingerprint or face is enrolled. Add one in device settings.', + notEnabled: 'Biometric login is off. Turn it on in app settings.', + unknown: 'Biometric authentication is unavailable on this device.', +}; + +/** Error carrying the specific reason biometric login could not run. */ +export class BiometricUnavailableError extends Error { + readonly code = 'BIOMETRIC_UNAVAILABLE'; + readonly reason: BiometricUnavailableReason; + + constructor(reason: BiometricUnavailableReason) { + super(REASON_MESSAGES[reason]); + this.name = 'BiometricUnavailableError'; + this.reason = reason; + } +} + +export interface BiometricProbe { + hasHardware: boolean; + isEnrolled: boolean; + isEnabledInApp: boolean; +} + +/** Returns the blocking reason, or null when biometric login may proceed. */ +export function getBiometricUnavailableReason( + probe: BiometricProbe +): BiometricUnavailableReason | null { + if (!probe.hasHardware) return 'noHardware'; + if (!probe.isEnrolled) return 'notEnrolled'; + if (!probe.isEnabledInApp) return 'notEnabled'; + return null; +} diff --git a/src/services/pushChannelSetup.ts b/src/services/pushChannelSetup.ts new file mode 100644 index 0000000..1b044b2 --- /dev/null +++ b/src/services/pushChannelSetup.ts @@ -0,0 +1,48 @@ +/** + * Android notification channel setup with retry and visible failure. (#785) + * + * Channel creation was wrapped in a bare try/catch that swallowed the error, + * so Android notifications silently never appeared and nothing in the logs + * said why. This retries once — channel creation fails transiently during + * cold start — and reports the failure instead of continuing quietly. + */ + +export interface ChannelSetupResult { + ok: boolean; + attempts: number; + error?: Error; +} + +type CreateChannel = () => Promise; +type ReportFailure = (message: string, error: Error) => void; + +const MAX_ATTEMPTS = 2; + +/** + * Runs `createChannel`, retrying once on failure. + * + * Never throws: startup should continue deliberately rather than crash, so the + * outcome is returned and the failure is handed to `reportFailure` (Sentry in + * production, a visible warning in dev). + */ +export async function setUpNotificationChannel( + createChannel: CreateChannel, + reportFailure: ReportFailure +): Promise { + let lastError = new Error('Notification channel setup did not run.'); + + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + await createChannel(); + return { ok: true, attempts: attempt }; + } catch (caught) { + lastError = caught instanceof Error ? caught : new Error(String(caught)); + } + } + + reportFailure( + `Android notification channel setup failed after ${MAX_ATTEMPTS} attempts.`, + lastError + ); + return { ok: false, attempts: MAX_ATTEMPTS, error: lastError }; +} diff --git a/src/services/sessionInvalidation.ts b/src/services/sessionInvalidation.ts new file mode 100644 index 0000000..111c35b --- /dev/null +++ b/src/services/sessionInvalidation.ts @@ -0,0 +1,50 @@ +/** + * Session invalidation sequence. (#786) + * + * After three consecutive 401s the refresh path cleared tokens and stopped + * there, leaving the socket connected and the user on an authenticated + * screen. This runs the remaining steps, never letting one failure stop them. + */ + +export const SESSION_INVALIDATED = 'SESSION_INVALIDATED' as const; + +export interface SessionInvalidationSteps { + clearTokens: () => Promise | void; + disconnectSocket: () => Promise | void; + navigateToLogin: () => Promise | void; + notify?: (event: typeof SESSION_INVALIDATED) => void; +} + +export interface SessionInvalidationOutcome { + completed: string[]; + failed: string[]; +} + +/** + * Executes every logout step, continuing past individual failures so a broken + * socket teardown cannot strand the user on an authenticated screen. + */ +export async function invalidateSession( + steps: SessionInvalidationSteps +): Promise { + const ordered: [string, () => Promise | void][] = [ + ['clearTokens', steps.clearTokens], + ['disconnectSocket', steps.disconnectSocket], + ['navigateToLogin', steps.navigateToLogin], + ]; + + const completed: string[] = []; + const failed: string[] = []; + + for (const [name, run] of ordered) { + try { + await run(); + completed.push(name); + } catch { + failed.push(name); + } + } + + steps.notify?.(SESSION_INVALIDATED); + return { completed, failed }; +} diff --git a/src/utils/conflictPayload.ts b/src/utils/conflictPayload.ts new file mode 100644 index 0000000..e9dd3d9 --- /dev/null +++ b/src/utils/conflictPayload.ts @@ -0,0 +1,44 @@ +/** + * 409 Conflict payload parsing. (#787) + * + * Conflicts were logged as raw response bodies, so the UI had nothing typed + * to drive a resolution prompt and the user saw a generic error with no way + * to resolve it. This normalises the shapes the API returns into one payload + * ConflictResolutionModal can consume. + */ + +export interface ConflictPayload { + resource: string; + serverVersion: string | null; + clientVersion: string | null; +} + +/** First readable string/number among `keys`, or null. */ +function readString(source: Record, ...keys: string[]): string | null { + for (const key of keys) { + const value = source[key]; + if (typeof value === 'string' && value.trim()) return value; + if (typeof value === 'number') return String(value); + } + return null; +} + +/** + * Returns a typed conflict payload, or null when `data` carries no + * recognisable conflict details — the caller should then fall back to the + * generic error path rather than opening an empty resolution modal. + */ +export function parseConflictPayload(data: unknown): ConflictPayload | null { + if (!data || typeof data !== 'object') return null; + const source = data as Record; + + const serverVersion = readString(source, 'serverVersion', 'server_version', 'currentVersion'); + const clientVersion = readString(source, 'clientVersion', 'client_version', 'submittedVersion'); + if (!serverVersion && !clientVersion) return null; + + return { + resource: readString(source, 'resource', 'entity', 'type') ?? 'record', + serverVersion, + clientVersion, + }; +}