diff --git a/web/src/App.tsx b/web/src/App.tsx index a2c4e2a..0e281fd 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,5 +1,6 @@ import { Routes, Route, Navigate } from "react-router-dom" import { Layout } from "@/components/Layout" +import { AccountsPage } from "@/pages/AccountsPage" import { DomainDiffPage } from "@/pages/DomainDiffPage" import { DomainsPage } from "@/pages/DomainsPage" @@ -14,7 +15,7 @@ export function App() { } /> } /> } /> - } /> + } /> } /> diff --git a/web/src/pages/AccountsPage.test.tsx b/web/src/pages/AccountsPage.test.tsx new file mode 100644 index 0000000..ca210ee --- /dev/null +++ b/web/src/pages/AccountsPage.test.tsx @@ -0,0 +1,103 @@ +import { render, screen, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { MemoryRouter } from "react-router-dom" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { AccountsPage } from "./AccountsPage" +import { api } from "@/api/client" +import { vi, beforeEach, test, expect } from "vitest" +import type { Account } from "@/api/types" + +const accounts: Account[] = [ + { id: "acc1", provider: "selectel", comment: "Main" }, + { id: "acc2", provider: "selectel", comment: "Backup" }, +] + +function renderPage() { + const qc = new QueryClient() + return render( + + + + + , + ) +} + +beforeEach(() => { + vi.spyOn(api, "listAccounts").mockResolvedValue(accounts) +}) + +test("отрисовывает список учёток без секрета", async () => { + renderPage() + + expect(await screen.findByText("Main")).toBeInTheDocument() + expect(screen.getByText("Backup")).toBeInTheDocument() + expect(screen.getAllByText("selectel").length).toBeGreaterThan(0) +}) + +test("форма создания вызывает api.createAccount с введённым secret и не показывает его после", async () => { + const createSpy = vi.spyOn(api, "createAccount").mockResolvedValue({ + id: "acc3", + provider: "selectel", + comment: "New account", + }) + const user = userEvent.setup() + renderPage() + + await screen.findByText("Main") + + const secretInput = screen.getByLabelText(/api-ключ/i) + expect(secretInput).toHaveAttribute("type", "password") + + await user.type(secretInput, "super-secret-token-123") + await user.type(screen.getByLabelText(/комментарий/i), "New account") + + await user.click(screen.getByRole("button", { name: /добавить учётку/i })) + + await waitFor(() => + expect(createSpy).toHaveBeenCalledWith({ + provider: "selectel", + secret: "super-secret-token-123", + comment: "New account", + }), + ) + + await waitFor(() => expect(secretInput).toHaveValue("")) + expect(screen.queryByText("super-secret-token-123")).not.toBeInTheDocument() + expect(screen.queryByDisplayValue("super-secret-token-123")).not.toBeInTheDocument() +}) + +test("содержит ссылку-инструкцию получения ключа Selectel", async () => { + renderPage() + await screen.findByText("Main") + + const link = screen.getByRole("link", { name: /selectel/i }) + expect(link).toHaveAttribute("href", expect.stringContaining("selectel.ru")) +}) + +test("ошибка создания учётки отображается пользователю", async () => { + vi.spyOn(api, "createAccount").mockRejectedValue(new Error("Не удалось создать учётку")) + const user = userEvent.setup() + renderPage() + + await screen.findByText("Main") + + await user.type(screen.getByLabelText(/api-ключ/i), "token-xyz") + await user.type(screen.getByLabelText(/комментарий/i), "Test") + await user.click(screen.getByRole("button", { name: /добавить учётку/i })) + + expect(await screen.findByRole("alert")).toHaveTextContent("Не удалось создать учётку") +}) + +test("удаление учётки вызывает api.deleteAccount", async () => { + const deleteSpy = vi.spyOn(api, "deleteAccount").mockResolvedValue(undefined) + vi.spyOn(window, "confirm").mockReturnValue(true) + const user = userEvent.setup() + renderPage() + + await screen.findByText("Main") + + await user.click(screen.getByRole("button", { name: /удалить.*main/i })) + + await waitFor(() => expect(deleteSpy).toHaveBeenCalledWith("acc1")) +}) diff --git a/web/src/pages/AccountsPage.tsx b/web/src/pages/AccountsPage.tsx new file mode 100644 index 0000000..ff227d8 --- /dev/null +++ b/web/src/pages/AccountsPage.tsx @@ -0,0 +1,201 @@ +import { useId } from "react" +import { Controller, useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { ExternalLink, Inbox, KeyRound, Loader2, Plus, Trash2 } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Field, + FieldContent, + FieldDescription, + FieldError, + FieldGroup, + FieldLabel, + FieldSet, +} from "@/components/ui/field" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { useAccounts, useCreateAccount, useDeleteAccount } from "@/hooks/useApi" + +const createAccountSchema = z.object({ + provider: z.literal("selectel"), + secret: z.string().min(1, "Укажите API-ключ"), + comment: z.string().min(1, "Укажите комментарий"), +}) + +type CreateAccountForm = z.infer + +export function AccountsPage() { + const accounts = useAccounts() + const createAccount = useCreateAccount() + const deleteAccount = useDeleteAccount() + const secretFieldId = useId() + const commentFieldId = useId() + + const accountList = accounts.data ?? [] + + const { + control, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(createAccountSchema), + defaultValues: { provider: "selectel", secret: "", comment: "" }, + }) + + function onSubmit(values: CreateAccountForm) { + createAccount.mutate(values, { + onSuccess: () => reset({ provider: "selectel", secret: "", comment: "" }), + }) + } + + function onDelete(id: string, comment: string) { + if (window.confirm(`Удалить учётную запись «${comment}»? Действие необратимо.`)) { + deleteAccount.mutate(id) + } + } + + return ( +
+
+ + providers + +

Учётные записи

+
+ +
+
+ + + API-ключ Selectel + + ( + + )} + /> + + + + + + Комментарий + + ( + + )} + /> + + + + + + + + + Провайдер: selectel. Ключ создаётся + в панели управления Selectel — раздел «API-ключи» — и используется только для + запроса, храниться в открытом виде он не будет.{" "} + + Получить ключ на my.selectel.ru + + + + +
+ +
+ {createAccount.isError && ( + + {createAccount.error.message} + + )} + +
+
+ + {deleteAccount.isError && ( + + {deleteAccount.error.message} + + )} + + {accountList.length === 0 ? ( +
+ + Учётных записей пока нет — добавьте первую выше. +
+ ) : ( + + + + Провайдер + Комментарий + Действия + + + + {accountList.map((a) => ( + + {a.provider} + {a.comment} + + + + + ))} + +
+ )} +
+ ) +}