263 lines
13 KiB
TypeScript
263 lines
13 KiB
TypeScript
"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 }
|