Files
MikrotikManager/components/ipsec/ipsec-cert-sheet.tsx
T

263 lines
13 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.
"use client"
import { useRef, useState } from "react"
import { IPSEC_MIN_PASSPHRASE, type IpsecCertBundle } from "@mmapp/contracts/ipsec"
import type { IpsecExistingCertExportOptions } from "@/shared/api/ipsec"
import { FormField, SectionTitle } from "@/components/form-kit"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Checkbox } from "@/components/ui/checkbox"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
import { toast } from "sonner"
import { DownloadIcon, CopyIcon, RefreshCwIcon } from "lucide-react"
function downloadBlob(filename: string, blob: Blob) {
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
function downloadText(filename: string, content: string) {
downloadBlob(filename, new Blob([content], { type: "text/plain;charset=utf-8" }))
}
function downloadB64(filename: string, b64: string, mime: string) {
const bin = atob(b64)
const bytes = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
downloadBlob(filename, new Blob([bytes], { type: mime }))
}
function randomPassphrase(): string {
// RouterOS требует ≥ IPSEC_MIN_PASSPHRASE символов
const bytes = new Uint8Array(IPSEC_MIN_PASSPHRASE + 1)
crypto.getRandomValues(bytes)
let s = ""
for (const b of bytes) s += "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"[b % 56]
return s
}
function IpsecCertSheet({
open,
onOpenChange,
bundle,
busy,
error,
onReexport,
certificateRole,
targetName,
previousAttemptFailed = false,
}: {
open: boolean
onOpenChange: (v: boolean) => void
bundle: IpsecCertBundle | null
busy?: boolean
error?: string | null
/** Перекачка с новой passphrase (серийник ключа остаётся на роутере). */
onReexport?: (passphrase: string, options: IpsecExistingCertExportOptions) => void | Promise<void>
certificateRole?: "ca" | "server" | "client" | "other"
targetName?: string
previousAttemptFailed?: boolean
}) {
const [confirmationOpen, setConfirmationOpen] = useState(false)
const [privateKeyConfirmed, setPrivateKeyConfirmed] = useState(false)
const [caKeyConfirmed, setCaKeyConfirmed] = useState(false)
const [newAttemptConfirmed, setNewAttemptConfirmed] = useState(false)
const submitting = useRef(false)
const isCa = certificateRole === "ca"
const confirmEnabled = privateKeyConfirmed && (!isCa || caKeyConfirmed) && (!previousAttemptFailed || newAttemptConfirmed) && !busy
const openExportConfirmation = () => {
if (busy || submitting.current) return
setPrivateKeyConfirmed(false)
setCaKeyConfirmed(false)
setNewAttemptConfirmed(false)
setConfirmationOpen(true)
}
const confirmExport = async () => {
if (!confirmEnabled || !onReexport || submitting.current) return
submitting.current = true
setConfirmationOpen(false)
try {
// One key per explicitly confirmed attempt. Nothing retries this call or
// allocates another key after an error, opening, selection, or rendering.
const requestKey = crypto.randomUUID()
await onReexport(randomPassphrase(), { privateKeyExport: true, caPrivateKeyExport: isCa && caKeyConfirmed, requestKey })
} catch {
toast.error("Экспорт не подтверждён. Перед новой попыткой проверьте состояние роутера.")
} finally {
submitting.current = false
}
}
const passphrase = bundle?.passphrase ?? ""
const canDownload = Boolean(bundle && !busy && passphrase.trim().length >= IPSEC_MIN_PASSPHRASE)
return (
<>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>Сертификат {bundle?.user || targetName ? `«${bundle?.user ?? targetName}»` : ""}</SheetTitle>
<SheetDescription>
.p12 для Windows/macOS/iOS · .sswan для strongSwan (Android/iOS)
</SheetDescription>
</SheetHeader>
<div className="flex-1 min-w-0 overflow-y-auto overflow-x-hidden px-6 py-5 flex flex-col gap-5">
{busy && <p role="status" className="text-sm text-muted-foreground">Загрузка сертификата…</p>}
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
{previousAttemptFailed && !busy && (
<p role="alert" className="text-sm text-destructive">Предыдущая попытка завершилась ошибкой; её результат на роутере может быть неизвестен. Проверьте временный файл и состояние экспорта перед новым запросом.</p>
)}
{!bundle ? (
!busy && onReexport ? (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">Экспорт .p12 включает закрытый ключ. Подтвердите его получение перед запросом к роутеру.</p>
<Button variant="outline" onClick={openExportConfirmation}>{previousAttemptFailed ? "Подготовить новый запрос" : "Экспортировать .p12"}</Button>
</div>
) : null
) : (
<>
<div className="flex flex-col gap-4">
<SectionTitle>Пароль архива .p12</SectionTitle>
<FormField
label="Passphrase"
required
hint={
passphrase.trim().length > 0 && passphrase.trim().length < IPSEC_MIN_PASSPHRASE
? `Минимум ${IPSEC_MIN_PASSPHRASE} символов — требование RouterOS`
: `Нужна при импорте .p12 на устройстве (минимум ${IPSEC_MIN_PASSPHRASE} символов)`
}
>
<div className="flex gap-2">
<Input
className="font-mono"
value={passphrase}
readOnly
aria-label="Пароль архива .p12"
/>
<Button
type="button"
size="icon"
variant="outline"
title="Подтвердить экспорт с новым паролем"
aria-label="Подтвердить экспорт с новым паролем"
disabled={busy || !onReexport}
onClick={openExportConfirmation}
>
<RefreshCwIcon className={`size-4 ${busy ? "animate-spin" : ""}`} />
</Button>
</div>
</FormField>
{bundle.serverEndpoint ? (
<p className="text-xs text-muted-foreground break-all">
Сервер: <span className="font-mono">{bundle.serverEndpoint}</span>
</p>
) : null}
</div>
<div className="flex flex-col gap-4">
<SectionTitle>Файлы</SectionTitle>
<Button
variant="outline"
className="justify-start min-w-0"
disabled={!canDownload}
onClick={() => {
if (!bundle) return
downloadB64(bundle.filename, bundle.contentB64, bundle.mime)
toast.success(`Скачан ${bundle.filename}`)
}}
>
<DownloadIcon className="size-4" />
<span className="truncate">{bundle.filename} (.p12, сертификат + ключ)</span>
</Button>
{bundle.sswanContent && bundle.sswanFilename ? (
<Button
variant="outline"
className="justify-start min-w-0"
disabled={!canDownload}
onClick={() => {
if (!bundle.sswanContent || !bundle.sswanFilename) return
downloadText(bundle.sswanFilename, bundle.sswanContent)
toast.success(`Скачан ${bundle.sswanFilename}`)
}}
>
<DownloadIcon className="size-4" />
<span className="truncate">{bundle.sswanFilename} (strongSwan)</span>
</Button>
) : null}
{bundle.instructions ? (
<Button
variant="ghost"
className="justify-start text-muted-foreground"
onClick={() => {
if (!bundle.instructions) return
void navigator.clipboard?.writeText(bundle.instructions)
toast.success("Инструкция скопирована")
}}
>
<CopyIcon className="size-4" />
Скопировать инструкцию по подключению
</Button>
) : null}
</div>
{bundle.instructions ? (
<pre className="max-h-72 overflow-y-auto overflow-x-hidden rounded-md border bg-muted/40 p-3 text-[11px] leading-relaxed whitespace-pre-wrap break-words">
{bundle.instructions}
</pre>
) : null}
</>
)}
</div>
<SheetFooter className="px-6 py-4 border-t shrink-0">
<SheetClose render={<Button variant="outline" className="w-full" />}>
Закрыть
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
<Dialog open={confirmationOpen && open} onOpenChange={setConfirmationOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Подтвердить экспорт {isCa ? "CA" : "сертификата"}</DialogTitle>
<DialogDescription>Запрос создаст на роутере временный .p12 с закрытым ключом сертификата {targetName ? `«${targetName}»` : ""}, прочитает его и удалит временный файл. Пароль архива будет сгенерирован после подтверждения.</DialogDescription>
</DialogHeader>
<label htmlFor="ipsec-export-private-key" className="flex items-start gap-2 text-sm">
<Checkbox id="ipsec-export-private-key" checked={privateKeyConfirmed} onCheckedChange={setPrivateKeyConfirmed} />
<span>Разрешаю экспорт и получение закрытого ключа выбранного сертификата.</span>
</label>
{isCa && (
<label htmlFor="ipsec-export-ca-key" className="flex items-start gap-2 text-sm">
<Checkbox id="ipsec-export-ca-key" checked={caKeyConfirmed} onCheckedChange={setCaKeyConfirmed} />
<span>Отдельно разрешаю экспорт закрытого ключа центра сертификации CA. Его владелец может подписывать другие сертификаты.</span>
</label>
)}
{previousAttemptFailed && (
<div className="space-y-3">
<p className="text-sm text-destructive">Результат предыдущего запроса может быть неизвестен. Новый запрос не продолжает и не подтверждает предыдущую попытку.</p>
<label htmlFor="ipsec-export-new-attempt" className="flex items-start gap-2 text-sm">
<Checkbox id="ipsec-export-new-attempt" checked={newAttemptConfirmed} onCheckedChange={setNewAttemptConfirmed} />
<span>Я проверил состояние роутера и явно запрашиваю новый экспорт.</span>
</label>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmationOpen(false)}>Отмена</Button>
<Button disabled={!confirmEnabled} onClick={() => void confirmExport()}>{previousAttemptFailed ? "Отправить новый запрос" : "Подтвердить экспорт"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
export { IpsecCertSheet, downloadText, downloadB64 }