feat(web): клиент API, токен и экран входа, хуки запросов (#21)

Типы ответов хаба, клиент с разбором ошибок (AuthError, HubDownError,
ApiError), хранилище токена с подпиской, экран входа с проверкой токена
до сохранения, QueryClient с выходом на экран входа при 401, хуки запросов.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-14 20:10:42 +04:00
parent bad6c73371
commit 51c2772dcc
13 changed files with 449 additions and 0 deletions

View File

@ -0,0 +1,50 @@
import { beforeEach, describe, expect, it } from "vitest";
import { clearToken, setToken } from "../auth/token.ts";
import { json, mockFetch } from "../test/fetchMock.ts";
import { api, ApiError, AuthError, checkToken, HubDownError } from "./client.ts";
describe("api", () => {
beforeEach(() => setToken("tok"));
it("шлёт токен и параметры, пустые параметры выбрасывает", async () => {
const fetch = mockFetch(() => json(200, { issues: [] }));
await api.get("/issues", { sprint: "S 2", q: "", epic: null, limit: 50 });
const [url, init] = fetch.mock.calls[0];
expect(url).toBe("/issues?sprint=S+2&limit=50");
expect((init!.headers as Record<string, string>).Authorization).toBe("Bearer tok");
expect(init!.cache).toBe("no-store");
});
it("PUT отправляет JSON", async () => {
const fetch = mockFetch((_m, _u, body) => json(200, body));
expect(await api.put("/issues/A-1/note", { rest_text: "x" })).toEqual({ rest_text: "x" });
expect((fetch.mock.calls[0][1]!.headers as Record<string, string>)["Content-Type"]).toBe("application/json; charset=utf-8");
});
it("401 — AuthError, 5xx и сеть — HubDownError, прочие 4xx — ApiError с detail", async () => {
mockFetch(() => json(401, { error: "unauthorized" }));
await expect(api.get("/board")).rejects.toBeInstanceOf(AuthError);
mockFetch(() => json(503, {}));
await expect(api.get("/board")).rejects.toBeInstanceOf(HubDownError);
mockFetch(() => { throw new TypeError("Failed to fetch"); });
await expect(api.get("/board")).rejects.toBeInstanceOf(HubDownError);
mockFetch(() => json(422, { error: "bad_note", detail: "неизвестные поля заметки: x" }));
const e = await api.put("/issues/A-1/note", { x: 1 }).catch((err: unknown) => err);
expect(e).toBeInstanceOf(ApiError);
expect(e).toMatchObject({ status: 422, error: "bad_note", detail: "неизвестные поля заметки: x" });
});
it("без токена — AuthError без запроса", async () => {
clearToken();
const fetch = mockFetch(() => json(200, {}));
await expect(api.get("/board")).rejects.toBeInstanceOf(AuthError);
expect(fetch).not.toHaveBeenCalled();
});
it("checkToken проверяет переданный токен, не сохранённый", async () => {
const fetch = mockFetch(() => json(200, { kind_labels: {} }));
await checkToken("candidate");
expect((fetch.mock.calls[0][1]!.headers as Record<string, string>).Authorization).toBe("Bearer candidate");
expect(fetch.mock.calls[0][0]).toBe("/settings");
});
});

58
web/src/api/client.ts Normal file
View File

@ -0,0 +1,58 @@
// единственный клиент HTTP API хаба: тот же хост, клиентский токен в Authorization, ответы — JSON.
// Ошибки: AuthError — 401 или нет токена; HubDownError — сеть или 5xx; ApiError — прочие 4xx с {error, detail}
import { getToken } from "../auth/token.ts";
import type { HubSettings } from "./types.ts";
export class AuthError extends Error {}
export class HubDownError extends Error {}
export class ApiError extends Error {
readonly status: number;
readonly error: string;
readonly detail: string | null;
constructor(status: number, error: string, detail: string | null) {
super(detail ? `${error}: ${detail}` : error);
this.status = status;
this.error = error;
this.detail = detail;
}
}
export type Params = Record<string, string | number | boolean | null | undefined>;
function withParams(path: string, params?: Params): string {
if (!params) return path;
const q = new URLSearchParams();
for (const [k, v] of Object.entries(params)) if (v !== null && v !== undefined && v !== "") q.set(k, String(v));
const s = q.toString();
return s ? `${path}?${s}` : path;
}
async function call<T>(method: string, path: string, body?: unknown, token = getToken()): Promise<T> {
if (!token) throw new AuthError("нет токена");
const headers: Record<string, string> = { Authorization: "Bearer " + token, Accept: "application/json" };
if (body !== undefined) headers["Content-Type"] = "application/json; charset=utf-8";
let resp: Response;
try {
resp = await fetch(path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), cache: "no-store" });
} catch {
throw new HubDownError("хаб не отвечает");
}
if (resp.status === 401) throw new AuthError("токен не подошёл");
let data: unknown = null;
try { data = await resp.json(); } catch { /* тело не JSON */ }
if (resp.status >= 500) throw new HubDownError(`хаб ответил ${resp.status}`);
if (!resp.ok) {
const d = (data ?? {}) as { error?: string; detail?: unknown };
throw new ApiError(resp.status, d.error ?? "http", d.detail === undefined || d.detail === null ? null : String(d.detail));
}
return data as T;
}
export const api = {
get: <T>(path: string, params?: Params) => call<T>("GET", withParams(path, params)),
post: <T>(path: string, body: unknown = {}) => call<T>("POST", path, body),
put: <T>(path: string, body: unknown) => call<T>("PUT", path, body),
};
// проверка токена до сохранения: экран входа
export const checkToken = (token: string) => call<HubSettings>("GET", "/settings", undefined, token);

