axe-web/js/api.js
edadmin ca3392157a
Some checks are pending
web / check (push) Waiting to run
web / build-web (push) Waiting to run
web / deploy (push) Blocked by required conditions
feat: prod web image + Gitea Actions, demo chips removed
2026-10-18 22:44:06 +03:00

183 lines
8.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
// api.js — обёртка над HTTP к бэкенду: хранение сессии, тихий refresh,
// единый формат ошибок. Никакой бизнес-логики — только транспорт.
const API = (() => {
// Пустой BASE = API на том же origin, что и страница: nginx (см.
// nginx.conf) проксирует API-пути на бэкенд. Фронту не нужны CORS и
// отдельный порт — один домен и локально, и в проде.
const BASE = '';
const STORE = 'building-mgmt.session';
// Ошибка API: status + короткий `error` из ответа (или HTTP N).
class ApiError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}
function load() {
try { return JSON.parse(localStorage.getItem(STORE)); } catch (e) { return null; }
}
function save(sess) { localStorage.setItem(STORE, JSON.stringify(sess)); }
function clear() { localStorage.removeItem(STORE); }
// Текущий профиль или null (не залогинен).
function user() {
const s = load();
return s ? s.user : null;
}
function session() { return load(); }
// Запомнить результат входа/обновления: в localStorage живут только
// access-токен, срок и профиль. Refresh-токен сюда никогда не попадает —
// он в httpOnly-куке, из JavaScript недоступен (защита от XSS).
function store(data) {
save(Object.assign({}, data, { expires_at: Date.now() + data.expires_in * 1000 }));
}
async function login(email, password) {
const data = await request('POST', '/auth/login', { email, password }, { auth: false });
store(data);
return data.user;
}
// «Забыли пароль»: сервер всегда отвечает одинаково ({ ok: true }) —
// ответ не перечисляет существующие ящики. 429 — лимит попыток (IP
// плюс 3 письма в сутки на один адрес).
async function forgotPassword(email) {
return request('POST', '/auth/forgot-password', { email }, { auth: false });
}
// Сброс по токену из письма. 400 — токен неизвестен, уже
// использован или истёк; слабый пароль тоже 400 с тем же кодом.
async function resetPassword(token, password) {
return request('POST', '/auth/reset-password', { token, password }, { auth: false });
}
// --- Telegram: уведомления о заявках, не взятых в работу за 24 часа ---
// Состояние своей привязки: enabled (настроен ли бот), bound/target,
// имя бота для ссылки. Чужим это поле сервер не отдаёт.
const tgStatus = () => get('/auth/telegram');
// Одноразовый код привязки + ссылка t.me/<bot>?start=<code>.
// 400 — бот не настроен, 429 — лимит (5 кодов за 5 минут).
const tgCode = () => post('/auth/telegram/code', {});
// Ручная цель (@группа или chat_id); пустая строка — отключить.
// 400 — формат не тот либо бот не видит чат.
const tgSet = (target) => request('PUT', '/auth/telegram', { target });
// То же, что tgSet(''): убрать цель и перестать слать уведомления.
const tgUnbind = () => del('/auth/telegram');
// Свой бот: токен от @BotFather. Сервер проверяет его через getMe,
// шифрует и запускает поллер. 400 — нет DATA_ENCRYPTION_KEY / формат
// не тот / getMe не принял, 429 — лимит попыток (5 за 5 минут).
const tgSetBot = (token) => post('/auth/telegram/bot', { token });
// Отключить своего бота: токен стирается, поллер гасится, дальше
// уведомления (если цель осталась) пойдут через бота сервера.
const tgDelBot = () => del('/auth/telegram/bot');
// Выход: гасим сессию на сервере (не даём ошибке сбить локальный выход).
// Тело не нужно: сервер берёт refresh-токен из куки и очищает её.
async function logout() {
clear();
try {
await request('POST', '/auth/logout', null, { auth: false, noRetry: true });
} catch (e) { /* локально уже вышли — серверная ошибка не мешает */ }
}
// Принудительный refresh (когда access-токен протух). Токен в теле
// не шлём — сервер читает refresh-куку (same-origin, кука уходит сама).
async function refreshNow() {
const s = load();
if (!s) throw new ApiError(401, 'no token');
const data = await request('POST', '/auth/refresh', null, { auth: false, noRetry: true });
store(data);
return data;
}
// Тихий refresh до запроса, если токен вот-вот протухнет.
async function ensureFresh() {
const s = load();
if (!s) throw new ApiError(401, 'no token');
if (s.expires_at - Date.now() > 15000) return;
await refreshNow();
}
// request(method, path, body, opts) — основной вызов.
// opts.auth — слать ли Authorization (по умолчанию да)
// opts.noRetry — не пробовать refresh при 401
async function request(method, path, body, opts) {
opts = opts || {};
const withAuth = opts.auth !== false;
if (withAuth) await ensureFresh();
// FormData (multipart, загрузка фото) не оборачиваем в JSON и не
// задаём Content-Type сами: браузер сам поставит boundary.
const isForm = (typeof FormData !== 'undefined') && (body instanceof FormData);
const headers = {};
if (!isForm) headers['Content-Type'] = 'application/json';
if (withAuth) {
const s = load();
if (s) headers.Authorization = 'Bearer ' + s.token;
}
let res;
try {
res = await fetch(BASE + path, {
method,
headers,
body: (body === undefined || body === null) ? undefined : (isForm ? body : JSON.stringify(body)),
// Куки (refresh-токен, Path=/auth) уходят только на своих —
// same-origin и есть поведение по умолчанию, но помечаем явно:
// вся сессия держится на этой куке.
credentials: 'same-origin',
});
} catch (e) {
// Это не ответ API, а обрыв связи (упал сервер/прокси): сырой
// "Failed to fetch" пользователю ничего не говорит.
throw new ApiError(0, 'сервер недоступен, попробуйте ещё раз');
}
if (res.status === 204) return null;
const text = await res.text();
let data = null;
if (text) { try { data = JSON.parse(text); } catch (e) { data = null; } }
if (!res.ok) {
// 401 при живой сессии — пробуем refresh ровно один раз.
if (res.status === 401 && withAuth && !opts.noRetry && !opts.retried) {
try {
await refreshNow();
} catch (e) {
clear(); // сессия мертва — на экран входа
throw new ApiError(401, 'no token');
}
return request(method, path, body, Object.assign({}, opts, { retried: true }));
}
throw new ApiError(res.status, (data && data.error) || ('HTTP ' + res.status));
}
return data;
}
const get = (path) => request('GET', path);
const post = (path, body) => request('POST', path, body);
const patch = (path, body) => request('PATCH', path, body);
const del = (path) => request('DELETE', path);
// Загрузка файла: тело — FormData, остальное (refresh, формат ошибок)
// — те же пути, что у request.
const upload = (path, formData) => request('POST', path, formData);
return {
user, login, forgotPassword, resetPassword, logout,
tgStatus, tgCode, tgSet, tgUnbind, tgSetBot, tgDelBot,
get, post, patch, del, upload,
};
})();