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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
This commit is contained in:
2026-07-04 20:23:34 +07:00
parent 568452846a
commit be408a216c
4 changed files with 170 additions and 37 deletions
+5 -1
View File
@@ -79,7 +79,11 @@ describe("api client", () => {
it("sends secret on account creation but path has no secret leakage in response typing", async () => { 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" }) 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] const [, opts] = spy.mock.calls[0]
expect((opts as RequestInit).method).toBe("POST") expect((opts as RequestInit).method).toBe("POST")
expect(String((opts as RequestInit).body)).toContain("TOKEN") expect(String((opts as RequestInit).body)).toContain("TOKEN")
+7 -1
View File
@@ -3,7 +3,13 @@ export interface Project { id: string; name: string }
export interface AuthState { user: User; project: Project } export interface AuthState { user: User; project: Project }
export interface Account { id: string; provider: string; comment: string } 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 RecordDTO { type: string; name: string; ttl: number; values: string[] }
export interface Template { id: string; name: string; records: RecordDTO[]; version: number } export interface Template { id: string; name: string; records: RecordDTO[]; version: number }
+41 -13
View File
@@ -44,7 +44,23 @@ test("отрисовывает список учёток без секрета",
expect(screen.getAllByText("selectel").length).toBeGreaterThan(0) 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({ const createSpy = vi.spyOn(api, "createAccount").mockResolvedValue({
id: "acc3", id: "acc3",
provider: "selectel", provider: "selectel",
@@ -55,10 +71,13 @@ test("форма создания вызывает api.createAccount с введ
await screen.findByText("Main") await screen.findByText("Main")
const secretInput = screen.getByLabelText(/api-ключ/i) const passwordInput = screen.getByLabelText(/пароль/i)
expect(secretInput).toHaveAttribute("type", "password") 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.type(screen.getByLabelText(/комментарий/i), "New account")
await user.click(screen.getByRole("button", { name: /добавить учётку/i })) await user.click(screen.getByRole("button", { name: /добавить учётку/i }))
@@ -66,36 +85,45 @@ test("форма создания вызывает api.createAccount с введ
await waitFor(() => await waitFor(() =>
expect(createSpy).toHaveBeenCalledWith(PROJECT_ID, { expect(createSpy).toHaveBeenCalledWith(PROJECT_ID, {
provider: "selectel", provider: "selectel",
secret: "super-secret-token-123",
comment: "New account", 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.queryByText("super-secret-token-123")).not.toBeInTheDocument()
expect(screen.queryByDisplayValue("super-secret-token-123")).not.toBeInTheDocument() expect(screen.queryByDisplayValue("super-secret-token-123")).not.toBeInTheDocument()
}) })
test("содержит ссылку-инструкцию получения ключа Selectel", async () => { test("содержит ссылку-инструкцию на раздел пользователей и ролей Selectel", async () => {
renderPage() renderPage()
await screen.findByText("Main") await screen.findByText("Main")
const link = screen.getByRole("link", { name: /selectel/i }) 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 () => { test("ошибка 400 invalid provider credentials отображается понятным текстом", async () => {
vi.spyOn(api, "createAccount").mockRejectedValue(new Error("Не удалось создать учётку")) vi.spyOn(api, "createAccount").mockRejectedValue(new Error("invalid provider credentials"))
const user = userEvent.setup() const user = userEvent.setup()
renderPage() renderPage()
await screen.findByText("Main") await screen.findByText("Main")
await user.type(screen.getByLabelText(/api-ключ/i), "token-xyz") await user.type(screen.getByLabelText(/имя сервисного пользователя/i), "svc-user")
await user.type(screen.getByLabelText(/комментарий/i), "Test") 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 })) 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 () => { test("удаление учётки вызывает api.deleteAccount", async () => {
+117 -22
View File
@@ -26,17 +26,41 @@ import { useAccounts, useCreateAccount, useDeleteAccount } from "@/hooks/useApi"
const createAccountSchema = z.object({ const createAccountSchema = z.object({
provider: z.literal("selectel"), provider: z.literal("selectel"),
secret: z.string().min(1, "Укажите API-ключ"), username: z.string().min(1, "Укажите имя сервисного пользователя"),
comment: 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<typeof createAccountSchema> type CreateAccountForm = z.infer<typeof createAccountSchema>
// 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() { export function AccountsPage() {
const accounts = useAccounts() const accounts = useAccounts()
const createAccount = useCreateAccount() const createAccount = useCreateAccount()
const deleteAccount = useDeleteAccount() const deleteAccount = useDeleteAccount()
const secretFieldId = useId() const usernameFieldId = useId()
const passwordFieldId = useId()
const accountIdFieldId = useId()
const projectNameFieldId = useId()
const commentFieldId = useId() const commentFieldId = useId()
const accountList = accounts.data ?? [] const accountList = accounts.data ?? []
@@ -48,13 +72,23 @@ export function AccountsPage() {
formState: { errors }, formState: { errors },
} = useForm<CreateAccountForm>({ } = useForm<CreateAccountForm>({
resolver: zodResolver(createAccountSchema), resolver: zodResolver(createAccountSchema),
defaultValues: { provider: "selectel", secret: "", comment: "" }, defaultValues: EMPTY_FORM,
}) })
function onSubmit(values: CreateAccountForm) { function onSubmit(values: CreateAccountForm) {
createAccount.mutate(values, { createAccount.mutate(
onSuccess: () => reset({ provider: "selectel", secret: "", comment: "" }), {
}) 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) { function onDelete(id: string, comment: string) {
@@ -74,28 +108,88 @@ export function AccountsPage() {
<form <form
onSubmit={handleSubmit(onSubmit)} onSubmit={handleSubmit(onSubmit)}
noValidate
className="flex flex-col gap-4 rounded-xl border border-border bg-card/60 p-4" className="flex flex-col gap-4 rounded-xl border border-border bg-card/60 p-4"
> >
<FieldSet className="gap-3"> <FieldSet className="gap-3">
<FieldGroup className="gap-3 sm:flex-row sm:items-start"> <FieldGroup className="flex-row flex-wrap items-start gap-3">
<Field className="sm:max-w-56"> <Field className="w-56">
<FieldLabel htmlFor={secretFieldId}>API-ключ Selectel</FieldLabel> <FieldLabel htmlFor={usernameFieldId}>Имя сервисного пользователя</FieldLabel>
<FieldContent> <FieldContent>
<Controller <Controller
control={control} control={control}
name="secret" name="username"
render={({ field }) => ( render={({ field }) => (
<Input <Input
{...field} {...field}
id={secretFieldId} id={usernameFieldId}
type="password"
autoComplete="off" autoComplete="off"
placeholder="••••••••••••" placeholder="service-dns"
aria-invalid={!!errors.secret} aria-invalid={!!errors.username}
/> />
)} )}
/> />
<FieldError errors={[errors.secret]} /> <FieldError errors={[errors.username]} />
</FieldContent>
</Field>
<Field className="w-56">
<FieldLabel htmlFor={passwordFieldId}>Пароль</FieldLabel>
<FieldContent>
<Controller
control={control}
name="password"
render={({ field }) => (
<Input
{...field}
id={passwordFieldId}
type="password"
autoComplete="off"
placeholder="••••••••••••"
aria-invalid={!!errors.password}
/>
)}
/>
<FieldError errors={[errors.password]} />
</FieldContent>
</Field>
<Field className="w-48">
<FieldLabel htmlFor={accountIdFieldId}>Номер аккаунта</FieldLabel>
<FieldContent>
<Controller
control={control}
name="accountId"
render={({ field }) => (
<Input
{...field}
id={accountIdFieldId}
className="font-dns"
placeholder="123456"
aria-invalid={!!errors.accountId}
/>
)}
/>
<FieldError errors={[errors.accountId]} />
</FieldContent>
</Field>
<Field className="w-48">
<FieldLabel htmlFor={projectNameFieldId}>Имя проекта</FieldLabel>
<FieldContent>
<Controller
control={control}
name="projectName"
render={({ field }) => (
<Input
{...field}
id={projectNameFieldId}
placeholder="default"
aria-invalid={!!errors.projectName}
/>
)}
/>
<FieldError errors={[errors.projectName]} />
</FieldContent> </FieldContent>
</Field> </Field>
@@ -122,16 +216,17 @@ export function AccountsPage() {
<FieldDescription className="flex items-start gap-2 rounded-lg border border-border/60 bg-background/40 px-3 py-2.5"> <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} /> <KeyRound className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" strokeWidth={1.75} />
<span> <span>
Провайдер: <span className="font-dns text-foreground">selectel</span>. Ключ создаётся Провайдер: <span className="font-dns text-foreground">selectel</span>. Создайте
в панели управления Selectel раздел «API-ключи» и используется только для сервисного пользователя в панели управления Selectel, выдайте ему роль на нужный
запроса, храниться в открытом виде он не будет.{" "} проект и укажите здесь его логин, пароль, номер аккаунта и имя проекта. Пароль
хранится в зашифрованном виде.{" "}
<a <a
href="https://my.selectel.ru/profile/apikeys" href="https://my.selectel.ru/iam/users"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="inline-flex items-center gap-1" className="inline-flex items-center gap-1"
> >
Получить ключ на my.selectel.ru Пользователи и роли на my.selectel.ru
<ExternalLink className="size-3" strokeWidth={1.75} /> <ExternalLink className="size-3" strokeWidth={1.75} />
</a> </a>
</span> </span>
@@ -141,7 +236,7 @@ export function AccountsPage() {
<div className="flex items-center justify-between gap-3 border-t border-border pt-3"> <div className="flex items-center justify-between gap-3 border-t border-border pt-3">
{createAccount.isError && ( {createAccount.isError && (
<span role="alert" className="font-dns text-xs text-destructive"> <span role="alert" className="font-dns text-xs text-destructive">
{createAccount.error.message} {createAccountErrorMessage(createAccount.error.message)}
</span> </span>
)} )}
<Button type="submit" disabled={createAccount.isPending} className="ml-auto"> <Button type="submit" disabled={createAccount.isPending} className="ml-auto">