feat(web): AccountsPage — CRUD учёток, secret-форма, инструкция Selectel

This commit is contained in:
2026-07-03 17:52:59 +07:00
parent ef003230fa
commit 4e91211a89
3 changed files with 306 additions and 1 deletions
+201
View File
@@ -0,0 +1,201 @@
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"),
secret: z.string().min(1, "Укажите API-ключ"),
comment: z.string().min(1, "Укажите комментарий"),
})
type CreateAccountForm = z.infer<typeof createAccountSchema>
export function AccountsPage() {
const accounts = useAccounts()
const createAccount = useCreateAccount()
const deleteAccount = useDeleteAccount()
const secretFieldId = useId()
const commentFieldId = useId()
const accountList = accounts.data ?? []
const {
control,
handleSubmit,
reset,
formState: { errors },
} = useForm<CreateAccountForm>({
resolver: zodResolver(createAccountSchema),
defaultValues: { provider: "selectel", secret: "", comment: "" },
})
function onSubmit(values: CreateAccountForm) {
createAccount.mutate(values, {
onSuccess: () => reset({ provider: "selectel", secret: "", comment: "" }),
})
}
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)}
className="flex flex-col gap-4 rounded-xl border border-border bg-card/60 p-4"
>
<FieldSet className="gap-3">
<FieldGroup className="gap-3 sm:flex-row sm:items-start">
<Field className="sm:max-w-56">
<FieldLabel htmlFor={secretFieldId}>API-ключ Selectel</FieldLabel>
<FieldContent>
<Controller
control={control}
name="secret"
render={({ field }) => (
<Input
{...field}
id={secretFieldId}
type="password"
autoComplete="off"
placeholder="••••••••••••"
aria-invalid={!!errors.secret}
/>
)}
/>
<FieldError errors={[errors.secret]} />
</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 раздел «API-ключи» и используется только для
запроса, храниться в открытом виде он не будет.{" "}
<a
href="https://my.selectel.ru/profile/apikeys"
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">
{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>
)
}