feat(web): AccountsPage — CRUD учёток, secret-форма, инструкция Selectel
This commit is contained in:
+2
-1
@@ -1,5 +1,6 @@
|
|||||||
import { Routes, Route, Navigate } from "react-router-dom"
|
import { Routes, Route, Navigate } from "react-router-dom"
|
||||||
import { Layout } from "@/components/Layout"
|
import { Layout } from "@/components/Layout"
|
||||||
|
import { AccountsPage } from "@/pages/AccountsPage"
|
||||||
import { DomainDiffPage } from "@/pages/DomainDiffPage"
|
import { DomainDiffPage } from "@/pages/DomainDiffPage"
|
||||||
import { DomainsPage } from "@/pages/DomainsPage"
|
import { DomainsPage } from "@/pages/DomainsPage"
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ export function App() {
|
|||||||
<Route path="/" element={<Navigate to="/domains" replace />} />
|
<Route path="/" element={<Navigate to="/domains" replace />} />
|
||||||
<Route path="/domains" element={<DomainsPage />} />
|
<Route path="/domains" element={<DomainsPage />} />
|
||||||
<Route path="/domains/:id" element={<DomainDiffPage />} />
|
<Route path="/domains/:id" element={<DomainDiffPage />} />
|
||||||
<Route path="/accounts" element={<Placeholder name="Accounts" />} />
|
<Route path="/accounts" element={<AccountsPage />} />
|
||||||
<Route path="/templates" element={<Placeholder name="Templates" />} />
|
<Route path="/templates" element={<Placeholder name="Templates" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter initialEntries={["/accounts"]}>
|
||||||
|
<AccountsPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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"))
|
||||||
|
})
|
||||||
@@ -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<typeof createAccountSchema>
|
||||||
|
|
||||||
|
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<CreateAccountForm>({
|
||||||
|
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 (
|
||||||
|
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
|
||||||
|
<header className="flex flex-col gap-1">
|
||||||
|
<span className="font-dns text-[11px] tracking-wider text-muted-foreground uppercase">
|
||||||
|
providers
|
||||||
|
</span>
|
||||||
|
<h1 className="text-xl font-semibold tracking-tight text-foreground">Учётные записи</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
className="flex flex-col gap-4 rounded-xl border border-border bg-card/60 p-4"
|
||||||
|
>
|
||||||
|
<FieldSet className="gap-3">
|
||||||
|
<FieldGroup className="gap-3 sm:flex-row sm:items-start">
|
||||||
|
<Field className="sm:max-w-56">
|
||||||
|
<FieldLabel htmlFor={secretFieldId}>API-ключ Selectel</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="secret"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
id={secretFieldId}
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="••••••••••••"
|
||||||
|
aria-invalid={!!errors.secret}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.secret]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor={commentFieldId}>Комментарий</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="comment"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
id={commentFieldId}
|
||||||
|
placeholder="Например, основной аккаунт"
|
||||||
|
aria-invalid={!!errors.comment}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.comment]} />
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
<FieldDescription className="flex items-start gap-2 rounded-lg border border-border/60 bg-background/40 px-3 py-2.5">
|
||||||
|
<KeyRound className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" strokeWidth={1.75} />
|
||||||
|
<span>
|
||||||
|
Провайдер: <span className="font-dns text-foreground">selectel</span>. Ключ создаётся
|
||||||
|
в панели управления Selectel — раздел «API-ключи» — и используется только для
|
||||||
|
запроса, храниться в открытом виде он не будет.{" "}
|
||||||
|
<a
|
||||||
|
href="https://my.selectel.ru/profile/apikeys"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
Получить ключ на my.selectel.ru
|
||||||
|
<ExternalLink className="size-3" strokeWidth={1.75} />
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</FieldDescription>
|
||||||
|
</FieldSet>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-3 border-t border-border pt-3">
|
||||||
|
{createAccount.isError && (
|
||||||
|
<span role="alert" className="font-dns text-xs text-destructive">
|
||||||
|
{createAccount.error.message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Button type="submit" disabled={createAccount.isPending} className="ml-auto">
|
||||||
|
{createAccount.isPending ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
|
||||||
|
) : (
|
||||||
|
<Plus className="size-4" strokeWidth={1.75} />
|
||||||
|
)}
|
||||||
|
Добавить учётку
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{deleteAccount.isError && (
|
||||||
|
<span role="alert" className="font-dns text-xs text-destructive">
|
||||||
|
{deleteAccount.error.message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{accountList.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center gap-2 rounded-xl border border-dashed border-border px-4 py-12 text-center text-sm text-muted-foreground">
|
||||||
|
<Inbox className="size-6" strokeWidth={1.5} />
|
||||||
|
Учётных записей пока нет — добавьте первую выше.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Провайдер</TableHead>
|
||||||
|
<TableHead>Комментарий</TableHead>
|
||||||
|
<TableHead className="text-right">Действия</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{accountList.map((a) => (
|
||||||
|
<TableRow key={a.id}>
|
||||||
|
<TableCell className="font-dns">{a.provider}</TableCell>
|
||||||
|
<TableCell>{a.comment}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`Удалить учётку ${a.comment}`}
|
||||||
|
onClick={() => onDelete(a.id, a.comment)}
|
||||||
|
disabled={deleteAccount.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" strokeWidth={1.75} />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user