Files
dns-autoresolver/web/src/pages/AccountsPage.tsx
T

297 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}