fix(ui): добавить новые компоненты для управления сертификатами
This commit is contained in:
@@ -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>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle>Основные</SectionTitle>
|
||||
<Field label="Сервер" required hint="RouterOS 7.22+, куда импортируется сертификат">
|
||||
<select
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||
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="">Выберите сервер</option>
|
||||
<option value="" disabled>
|
||||
Выбрать сервер…
|
||||
</option>
|
||||
{serverList.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
{s.name} ({s.site})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">Имя сертификата на роутере</label>
|
||||
</Field>
|
||||
<Field label="Имя сертификата на роутере" required hint="Имя объекта /certificate на устройстве">
|
||||
<Input
|
||||
className="font-mono"
|
||||
value={issueCertName}
|
||||
onChange={(e) => setIssueCertName(e.target.value)}
|
||||
placeholder="router-le"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">Common Name</label>
|
||||
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">SAN (по одному в строке)</label>
|
||||
</Field>
|
||||
<Field label="SAN" hint="По одному имени в строке">
|
||||
<textarea
|
||||
className="min-h-24 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
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'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 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)}
|
||||
/>
|
||||
www
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={issueTrustApi}
|
||||
onChange={(e) => setIssueTrustApi(e.target.checked)}
|
||||
/>
|
||||
api
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -1027,13 +1091,15 @@ 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's Encrypt через DNS-01 (Cloudflare) и импорт на выбранный RouterOS.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5">
|
||||
<CertPartIssueForm
|
||||
serverList={serverList}
|
||||
issueServerId={issueServerId}
|
||||
@@ -1049,11 +1115,14 @@ export default function CertificatesPage() {
|
||||
issueTrustApi={issueTrustApi}
|
||||
setIssueTrustApi={setIssueTrustApi}
|
||||
/>
|
||||
<SheetFooter className="mt-4">
|
||||
<SheetClose render={<Button variant="outline" disabled={issueBusy} />}>
|
||||
</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()
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user