chore(api): don't trigger logout on anonymous 401 + skip pushScan for guests

Symptom: a guest scanning a plant fired POST /api/mobile/scans without
a Bearer token, the backend rightfully replied 401, and the apiPost
emitter dispatched 'unauthorized' which AuthContext interpreted as
"session lost" and wiped the local guest, kicking the user back to
Onboarding.

Two fixes:
1. apiGet/apiPost now track whether a Bearer was actually attached
   to the request and only emit the 'unauthorized' event when one was
   sent. An anonymous 401 stays a plain SERVER error.
2. pushScan() short-circuits if getToken() returns null, so guests
   never even hit the network for scan persistence.

Combined effect: guests stay guests, registered users still get
session-revocation feedback when their token is rejected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yanis 2026-05-01 14:02:24 +02:00
parent c5d59fc092
commit e34e0db34c
2 changed files with 27 additions and 11 deletions

View file

@ -30,20 +30,23 @@ type FetchOpts = {
async function buildHeaders(
base: HeadersInit,
withAuth: boolean,
): Promise<Headers> {
): Promise<{ headers: Headers; tokenSent: boolean }> {
const headers = new Headers(base);
let tokenSent = false;
if (withAuth) {
const token = await getToken();
if (token) {
headers.set("Authorization", `Bearer ${token}`);
tokenSent = true;
}
}
return headers;
return { headers, tokenSent };
}
async function handleResponse<T>(
res: Response,
opts: FetchOpts,
tokenSent: boolean,
): Promise<ApiResponse<T>> {
if (!res.ok) {
// Try to parse the body for richer error info (banned/bannedReason).
@ -54,7 +57,10 @@ async function handleResponse<T>(
// ignore
}
if (res.status === 401) {
// Only treat 401 as "session lost" when we actually tried to authenticate.
// A 401 on an anonymous request is just the backend rejecting the call —
// it must not log the local user out (they may legitimately be a guest).
if (res.status === 401 && tokenSent) {
emitAuthEvent({ type: "unauthorized" });
} else if (
res.status === 403 &&
@ -127,13 +133,17 @@ export async function apiGet<T>(
const timeoutId = setTimeout(() => controller.abort(), API_CONFIG.timeout);
try {
const { headers, tokenSent } = await buildHeaders(
{ Accept: "application/json" },
!!opts.auth,
);
const res = await fetch(url.toString(), {
method: "GET",
headers: await buildHeaders({ Accept: "application/json" }, !!opts.auth),
headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
return handleResponse<T>(res, opts);
return handleResponse<T>(res, opts, tokenSent);
} catch (err) {
clearTimeout(timeoutId);
return { success: false, error: asApiError(err) };
@ -150,17 +160,18 @@ export async function apiPost<T>(
const timeoutId = setTimeout(() => controller.abort(), API_CONFIG.timeout);
try {
const { headers, tokenSent } = await buildHeaders(
{ Accept: "application/json", "Content-Type": "application/json" },
!!opts.auth,
);
const res = await fetch(url, {
method: "POST",
headers: await buildHeaders(
{ Accept: "application/json", "Content-Type": "application/json" },
!!opts.auth,
),
headers,
body: JSON.stringify(body),
signal: controller.signal,
});
clearTimeout(timeoutId);
return handleResponse<T>(res, opts);
return handleResponse<T>(res, opts, tokenSent);
} catch (err) {
clearTimeout(timeoutId);
return { success: false, error: asApiError(err) };

View file

@ -1,5 +1,5 @@
import { apiPost } from '@/services/api/client';
import { getDeviceId } from '@/services/auth/tokenStorage';
import { getDeviceId, getToken } from '@/services/auth/tokenStorage';
import type { ScanRecord } from '@/types/detection';
interface PushScanResponse {
@ -17,6 +17,11 @@ interface PushScanResponse {
// never blocks on this — failures are silent (offline, no account, etc.).
// Confidence on the device is 0-100; the backend stores 0-1.
export async function pushScan(record: ScanRecord) {
// Guests have no server account → no token → don't even try. Avoids
// spamming the backend with 401s and keeps the mobile UX silent.
const token = await getToken();
if (!token) return null;
const deviceId = await getDeviceId();
const body = {