e8e7371f09
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
297 lines
11 KiB
TypeScript
297 lines
11 KiB
TypeScript
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"),
|
||
username: z.string().trim().min(1, "Укажите имя сервисного пользователя"),
|
||
password: z.string().min(1, "Укажите пароль"),
|
||
accountId: z.string().trim().min(1, "Укажите номер аккаунта"),
|
||
projectName: z.string().trim().min(1, "Укажите имя проекта"),
|
||
comment: z.string(),
|
||
})
|
||
|
||
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() {
|
||
const accounts = useAccounts()
|
||
const createAccount = useCreateAccount()
|
||
const deleteAccount = useDeleteAccount()
|
||
const usernameFieldId = useId()
|
||
const passwordFieldId = useId()
|
||
const accountIdFieldId = useId()
|
||
const projectNameFieldId = useId()
|
||
const commentFieldId = useId()
|
||
|
||
const accountList = accounts.data ?? []
|
||
|
||
const {
|
||
control,
|
||
handleSubmit,
|
||
reset,
|
||
formState: { errors },
|
||
} = useForm<CreateAccountForm>({
|
||
resolver: zodResolver(createAccountSchema),
|
||
defaultValues: EMPTY_FORM,
|
||
})
|
||
|
||
function onSubmit(values: CreateAccountForm) {
|
||
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) {
|
||
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)}
|
||
noValidate
|
||
className="flex flex-col gap-4 rounded-xl border border-border bg-card/60 p-4"
|
||
>
|
||
<FieldSet className="gap-3">
|
||
<FieldGroup className="flex-row flex-wrap items-start gap-3">
|
||
<Field className="w-56">
|
||
<FieldLabel htmlFor={usernameFieldId}>Имя сервисного пользователя</FieldLabel>
|
||
<FieldContent>
|
||
<Controller
|
||
control={control}
|
||
name="username"
|
||
render={({ field }) => (
|
||
<Input
|
||
{...field}
|
||
id={usernameFieldId}
|
||
autoComplete="off"
|
||
placeholder="service-dns"
|
||
aria-invalid={!!errors.username}
|
||
/>
|
||
)}
|
||
/>
|
||
<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>
|
||
</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, выдайте ему роль на нужный
|
||
проект и укажите здесь его логин, пароль, номер аккаунта и имя проекта. Пароль
|
||
хранится в зашифрованном виде.{" "}
|
||
<a
|
||
href="https://my.selectel.ru/iam/users"
|
||
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">
|
||
{createAccountErrorMessage(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>
|
||
)
|
||
}
|