46
web/src/api/queries.ts Normal file
View File

@ -0,0 +1,46 @@
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "./client.ts";
import type { BoardSnapshot, HubSettings, IssueDetail, IssueRow, Note, NoteFields } from "./types.ts";
export const ISSUE_FILTER_NAMES = ["sprint", "assignee", "work_kind", "fact_status", "status", "label", "epic", "neighbor", "q"] as const;
export type IssueFilters = Partial<Record<(typeof ISSUE_FILTER_NAMES)[number], string>>;
export const ISSUE_PAGE = 50;
export const qk = {
settings: ["settings"] as const,
board: ["board"] as const,
issues: (f: IssueFilters) => ["issues", f] as const,
issue: (key: string) => ["issue", key] as const,
};
export const useSettings = () =>
useQuery({ queryKey: qk.settings, queryFn: () => api.get<HubSettings>("/settings"), staleTime: Infinity });
export const useBoard = () => useQuery({ queryKey: qk.board, queryFn: () => api.get<BoardSnapshot>("/board") });
export const useIssue = (key: string) =>
useQuery({ queryKey: qk.issue(key), queryFn: () => api.get<IssueDetail>(`/issues/${encodeURIComponent(key)}`) });
// список порциями по ISSUE_PAGE: следующая порция есть, пока последняя полная
export const useIssues = (filters: IssueFilters) =>
useInfiniteQuery({
queryKey: qk.issues(filters),
initialPageParam: 0,
queryFn: ({ pageParam }) =>
api.get<{ issues: IssueRow[] }>("/issues", { ...filters, limit: ISSUE_PAGE, offset: pageParam }).then((r) => r.issues),
getNextPageParam: (last, all) => (last.length < ISSUE_PAGE ? undefined : all.length * ISSUE_PAGE),
});
// сохранение заметки: key фиксируется при создании мутации — ответ пишется в карточку той задачи, для которой отправлен
export function useSaveNote(key: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (fields: NoteFields) =>
api.put<Note>(`/issues/${encodeURIComponent(key)}/note`, { ...fields, updated_by: "web" }),
onSuccess: (note) => {
qc.setQueryData<IssueDetail>(qk.issue(key), (d) => (d ? { ...d, note } : d));
void qc.invalidateQueries({ queryKey: ["issues"] });
void qc.invalidateQueries({ queryKey: qk.board });
},
});
}

View File

@ -0,0 +1,13 @@
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
import { clearToken, getToken } from "../auth/token.ts";
import { AuthError } from "./client.ts";
// 401 в любом запросе или действии — экран входа с пояснением «токен не подошёл»
export function makeQueryClient(): QueryClient {
const onError = (e: unknown) => { if (e instanceof AuthError) clearToken(getToken() ? "bad" : "none"); };
return new QueryClient({
queryCache: new QueryCache({ onError }),
mutationCache: new MutationCache({ onError }),
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false, staleTime: 30_000 } },
});
}

54
web/src/api/types.ts Normal file
View File

