Skip to content
Merged
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
50 changes: 50 additions & 0 deletions src/services/biometricAvailability.ts
Original file line number Diff line number Diff line change
@@ -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<BiometricUnavailableReason, string> = {
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;
}
48 changes: 48 additions & 0 deletions src/services/pushChannelSetup.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
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<ChannelSetupResult> {
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 };
}
50 changes: 50 additions & 0 deletions src/services/sessionInvalidation.ts
Original file line number Diff line number Diff line change
@@ -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> | void;
disconnectSocket: () => Promise<void> | void;
navigateToLogin: () => Promise<void> | 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<SessionInvalidationOutcome> {
const ordered: [string, () => Promise<void> | 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 };
}
44 changes: 44 additions & 0 deletions src/utils/conflictPayload.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>, ...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<string, unknown>;

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,
};
}
Loading