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:
parent
c5d59fc092
commit
e34e0db34c
|
|
@ -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) };
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Reference in a new issue