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() {
  dropSession();
  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");
}

// --- Личный кабинет (app/api/me_routes.py): любой аутентифицированный. ---

/** Профиль: без ролевого гейта, role в ответе (system_role) — с этого гейт роутера. */
export function me() {
  return apiFetch("/api/v1/me");
}

// --- Сессия для роут-гейта: один общий промис, кешированный по access-токену.
// Тихое обновление токена меняет ключ кеша -> рефетч; параллельные вызовы
// сходятся в один полёт. Ошибка не кешируется навечно — гейт уведёт на /login. ---
let meCache = null;
let meCacheToken = "";

export function getSession() {
  const token = getToken();
  if (!token) return Promise.resolve(null);
  if (meCache && meCacheToken === token) return meCache;
  meCacheToken = token;
  meCache = me().catch((err) => {
    meCache = null;
    throw err;
  });
  return meCache;
}

function dropSession() {
  meCache = null;
  meCacheToken = "";
}

// --- Admin API (app/api/admin_routes.py). Все эндпоинты под require_admin. ---

export const api = {
  // Источники и ключи
  listSources: () => apiFetch("/api/v1/admin/sources"),
  createSource: (data) => apiFetch("/api/v1/admin/sources", { method: "POST", body: JSON.stringify(data) }),
  deleteSource: (id) => apiFetch(`/api/v1/admin/sources/${id}`, { method: "DELETE" }),
  listKeys: (sourceId) => apiFetch(`/api/v1/admin/sources/${sourceId}/keys`),
  createKey: (sourceId, data) =>
    apiFetch(`/api/v1/admin/sources/${sourceId}/keys`, { method: "POST", body: JSON.stringify(data) }),
  revokeKey: (sourceId, keyId) =>
    apiFetch(`/api/v1/admin/sources/${sourceId}/keys/${keyId}/revoke`, { method: "POST" }),

  // Типы
  listTypes: () => apiFetch("/api/v1/admin/types"),
  createType: (data) => apiFetch("/api/v1/admin/types", { method: "POST", body: JSON.stringify(data) }),
  deleteType: (id) => apiFetch(`/api/v1/admin/types/${id}`, { method: "DELETE" }),

  // Цели каналов
  listTargets: () => apiFetch("/api/v1/admin/targets"),
  createTarget: (data) => apiFetch("/api/v1/admin/targets", { method: "POST", body: JSON.stringify(data) }),
  patchTarget: (id, data) => apiFetch(`/api/v1/admin/targets/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
  deleteTarget: (id) => apiFetch(`/api/v1/admin/targets/${id}`, { method: "DELETE" }),

  // Правила
  listRules: () => apiFetch("/api/v1/admin/rules"),
  createRule: (data) => apiFetch("/api/v1/admin/rules", { method: "POST", body: JSON.stringify(data) }),
  patchRule: (id, data) => apiFetch(`/api/v1/admin/rules/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
  deleteRule: (id) => apiFetch(`/api/v1/admin/rules/${id}`, { method: "DELETE" }),

  // Поток (только чтение)
  listEvents: (query = {}) => apiFetch(`/api/v1/admin/events?${new URLSearchParams(query)}`),
  listDeliveries: (query = {}) => apiFetch(`/api/v1/admin/deliveries?${new URLSearchParams(query)}`),

  // Личный кабинет
  myEvents: (query = {}) => apiFetch(`/api/v1/me/events?${new URLSearchParams(query)}`),
};

/** Логаут: revoke всех входов пользователя (наш + на gnexus-auth). */
export async function logout() {
  try {
    await apiFetch("/auth/logout", { method: "POST" });
  } finally {
    clearToken();
  }
}