fix(ui): добавить новые компоненты для управления сертификатами

This commit is contained in:
Denozordec
2026-05-12 20:11:22 +07:00
parent 6c8ca17590
commit 53ea0e74a3
2 changed files with 145 additions and 76 deletions
+144 -75
View File
@@ -93,6 +93,60 @@ function daysLeftBar(days: number, total = 365): number {
return Math.min(100, Math.round((days / total) * 100))
}
function Field({
label,
hint,
required,
children,
}: {
label: string
hint?: string
required?: boolean
children: ReactNode
}) {
return (
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">
{label}
{required && <span className="text-destructive ml-0.5">*</span>}
</label>
{children}
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
</div>
)
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors",
checked ? "bg-primary" : "bg-input",
)}
>
<span
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
checked ? "translate-x-4" : "translate-x-0",
)}
/>
</button>
)
}
function SectionTitle({ children }: { children: ReactNode }) {
return (
<div className="flex items-center gap-2 py-0.5">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
<div className="flex-1 h-px bg-border" />
</div>
)
}
function mockToDto(cert: (typeof routerCertificates)[number]): CertificateDto {
return {
id: cert.id,
@@ -641,65 +695,75 @@ function CertPartIssueForm({
setIssueTrustApi: (v: boolean) => void
}) {
return (
<div className="flex flex-col gap-4 py-4">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">Сервер</label>
<select
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
value={issueServerId}
onChange={(e) => setIssueServerId(e.target.value)}
>
<option value="">Выберите сервер</option>
{serverList.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-4">
<SectionTitle>Основные</SectionTitle>
<Field label="Сервер" required hint="RouterOS 7.22+, куда импортируется сертификат">
<select
value={issueServerId}
onChange={(e) => setIssueServerId(e.target.value)}
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
>
<option value="" disabled>
Выбрать сервер
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">Имя сертификата на роутере</label>
<Input
value={issueCertName}
onChange={(e) => setIssueCertName(e.target.value)}
placeholder="router-le"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">Common Name</label>
<Input
value={issueCommonName}
onChange={(e) => setIssueCommonName(e.target.value)}
placeholder="vpn.example.com"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">SAN (по одному в строке)</label>
<textarea
className="min-h-24 rounded-md border border-input bg-background px-3 py-2 text-sm"
value={issueSans}
onChange={(e) => setIssueSans(e.target.value)}
placeholder="www.example.com"
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">Trust store</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={issueTrustWww}
onChange={(e) => setIssueTrustWww(e.target.checked)}
{serverList.map((s) => (
<option key={s.id} value={s.id}>
{s.name} ({s.site})
</option>
))}
</select>
</Field>
<Field label="Имя сертификата на роутере" required hint="Имя объекта /certificate на устройстве">
<Input
className="font-mono"
value={issueCertName}
onChange={(e) => setIssueCertName(e.target.value)}
placeholder="router-le"
/>
www
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={issueTrustApi}
onChange={(e) => setIssueTrustApi(e.target.checked)}
</Field>
</div>
<div className="flex flex-col gap-4">
<SectionTitle>Домены</SectionTitle>
<Field label="Common Name" required hint="Основное имя в сертификате">
<Input
className="font-mono"
value={issueCommonName}
onChange={(e) => setIssueCommonName(e.target.value)}
placeholder="vpn.example.com"
/>
api
</label>
</Field>
<Field label="SAN" hint="По одному имени в строке">
<textarea
className="min-h-24 w-full rounded-lg border border-input bg-background px-2.5 py-2 text-sm font-mono text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
value={issueSans}
onChange={(e) => setIssueSans(e.target.value)}
placeholder="www.example.com"
/>
</Field>
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3 text-xs text-muted-foreground">
<p className="font-medium text-foreground mb-1">Let&apos;s Encrypt · DNS-01 (Cloudflare)</p>
<p>TXT-запись создаётся в Cloudflare, сертификат импортируется на выбранный RouterOS.</p>
</div>
</div>
<div className="flex flex-col gap-4">
<SectionTitle>Импорт на RouterOS</SectionTitle>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Trust store · www</p>
<p className="text-xs text-muted-foreground">Веб-интерфейс и HTTPS-сервисы</p>
</div>
<Toggle checked={issueTrustWww} onChange={setIssueTrustWww} />
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Trust store · api</p>
<p className="text-xs text-muted-foreground">REST API и управление</p>
</div>
<Toggle checked={issueTrustApi} onChange={setIssueTrustApi} />
</div>
</div>
</div>
)
@@ -1027,33 +1091,38 @@ export default function CertificatesPage() {
</div>
<Sheet open={issueOpen} onOpenChange={setIssueOpen}>
<SheetContent className="sm:max-w-lg overflow-y-auto">
<SheetHeader>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>Выпуск сертификата</SheetTitle>
<SheetDescription>
Let&apos;s Encrypt через DNS-01 (Cloudflare) и импорт на выбранный RouterOS.
</SheetDescription>
</SheetHeader>
<CertPartIssueForm
serverList={serverList}
issueServerId={issueServerId}
setIssueServerId={setIssueServerId}
issueCertName={issueCertName}
setIssueCertName={setIssueCertName}
issueCommonName={issueCommonName}
setIssueCommonName={setIssueCommonName}
issueSans={issueSans}
setIssueSans={setIssueSans}
issueTrustWww={issueTrustWww}
setIssueTrustWww={setIssueTrustWww}
issueTrustApi={issueTrustApi}
setIssueTrustApi={setIssueTrustApi}
/>
<SheetFooter className="mt-4">
<SheetClose render={<Button variant="outline" disabled={issueBusy} />}>
<div className="flex-1 overflow-y-auto px-6 py-5">
<CertPartIssueForm
serverList={serverList}
issueServerId={issueServerId}
setIssueServerId={setIssueServerId}
issueCertName={issueCertName}
setIssueCertName={setIssueCertName}
issueCommonName={issueCommonName}
setIssueCommonName={setIssueCommonName}
issueSans={issueSans}
setIssueSans={setIssueSans}
issueTrustWww={issueTrustWww}
setIssueTrustWww={setIssueTrustWww}
issueTrustApi={issueTrustApi}
setIssueTrustApi={setIssueTrustApi}
/>
</div>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" disabled={issueBusy} />}>
Отмена
</SheetClose>
<Button
className="flex-1"
disabled={!liveReady || issueBusy}
onClick={() => {
void handleIssue()