Files
pos/client/src/api/client.ts
jason c426b19b7c Add token auto-refresh, single-transaction endpoint, shift summary, and CI
- client/api/client.ts: shared refreshPromise prevents concurrent refresh races;
  dispatches auth:logout event when refresh fails
- client/context/AuthContext.tsx: listen for auth:logout to clear user state
- server/routes/transactions.ts: POST / real-time single transaction through
  payment abstraction (201 completed, 202 pending); GET /reports/shift shift
  window totals with averageTransaction, shiftOpen/shiftClose timestamps
- .github/workflows/ci.yml: server typecheck+build, client typecheck+build,
  Docker smoke-test on push/PR to main

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 07:06:59 -05:00

87 lines
2.8 KiB
TypeScript

const BASE = "/api/v1";
export function setTokens(accessToken: string, refreshToken: string) {
localStorage.setItem("accessToken", accessToken);
localStorage.setItem("refreshToken", refreshToken);
}
export function clearTokens() {
localStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken");
}
function getToken(): string | null {
return localStorage.getItem("accessToken");
}
// Single in-flight refresh promise so concurrent 401s don't fire multiple refreshes
let refreshPromise: Promise<string | null> | null = null;
async function tryRefresh(): Promise<string | null> {
const refreshToken = localStorage.getItem("refreshToken");
if (!refreshToken) return null;
try {
const res = await fetch(`${BASE}/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
if (!res.ok) {
clearTokens();
return null;
}
const data = await res.json() as { accessToken: string; refreshToken: string };
setTokens(data.accessToken, data.refreshToken);
return data.accessToken;
} catch {
clearTokens();
return null;
}
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const doFetch = async (token: string | null) => {
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(options.headers as Record<string, string>),
};
if (token) headers["Authorization"] = `Bearer ${token}`;
return fetch(`${BASE}${path}`, { ...options, headers });
};
let res = await doFetch(getToken());
// On 401, attempt one token refresh then retry
if (res.status === 401) {
if (!refreshPromise) {
refreshPromise = tryRefresh().finally(() => { refreshPromise = null; });
}
const newToken = await refreshPromise;
if (!newToken) {
// Refresh failed — signal the app to go to login
window.dispatchEvent(new CustomEvent("auth:logout"));
throw new Error("Session expired. Please sign in again.");
}
res = await doFetch(newToken);
}
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const message = (body as { error?: { message?: string } })?.error?.message ?? `HTTP ${res.status}`;
throw new Error(message);
}
return res.json() as Promise<T>;
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body: unknown) => request<T>(path, { method: "POST", body: JSON.stringify(body) }),
put: <T>(path: string, body: unknown) => request<T>(path, { method: "PUT", body: JSON.stringify(body) }),
patch: <T>(path: string, body: unknown) => request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
};