@ -0,0 +1,54 @@
// ответы HTTP API хаба (openspec/specs/hub-api); поля — как отдаёт хаб, без переименований
export interface BoardIssue {
key: string; summary: string | null; issue_type: string | null; status: string | null; priority: string | null;
assignee: string | null; sprint: string | null; duedate: string | null; planned_start: string | null;
planned_end: string | null; epic_key: string | null; labels: string[]; neighbor: boolean;
fact_status: string | null; work_kind: string | null;
}
export interface BoardLink { source: string; target: string; link_type: string }
export interface SyncRun { started_at: string; finished_at: string; issues_count: number }
export interface BoardSnapshot { generated_at: string; last_sync: SyncRun | null; issues: BoardIssue[]; links: BoardLink[] }
// словари настроек: kind_labels — вид работы → метка Jira, link_types — логический тип → имя типа связи Jira,
// status_labels — статус по факту → метка Jira
export interface HubSettings {
kind_labels?: Record<string, string>;
link_types?: Record<string, string>;
status_labels?: Record<string, string>;
[key: string]: unknown;
}
export interface IssueRow {
key: string; id: string | null; issue_type: string | null; summary: string | null; description: string | null;
status: string | null; priority: string | null; assignee: string | null; reporter: string | null;
duedate: string | null; planned_start: string | null; planned_end: string | null; epic_key: string | null;
labels: string[]; sprint: string | null; created: string | null; jira_updated: string | null; fetched_at: string;
neighbor: boolean; fact_status?: string | null; work_kind?: string | null;
}
export const NOTE_FIELDS = ["fact_status", "work_kind", "done_text", "rest_text", "owner_note"] as const;
export type NoteField = (typeof NOTE_FIELDS)[number];
export type NoteFields = Partial<Record<NoteField, string | null>>;
export type Note = Record<NoteField, string | null> & { key: string; updated_at: string; updated_by: string };
export interface IssueCommand {
id: number; batch_id: number; type: string; status: string; finished_at: string | null; error: string | null;
}
export interface IssueDetail extends IssueRow {
links_out: { target_key: string; link_type: string }[];
links_in: { source_key: string; link_type: string }[];
note: Note | null;
commands: IssueCommand[];
}
export type BatchStatus = "queued" | "running" | "done" | "cancelled";
export interface BatchCommand {
id: number; seq: number; type: string; status: string; error: string | null; finished_at: string | null;
}
export interface Batch {
id: number; title: string; author: string; created_at: string; cancelled_at: string | null; status: BatchStatus;
counts: Record<string, number>; remaining_seconds: number; commands?: BatchCommand[];
}
export interface AgentView { online: boolean; last_seen_at: string | null; warning: string | null }
export interface CreatedBatch { id: number; estimate_seconds: number; count: number }

View File

@ -0,0 +1,9 @@
import type { ReactNode } from "react";
import { LoginScreen } from "./LoginScreen.tsx";
import { useAuth } from "./useAuth.ts";
// пока нет токена — только экран входа: страниц и их фоновых запросов нет (M-6)
export function AuthGate({ children }: { children: ReactNode }) {
const { token } = useAuth();
return token ? <>{children}</> : <LoginScreen />;
}

View File

