fix(config): добавить новые зависимости и обновить конфигурацию компонентов
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 2m37s
Docker images / frontend-image (push) Successful in 2m22s
Docker images / updater-image (push) Successful in 38s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 8s
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 2m37s
Docker images / frontend-image (push) Successful in 2m22s
Docker images / updater-image (push) Successful in 38s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 8s
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
|
||||
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||
import { routerCertificates, servers as mockServers } from "@/lib/data"
|
||||
import type { CertStatus, Server } from "@/lib/data"
|
||||
import type { CertificateDto } from "@mmapp/contracts/certificates"
|
||||
@@ -47,7 +49,19 @@ import {
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
RefreshCwIcon,
|
||||
UploadIcon,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
Stepper,
|
||||
StepperContent,
|
||||
StepperIndicator,
|
||||
StepperItem,
|
||||
StepperNav,
|
||||
StepperPanel,
|
||||
StepperSeparator,
|
||||
StepperTitle,
|
||||
StepperTrigger,
|
||||
} from "@/components/reui/stepper"
|
||||
|
||||
const STATUS_CONFIG: Record<
|
||||
CertStatus,
|
||||
@@ -93,60 +107,6 @@ 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,
|
||||
@@ -679,6 +639,7 @@ function CertPartIssueForm({
|
||||
setIssueTrustWww,
|
||||
issueTrustApi,
|
||||
setIssueTrustApi,
|
||||
step,
|
||||
}: {
|
||||
serverList: Server[]
|
||||
issueServerId: string
|
||||
@@ -693,12 +654,15 @@ function CertPartIssueForm({
|
||||
setIssueTrustWww: (v: boolean) => void
|
||||
issueTrustApi: boolean
|
||||
setIssueTrustApi: (v: boolean) => void
|
||||
step?: 1 | 2 | 3 | 4
|
||||
}) {
|
||||
const showAll = step == null
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
{(showAll || step === 1) && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle>Основные</SectionTitle>
|
||||
<Field label="Сервер" required hint="RouterOS 7.22+, куда импортируется сертификат">
|
||||
<FormField label="Сервер" required hint="RouterOS 7.22+, куда импортируется сертификат">
|
||||
<select
|
||||
value={issueServerId}
|
||||
onChange={(e) => setIssueServerId(e.target.value)}
|
||||
@@ -713,41 +677,45 @@ function CertPartIssueForm({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Имя сертификата на роутере" required hint="Имя объекта /certificate на устройстве">
|
||||
</FormField>
|
||||
<FormField label="Имя сертификата на роутере" required hint="Имя объекта /certificate на устройстве">
|
||||
<Input
|
||||
className="font-mono"
|
||||
value={issueCertName}
|
||||
onChange={(e) => setIssueCertName(e.target.value)}
|
||||
placeholder="router-le"
|
||||
/>
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(showAll || step === 2) && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle>Домены</SectionTitle>
|
||||
<Field label="Common Name" required hint="Основное имя в сертификате">
|
||||
<FormField label="Common Name" required hint="Основное имя в сертификате">
|
||||
<Input
|
||||
className="font-mono"
|
||||
value={issueCommonName}
|
||||
onChange={(e) => setIssueCommonName(e.target.value)}
|
||||
placeholder="vpn.example.com"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="SAN" hint="По одному имени в строке">
|
||||
</FormField>
|
||||
<FormField 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>
|
||||
</FormField>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{(showAll || step === 3) && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle>Импорт на RouterOS</SectionTitle>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -755,16 +723,29 @@ function CertPartIssueForm({
|
||||
<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} />
|
||||
<FormToggle 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} />
|
||||
<FormToggle checked={issueTrustApi} onChange={setIssueTrustApi} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(showAll || step === 4) && (
|
||||
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3 text-sm">
|
||||
<p className="font-medium mb-2">Проверьте параметры</p>
|
||||
<ul className="text-xs text-muted-foreground space-y-1">
|
||||
<li>Сервер: {serverList.find((s) => s.id === issueServerId)?.name ?? "—"}</li>
|
||||
<li>Имя: {issueCertName || "—"}</li>
|
||||
<li>CN: {issueCommonName || "—"}</li>
|
||||
<li>Trust www: {issueTrustWww ? "да" : "нет"} · api: {issueTrustApi ? "да" : "нет"}</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -783,6 +764,8 @@ export default function CertificatesPage() {
|
||||
const [serverList, setServerList] = useState<Server[]>([])
|
||||
|
||||
const [issueOpen, setIssueOpen] = useState(false)
|
||||
const [issueStep, setIssueStep] = useState(1)
|
||||
const [importOpen, setImportOpen] = useState(false)
|
||||
const [issueBusy, setIssueBusy] = useState(false)
|
||||
const [issueServerId, setIssueServerId] = useState("")
|
||||
const [issueCertName, setIssueCertName] = useState("")
|
||||
@@ -1004,7 +987,11 @@ export default function CertificatesPage() {
|
||||
<RefreshCwIcon className={cn("size-4", loadState === "loading" && "animate-spin")} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button size="sm" disabled={!liveReady || issueBusy} onClick={() => setIssueOpen(true)}>
|
||||
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||
<UploadIcon className="size-4" />
|
||||
Импорт
|
||||
</Button>
|
||||
<Button size="sm" disabled={!liveReady || issueBusy} onClick={() => { setIssueStep(1); setIssueOpen(true) }}>
|
||||
<PlusIcon className="size-4" />
|
||||
Выпустить сертификат
|
||||
</Button>
|
||||
@@ -1090,7 +1077,7 @@ export default function CertificatesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Sheet open={issueOpen} onOpenChange={setIssueOpen}>
|
||||
<Sheet open={issueOpen} onOpenChange={(v) => { setIssueOpen(v); if (!v) setIssueStep(1) }}>
|
||||
<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>
|
||||
@@ -1099,40 +1086,87 @@ export default function CertificatesPage() {
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<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>
|
||||
<Stepper value={issueStep} onValueChange={setIssueStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
|
||||
<StepperNav className="mb-5">
|
||||
{[
|
||||
{ step: 1, title: "Основные" },
|
||||
{ step: 2, title: "Домены" },
|
||||
{ step: 3, title: "Импорт" },
|
||||
{ step: 4, title: "Проверка" },
|
||||
].map(({ step, title }, i, arr) => (
|
||||
<StepperItem key={step} step={step}>
|
||||
<StepperTrigger>
|
||||
<StepperIndicator>{step}</StepperIndicator>
|
||||
<StepperTitle className="sr-only">{title}</StepperTitle>
|
||||
</StepperTrigger>
|
||||
{i < arr.length - 1 && <StepperSeparator />}
|
||||
</StepperItem>
|
||||
))}
|
||||
</StepperNav>
|
||||
<StepperPanel className="flex-1 overflow-y-auto">
|
||||
{[1, 2, 3, 4].map((s) => (
|
||||
<StepperContent key={s} value={s}>
|
||||
<CertPartIssueForm
|
||||
step={s as 1 | 2 | 3 | 4}
|
||||
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}
|
||||
/>
|
||||
</StepperContent>
|
||||
))}
|
||||
</StepperPanel>
|
||||
</Stepper>
|
||||
|
||||
<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()
|
||||
}}
|
||||
>
|
||||
{issueBusy ? "Выпуск…" : "Выпустить"}
|
||||
</Button>
|
||||
{issueStep > 1 && (
|
||||
<Button variant="outline" className="flex-1" disabled={issueBusy} onClick={() => setIssueStep((s) => s - 1)}>
|
||||
Назад
|
||||
</Button>
|
||||
)}
|
||||
{issueStep < 4 ? (
|
||||
<Button
|
||||
className="flex-1"
|
||||
disabled={issueStep === 1 && (!issueServerId || !issueCertName)}
|
||||
onClick={() => setIssueStep((s) => s + 1)}
|
||||
>
|
||||
Далее
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="flex-1"
|
||||
disabled={!liveReady || issueBusy}
|
||||
onClick={() => { void handleIssue() }}
|
||||
>
|
||||
{issueBusy ? "Выпуск…" : "Выпустить"}
|
||||
</Button>
|
||||
)}
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<FileImportDialog
|
||||
open={importOpen}
|
||||
onOpenChange={setImportOpen}
|
||||
title="Импорт сертификата"
|
||||
description="Загрузите PEM, CRT или PKCS#12 для импорта на RouterOS"
|
||||
accept=".pem,.crt,.cer,.p12,.pfx"
|
||||
onImport={async (files) => {
|
||||
toast.success(`Файл ${files[0]?.name} готов к импорту на роутер`)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user