Initial commit
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
// Single client for the Car Control API Server. The web app never talks to
|
||||
// PocketBase directly — only to these endpoints (proxied to the API Server in
|
||||
// dev via vite.config.js).
|
||||
|
||||
// Default API base: the Vite env override, else the same-origin "/api" (proxied
|
||||
// to the API Server in dev). A user can override this at runtime via the login
|
||||
// screen's "Server settings" — stored in localStorage and used for every call.
|
||||
export const DEFAULT_API_BASE = import.meta.env.VITE_API_BASE || "/api";
|
||||
const SERVER_KEY = "cc_server_url";
|
||||
|
||||
// Resolved fresh on each request so changing it takes effect without a reload.
|
||||
function apiBase() {
|
||||
return localStorage.getItem(SERVER_KEY) || DEFAULT_API_BASE;
|
||||
}
|
||||
|
||||
export function getServerUrl() {
|
||||
return localStorage.getItem(SERVER_KEY) || "";
|
||||
}
|
||||
|
||||
// Persist a custom API base URL (trailing slashes trimmed). Empty/blank clears
|
||||
// the override, falling back to the default.
|
||||
export function setServerUrl(url) {
|
||||
const trimmed = (url || "").trim().replace(/\/+$/, "");
|
||||
if (trimmed) localStorage.setItem(SERVER_KEY, trimmed);
|
||||
else localStorage.removeItem(SERVER_KEY);
|
||||
}
|
||||
|
||||
export const TOKEN_KEY = "cc_token";
|
||||
export const USER_KEY = "cc_user";
|
||||
|
||||
function authHeader() {
|
||||
const t = localStorage.getItem(TOKEN_KEY);
|
||||
return t ? { Authorization: "Bearer " + t } : {};
|
||||
}
|
||||
|
||||
async function handleResponse(res, path) {
|
||||
// An expired/invalid token on any non-login call ends the session.
|
||||
if (res.status === 401 && path !== "/auth/login") {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
if (location.pathname !== "/login") location.href = "/login";
|
||||
throw new Error("Session expired — please log in again.");
|
||||
}
|
||||
|
||||
if (res.status === 204) return null;
|
||||
const text = await res.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
if (!res.ok) {
|
||||
const msg = (data && (data.error || data.message)) || res.statusText;
|
||||
throw new Error(msg);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const res = await fetch(apiBase() + path, {
|
||||
headers: { "Content-Type": "application/json", ...authHeader(), ...(options.headers || {}) },
|
||||
...options,
|
||||
});
|
||||
return handleResponse(res, path);
|
||||
}
|
||||
|
||||
// Like request(), but for multipart/form-data bodies (file uploads) — the
|
||||
// browser sets its own Content-Type (with boundary), so we must not.
|
||||
async function requestForm(path, options = {}) {
|
||||
const res = await fetch(apiBase() + path, { headers: { ...authHeader() }, ...options });
|
||||
return handleResponse(res, path);
|
||||
}
|
||||
|
||||
// Fetches a binary response (image, export file) as a Blob, since it needs the
|
||||
// Authorization header — a plain <img src> or <a href> can't attach one.
|
||||
async function requestBlob(path) {
|
||||
const res = await fetch(apiBase() + path, { headers: authHeader() });
|
||||
if (res.status === 401) return handleResponse(res, path);
|
||||
if (!res.ok) throw new Error("Request failed: " + res.statusText);
|
||||
const filename = (res.headers.get("Content-Disposition") || "").match(/filename="([^"]+)"/)?.[1];
|
||||
return { blob: await res.blob(), filename };
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// Auth
|
||||
login: (email, password) =>
|
||||
request("/auth/login", { method: "POST", body: JSON.stringify({ email, password }) }),
|
||||
me: () => request("/auth/me"),
|
||||
|
||||
// Cars
|
||||
listCars: () => request("/cars"),
|
||||
getCar: (id) => request(`/cars/${id}`),
|
||||
createCar: (body) => request("/cars", { method: "POST", body: JSON.stringify(body) }),
|
||||
updateCar: (id, body) => request(`/cars/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
deleteCar: (id) => request(`/cars/${id}`, { method: "DELETE" }),
|
||||
|
||||
// Sharing (owner-only). A share grants another user read or write access.
|
||||
listCarShares: (carId) => request(`/cars/${carId}/shares`),
|
||||
addCarShare: (carId, email, permission) =>
|
||||
request(`/cars/${carId}/shares`, { method: "POST", body: JSON.stringify({ email, permission }) }),
|
||||
removeCarShare: (carId, userId) =>
|
||||
request(`/cars/${carId}/shares/${userId}`, { method: "DELETE" }),
|
||||
|
||||
// Service records
|
||||
listCarServices: (carId) => request(`/cars/${carId}/service-records`),
|
||||
createService: (body) =>
|
||||
request("/service-records", { method: "POST", body: JSON.stringify(body) }),
|
||||
updateService: (id, body) =>
|
||||
request(`/service-records/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
deleteService: (id) => request(`/service-records/${id}`, { method: "DELETE" }),
|
||||
|
||||
// Parts
|
||||
listCarParts: (carId) => request(`/cars/${carId}/parts`),
|
||||
createPart: (body) => request("/parts", { method: "POST", body: JSON.stringify(body) }),
|
||||
updatePart: (id, body) =>
|
||||
request(`/parts/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
deletePart: (id) => request(`/parts/${id}`, { method: "DELETE" }),
|
||||
|
||||
// Admin — user management (admin role only)
|
||||
listUsers: () => request("/admin/users"),
|
||||
createUser: (body) => request("/admin/users", { method: "POST", body: JSON.stringify(body) }),
|
||||
updateUser: (id, body) => request(`/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
setUserPassword: (id, newPassword) =>
|
||||
request(`/admin/users/${id}/password`, { method: "POST", body: JSON.stringify({ newPassword }) }),
|
||||
deleteUser: (id) => request(`/admin/users/${id}`, { method: "DELETE" }),
|
||||
|
||||
// Settings — account/profile/appearance
|
||||
getMe: () => request("/me"),
|
||||
updateMe: (body) => request("/me", { method: "PATCH", body: JSON.stringify(body) }),
|
||||
changePassword: (oldPassword, newPassword) =>
|
||||
request("/me/password", { method: "POST", body: JSON.stringify({ oldPassword, newPassword }) }),
|
||||
requestVerification: () => request("/me/verify/request", { method: "POST" }),
|
||||
uploadAvatar: (file) => {
|
||||
const form = new FormData();
|
||||
form.append("avatar", file);
|
||||
return requestForm("/me/avatar", { method: "POST", body: form });
|
||||
},
|
||||
deleteAvatar: () => request("/me/avatar", { method: "DELETE" }),
|
||||
getAvatarBlob: () => requestBlob("/me/avatar"),
|
||||
|
||||
// Settings — privacy & security (active sessions)
|
||||
listSessions: () => request("/sessions"),
|
||||
revokeSession: (id) => request(`/sessions/${id}`, { method: "DELETE" }),
|
||||
revokeOtherSessions: () => request("/sessions", { method: "DELETE" }),
|
||||
|
||||
// Settings — advanced / danger zone
|
||||
exportData: () => requestBlob("/me/export"),
|
||||
importData: (payload) => request("/me/import", { method: "POST", body: JSON.stringify(payload) }),
|
||||
requestAccountDeletion: (confirmEmail) =>
|
||||
request("/me/delete", { method: "POST", body: JSON.stringify({ confirmEmail }) }),
|
||||
cancelAccountDeletion: () => request("/me/delete/cancel", { method: "POST" }),
|
||||
finalizeAccountDeletion: () => request("/me", { method: "DELETE" }),
|
||||
};
|
||||
Reference in New Issue
Block a user