From be408a216cc71c5e66be99c348e536f7318eec03 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 4 Jul 2026 20:23:34 +0700 Subject: [PATCH] feat(web): Selectel service-user account form (IAM credentials) Replace the single API-key field with 4 IAM service-user fields (username, password, account_id, project_name) matching the new backend contract; map 400 "invalid provider credentials" to a user-facing message. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3 --- web/src/api/client.test.ts | 6 +- web/src/api/types.ts | 8 +- web/src/pages/AccountsPage.test.tsx | 54 ++++++++--- web/src/pages/AccountsPage.tsx | 139 +++++++++++++++++++++++----- 4 files changed, 170 insertions(+), 37 deletions(-) diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index f9ad71d..ced6d5d 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -79,7 +79,11 @@ describe("api client", () => { it("sends secret on account creation but path has no secret leakage in response typing", async () => { const spy = mockFetch({ id: "a2", provider: "selectel", comment: "prod" }) - await api.createAccount(PROJECT_ID, { provider: "selectel", secret: "TOKEN", comment: "prod" }) + await api.createAccount(PROJECT_ID, { + provider: "selectel", + secret: { username: "svc-user", password: "TOKEN", account_id: "123456", project_name: "default" }, + comment: "prod", + }) const [, opts] = spy.mock.calls[0] expect((opts as RequestInit).method).toBe("POST") expect(String((opts as RequestInit).body)).toContain("TOKEN") diff --git a/web/src/api/types.ts b/web/src/api/types.ts index da63682..4c8c540 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -3,7 +3,13 @@ export interface Project { id: string; name: string } export interface AuthState { user: User; project: Project } export interface Account { id: string; provider: string; comment: string } -export interface CreateAccountInput { provider: string; secret: string; comment: string } +export interface SelectelSecret { + username: string + password: string + account_id: string + project_name: string +} +export interface CreateAccountInput { provider: string; secret: SelectelSecret; comment: string } export interface RecordDTO { type: string; name: string; ttl: number; values: string[] } export interface Template { id: string; name: string; records: RecordDTO[]; version: number } diff --git a/web/src/pages/AccountsPage.test.tsx b/web/src/pages/AccountsPage.test.tsx index c82d8ab..8943130 100644 --- a/web/src/pages/AccountsPage.test.tsx +++ b/web/src/pages/AccountsPage.test.tsx @@ -44,7 +44,23 @@ test("отрисовывает список учёток без секрета", expect(screen.getAllByText("selectel").length).toBeGreaterThan(0) }) -test("форма создания вызывает api.createAccount с введённым secret и не показывает его после", async () => { +test("сабмит с пустыми полями показывает zod-ошибки и не вызывает createAccount", async () => { + const createSpy = vi.spyOn(api, "createAccount") + const user = userEvent.setup() + renderPage() + + await screen.findByText("Main") + + await user.click(screen.getByRole("button", { name: /добавить учётку/i })) + + expect(await screen.findByText("Укажите имя сервисного пользователя")).toBeInTheDocument() + expect(screen.getByText("Укажите пароль")).toBeInTheDocument() + expect(screen.getByText("Укажите номер аккаунта")).toBeInTheDocument() + expect(screen.getByText("Укажите имя проекта")).toBeInTheDocument() + expect(createSpy).not.toHaveBeenCalled() +}) + +test("заполнение 4 полей и сабмит вызывает createAccount с secret в snake_case и не показывает пароль после", async () => { const createSpy = vi.spyOn(api, "createAccount").mockResolvedValue({ id: "acc3", provider: "selectel", @@ -55,10 +71,13 @@ test("форма создания вызывает api.createAccount с введ await screen.findByText("Main") - const secretInput = screen.getByLabelText(/api-ключ/i) - expect(secretInput).toHaveAttribute("type", "password") + const passwordInput = screen.getByLabelText(/пароль/i) + expect(passwordInput).toHaveAttribute("type", "password") - await user.type(secretInput, "super-secret-token-123") + await user.type(screen.getByLabelText(/имя сервисного пользователя/i), "svc-user") + await user.type(passwordInput, "super-secret-token-123") + await user.type(screen.getByLabelText(/номер аккаунта/i), "123456") + await user.type(screen.getByLabelText(/имя проекта/i), "default") await user.type(screen.getByLabelText(/комментарий/i), "New account") await user.click(screen.getByRole("button", { name: /добавить учётку/i })) @@ -66,36 +85,45 @@ test("форма создания вызывает api.createAccount с введ await waitFor(() => expect(createSpy).toHaveBeenCalledWith(PROJECT_ID, { provider: "selectel", - secret: "super-secret-token-123", comment: "New account", + secret: { + username: "svc-user", + password: "super-secret-token-123", + account_id: "123456", + project_name: "default", + }, }), ) - await waitFor(() => expect(secretInput).toHaveValue("")) + await waitFor(() => expect(passwordInput).toHaveValue("")) expect(screen.queryByText("super-secret-token-123")).not.toBeInTheDocument() expect(screen.queryByDisplayValue("super-secret-token-123")).not.toBeInTheDocument() }) -test("содержит ссылку-инструкцию получения ключа Selectel", async () => { +test("содержит ссылку-инструкцию на раздел пользователей и ролей Selectel", async () => { renderPage() await screen.findByText("Main") const link = screen.getByRole("link", { name: /selectel/i }) - expect(link).toHaveAttribute("href", expect.stringContaining("selectel.ru")) + expect(link).toHaveAttribute("href", expect.stringContaining("selectel.ru/iam/users")) }) -test("ошибка создания учётки отображается пользователю", async () => { - vi.spyOn(api, "createAccount").mockRejectedValue(new Error("Не удалось создать учётку")) +test("ошибка 400 invalid provider credentials отображается понятным текстом", async () => { + vi.spyOn(api, "createAccount").mockRejectedValue(new Error("invalid provider credentials")) 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.type(screen.getByLabelText(/имя сервисного пользователя/i), "svc-user") + await user.type(screen.getByLabelText(/пароль/i), "wrong") + await user.type(screen.getByLabelText(/номер аккаунта/i), "123456") + await user.type(screen.getByLabelText(/имя проекта/i), "default") await user.click(screen.getByRole("button", { name: /добавить учётку/i })) - expect(await screen.findByRole("alert")).toHaveTextContent("Не удалось создать учётку") + expect(await screen.findByRole("alert")).toHaveTextContent( + "Selectel отклонил учётные данные — проверьте логин, пароль, номер аккаунта и имя проекта.", + ) }) test("удаление учётки вызывает api.deleteAccount", async () => { diff --git a/web/src/pages/AccountsPage.tsx b/web/src/pages/AccountsPage.tsx index ff227d8..b782bcd 100644 --- a/web/src/pages/AccountsPage.tsx +++ b/web/src/pages/AccountsPage.tsx @@ -26,17 +26,41 @@ 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, "Укажите комментарий"), + username: z.string().min(1, "Укажите имя сервисного пользователя"), + password: z.string().min(1, "Укажите пароль"), + accountId: z.string().min(1, "Укажите номер аккаунта"), + projectName: z.string().min(1, "Укажите имя проекта"), + comment: z.string(), }) type CreateAccountForm = z.infer +// API возвращает generic "invalid provider credentials" (нарочно, чтобы не +// палить детали) — переводим в понятную пользователю формулировку. +function createAccountErrorMessage(message: string): string { + if (message.toLowerCase().includes("invalid provider credentials")) { + return "Selectel отклонил учётные данные — проверьте логин, пароль, номер аккаунта и имя проекта." + } + return message +} + +const EMPTY_FORM: CreateAccountForm = { + provider: "selectel", + username: "", + password: "", + accountId: "", + projectName: "", + comment: "", +} + export function AccountsPage() { const accounts = useAccounts() const createAccount = useCreateAccount() const deleteAccount = useDeleteAccount() - const secretFieldId = useId() + const usernameFieldId = useId() + const passwordFieldId = useId() + const accountIdFieldId = useId() + const projectNameFieldId = useId() const commentFieldId = useId() const accountList = accounts.data ?? [] @@ -48,13 +72,23 @@ export function AccountsPage() { formState: { errors }, } = useForm({ resolver: zodResolver(createAccountSchema), - defaultValues: { provider: "selectel", secret: "", comment: "" }, + defaultValues: EMPTY_FORM, }) function onSubmit(values: CreateAccountForm) { - createAccount.mutate(values, { - onSuccess: () => reset({ provider: "selectel", secret: "", comment: "" }), - }) + createAccount.mutate( + { + provider: "selectel", + comment: values.comment, + secret: { + username: values.username.trim(), + password: values.password, + account_id: values.accountId.trim(), + project_name: values.projectName.trim(), + }, + }, + { onSuccess: () => reset(EMPTY_FORM) }, + ) } function onDelete(id: string, comment: string) { @@ -74,28 +108,88 @@ export function AccountsPage() {
- - - API-ключ Selectel + + + Имя сервисного пользователя ( )} /> - + + + + + + Пароль + + ( + + )} + /> + + + + + + Номер аккаунта + + ( + + )} + /> + + + + + + Имя проекта + + ( + + )} + /> + @@ -122,16 +216,17 @@ export function AccountsPage() { - Провайдер: selectel. Ключ создаётся - в панели управления Selectel — раздел «API-ключи» — и используется только для - запроса, храниться в открытом виде он не будет.{" "} + Провайдер: selectel. Создайте + сервисного пользователя в панели управления Selectel, выдайте ему роль на нужный + проект и укажите здесь его логин, пароль, номер аккаунта и имя проекта. Пароль + хранится в зашифрованном виде.{" "} - Получить ключ на my.selectel.ru + Пользователи и роли на my.selectel.ru @@ -141,7 +236,7 @@ export function AccountsPage() {
{createAccount.isError && ( - {createAccount.error.message} + {createAccountErrorMessage(createAccount.error.message)} )}