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:
@@ -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")
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user