187 lines
7.6 KiB
TypeScript
187 lines
7.6 KiB
TypeScript
"use client"
|
|
|
|
|
|
import { IPSEC_MIN_PASSPHRASE, type IpsecCertBundle } from "@mmapp/contracts/ipsec"
|
|
import { FormField, SectionTitle } from "@/components/form-kit"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
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,
|
|
}: {
|
|
open: boolean
|
|
onOpenChange: (v: boolean) => void
|
|
bundle: IpsecCertBundle | null
|
|
busy?: boolean
|
|
error?: string | null
|
|
/** Перекачка с новой passphrase (серийник ключа остаётся на роутере). */
|
|
onReexport?: (passphrase: string) => void | Promise<void>
|
|
}) {
|
|
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 ? `«${bundle.user}»` : ""}</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>}
|
|
{!bundle ? (
|
|
!busy && onReexport ? <Button variant="outline" onClick={() => void onReexport(randomPassphrase())}>Повторить экспорт</Button> : 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={() => {
|
|
const next = randomPassphrase()
|
|
if (onReexport) void onReexport(next)
|
|
}}
|
|
>
|
|
<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>
|
|
)
|
|
}
|
|
|
|
export { IpsecCertSheet, downloadText, downloadB64 }
|