Add personal API keys and searchable API docs with browser coverage
This commit is contained in:
@@ -18,6 +18,8 @@ docker build -t otche-web:local .
|
||||
|
||||
`dist/`, dependencies, `.env` overrides and build metadata are generated/ignored. There are no external static assets or submodules required by the build. Keep `package-lock.json` tracked.
|
||||
|
||||
Run the permanent Chromium browser suite with `npx playwright install chromium` followed by `npm test`. Playwright starts an isolated Vite server on loopback port 4173 and exercises the routed application with deterministic HTTP fixtures; no running backend, administrator credentials, or private state is needed. Coverage includes key creation/scopes/expiry, one-time token handling, failed responses, revocation, limits, mobile layout, and the read-only documentation catalog. `e2e/fixtures/openapi.json` is a verbatim snapshot of the backend's embedded `internal/api/openapi.json`; refresh it when that public contract changes. Traces and reports use the ignored `test-results/` and `playwright-report/` directories.
|
||||
|
||||
For local development, start the real API with a dedicated local database, `PUBLIC_ORIGIN=http://127.0.0.1:5173` and `ALLOW_INSECURE_HTTP=true`, then:
|
||||
|
||||
```sh
|
||||
@@ -35,6 +37,16 @@ Report file collection is shown only in the selected attempt's Grub panel: reque
|
||||
|
||||
The report journal follows the selected machine attempt, using the same event scope as its video timeline. Switching a machine or historical attempt replaces the journal; job-wide and other-attempt events are not mixed into it.
|
||||
|
||||
## API keys and integration reference
|
||||
|
||||
The authenticated navigation includes **API-ключи** (`/api-keys`) and **Документация API** (`/docs`). The read-only documentation page uses the public OpenAPI 3.1 contract at `/api/v1/openapi.json`; it does not execute requests. API-key management always uses the existing browser session and CSRF-protected same-origin client, never a Bearer token or administrator endpoints.
|
||||
|
||||
Key creation requires a name (trimmed, 1–80 characters, no control characters), an integer lifetime of 1–365 days (UI default: 90), and explicit scopes. The six available scopes are `profiles:read`, `jobs:read`, `jobs:write`, `uploads:write`, `uploads:read`, and `artifacts:read`; only the first two are selected initially. Write scopes never imply read scopes. Keys access only their owner's data, including keys created by administrators, and cannot access administration, authentication, or key-management endpoints. At most 20 unrevoked, unexpired keys may coexist.
|
||||
|
||||
The complete token appears only in the successful creation panel. It remains only in component memory, is never stored in browser storage or URLs, and is copied to the clipboard only by explicit user action. Dismissing the panel, leaving the page, or reloading discards it; save it in a secure secret store first. The registry shows only prefixes, permissions, dates, and active/expired/revoked states. Last use is approximate to a minute. Revocation requires confirmation and cannot be undone.
|
||||
|
||||
Mutations refresh the registry without clearing a newly issued token on a list-refresh failure. Failed creation is never automatically retried: an interrupted response may mean the server created a key whose token was not received. Refresh the registry, revoke that inaccessible key if present, then create a replacement. Polling the metadata list does not resend creation or revocation requests.
|
||||
|
||||
## Visual system
|
||||
|
||||
The complete interface uses a Windows 95/98-inspired light design: patterned teal desktop, silver application surfaces, navy window title bars, raised/sunken square controls, system typography and real-data monospace counters. `src/tokens.css` owns palette/font/bevel tokens; `src/styles.css` is the single component/responsive stylesheet, not a theme override layered over the former dark UI. No remote fonts, decorative dependencies or simulated system controls are required.
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import type { ApiKey, ApiKeyScope } from "../src/models";
|
||||
|
||||
const now = new Date("2026-09-26T12:00:00Z");
|
||||
const token = `otche_${"a".repeat(64)}`;
|
||||
const defaultScopes: ApiKeyScope[] = ["profiles:read", "jobs:read"];
|
||||
|
||||
function key(overrides: Partial<ApiKey> = {}): ApiKey {
|
||||
return {
|
||||
id: "key-1",
|
||||
name: "CI pipeline",
|
||||
prefix: token.slice(0, 18),
|
||||
scopes: defaultScopes,
|
||||
created_at: "2026-09-25T12:00:00Z",
|
||||
expires_at: "2026-12-25T12:00:00Z",
|
||||
last_used_at: null,
|
||||
revoked_at: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function server(page: Page, initial: ApiKey[] = []) {
|
||||
const state = {
|
||||
items: initial,
|
||||
creates: [] as {
|
||||
name: string;
|
||||
scopes: ApiKeyScope[];
|
||||
expires_in_days: number;
|
||||
}[],
|
||||
deletes: [] as string[],
|
||||
listFails: false,
|
||||
loseCreateResponse: false,
|
||||
createStatus: 201,
|
||||
revokeFails: false,
|
||||
releaseCreate: null as (() => void) | null,
|
||||
holdCreate: false,
|
||||
};
|
||||
await page.clock.install({ time: now });
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (path === "/api/v1/auth/session") {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
user: { id: "owner-1", username: "test-operator", role: "operator" },
|
||||
csrf_token: "test-csrf",
|
||||
},
|
||||
});
|
||||
} else if (path === "/api/v1/api-keys" && request.method() === "GET") {
|
||||
await route.fulfill(
|
||||
state.listFails
|
||||
? {
|
||||
status: 503,
|
||||
json: { error: { code: "unavailable", message: "Unavailable" } },
|
||||
}
|
||||
: { json: { items: state.items } },
|
||||
);
|
||||
} else if (path === "/api/v1/api-keys" && request.method() === "POST") {
|
||||
const body = request.postDataJSON();
|
||||
state.creates.push(body);
|
||||
if (state.holdCreate)
|
||||
await new Promise<void>((resolve) => {
|
||||
state.releaseCreate = resolve;
|
||||
});
|
||||
if (state.createStatus !== 201) {
|
||||
await route.fulfill({
|
||||
status: state.createStatus,
|
||||
json: {
|
||||
error: {
|
||||
code:
|
||||
state.createStatus === 409 ? "key_limit" : "invalid_request",
|
||||
message: "Rejected",
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
const created = key({
|
||||
id: `created-${state.creates.length}`,
|
||||
name: body.name,
|
||||
scopes: body.scopes,
|
||||
expires_at: new Date(
|
||||
now.getTime() + body.expires_in_days * 86_400_000,
|
||||
).toISOString(),
|
||||
});
|
||||
state.items = [created, ...state.items];
|
||||
if (state.loseCreateResponse) await route.abort("connectionfailed");
|
||||
else await route.fulfill({ status: 201, json: { key: created, token } });
|
||||
} else if (
|
||||
path.startsWith("/api/v1/api-keys/") &&
|
||||
request.method() === "DELETE"
|
||||
) {
|
||||
const id = decodeURIComponent(path.split("/").at(-1)!);
|
||||
state.deletes.push(id);
|
||||
if (state.revokeFails) {
|
||||
await route.fulfill({
|
||||
status: 503,
|
||||
json: { error: { code: "unavailable", message: "Unavailable" } },
|
||||
});
|
||||
} else {
|
||||
state.items = state.items.map((item) =>
|
||||
item.id === id ? { ...item, revoked_at: now.toISOString() } : item,
|
||||
);
|
||||
await route.fulfill({ status: 204 });
|
||||
}
|
||||
} else {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
json: {
|
||||
error: { code: "not_found", message: "No fixture for this route" },
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
async function openKeys(page: Page) {
|
||||
await page.goto("/api-keys");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "API-ключи", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("Получаем ваши API-ключи…")).toBeHidden();
|
||||
}
|
||||
|
||||
async function create(page: Page, name = "Nightly pipeline") {
|
||||
await page.getByLabel("Название ключа").fill(name);
|
||||
await page.getByRole("button", { name: "Создать ключ", exact: true }).click();
|
||||
await expect(page.getByLabel("Секретный токен")).toHaveValue(token);
|
||||
}
|
||||
|
||||
async function expectNoPersistentToken(page: Page) {
|
||||
const visibleToOtherCode = await page.evaluate(() => ({
|
||||
local: Object.values(localStorage),
|
||||
session: Object.values(sessionStorage),
|
||||
cookies: document.cookie,
|
||||
location: location.href,
|
||||
links: Array.from(document.querySelectorAll("a"), (link) => link.href),
|
||||
}));
|
||||
expect(JSON.stringify(visibleToOtherCode)).not.toContain(token);
|
||||
}
|
||||
|
||||
test("creates with least-privilege defaults and returns only metadata in the registry", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await server(page);
|
||||
await openKeys(page);
|
||||
await expect(
|
||||
page.getByText("Ключей пока нет", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByLabel("Срок действия, дней")).toHaveValue("90");
|
||||
await expect(
|
||||
page.getByRole("checkbox", { name: /Чтение профилей/ }),
|
||||
).toBeChecked();
|
||||
await expect(
|
||||
page.getByRole("checkbox", { name: /Чтение заданий/ }),
|
||||
).toBeChecked();
|
||||
for (const name of [
|
||||
/Управление заданиями/,
|
||||
/Загрузка файлов/,
|
||||
/Скачивание образцов/,
|
||||
/Чтение результатов/,
|
||||
]) {
|
||||
await expect(page.getByRole("checkbox", { name })).not.toBeChecked();
|
||||
}
|
||||
await create(page, " Nightly pipeline ");
|
||||
expect(state.creates).toEqual([
|
||||
{ name: "Nightly pipeline", scopes: defaultScopes, expires_in_days: 90 },
|
||||
]);
|
||||
const card = page.getByRole("article", { name: "Nightly pipeline" });
|
||||
await expect(card).toContainText("Действует");
|
||||
await expect(card).toContainText(token.slice(0, 18));
|
||||
await expect(card).not.toContainText(token);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Создать ключ", exact: true }),
|
||||
).toBeDisabled();
|
||||
await expect(page.getByRole("heading", { name: /Токен для/ })).toBeFocused();
|
||||
});
|
||||
|
||||
test("scope selection is independent and lifetime boundaries prevent invalid submission", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await server(page);
|
||||
await openKeys(page);
|
||||
await page.getByLabel("Название ключа").fill("Write-only uploader");
|
||||
await page.getByRole("checkbox", { name: /Чтение профилей/ }).uncheck();
|
||||
await page.getByRole("checkbox", { name: /Чтение заданий/ }).uncheck();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Создать ключ", exact: true }),
|
||||
).toBeDisabled();
|
||||
await page.getByRole("checkbox", { name: /Загрузка файлов/ }).check();
|
||||
const expiry = page.getByLabel("Срок действия, дней");
|
||||
for (const invalid of ["0", "366", "1.5"]) {
|
||||
await expiry.fill(invalid);
|
||||
await page
|
||||
.getByRole("button", { name: "Создать ключ", exact: true })
|
||||
.click();
|
||||
await expect(expiry).toBeFocused();
|
||||
expect(state.creates).toEqual([]);
|
||||
}
|
||||
await expiry.fill("365");
|
||||
await page.getByRole("button", { name: "Создать ключ", exact: true }).click();
|
||||
await expect(page.getByLabel("Секретный токен")).toHaveValue(token);
|
||||
expect(state.creates).toEqual([
|
||||
{
|
||||
name: "Write-only uploader",
|
||||
scopes: ["uploads:write"],
|
||||
expires_in_days: 365,
|
||||
},
|
||||
]);
|
||||
const card = page.getByRole("article", { name: "Write-only uploader" });
|
||||
await expect(card).toContainText("uploads:write");
|
||||
await expect(card).not.toContainText("uploads:read");
|
||||
});
|
||||
|
||||
test("invalid names cannot issue keys and a pending create cannot be resubmitted", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await server(page);
|
||||
await openKeys(page);
|
||||
for (const invalid of [" ", "a".repeat(81)]) {
|
||||
await page.getByLabel("Название ключа").fill(invalid);
|
||||
await page
|
||||
.getByRole("button", { name: "Создать ключ", exact: true })
|
||||
.click();
|
||||
await expect(page.getByRole("alert")).toContainText("1–80");
|
||||
expect(state.creates).toEqual([]);
|
||||
}
|
||||
state.holdCreate = true;
|
||||
await page.getByLabel("Название ключа").fill("Single request");
|
||||
await page.getByRole("button", { name: "Создать ключ", exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Создаём ключ…" }),
|
||||
).toBeDisabled();
|
||||
await expect(page.getByLabel("Название ключа")).toBeDisabled();
|
||||
await expect.poll(() => Boolean(state.releaseCreate)).toBe(true);
|
||||
await page.keyboard.press("Enter");
|
||||
expect(state.creates).toHaveLength(1);
|
||||
state.releaseCreate!();
|
||||
await expect(page.getByLabel("Секретный токен")).toHaveValue(token);
|
||||
});
|
||||
|
||||
test("token copying is explicit and dismissal removes it without persistent leaks", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
const messages: string[] = [];
|
||||
const outgoing: string[] = [];
|
||||
page.on("console", (message) => messages.push(message.text()));
|
||||
page.on("request", (request) =>
|
||||
outgoing.push(request.url() + (request.postData() ?? "")),
|
||||
);
|
||||
await server(page);
|
||||
await openKeys(page);
|
||||
await page.evaluate(() =>
|
||||
navigator.clipboard.writeText("untouched clipboard"),
|
||||
);
|
||||
await create(page);
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(
|
||||
"untouched clipboard",
|
||||
);
|
||||
await expectNoPersistentToken(page);
|
||||
await page
|
||||
.getByRole("button", { name: "Скопировать токен", exact: true })
|
||||
.click();
|
||||
await expect(page.getByRole("status")).toContainText("Токен скопирован");
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(token);
|
||||
await page.getByRole("button", { name: "Скрыть токен навсегда" }).click();
|
||||
await expect(page.getByLabel("Секретный токен")).toHaveCount(0);
|
||||
await expect(page.getByLabel("Название ключа")).toBeFocused();
|
||||
await expectNoPersistentToken(page);
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByRole("article", { name: "Nightly pipeline" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByLabel("Секретный токен")).toHaveCount(0);
|
||||
expect(messages.join("\n")).not.toContain(token);
|
||||
expect(outgoing.join("\n")).not.toContain(token);
|
||||
});
|
||||
|
||||
test("route navigation discards the one-time token rather than restoring it on back", async ({
|
||||
page,
|
||||
}) => {
|
||||
await server(page);
|
||||
await openKeys(page);
|
||||
await create(page);
|
||||
await page
|
||||
.getByRole("main")
|
||||
.getByRole("link", { name: "Документация API" })
|
||||
.click();
|
||||
await expect(page).toHaveURL(/\/docs$/);
|
||||
await expect(page.getByLabel("Секретный токен")).toHaveCount(0);
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/\/api-keys$/);
|
||||
await expect(
|
||||
page.getByRole("article", { name: "Nightly pipeline" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByLabel("Секретный токен")).toHaveCount(0);
|
||||
await expectNoPersistentToken(page);
|
||||
});
|
||||
|
||||
test("lost creation response is not retried by polling or manual list refresh", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await server(page);
|
||||
state.loseCreateResponse = true;
|
||||
await openKeys(page);
|
||||
await page.getByLabel("Название ключа").fill("Response lost");
|
||||
await page.getByRole("button", { name: "Создать ключ", exact: true }).click();
|
||||
await expect(page.getByRole("alert")).toContainText(
|
||||
"Запрос не повторяется автоматически",
|
||||
);
|
||||
await expect(page.getByLabel("Секретный токен")).toHaveCount(0);
|
||||
await page.clock.fastForward(180_000);
|
||||
await page.getByRole("button", { name: "Обновить", exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole("article", { name: "Response lost" }),
|
||||
).toBeVisible();
|
||||
expect(state.creates).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("registry refresh failure preserves the newly issued secret until dismissal", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await server(page);
|
||||
await openKeys(page);
|
||||
state.listFails = true;
|
||||
await create(page);
|
||||
await expect(page.getByRole("alert")).toContainText(
|
||||
"Не удалось обновить список",
|
||||
);
|
||||
await expect(page.getByLabel("Секретный токен")).toHaveValue(token);
|
||||
state.listFails = false;
|
||||
await page.getByRole("button", { name: "Повторить", exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole("article", { name: "Nightly pipeline" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("alert")).toHaveCount(0);
|
||||
await expect(page.getByLabel("Секретный токен")).toHaveValue(token);
|
||||
expect(state.creates).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("revocation requires confirmation; cancel and Escape preserve a working key", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await server(page, [key()]);
|
||||
await openKeys(page);
|
||||
const card = page.getByRole("article", { name: "CI pipeline" });
|
||||
const revoke = card.getByRole("button", { name: /Отозвать/ });
|
||||
await revoke.click();
|
||||
const dialog = page.getByRole("alertdialog");
|
||||
await expect(dialog).toContainText("CI pipeline");
|
||||
await dialog.getByRole("button", { name: "Назад", exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(revoke).toBeFocused();
|
||||
expect(state.deletes).toEqual([]);
|
||||
await revoke.click();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toHaveCount(0);
|
||||
expect(state.deletes).toEqual([]);
|
||||
await revoke.click();
|
||||
await dialog
|
||||
.getByRole("button", { name: "Подтвердить", exact: true })
|
||||
.click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(
|
||||
card.getByText("Отозван", { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(card.getByRole("button", { name: /Отозвать/ })).toHaveCount(0);
|
||||
expect(state.deletes).toEqual(["key-1"]);
|
||||
});
|
||||
|
||||
test("failed revocation remains an error and does not display a revoked key", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await server(page, [key()]);
|
||||
state.revokeFails = true;
|
||||
await openKeys(page);
|
||||
const card = page.getByRole("article", { name: "CI pipeline" });
|
||||
await card.getByRole("button", { name: /Отозвать/ }).click();
|
||||
await page
|
||||
.getByRole("alertdialog")
|
||||
.getByRole("button", { name: "Подтвердить" })
|
||||
.click();
|
||||
await expect(page.getByRole("alert")).toContainText(
|
||||
"Не удалось подтвердить отзыв",
|
||||
);
|
||||
await expect(card).toContainText("Действует");
|
||||
await expect(card.getByRole("button", { name: /Отозвать/ })).toBeEnabled();
|
||||
});
|
||||
|
||||
test("expired and revoked keys do not count toward the active-key cap", async ({
|
||||
page,
|
||||
}) => {
|
||||
const active = Array.from({ length: 20 }, (_, index) =>
|
||||
key({ id: `active-${index}`, name: `Active ${index}` }),
|
||||
);
|
||||
await server(page, [
|
||||
...active,
|
||||
key({ id: "expired", name: "Expired", expires_at: "2026-09-26T11:59:00Z" }),
|
||||
key({ id: "revoked", name: "Revoked", revoked_at: "2026-09-26T10:00:00Z" }),
|
||||
]);
|
||||
await openKeys(page);
|
||||
await expect(
|
||||
page.getByRole("article", { name: "Expired", exact: true }),
|
||||
).toContainText("Истёк");
|
||||
await expect(
|
||||
page.getByRole("article", { name: "Revoked", exact: true }),
|
||||
).toContainText("Отозван");
|
||||
await expect(page.getByText(/Действующих: 20 из 20/)).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Создать ключ", exact: true }),
|
||||
).toBeDisabled();
|
||||
await page
|
||||
.getByRole("article", { name: "Active 0", exact: true })
|
||||
.getByRole("button", { name: /Отозвать/ })
|
||||
.click();
|
||||
await page
|
||||
.getByRole("alertdialog")
|
||||
.getByRole("button", { name: "Подтвердить" })
|
||||
.click();
|
||||
await expect(page.getByText(/Действующих: 19 из 20/)).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Создать ключ", exact: true }),
|
||||
).toBeEnabled();
|
||||
});
|
||||
|
||||
test("server-side cap race displays a recoverable error without issuing a fake secret", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await server(page);
|
||||
state.createStatus = 409;
|
||||
await openKeys(page);
|
||||
await page.getByLabel("Название ключа").fill("Raced limit");
|
||||
await page.getByRole("button", { name: "Создать ключ", exact: true }).click();
|
||||
await expect(page.getByRole("alert")).toContainText("20 действующих ключей");
|
||||
await expect(page.getByLabel("Секретный токен")).toHaveCount(0);
|
||||
expect(state.creates).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("mobile key creation, token, registry and modal fit the viewport", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await server(page);
|
||||
await openKeys(page);
|
||||
await create(page, "Интеграция".repeat(8));
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Скопировать токен", exact: true }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
await page.getByRole("button", { name: "Скрыть токен навсегда" }).click();
|
||||
await page
|
||||
.getByRole("article")
|
||||
.getByRole("button", { name: /Отозвать/ })
|
||||
.click();
|
||||
await expect(page.getByRole("alertdialog")).toBeVisible();
|
||||
const bounds = await page.getByRole("alertdialog").boundingBox();
|
||||
expect(bounds).not.toBeNull();
|
||||
expect(bounds!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(375);
|
||||
await page.keyboard.press("Escape");
|
||||
await page.getByRole("button", { name: "Открыть меню", exact: true }).click();
|
||||
const navigation = page.getByRole("dialog", { name: "Навигация" });
|
||||
await expect(
|
||||
navigation.getByRole("link", { name: "API-ключи" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
navigation.getByRole("link", { name: "Документация API" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("initial loading and failure stay explicit until a successful registry retry", async ({
|
||||
page,
|
||||
}) => {
|
||||
await server(page, [key()]);
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await page.route("**/api/v1/api-keys", async (route) => {
|
||||
await gate;
|
||||
await route.fulfill({
|
||||
status: 503,
|
||||
json: { error: { code: "unavailable", message: "Unavailable" } },
|
||||
});
|
||||
});
|
||||
await page.goto("/api-keys");
|
||||
await expect(page.getByRole("status")).toContainText(
|
||||
"Получаем ваши API-ключи",
|
||||
);
|
||||
await expect(page.getByRole("article")).toHaveCount(0);
|
||||
release();
|
||||
await expect(page.getByRole("alert")).toBeVisible();
|
||||
await expect(page.getByText("Ключей пока нет", { exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await page.unroute("**/api/v1/api-keys");
|
||||
await page.getByRole("button", { name: "Повторить", exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole("article", { name: "CI pipeline" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("alert")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("denied clipboard permission retains the selectable token and explains manual copying", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await context.grantPermissions([]);
|
||||
await server(page);
|
||||
await openKeys(page);
|
||||
await create(page);
|
||||
await page
|
||||
.getByRole("button", { name: "Скопировать токен", exact: true })
|
||||
.click();
|
||||
await expect(page.getByRole("status")).toContainText("вручную");
|
||||
const secret = page.getByLabel("Секретный токен");
|
||||
await expect(secret).toHaveValue(token);
|
||||
await secret.focus();
|
||||
await secret.press("ControlOrMeta+A");
|
||||
expect(
|
||||
await secret.evaluate((element: HTMLTextAreaElement) =>
|
||||
element.value.slice(element.selectionStart, element.selectionEnd),
|
||||
),
|
||||
).toBe(token);
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const openapiBytes = readFileSync(
|
||||
new URL("./fixtures/openapi.json", import.meta.url),
|
||||
);
|
||||
const openapi = JSON.parse(openapiBytes.toString("utf8")) as {
|
||||
"x-api-key-scopes": Record<string, string>;
|
||||
};
|
||||
|
||||
async function serveDocumentation(page: Page) {
|
||||
const state = {
|
||||
failure: false,
|
||||
requests: 0,
|
||||
mutations: [] as string[],
|
||||
cookies: [] as (string | undefined)[],
|
||||
};
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (request.method() !== "GET")
|
||||
state.mutations.push(`${request.method()} ${path}`);
|
||||
if (path === "/api/v1/auth/session") {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
user: { id: "reader", username: "reader", role: "operator" },
|
||||
csrf_token: "fixture-csrf",
|
||||
},
|
||||
});
|
||||
} else if (path === "/api/v1/openapi.json") {
|
||||
state.requests++;
|
||||
state.cookies.push(request.headers()["cookie"]);
|
||||
await route.fulfill(
|
||||
state.failure
|
||||
? {
|
||||
status: 503,
|
||||
json: { error: { code: "unavailable", message: "Unavailable" } },
|
||||
}
|
||||
: { json: openapi },
|
||||
);
|
||||
} else {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
json: { error: { code: "not_found", message: "Not found" } },
|
||||
});
|
||||
}
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
function routeSummary(page: Page, method: string, path: string) {
|
||||
return page
|
||||
.locator("summary")
|
||||
.filter({ hasText: `${method} /api/v1${path} —` });
|
||||
}
|
||||
|
||||
test("catalog displays the real contract, scopes and request details without executing requests", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const state = await serveDocumentation(page);
|
||||
await context.addCookies([
|
||||
{
|
||||
name: "otche_session",
|
||||
value: "fixture-session",
|
||||
url: "http://127.0.0.1:4173",
|
||||
},
|
||||
]);
|
||||
await page.goto("/docs");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Документация API", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByLabel("Поиск по справочнику")).toBeVisible();
|
||||
for (const scope of Object.keys(openapi["x-api-key-scopes"])) {
|
||||
await expect(page.locator("dt").filter({ hasText: scope })).toBeVisible();
|
||||
}
|
||||
await expect(routeSummary(page, "GET", "/health")).toBeVisible();
|
||||
await expect(routeSummary(page, "POST", "/jobs")).toBeVisible();
|
||||
const upload = routeSummary(page, "POST", "/uploads");
|
||||
await upload.click();
|
||||
const details = upload.locator("xpath=..");
|
||||
await expect(
|
||||
details.getByText("uploads:write", { exact: true }),
|
||||
).toBeVisible();
|
||||
const bodySummary = details
|
||||
.locator("summary")
|
||||
.filter({ hasText: /^Тело запроса$/ });
|
||||
await bodySummary.click();
|
||||
const requestBody = bodySummary.locator("xpath=..").locator("pre");
|
||||
await expect(requestBody).toBeVisible();
|
||||
await expect(requestBody).toContainText("application/octet-stream");
|
||||
expect(state.cookies.every((cookie) => cookie === undefined)).toBe(true);
|
||||
expect(state.mutations).toEqual([]);
|
||||
await expect(page.getByRole("textbox")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("search narrows route scopes and schemas and recovers from no results", async ({
|
||||
page,
|
||||
}) => {
|
||||
await serveDocumentation(page);
|
||||
await page.goto("/docs");
|
||||
const search = page.getByLabel("Поиск по справочнику");
|
||||
await search.fill("uploads:write");
|
||||
await expect(routeSummary(page, "POST", "/uploads")).toBeVisible();
|
||||
await expect(routeSummary(page, "GET", "/health")).toHaveCount(0);
|
||||
await search.fill("no-such-route-or-schema");
|
||||
await expect(
|
||||
page.getByText("Маршруты не найдены. Измените поисковый запрос."),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("Схемы не найдены. Измените поисковый запрос."),
|
||||
).toBeVisible();
|
||||
await search.fill("APIKeyInput");
|
||||
const schema = page.locator("summary").filter({ hasText: /^APIKeyInput$/ });
|
||||
await expect(schema).toBeVisible();
|
||||
await schema.click();
|
||||
await expect(schema.locator("xpath=..").locator("pre")).toContainText(
|
||||
'"expires_in_days"',
|
||||
);
|
||||
await expect(schema.locator("xpath=..").locator("pre")).toContainText(
|
||||
'"maximum": 365',
|
||||
);
|
||||
await search.clear();
|
||||
await expect(routeSummary(page, "GET", "/health")).toBeVisible();
|
||||
});
|
||||
|
||||
test("failed loading has no fabricated catalog and explicit retry loads the document", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = await serveDocumentation(page);
|
||||
state.failure = true;
|
||||
await page.goto("/docs");
|
||||
await expect(page.getByRole("alert")).toContainText("503");
|
||||
await expect(page.getByLabel("Поиск по справочнику")).toHaveCount(0);
|
||||
await expect(routeSummary(page, "GET", "/health")).toHaveCount(0);
|
||||
const beforeRetry = state.requests;
|
||||
state.failure = false;
|
||||
await page.getByRole("button", { name: "Повторить", exact: true }).click();
|
||||
await expect(routeSummary(page, "GET", "/health")).toBeVisible();
|
||||
await expect(page.getByRole("alert")).toHaveCount(0);
|
||||
expect(state.requests).toBe(beforeRetry + 1);
|
||||
});
|
||||
|
||||
test("route and schema disclosures are keyboard-operable", async ({ page }) => {
|
||||
await serveDocumentation(page);
|
||||
await page.goto("/docs");
|
||||
const search = page.getByLabel("Поиск по справочнику");
|
||||
await search.fill("/api-keys");
|
||||
await search.press("Tab");
|
||||
const list = routeSummary(page, "GET", "/api-keys");
|
||||
await expect(list).toBeFocused();
|
||||
await page.keyboard.press("Enter");
|
||||
const details = list.locator("xpath=..");
|
||||
await expect(
|
||||
details.getByText("Доступ: Только cookie-вход/сессия, не API-ключ", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(
|
||||
details.getByText("Доступ: Только cookie-вход/сессия, не API-ключ", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeHidden();
|
||||
await search.fill("APIKeyInput");
|
||||
const schema = page.locator("summary").filter({ hasText: /^APIKeyInput$/ });
|
||||
await schema.focus();
|
||||
await page.keyboard.press("Space");
|
||||
await expect(schema.locator("xpath=..").locator("pre")).toBeVisible();
|
||||
await page.keyboard.press("Space");
|
||||
await expect(schema.locator("xpath=..").locator("pre")).toBeHidden();
|
||||
});
|
||||
|
||||
test("download is the complete served OpenAPI document", async ({ page }) => {
|
||||
await page.goto("/docs");
|
||||
await expect(page.getByLabel("Поиск по справочнику")).toBeVisible();
|
||||
const pending = page.waitForEvent("download");
|
||||
await page.getByRole("link", { name: "Скачать OpenAPI JSON" }).click();
|
||||
const download = await pending;
|
||||
expect(download.suggestedFilename()).toBe("otche-openapi.json");
|
||||
const path = await download.path();
|
||||
expect(path).not.toBeNull();
|
||||
const downloaded = await readFile(path!);
|
||||
expect(downloaded.equals(openapiBytes)).toBe(true);
|
||||
expect(JSON.parse(downloaded.toString("utf8"))).toEqual(openapi);
|
||||
});
|
||||
|
||||
test("an unsupported document produces a visible error instead of a partial catalog", async ({
|
||||
page,
|
||||
}) => {
|
||||
await serveDocumentation(page);
|
||||
await page.route("**/api/v1/openapi.json", (route) =>
|
||||
route.fulfill({
|
||||
json: { openapi: "2.0", info: { title: "Wrong version" }, paths: {} },
|
||||
}),
|
||||
);
|
||||
await page.goto("/docs");
|
||||
await expect(page.getByRole("alert")).toBeVisible();
|
||||
await expect(page.getByLabel("Поиск по справочнику")).toHaveCount(0);
|
||||
await expect(routeSummary(page, "GET", "/health")).toHaveCount(0);
|
||||
await page.unroute("**/api/v1/openapi.json");
|
||||
await page.getByRole("button", { name: "Повторить", exact: true }).click();
|
||||
await expect(routeSummary(page, "GET", "/health")).toBeVisible();
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
import { createServer } from "node:http";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const openapi = readFileSync(
|
||||
new URL("./fixtures/openapi.json", import.meta.url),
|
||||
);
|
||||
const session = JSON.stringify({
|
||||
user: { id: "reader", username: "reader", role: "operator" },
|
||||
csrf_token: "fixture-csrf",
|
||||
});
|
||||
|
||||
createServer((request, response) => {
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
if (request.method !== "GET") {
|
||||
response.writeHead(405, { Allow: "GET" });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
if (request.url === "/health") {
|
||||
response.writeHead(200, { "Content-Type": "text/plain" });
|
||||
response.end("ready");
|
||||
} else if (request.url === "/api/v1/openapi.json") {
|
||||
response.writeHead(200, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Length": openapi.length,
|
||||
});
|
||||
response.end(openapi);
|
||||
} else if (request.url === "/api/v1/auth/session") {
|
||||
response.writeHead(200, { "Content-Type": "application/json" });
|
||||
response.end(session);
|
||||
} else {
|
||||
response.writeHead(404, { "Content-Type": "application/json" });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
error: { code: "not_found", message: "No fixture for this route" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}).listen(4174, "127.0.0.1");
|
||||
Generated
+46
@@ -14,6 +14,7 @@
|
||||
"react-router-dom": "^7.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/react": "^19.0.3",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
@@ -815,6 +816,22 @@
|
||||
"node": "^22.20 || ^24.12 || >=25"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -1602,6 +1619,35 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.28",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||
|
||||
+3
-1
@@ -6,7 +6,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview --host 127.0.0.1"
|
||||
"preview": "vite preview --host 127.0.0.1",
|
||||
"test": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.468.0",
|
||||
@@ -15,6 +16,7 @@
|
||||
"react-router-dom": "^7.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/react": "^19.0.3",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
fullyParallel: true,
|
||||
forbidOnly: Boolean(process.env.CI),
|
||||
retries: 0,
|
||||
reporter: "list",
|
||||
use: {
|
||||
baseURL: "http://127.0.0.1:4173",
|
||||
trace: "retain-on-failure",
|
||||
},
|
||||
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||
webServer: [
|
||||
{
|
||||
command: "node e2e/serve-api.mjs",
|
||||
url: "http://127.0.0.1:4174/health",
|
||||
reuseExistingServer: false,
|
||||
},
|
||||
{
|
||||
command: "npm run dev -- --port 4173 --strictPort",
|
||||
url: "http://127.0.0.1:4173",
|
||||
env: { OTCHE_API_PROXY: "http://127.0.0.1:4174" },
|
||||
reuseExistingServer: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
+12
-1
@@ -13,6 +13,7 @@ import {
|
||||
Activity,
|
||||
ArrowRight,
|
||||
Box,
|
||||
BookOpen,
|
||||
ChevronRight,
|
||||
Cpu,
|
||||
FlaskConical,
|
||||
@@ -34,6 +35,8 @@ import { DashboardPage, EnvironmentsPage, JobsPage } from "./pages/Overview";
|
||||
import { NewAnalysisPage } from "./pages/NewAnalysis";
|
||||
import { JobDetailPage } from "./pages/JobDetail";
|
||||
import { AdminPage } from "./pages/Admin";
|
||||
import { ApiKeysPage } from "./pages/ApiKeys";
|
||||
import { DocsPage } from "./pages/Docs";
|
||||
|
||||
function Brand() {
|
||||
return (
|
||||
@@ -248,6 +251,8 @@ function Shell() {
|
||||
{ to: "/new", label: "Новый анализ", icon: FlaskConical },
|
||||
{ to: "/jobs", label: "Задания", icon: ListFilter },
|
||||
{ to: "/environments", label: "Окружения", icon: Cpu },
|
||||
{ to: "/api-keys", label: "API-ключи", icon: LockKeyhole },
|
||||
{ to: "/docs", label: "Документация API", icon: BookOpen },
|
||||
];
|
||||
const currentSection =
|
||||
location.pathname === "/"
|
||||
@@ -260,7 +265,11 @@ function Shell() {
|
||||
? "Задания"
|
||||
: location.pathname.startsWith("/admin")
|
||||
? "Администрирование"
|
||||
: "Окружения";
|
||||
: location.pathname.startsWith("/api-keys")
|
||||
? "API-ключи"
|
||||
: location.pathname.startsWith("/docs")
|
||||
? "Документация API"
|
||||
: "Окружения";
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<a className="skip-link" href="#main">
|
||||
@@ -504,6 +513,8 @@ export function App() {
|
||||
<Route path="jobs" element={<JobsPage />} />
|
||||
<Route path="jobs/:id" element={<JobDetailPage />} />
|
||||
<Route path="environments" element={<EnvironmentsPage />} />
|
||||
<Route path="api-keys" element={<ApiKeysPage />} />
|
||||
<Route path="docs" element={<DocsPage />} />
|
||||
<Route
|
||||
path="admin"
|
||||
element={
|
||||
|
||||
@@ -471,7 +471,6 @@ export function Confirm({
|
||||
<h2 id="confirm-title">{title}</h2>
|
||||
</div>
|
||||
<button
|
||||
autoFocus
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label="Закрыть"
|
||||
|
||||
@@ -10,6 +10,27 @@ export interface Session {
|
||||
user: User;
|
||||
csrf_token: string;
|
||||
}
|
||||
export type ApiKeyScope =
|
||||
| "profiles:read"
|
||||
| "jobs:read"
|
||||
| "jobs:write"
|
||||
| "uploads:write"
|
||||
| "uploads:read"
|
||||
| "artifacts:read";
|
||||
export interface ApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
prefix: string;
|
||||
scopes: ApiKeyScope[];
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
last_used_at: string | null;
|
||||
revoked_at: string | null;
|
||||
}
|
||||
export interface CreatedApiKey {
|
||||
key: ApiKey;
|
||||
token: string;
|
||||
}
|
||||
export interface Collection<T> {
|
||||
items: T[];
|
||||
total?: number;
|
||||
|
||||
@@ -0,0 +1,549 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { BookOpen, Copy, KeyRound, Plus, RefreshCw, X } from "lucide-react";
|
||||
import { api, ApiError, idPath } from "../api";
|
||||
import {
|
||||
Confirm,
|
||||
Empty,
|
||||
ErrorState,
|
||||
Field,
|
||||
Loading,
|
||||
Notice,
|
||||
PageHeader,
|
||||
Panel,
|
||||
formatDate,
|
||||
} from "../components";
|
||||
import { useQuery } from "../hooks";
|
||||
import type { ApiKey, ApiKeyScope, CreatedApiKey } from "../models";
|
||||
|
||||
const scopes: { value: ApiKeyScope; label: string; description: string }[] = [
|
||||
{
|
||||
value: "profiles:read",
|
||||
label: "Чтение профилей",
|
||||
description: "Доступные профили Windows для анализа.",
|
||||
},
|
||||
{
|
||||
value: "jobs:read",
|
||||
label: "Чтение заданий",
|
||||
description: "Обзор, список своих заданий, их состояние и события.",
|
||||
},
|
||||
{
|
||||
value: "jobs:write",
|
||||
label: "Управление заданиями",
|
||||
description: "Создание, отмена и повтор своих заданий.",
|
||||
},
|
||||
{
|
||||
value: "uploads:write",
|
||||
label: "Загрузка файлов",
|
||||
description: "Загрузка образцов для анализа.",
|
||||
},
|
||||
{
|
||||
value: "uploads:read",
|
||||
label: "Скачивание образцов",
|
||||
description: "Получение содержимого своих загруженных файлов.",
|
||||
},
|
||||
{
|
||||
value: "artifacts:read",
|
||||
label: "Чтение результатов",
|
||||
description:
|
||||
"Список и скачивание своих артефактов, доступ к видео попыток.",
|
||||
},
|
||||
];
|
||||
const scopeLabels: Record<string, string> = Object.fromEntries(
|
||||
scopes.map((scope) => [scope.value, scope.label]),
|
||||
);
|
||||
const lastUseFormat = new Intl.DateTimeFormat("ru-RU", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
});
|
||||
|
||||
function keyState(key: ApiKey, now: number) {
|
||||
if (key.revoked_at) return "revoked";
|
||||
return Date.parse(key.expires_at) <= now ? "expired" : "active";
|
||||
}
|
||||
|
||||
function mutationError(error: unknown, creating: boolean): Error {
|
||||
if (error instanceof ApiError) {
|
||||
if (error.code === "key_limit")
|
||||
return new Error(
|
||||
"Достигнут лимит: 20 действующих ключей. Отзовите ненужный ключ перед созданием нового.",
|
||||
);
|
||||
if (error.code === "invalid_request")
|
||||
return new Error("Проверьте имя, срок действия и выбранные разрешения.");
|
||||
if (error.code === "session_required" || error.status === 401)
|
||||
return new Error(
|
||||
"Управление ключами доступно только после входа через браузер. Войдите заново.",
|
||||
);
|
||||
if (error.status === 403)
|
||||
return new Error(
|
||||
"Сессия или проверка безопасности устарела. Обновите страницу перед следующей операцией.",
|
||||
);
|
||||
if (error.status === 404)
|
||||
return new Error("Ключ не найден. Обновите список ключей.");
|
||||
}
|
||||
return new Error(
|
||||
creating
|
||||
? "Не удалось подтвердить создание ключа. Запрос не повторяется автоматически. Обновите список: если ключ появился, но токен не получен, отзовите его и создайте новый."
|
||||
: "Не удалось подтвердить отзыв. Обновите список и проверьте состояние ключа.",
|
||||
);
|
||||
}
|
||||
|
||||
function SecretPanel({
|
||||
value,
|
||||
dismiss,
|
||||
}: {
|
||||
value: CreatedApiKey;
|
||||
dismiss: () => void;
|
||||
}) {
|
||||
const heading = useRef<HTMLHeadingElement>(null);
|
||||
const [copyStatus, setCopyStatus] = useState("");
|
||||
useEffect(() => {
|
||||
heading.current?.focus();
|
||||
}, []);
|
||||
return (
|
||||
<Panel
|
||||
title="Ключ создан — сохраните токен сейчас"
|
||||
className="api-key-secret"
|
||||
>
|
||||
<div className="panel-padding">
|
||||
<h3 ref={heading} tabIndex={-1}>
|
||||
Токен для «{value.key.name}» показан только один раз
|
||||
</h3>
|
||||
<Notice tone="warning">
|
||||
<strong>
|
||||
Скопируйте его в защищённое хранилище до закрытия панели.
|
||||
</strong>
|
||||
<p>
|
||||
После закрытия, перехода в другой раздел или перезагрузки страницы
|
||||
посмотреть токен снова нельзя. Не отправляйте его в переписку,
|
||||
журналы или URL. Утраченный токен нужно отозвать и заменить новым.
|
||||
</p>
|
||||
</Notice>
|
||||
<Field
|
||||
label="Секретный токен"
|
||||
hint="Копирование происходит только по вашему нажатию. При недоступном буфере обмена выделите текст вручную."
|
||||
>
|
||||
<textarea
|
||||
className="mono api-key-token"
|
||||
readOnly
|
||||
rows={3}
|
||||
value={value.token}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</Field>
|
||||
<div className="actions">
|
||||
<button
|
||||
type="button"
|
||||
className="button primary"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value.token);
|
||||
setCopyStatus(
|
||||
"Токен скопирован. Сохраните его в защищённом хранилище.",
|
||||
);
|
||||
} catch {
|
||||
setCopyStatus(
|
||||
"Не удалось скопировать. Выделите токен и скопируйте его вручную.",
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Copy size={16} aria-hidden="true" />
|
||||
Скопировать токен
|
||||
</button>
|
||||
<button type="button" className="button secondary" onClick={dismiss}>
|
||||
<X size={16} aria-hidden="true" />
|
||||
Скрыть токен навсегда
|
||||
</button>
|
||||
</div>
|
||||
<p className="small-text" role="status">
|
||||
{copyStatus}
|
||||
</p>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApiKeysPage() {
|
||||
const keys = useQuery<{ items: ApiKey[] }>("/api-keys", 60_000);
|
||||
const [name, setName] = useState("");
|
||||
const [expiry, setExpiry] = useState("90");
|
||||
const [selected, setSelected] = useState<ApiKeyScope[]>([
|
||||
"profiles:read",
|
||||
"jobs:read",
|
||||
]);
|
||||
const [secret, setSecret] = useState<CreatedApiKey | null>(null);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [notice, setNotice] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [revoking, setRevoking] = useState<ApiKey | null>(null);
|
||||
const [now, setNow] = useState(Date.now);
|
||||
const mutation = useRef<AbortController | null>(null);
|
||||
const nameInput = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 30_000);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
mutation.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
const activeCount = keys.data?.items.filter(
|
||||
(key) => keyState(key, now) === "active",
|
||||
).length;
|
||||
const atLimit = activeCount !== undefined && activeCount >= 20;
|
||||
const create = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (mutation.current || secret || atLimit) return;
|
||||
const trimmed = name.trim();
|
||||
const days = Number(expiry);
|
||||
if (
|
||||
!trimmed ||
|
||||
Array.from(trimmed).length > 80 ||
|
||||
/[\u0000-\u001f\u007f-\u009f]/u.test(trimmed) ||
|
||||
!Number.isInteger(days) ||
|
||||
days < 1 ||
|
||||
days > 365 ||
|
||||
!selected.length
|
||||
) {
|
||||
setError(
|
||||
new Error(
|
||||
"Укажите имя длиной 1–80 символов без управляющих знаков, целый срок от 1 до 365 дней и хотя бы одно разрешение.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
mutation.current = controller;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setNotice("");
|
||||
try {
|
||||
const created = await api<CreatedApiKey>("/api-keys", {
|
||||
method: "POST",
|
||||
body: { name: trimmed, scopes: selected, expires_in_days: days },
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (controller.signal.aborted) return;
|
||||
setSecret(created);
|
||||
setName("");
|
||||
keys.reload();
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) setError(mutationError(error, true));
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setBusy(false);
|
||||
mutation.current = null;
|
||||
}
|
||||
};
|
||||
const revoke = async () => {
|
||||
if (!revoking || mutation.current) return;
|
||||
const key = revoking;
|
||||
const controller = new AbortController();
|
||||
mutation.current = controller;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setNotice("");
|
||||
try {
|
||||
await api<void>(`/api-keys/${idPath(key.id)}`, {
|
||||
method: "DELETE",
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (controller.signal.aborted) return;
|
||||
if (secret?.key.id === key.id) setSecret(null);
|
||||
setNotice(
|
||||
`Ключ «${key.name}» отозван. Запросы с ним больше не принимаются.`,
|
||||
);
|
||||
keys.reload();
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) setError(mutationError(error, false));
|
||||
} finally {
|
||||
if (!controller.signal.aborted) {
|
||||
setBusy(false);
|
||||
setRevoking(null);
|
||||
}
|
||||
mutation.current = null;
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="Личный доступ"
|
||||
title="API-ключи"
|
||||
description="Доступ для скриптов и интеграций без передачи пароля от вашей учётной записи."
|
||||
action={
|
||||
<Link className="button secondary" to="/docs">
|
||||
<BookOpen size={16} aria-hidden="true" />
|
||||
Документация API
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<Notice>
|
||||
Ключи работают только с вашими данными, даже если вы администратор. Они
|
||||
не дают доступа к администрированию, входу и управлению ключами.
|
||||
Создание и отзыв доступны только в браузерной сессии.
|
||||
</Notice>
|
||||
{secret && (
|
||||
<SecretPanel
|
||||
value={secret}
|
||||
dismiss={() => {
|
||||
setSecret(null);
|
||||
requestAnimationFrame(() => {
|
||||
if (nameInput.current?.matches(":enabled"))
|
||||
nameInput.current.focus();
|
||||
else document.getElementById("api-key-registry")?.focus();
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{error && <ErrorState error={error} />}
|
||||
{notice && (
|
||||
<div role="status">
|
||||
<Notice tone="success">{notice}</Notice>
|
||||
</div>
|
||||
)}
|
||||
<Panel
|
||||
title="Создать ключ"
|
||||
subtitle="Не более 20 действующих ключей. Выдавайте только необходимые разрешения."
|
||||
>
|
||||
<form
|
||||
className="panel-padding"
|
||||
onSubmit={(event) => void create(event)}
|
||||
>
|
||||
<fieldset
|
||||
className="api-key-form-fields"
|
||||
disabled={busy || Boolean(secret) || atLimit}
|
||||
>
|
||||
<legend className="sr-only">Параметры нового API-ключа</legend>
|
||||
<div className="form-grid">
|
||||
<Field
|
||||
label="Название ключа"
|
||||
hint="1–80 символов: например, название вашей интеграции."
|
||||
>
|
||||
<input
|
||||
ref={nameInput}
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
required
|
||||
autoComplete="off"
|
||||
placeholder="Ежедневный анализ"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Срок действия, дней"
|
||||
hint="От 1 до 365 дней. После истечения создайте новый ключ."
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
step={1}
|
||||
required
|
||||
value={expiry}
|
||||
onChange={(event) => setExpiry(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<fieldset
|
||||
className="api-key-scopes"
|
||||
aria-describedby="api-key-scope-hint"
|
||||
>
|
||||
<legend>Разрешения</legend>
|
||||
<p id="api-key-scope-hint" className="muted small-text">
|
||||
Разрешения независимы: запись не даёт права чтения. По умолчанию
|
||||
выбрано только чтение профилей и заданий.
|
||||
</p>
|
||||
<div className="api-key-scope-grid">
|
||||
{scopes.map((scope) => (
|
||||
<label className="checkbox-field" key={scope.value}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(scope.value)}
|
||||
onChange={(event) =>
|
||||
setSelected((current) =>
|
||||
event.target.checked
|
||||
? [...current, scope.value]
|
||||
: current.filter((value) => value !== scope.value),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
<strong>{scope.label}</strong>
|
||||
<code>{scope.value}</code>
|
||||
<small>{scope.description}</small>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
{!selected.length && (
|
||||
<p className="small-text" role="status">
|
||||
Выберите хотя бы одно разрешение для создания ключа.
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
className="button primary"
|
||||
disabled={!selected.length}
|
||||
>
|
||||
<Plus size={16} aria-hidden="true" />
|
||||
{busy && !revoking ? "Создаём ключ…" : "Создать ключ"}
|
||||
</button>
|
||||
</fieldset>
|
||||
{secret && (
|
||||
<p className="muted small-text">
|
||||
Сначала сохраните показанный токен и закройте его панель.
|
||||
</p>
|
||||
)}
|
||||
{atLimit && (
|
||||
<Notice tone="warning">
|
||||
Достигнут лимит действующих ключей. Отзовите ненужный ключ.
|
||||
</Notice>
|
||||
)}
|
||||
</form>
|
||||
</Panel>
|
||||
<Panel
|
||||
title="Мои ключи"
|
||||
subtitle={
|
||||
activeCount === undefined
|
||||
? "Полные токены не хранятся в этом списке."
|
||||
: `Действующих: ${activeCount} из 20. Список обновляется раз в минуту.`
|
||||
}
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
className="button small secondary"
|
||||
disabled={keys.loading || busy}
|
||||
onClick={keys.reload}
|
||||
>
|
||||
<RefreshCw size={14} aria-hidden="true" />
|
||||
Обновить
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="panel-padding"
|
||||
id="api-key-registry"
|
||||
tabIndex={-1}
|
||||
aria-label="Мои ключи"
|
||||
>
|
||||
{keys.loading && <Loading label="Получаем ваши API-ключи…" />}
|
||||
{keys.error && (
|
||||
<>
|
||||
<ErrorState
|
||||
error={
|
||||
new Error(
|
||||
"Не удалось обновить список ключей. Проверьте соединение и нажмите «Обновить».",
|
||||
)
|
||||
}
|
||||
retry={keys.reload}
|
||||
/>
|
||||
{keys.data && (
|
||||
<p className="muted small-text">
|
||||
Ниже — последние полученные данные. Состояние ключей могло
|
||||
измениться.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!keys.loading && !keys.error && keys.data?.items.length === 0 && (
|
||||
<Empty title="Ключей пока нет">
|
||||
Создайте ключ выше, когда понадобится подключить свою интеграцию.
|
||||
</Empty>
|
||||
)}
|
||||
<div className="api-key-list">
|
||||
{keys.data?.items.map((key) => {
|
||||
const state = keyState(key, now);
|
||||
return (
|
||||
<article
|
||||
key={key.id}
|
||||
className="api-key-card"
|
||||
aria-labelledby={`api-key-${key.id}`}
|
||||
>
|
||||
<div className="api-key-card-heading">
|
||||
<h3 id={`api-key-${key.id}`}>
|
||||
<KeyRound size={17} aria-hidden="true" />
|
||||
{key.name}
|
||||
</h3>
|
||||
<span className={`api-key-state ${state}`}>
|
||||
{state === "active"
|
||||
? "Действует"
|
||||
: state === "expired"
|
||||
? "Истёк"
|
||||
: "Отозван"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="api-key-prefix">
|
||||
Префикс: <code>{key.prefix}…</code>
|
||||
</p>
|
||||
<ul
|
||||
className="api-key-permissions"
|
||||
aria-label="Разрешения ключа"
|
||||
>
|
||||
{key.scopes.map((scope) => (
|
||||
<li key={scope}>
|
||||
<span>{scopeLabels[scope] ?? scope}</span>{" "}
|
||||
<code>{scope}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<dl className="api-key-metadata">
|
||||
<div>
|
||||
<dt>Создан</dt>
|
||||
<dd>{formatDate(key.created_at)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Истекает</dt>
|
||||
<dd>{formatDate(key.expires_at)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Последнее использование</dt>
|
||||
<dd>
|
||||
{key.last_used_at
|
||||
? `≈ ${lastUseFormat.format(new Date(key.last_used_at))}`
|
||||
: "Не зафиксировано"}
|
||||
</dd>
|
||||
</div>
|
||||
{key.revoked_at && (
|
||||
<div>
|
||||
<dt>Отозван</dt>
|
||||
<dd>{formatDate(key.revoked_at)}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
{!key.revoked_at && (
|
||||
<button
|
||||
type="button"
|
||||
className="button small destructive"
|
||||
disabled={busy}
|
||||
onClick={() => setRevoking(key)}
|
||||
>
|
||||
Отозвать
|
||||
<span className="sr-only"> ключ «{key.name}»</span>
|
||||
</button>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{Boolean(keys.data?.items.length) && (
|
||||
<p className="muted small-text">
|
||||
Время последнего использования приблизительное, с точностью около
|
||||
минуты. Отзыв необратим; сохранённые результаты не удаляются.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
{revoking && (
|
||||
<Confirm
|
||||
title="Отозвать API-ключ?"
|
||||
description={`Ключ «${revoking.name}» (${revoking.prefix}…) перестанет работать. Интеграции с этим ключом потеряют доступ. Отменить отзыв нельзя.`}
|
||||
busy={busy}
|
||||
danger
|
||||
confirm={() => void revoke()}
|
||||
close={() => setRevoking(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ErrorState, Loading, Notice, PageHeader, Panel } from "../components";
|
||||
|
||||
interface Operation {
|
||||
summary?: string;
|
||||
description?: string;
|
||||
operationId?: string;
|
||||
"x-required-scope"?: string;
|
||||
"x-session-only"?: boolean;
|
||||
security?: Record<string, string[]>[];
|
||||
parameters?: unknown[];
|
||||
requestBody?: unknown;
|
||||
responses: Record<string, unknown>;
|
||||
}
|
||||
interface OpenAPIDocument {
|
||||
openapi: string;
|
||||
info: { title: string; version: string; description?: string };
|
||||
paths: Record<string, Record<string, Operation>>;
|
||||
"x-api-key-scopes": Record<string, string>;
|
||||
components: {
|
||||
schemas: Record<string, unknown>;
|
||||
securitySchemes: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
const methods: Record<string, true> = {
|
||||
get: true,
|
||||
post: true,
|
||||
put: true,
|
||||
patch: true,
|
||||
delete: true,
|
||||
head: true,
|
||||
options: true,
|
||||
};
|
||||
const documentURL = "/api/v1/openapi.json";
|
||||
const quickstart = `# POSIX shell + curl + jq. Переменные задайте через секрет-хранилище.
|
||||
# OTCHE_ORIGIN — HTTPS origin; OTCHE_API_KEY — ключ; SAMPLE_PATH — локальный файл.
|
||||
: "\${OTCHE_ORIGIN:?}" "\${OTCHE_API_KEY:?}" "\${SAMPLE_PATH:?}"
|
||||
API="\${OTCHE_ORIGIN%/}/api/v1"
|
||||
|
||||
# Выберите явно UUID допущенного профиля и задайте PROFILE_ID.
|
||||
curl --fail-with-body --silent --show-error \\
|
||||
-H "Authorization: Bearer $OTCHE_API_KEY" "$API/profiles" |
|
||||
jq '.items[] | select(.enabled and .state == "published" and .qualification == "qualified" and .current_revision_id != null) | {id,name,online_available}'
|
||||
: "\${PROFILE_ID:?}"
|
||||
|
||||
# Тело — сырые байты, НЕ multipart; имя — percent-encoded basename.
|
||||
NAME=$(printf '%s' "\${SAMPLE_PATH##*/}" | jq -sRr @uri)
|
||||
UPLOAD=$(curl --fail-with-body --silent --show-error \\
|
||||
-H "Authorization: Bearer $OTCHE_API_KEY" \\
|
||||
-H 'Content-Type: application/octet-stream' -H "X-Filename: $NAME" \\
|
||||
--data-binary "@$SAMPLE_PATH" "$API/uploads") || exit 1
|
||||
UPLOAD_ID=$(printf '%s' "$UPLOAD" | jq -er '.id') || exit 1
|
||||
|
||||
REQUEST=$(jq -n --arg upload "$UPLOAD_ID" --arg profile "$PROFILE_ID" '{
|
||||
upload_id:$upload, profile_ids:[$profile], settings:{
|
||||
internet:"offline", allowed_cidrs:[], duration_seconds:60,
|
||||
filename:"original", privilege:"user", args_mode:"none", args:[],
|
||||
set_zoneid:true, dll_mode:"regsvr32", dll_export:"",
|
||||
architecture:"auto", wsh_host:"cscript", msi_ui:"full", grub_paths:[]
|
||||
}
|
||||
}')
|
||||
JOB=$(curl --fail-with-body --silent --show-error \\
|
||||
-H "Authorization: Bearer $OTCHE_API_KEY" -H 'Content-Type: application/json' \\
|
||||
--data-binary "$REQUEST" "$API/jobs") || exit 1
|
||||
JOB_ID=$(printf '%s' "$JOB" | jq -er '.id') || exit 1
|
||||
|
||||
while :; do
|
||||
JOB=$(curl --fail-with-body --silent --show-error \\
|
||||
-H "Authorization: Bearer $OTCHE_API_KEY" "$API/jobs/$JOB_ID") || exit 1
|
||||
STATUS=$(printf '%s' "$JOB" | jq -er '.status') || exit 1
|
||||
case "$STATUS" in completed|cancelled|failed) break ;; esac
|
||||
sleep 3
|
||||
done
|
||||
printf '%s' "$JOB" | jq '.runs[].attempts[] | {id,outcome,findings,telemetry,cleanup,report}'
|
||||
|
||||
curl --fail-with-body --silent --show-error \\
|
||||
-H "Authorization: Bearer $OTCHE_API_KEY" "$API/jobs/$JOB_ID/artifacts" |
|
||||
jq '.items[] | {id,kind,filename,size,sha256}'
|
||||
# Задайте ARTIFACT_ID из выбранного элемента списка.
|
||||
: "\${ARTIFACT_ID:?}"
|
||||
curl --fail-with-body --silent --show-error \\
|
||||
-H "Authorization: Bearer $OTCHE_API_KEY" \\
|
||||
--output ./artifact.download "$API/artifacts/$ARTIFACT_ID/content"`;
|
||||
|
||||
function JSONDetails({ title, value }: { title: string; value: unknown }) {
|
||||
return (
|
||||
<details>
|
||||
<summary>{title}</summary>
|
||||
<pre>
|
||||
<code>{JSON.stringify(value, null, 2)}</code>
|
||||
</pre>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocsPage() {
|
||||
const [document, setDocument] = useState<OpenAPIDocument | null>(null);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [reload, setReload] = useState(0);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setDocument(null);
|
||||
setError(null);
|
||||
async function load() {
|
||||
try {
|
||||
const response = await fetch(documentURL, {
|
||||
credentials: "omit",
|
||||
signal: controller.signal,
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error(`Справочник недоступен (HTTP ${response.status}).`);
|
||||
const value: OpenAPIDocument = await response.json();
|
||||
if (
|
||||
!value ||
|
||||
typeof value.openapi !== "string" ||
|
||||
!value.openapi.startsWith("3.1.") ||
|
||||
!value.info ||
|
||||
typeof value.info.title !== "string" ||
|
||||
!value.paths ||
|
||||
typeof value.paths !== "object" ||
|
||||
!value.components?.schemas ||
|
||||
!value.components.securitySchemes ||
|
||||
!value["x-api-key-scopes"]
|
||||
) {
|
||||
throw new Error(
|
||||
"Сервер вернул неподдерживаемую спецификацию OpenAPI.",
|
||||
);
|
||||
}
|
||||
if (!controller.signal.aborted) setDocument(value);
|
||||
} catch (cause) {
|
||||
if (!controller.signal.aborted) {
|
||||
setError(
|
||||
cause instanceof Error
|
||||
? cause
|
||||
: new Error("Не удалось загрузить справочник."),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
void load();
|
||||
return () => controller.abort();
|
||||
}, [reload]);
|
||||
|
||||
const query = search.trim().toLocaleLowerCase();
|
||||
const operations = useMemo(
|
||||
() =>
|
||||
document
|
||||
? Object.entries(document.paths).flatMap(([path, item]) =>
|
||||
Object.entries(item)
|
||||
.filter(([method]) => methods[method])
|
||||
.map(([method, operation]) => ({
|
||||
path,
|
||||
method: method.toUpperCase(),
|
||||
operation,
|
||||
})),
|
||||
)
|
||||
: [],
|
||||
[document],
|
||||
);
|
||||
const visibleOperations = operations.filter(({ path, method, operation }) =>
|
||||
`${path} ${method} ${operation.summary ?? ""} ${operation.description ?? ""} ${operation["x-required-scope"] ?? ""}`
|
||||
.toLocaleLowerCase()
|
||||
.includes(query),
|
||||
);
|
||||
const schemas = Object.entries(document?.components.schemas ?? {}).filter(
|
||||
([name, schema]) =>
|
||||
`${name} ${JSON.stringify(schema)}`.toLocaleLowerCase().includes(query),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="api-docs">
|
||||
<PageHeader
|
||||
eyebrow="Справка"
|
||||
title="Документация API"
|
||||
description="Автоматизация без административного доступа. Примеры только для чтения: страница не выполняет команды и не принимает ключи."
|
||||
action={
|
||||
<a
|
||||
className="button secondary"
|
||||
href={documentURL}
|
||||
download="otche-openapi.json"
|
||||
>
|
||||
Скачать OpenAPI JSON
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
<Notice tone="warning">
|
||||
<strong>Наблюдение, а не сертификат безопасности.</strong>
|
||||
<p>
|
||||
Результат not_observed не означает, что файл безопасен. Проверяйте
|
||||
outcome, findings, telemetry и cleanup; отсутствие данных —
|
||||
неизвестность. Не открывайте исходники и артефакты на доверенном
|
||||
компьютере.
|
||||
</p>
|
||||
</Notice>
|
||||
<Panel title="Авторизация и жизненный цикл ключа">
|
||||
<div className="panel-padding">
|
||||
<p>
|
||||
Выпустите ключ в разделе «API-ключи» через браузерную сессию. Токен
|
||||
показывается один раз; храните его в секрет-хранилище. Срок — 1–365
|
||||
дней, максимум 20 активных ключей. Выдавайте только необходимые
|
||||
независимые права: запись не включает чтение.
|
||||
</p>
|
||||
<pre>
|
||||
<code>{"Authorization: Bearer $OTCHE_API_KEY"}</code>
|
||||
</pre>
|
||||
<p>
|
||||
Ключ видит только данные владельца, даже если выпущен
|
||||
администратором. Authorization имеет приоритет над cookie, неверный
|
||||
ключ не переключается на сессию. Ключи не принимаются в URL; CORS не
|
||||
включён. Проверяйте TLS-сертификат, не используйте curl -k. Не
|
||||
включайте shell tracing с секретом.
|
||||
</p>
|
||||
<p>
|
||||
Управление ключами и auth/admin доступны только cookie-сессии. Её
|
||||
мутации требуют точный Origin и X-CSRF-Token (login требует только
|
||||
Origin). Bearer-запросам CSRF не нужен. Отзыв блокирует новые
|
||||
запросы, но не отменяет принятые задания. Сброс пароля
|
||||
администратором и отключение аккаунта навсегда отзывают ключи;
|
||||
включение аккаунта не восстанавливает их.
|
||||
</p>
|
||||
</div>
|
||||
</Panel>
|
||||
<Panel
|
||||
title="Быстрый старт: загрузить → запустить → получить результат"
|
||||
subtitle="POSIX shell, curl с --fail-with-body и jq. Адрес, ключ, файл и UUID задаются вне примера."
|
||||
>
|
||||
<div className="panel-padding">
|
||||
<p>
|
||||
Нужны права чтения профилей, записи загрузок и заданий, чтения
|
||||
заданий и артефактов. Выберите допущенный профиль явно; пример
|
||||
использует offline и полный набор настроек. Для DLL с rundll32
|
||||
необходимо явно задать совместимый export.
|
||||
</p>
|
||||
<pre>
|
||||
<code>{quickstart}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</Panel>
|
||||
<Panel title="Границы выполнения и обработка ответов">
|
||||
<div className="panel-padding">
|
||||
<p>
|
||||
duration_seconds: 30, 60, 90, 120, 180, 300, 600, 900 или 1200 —
|
||||
окно наблюдения, не общий срок задания. Очередь, подготовка и сбор
|
||||
добавляют время; заблокированный до запуска образец может иметь null
|
||||
start/PID/duration.
|
||||
</p>
|
||||
<p>
|
||||
Online требует актуального доказательства изоляции, доступного
|
||||
online-профиля и явного allowed_cidrs. 0.0.0.0/0 разрешает только
|
||||
публичный IPv4, защищённая инфраструктура всегда закрыта. Offline —
|
||||
без NIC. Grub собирает только заданные обычные локальные
|
||||
Windows-файлы выбранным user/admin token, не произвольные файлы
|
||||
хоста; изменяемые логи — best-effort снимки.
|
||||
</p>
|
||||
<p>
|
||||
Не повторяйте записи автоматически после сетевой ошибки: нет
|
||||
idempotency-key протокола, сервер мог уже принять запрос. Retry
|
||||
задания создаёт новые попытки для всех Runs, сохраняя старые;
|
||||
исходные ревизии и admission перепроверяются. Квоты и ограничения
|
||||
общие с UI.
|
||||
</p>
|
||||
<p>
|
||||
401 — аутентификация; 403 — недостаточно прав, нужен cookie-вход или
|
||||
отклонён CSRF/Origin; 409 — конфликт ключей/admission/попыток; 413 —
|
||||
загрузка слишком велика/неполна; 429 quota_exceeded — квота, не
|
||||
частотный лимит API. Ошибки JSON содержат error.code/message, но
|
||||
HTTP Range/conditional ответы файлов могут быть text/plain или без
|
||||
тела.
|
||||
</p>
|
||||
<p>
|
||||
Список заданий возвращает next_page и краткие Runs без Attempts (25
|
||||
по умолчанию, максимум 100). События — JSON polling по after, до 500
|
||||
за запрос: курсор равен последнему id. Файлы поддерживают Range
|
||||
(206/416); conditional GET — 304. MP4 отдаётся inline, остальные
|
||||
файлы — attachment application/octet-stream.
|
||||
</p>
|
||||
</div>
|
||||
</Panel>
|
||||
{error ? (
|
||||
<ErrorState
|
||||
error={error}
|
||||
retry={() => setReload((value) => value + 1)}
|
||||
/>
|
||||
) : !document ? (
|
||||
<Loading label="Загружаем справочник OpenAPI…" />
|
||||
) : (
|
||||
<>
|
||||
<Panel
|
||||
title="Независимые права доступа"
|
||||
subtitle={`OpenAPI ${document.openapi} · версия ${document.info.version}`}
|
||||
>
|
||||
<div className="panel-padding">
|
||||
<dl className="compact-details">
|
||||
{Object.entries(document["x-api-key-scopes"]).map(
|
||||
([scope, description]) => (
|
||||
<div key={scope}>
|
||||
<dt>
|
||||
<code>{scope}</code>
|
||||
</dt>
|
||||
<dd>{description}</dd>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</dl>
|
||||
<JSONDetails
|
||||
title="Схемы авторизации (JSON)"
|
||||
value={document.components.securitySchemes}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
<Panel
|
||||
title="Справочник маршрутов"
|
||||
subtitle="Каталог, права, параметры, тела и ответы загружаются из спецификации работающего API."
|
||||
>
|
||||
<div className="panel-padding">
|
||||
<label htmlFor="docs-search">Поиск по справочнику</label>
|
||||
<input
|
||||
id="docs-search"
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Маршрут, право, поле или схема"
|
||||
/>
|
||||
<p className="muted small-text" role="status">
|
||||
Маршрутов: {visibleOperations.length}; схем: {schemas.length}.
|
||||
</p>
|
||||
{visibleOperations.length === 0 && (
|
||||
<p>Маршруты не найдены. Измените поисковый запрос.</p>
|
||||
)}
|
||||
{visibleOperations.map(({ path, method, operation }) => (
|
||||
<details key={`${method} ${path}`}>
|
||||
<summary>
|
||||
<code>
|
||||
{method} /api/v1{path}
|
||||
</code>{" "}
|
||||
— {operation.summary ?? operation.operationId}
|
||||
</summary>
|
||||
<p>{operation.description}</p>
|
||||
<p>
|
||||
<strong>Доступ: </strong>
|
||||
{operation["x-required-scope"] ? (
|
||||
<>
|
||||
<code>{operation["x-required-scope"]}</code> или
|
||||
cookie-сессия
|
||||
</>
|
||||
) : operation["x-session-only"] ? (
|
||||
"Только cookie-вход/сессия, не API-ключ"
|
||||
) : (
|
||||
"Публичный"
|
||||
)}
|
||||
</p>
|
||||
{operation.parameters && operation.parameters.length > 0 && (
|
||||
<JSONDetails
|
||||
title="Параметры запроса"
|
||||
value={operation.parameters}
|
||||
/>
|
||||
)}
|
||||
{operation.requestBody !== undefined && (
|
||||
<JSONDetails
|
||||
title="Тело запроса"
|
||||
value={operation.requestBody}
|
||||
/>
|
||||
)}
|
||||
<JSONDetails
|
||||
title="Коды и тела ответов"
|
||||
value={operation.responses}
|
||||
/>
|
||||
<JSONDetails
|
||||
title="Операция целиком (JSON)"
|
||||
value={operation}
|
||||
/>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
<Panel
|
||||
title="Схемы данных"
|
||||
subtitle="Раскройте схему для required, enum, ограничений и nullable-полей. $ref указывает на другую схему в этом разделе."
|
||||
>
|
||||
<div className="panel-padding">
|
||||
{schemas.length === 0 ? (
|
||||
<p>Схемы не найдены. Измените поисковый запрос.</p>
|
||||
) : (
|
||||
schemas.map(([name, schema]) => (
|
||||
<JSONDetails key={name} title={name} value={schema} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+134
@@ -2527,6 +2527,7 @@ summary svg {
|
||||
padding: 22px;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.modal > .actions {
|
||||
justify-content: flex-end;
|
||||
@@ -3078,6 +3079,139 @@ summary svg {
|
||||
}
|
||||
}
|
||||
|
||||
/* Personal API keys share native controls and inset property panels. */
|
||||
.api-key-form-fields {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.api-key-scopes {
|
||||
min-width: 0;
|
||||
margin: 0 0 18px;
|
||||
padding: 8px 14px 14px;
|
||||
border: 2px groove #fff;
|
||||
}
|
||||
.api-key-scopes legend {
|
||||
padding: 0 6px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.api-key-scope-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 24px;
|
||||
}
|
||||
.api-key-scope-grid .checkbox-field > span {
|
||||
min-width: 0;
|
||||
}
|
||||
.api-key-scope-grid code {
|
||||
font-size: 11px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.api-key-secret h3 {
|
||||
margin-top: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.api-key-token {
|
||||
resize: vertical;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-all;
|
||||
}
|
||||
.api-key-list {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
.api-key-card {
|
||||
background: var(--surface);
|
||||
border: 2px solid;
|
||||
border-color: #808080 #fff #fff #808080;
|
||||
box-shadow: var(--sunken);
|
||||
padding: 14px;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.api-key-card-heading {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.api-key-card-heading h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.api-key-card-heading h3 svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.api-key-state {
|
||||
padding: 3px 8px;
|
||||
border: 1px solid currentColor;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.api-key-state.active {
|
||||
color: var(--success);
|
||||
background: #efffdf;
|
||||
}
|
||||
.api-key-state.expired {
|
||||
color: #6f4d00;
|
||||
background: var(--note);
|
||||
}
|
||||
.api-key-state.revoked {
|
||||
color: var(--danger);
|
||||
background: #fff1ed;
|
||||
}
|
||||
.api-key-prefix {
|
||||
margin: 10px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.api-key-permissions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.api-key-permissions li {
|
||||
padding: 4px 7px;
|
||||
background: var(--bg);
|
||||
border: 1px solid #808080;
|
||||
font-size: 11px;
|
||||
}
|
||||
.api-key-permissions code {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
}
|
||||
.api-key-metadata {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px 16px;
|
||||
margin: 14px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.api-key-metadata dt {
|
||||
color: var(--muted);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.api-key-metadata dd {
|
||||
margin: 0;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.api-key-scope-grid,
|
||||
.api-key-metadata {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.api-key-secret .actions .button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive density: the era is visual, not an excuse for broken controls. */
|
||||
@media (min-width: 1600px) {
|
||||
.main-content {
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
"/api/": {
|
||||
target: process.env.OTCHE_API_PROXY || "http://127.0.0.1:8080",
|
||||
changeOrigin: false,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user