@ -0,0 +1,65 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it } from "vitest";
import { json, mockFetch } from "../test/fetchMock.ts";
import { renderWithProviders } from "../test/render.tsx";
import { AuthGate } from "./AuthGate.tsx";
import { clearToken, getAuth } from "./token.ts";
const app = <AuthGate><p>страница приложения</p></AuthGate>;
describe("вход", () => {
beforeEach(() => clearToken());
it("без токена — экран входа, страница не видна", () => {
renderWithProviders(app);
expect(screen.getByRole("heading", { name: "Вход в хаб" })).toBeInTheDocument();
expect(screen.queryByText("страница приложения")).not.toBeInTheDocument();
expect(screen.getByText(/uv run jira-hub tokens/)).toBeInTheDocument();
});
it("верный токен сохраняется и открывает приложение", async () => {
const fetch = mockFetch(() => json(200, {}));
renderWithProviders(app);
await userEvent.type(screen.getByLabelText("Клиентский токен хаба"), "good-token");
await userEvent.click(screen.getByRole("button", { name: "Войти" }));
expect(await screen.findByText("страница приложения")).toBeInTheDocument();
expect(localStorage.getItem("jira-hub-token")).toBe("good-token");
expect(fetch.mock.calls[0][0]).toBe("/settings");
});
it("неверный токен не сохраняется, поле не стирается", async () => {
mockFetch(() => json(401, { error: "unauthorized" }));
renderWithProviders(app);
const input = screen.getByLabelText("Клиентский токен хаба");
await userEvent.type(input, "wrong");
await userEvent.click(screen.getByRole("button", { name: "Войти" }));
expect(await screen.findByText(/Токен не подошёл/)).toBeInTheDocument();
expect(input).toHaveValue("wrong");
expect(localStorage.getItem("jira-hub-token")).toBeNull();
});
it("кириллица или пробел — «недопустимые символы», запроса нет", async () => {
const fetch = mockFetch(() => json(200, {}));
renderWithProviders(app);
await userEvent.type(screen.getByLabelText("Клиентский токен хаба"), "ток ен");
await userEvent.click(screen.getByRole("button", { name: "Войти" }));
expect(screen.getByText(/недопустимые символы/)).toBeInTheDocument();
expect(fetch).not.toHaveBeenCalled();
});
it("хаб не отвечает — своё сообщение, не «токен не подошёл»", async () => {
mockFetch(() => { throw new TypeError("Failed to fetch"); });
renderWithProviders(app);
await userEvent.type(screen.getByLabelText("Клиентский токен хаба"), "tok");
await userEvent.click(screen.getByRole("button", { name: "Войти" }));
expect(await screen.findByText(/Хаб не отвечает/)).toBeInTheDocument();
});
it("после 401 в приложении экран входа поясняет причину", async () => {
clearToken("bad");
renderWithProviders(app);
await waitFor(() => expect(getAuth().reason).toBe("bad"));
expect(screen.getByText(/Токен не подошёл/)).toBeInTheDocument();
});
});

View File

@ -0,0 +1,48 @@
import { useQueryClient } from "@tanstack/react-query";
import { useState, type FormEvent } from "react";
import { AuthError, checkToken } from "../api/client.ts";
import { setToken, TOKEN_RE } from "./token.ts";
import { useAuth } from "./useAuth.ts";
const BAD_TOKEN = "Токен не подошёл. Проверьте его по uv run jira-hub tokens: токен могли пересоздать.";
const BAD_CHARS = "В токене недопустимые символы: скопируйте строку после «клиентский токен:» целиком, без пробелов и кириллицы.";
const HUB_DOWN = "Хаб не отвечает: проверьте, что jira-hub запущен, и попробуйте снова.";
export function LoginScreen() {
const { reason } = useAuth();
const qc = useQueryClient();
const [value, setValue] = useState("");
const [error, setError] = useState(reason === "bad" ? BAD_TOKEN : "");
const [busy, setBusy] = useState(false);
async function submit(ev: FormEvent) {
ev.preventDefault();
const t = value.trim();
if (!t) return;
if (!TOKEN_RE.test(t)) { setError(BAD_CHARS); return; }
setBusy(true);
try {
await checkToken(t);
qc.clear();
setToken(t);
} catch (e) {
setError(e instanceof AuthError ? BAD_TOKEN : HUB_DOWN);
} finally {
setBusy(false);
}
}
return (
<div className="login">
<h2>Вход в хаб</h2>
<p>Приложению нужен клиентский токен хаба. Его показывает команда <code>uv run jira-hub tokens</code> в каталоге
jira-hub на этом Mac. Токен сохранится в этом браузере.</p>
{error && <p className="err" role="alert">{error}</p>}
<form onSubmit={submit}>
<input type="password" autoComplete="off" spellCheck={false} aria-label="Клиентский токен хаба"
placeholder="клиентский токен" value={value} onChange={(e) => setValue(e.target.value)} />
<button className="btn" type="submit" disabled={busy}>Войти</button>
</form>
</div>
);
}

View File

