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>
This commit is contained in:
@@ -1,9 +1,5 @@
|
||||
const BASE = "/api/v1";
|
||||
|
||||
function getToken(): string | null {
|
||||
return localStorage.getItem("accessToken");
|
||||
}
|
||||
|
||||
export function setTokens(accessToken: string, refreshToken: string) {
|
||||
localStorage.setItem("accessToken", accessToken);
|
||||
localStorage.setItem("refreshToken", refreshToken);
|
||||
@@ -14,22 +10,67 @@ export function clearTokens() {
|
||||
localStorage.removeItem("refreshToken");
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const token = getToken();
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
function getToken(): string | null {
|
||||
return localStorage.getItem("accessToken");
|
||||
}
|
||||
|
||||
const res = await fetch(`${BASE}${path}`, { ...options, headers });
|
||||
// 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?.error?.message ?? `HTTP ${res.status}`;
|
||||
const message = (body as { error?: { message?: string } })?.error?.message ?? `HTTP ${res.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
@@ -37,12 +78,9 @@ async function request<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" }),
|
||||
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" }),
|
||||
};
|
||||
|
||||
@@ -41,6 +41,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
} else {
|
||||
setState({ user: null, loading: false });
|
||||
}
|
||||
|
||||
// When the API layer exhausts its refresh retry, force logout
|
||||
const onForcedLogout = () => setState({ user: null, loading: false });
|
||||
window.addEventListener("auth:logout", onForcedLogout);
|
||||
return () => window.removeEventListener("auth:logout", onForcedLogout);
|
||||
}, [fetchMe]);
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
|
||||
Reference in New Issue
Block a user