Newer
Older
gn-synapse / frontend / src / api.js
/** Тонкий клиент к API Synapse. Токены — opaque Bearer от gnexus-auth, приходят
 * с /auth/callback (сервер сам ведёт OAuth, UI только принимает результат). */

const TOKEN_KEY = "synapse.access_token";
const REFRESH_KEY = "synapse.refresh_token";

function getItem(key) {
  try {
    return localStorage.getItem(key) || "";
  } catch {
    return "";
  }
}

export function getToken() {
  return getItem(TOKEN_KEY);
}

export function setToken(token) {
  try {
    localStorage.setItem(TOKEN_KEY, token);
  } catch {
    /* приватный режим — жить без сохранения сессии */
  }
}

export function getRefreshToken() {
  return getItem(REFRESH_KEY);
}

export function setRefreshToken(token) {
  try {
    localStorage.setItem(REFRESH_KEY, token);
  } catch {
    /* см. setToken */
  }
}

export function clearToken() {
  try {
    localStorage.removeItem(TOKEN_KEY);
    localStorage.removeItem(REFRESH_KEY);
  } catch {
    /* игнорируем */
  }
}

let refreshing = null;

/** Тихое обновление access-токена по refresh-токену (одним полётом на все 401). */
function refreshAccessToken() {
  refreshing ??= fetch("/auth/refresh", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ refresh_token: getRefreshToken() }),
  })
    .then(async (response) => {
      if (!response.ok) throw new Error("refresh_failed");
      const payload = await response.json();
      setToken(payload.access_token);
      if (payload.refresh_token) setRefreshToken(payload.refresh_token);
      return payload.access_token;
    })
    .finally(() => {
      refreshing = null;
    });
  return refreshing;
}

async function apiFetch(path, options = {}, retried = false) {
  const headers = { ...(options.headers || {}) };
  const token = getToken();
  if (token) headers.Authorization = `Bearer ${token}`;
  if (options.body) headers["Content-Type"] = "application/json";

  let response = await fetch(path, { ...options, headers });

  // Токен истёк — пробуем обновить и повторить запрос один раз.
  if (response.status === 401 && retried === false && getRefreshToken()) {
    try {
      await refreshAccessToken();
      return apiFetch(path, options, true);
    } catch {
      clearToken();
    }
  }

  if (!response.ok) {
    let detail = response.statusText;
    try {
      const payload = await response.json();
      if (payload.detail) detail = payload.detail;
    } catch {
      /* не JSON — берём statusText */
    }
    const error = new Error(detail);
    error.status = response.status;
    throw error;
  }
  return response.json();
}

export function healthz() {
  return apiFetch("/api/healthz");
}

/** Профиль текущего пользователя. 401 — токен невалиден, 403 — роль ниже admin. */
export function adminMe() {
  return apiFetch("/api/v1/admin/me");
}

/** Логаут: revoke access-токена на gnexus-auth. */
export async function logout() {
  try {
    await apiFetch("/auth/revoke", { method: "POST" });
  } finally {
    clearToken();
  }
}