@ -0,0 +1,40 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
describe("token", () => {
beforeEach(() => { vi.resetModules(); localStorage.clear(); });
afterEach(() => { vi.restoreAllMocks(); });
it("читает токен, сохранённый прежней доской", async () => {
localStorage.setItem("jira-hub-token", "abc");
const t = await import("./token.ts");
expect(t.getToken()).toBe("abc");
});
it("setToken и clearToken пишут в localStorage и оповещают подписчиков", async () => {
const t = await import("./token.ts");
const fn = vi.fn();
t.subscribe(fn);
t.setToken("xyz");
expect(localStorage.getItem("jira-hub-token")).toBe("xyz");
t.clearToken("bad");
expect(t.getAuth()).toEqual({ token: "", reason: "bad" });
expect(localStorage.getItem("jira-hub-token")).toBeNull();
expect(fn).toHaveBeenCalledTimes(2);
});
it("закрытое хранилище: токен живёт в памяти, исключений нет", async () => {
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new DOMException("closed", "SecurityError"); });
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new DOMException("closed", "SecurityError"); });
const t = await import("./token.ts");
expect(t.getToken()).toBe("");
t.setToken("mem");
expect(t.getToken()).toBe("mem");
});
it("TOKEN_RE пропускает только печатный ASCII без пробелов", async () => {
const { TOKEN_RE } = await import("./token.ts");
expect(TOKEN_RE.test("Ab-_9~")).toBe(true);
expect(TOKEN_RE.test("ab c")).toBe(false);
expect(TOKEN_RE.test("токен")).toBe(false);
});
});

33
web/src/auth/token.ts Normal file
View File

@ -0,0 +1,33 @@
// клиентский токен хаба: localStorage["jira-hub-token"] (тот же ключ, что у прежней доски) и копия в памяти на случай
// закрытого хранилища. reason — почему показан экран входа: "bad" — токен не подошёл (ответ 401)
export const TOKEN_KEY = "jira-hub-token";
export const TOKEN_RE = /^[\x21-\x7e]+$/; // печатный ASCII без пробелов: иначе fetch бросит TypeError на заголовке
export type LoginReason = "none" | "bad";
export interface AuthSnapshot { token: string; reason: LoginReason }
const readStored = (): string => {
try { return localStorage.getItem(TOKEN_KEY) ?? ""; } catch { return ""; }
};
let state: AuthSnapshot = { token: readStored(), reason: "none" };
const listeners = new Set<() => void>();
const emit = (next: AuthSnapshot) => { state = next; listeners.forEach((l) => l()); };
export const getAuth = (): AuthSnapshot => state;
export const getToken = (): string => state.token;
export function setToken(token: string): void {
try { localStorage.setItem(TOKEN_KEY, token); } catch { /* закрытое хранилище: токен только в памяти */ }
emit({ token, reason: "none" });
}
export function clearToken(reason: LoginReason = "none"): void {
try { localStorage.removeItem(TOKEN_KEY); } catch { /* закрытое хранилище */ }
emit({ token: "", reason });
}
export function subscribe(fn: () => void): () => void {
listeners.add(fn);
return () => { listeners.delete(fn); };
}

4
web/src/auth/useAuth.ts Normal file
View File

@ -0,0 +1,4 @@
import { useSyncExternalStore } from "react";
import { getAuth, subscribe, type AuthSnapshot } from "./token.ts";
export const useAuth = (): AuthSnapshot => useSyncExternalStore(subscribe, getAuth);

16
web/src/test/fetchMock.ts Normal file
View File

@ -0,0 +1,16 @@
import { vi } from "vitest";
export function json(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
}
// подмена fetch: handler получает метод, URL (относительно http://hub.test) и разобранное тело
export function mockFetch(handler: (method: string, url: URL, body: unknown) => Response | Promise<Response>) {
const fn = vi.fn(async (input: RequestInfo | URL, init: RequestInit = {}) => {
const url = new URL(String(input), "http://hub.test");
const body = typeof init.body === "string" ? JSON.parse(init.body) : undefined;
return handler(init.method ?? "GET", url, body);
});
vi.stubGlobal("fetch", fn);
return fn;
}

13
web/src/test/render.tsx Normal file
View File

@ -0,0 +1,13 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { render } from "@testing-library/react";
import type { ReactElement } from "react";
import { createMemoryRouter, RouterProvider } from "react-router";
import { makeQueryClient } from "../api/queryClient.ts";
// компонент под QueryClient и data-роутером в памяти: path — шаблон маршрута (для useParams), route — открытый адрес
export function renderWithProviders(ui: ReactElement, { route = "/", path = "*" }: { route?: string; path?: string } = {}) {
const client = makeQueryClient();
const router = createMemoryRouter([{ path, element: ui }, { path: "*", element: <p>другая страница</p> }],
{ initialEntries: [route] });
return { client, router, ...render(<QueryClientProvider client={client}><RouterProvider router={router} /></QueryClientProvider>) };
}