"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 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 ( <> Сертификат {bundle?.user || targetName ? `«${bundle?.user ?? targetName}»` : ""} .p12 для Windows/macOS/iOS · .sswan для strongSwan (Android/iOS)
{busy &&

Загрузка сертификата…

} {error &&

{error}

} {previousAttemptFailed && !busy && (

Предыдущая попытка завершилась ошибкой; её результат на роутере может быть неизвестен. Проверьте временный файл и состояние экспорта перед новым запросом.

)} {!bundle ? ( !busy && onReexport ? (

Экспорт .p12 включает закрытый ключ. Подтвердите его получение перед запросом к роутеру.

) : null ) : ( <>
Пароль архива .p12 0 && passphrase.trim().length < IPSEC_MIN_PASSPHRASE ? `Минимум ${IPSEC_MIN_PASSPHRASE} символов — требование RouterOS` : `Нужна при импорте .p12 на устройстве (минимум ${IPSEC_MIN_PASSPHRASE} символов)` } >
{bundle.serverEndpoint ? (

Сервер: {bundle.serverEndpoint}

) : null}
Файлы {bundle.sswanContent && bundle.sswanFilename ? ( ) : null} {bundle.instructions ? ( ) : null}
{bundle.instructions ? (
                  {bundle.instructions}
                
) : null} )}
}> Закрыть
Подтвердить экспорт {isCa ? "CA" : "сертификата"} Запрос создаст на роутере временный .p12 с закрытым ключом сертификата {targetName ? `«${targetName}»` : ""}, прочитает его и удалит временный файл. Пароль архива будет сгенерирован после подтверждения. {isCa && ( )} {previousAttemptFailed && (

Результат предыдущего запроса может быть неизвестен. Новый запрос не продолжает и не подтверждает предыдущую попытку.

)}
) } export { IpsecCertSheet, downloadText, downloadB64 }