- 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>
87 lines
2.8 KiB
TypeScript
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" }),
|
|
};
|