Files
MikrotikManager/components/ipsec/ipsec-cert-sheet.tsx
T
DenozordecandCursor 752256f12e
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-test (push) Successful in 1m51s
Docker images / frontend-image (push) Successful in 2m19s
Docker images / updater-image (push) Successful in 39s
Docker images / backend-image (push) Successful in 2m40s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 11s
fix(ipsec): требовать пароль .p12 не короче 8 символов
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 00:10:10 +07:00

194 lines
7.4 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 { useEffect, useMemo, useState } from "react"
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,
onReexport,
}: {
open: boolean
onOpenChange: (v: boolean) => void
bundle: IpsecCertBundle | null
busy?: boolean
/** Перекачка с новой passphrase (серийник ключа остаётся на роутере). */
onReexport?: (passphrase: string) => void | Promise<void>
}) {
const [passphrase, setPassphrase] = useState("")
useEffect(() => {
if (!open) return
const initial = bundle?.passphrase ?? ""
queueMicrotask(() => setPassphrase(initial))
}, [open, bundle])
const canDownload = useMemo(
() => Boolean(bundle && passphrase.trim().length >= IPSEC_MIN_PASSPHRASE),
[bundle, 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 overflow-y-auto px-6 py-5 flex flex-col gap-5">
{!bundle ? (
<p className="text-sm text-muted-foreground">
Бандл сертификата пуст перезапустите экспорт с новой парольной фразой.
</p>
) : (
<>
<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}
onChange={(e) => setPassphrase(e.target.value)}
/>
<Button
type="button"
size="icon"
variant="outline"
title="Сгенерировать и перекачать"
disabled={busy}
onClick={() => {
const next = randomPassphrase()
setPassphrase(next)
if (onReexport) void onReexport(next)
}}
>
<RefreshCwIcon className={`size-4 ${busy ? "animate-spin" : ""}`} />
</Button>
</div>
</FormField>
{bundle.serverEndpoint ? (
<p className="text-xs text-muted-foreground">
Сервер: <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"
disabled={!canDownload}
onClick={() => {
if (!bundle) return
downloadB64(bundle.filename, bundle.contentB64, bundle.mime)
toast.success(`Скачан ${bundle.filename}`)
}}
>
<DownloadIcon className="size-4" />
{bundle.filename} (.p12, сертификат + ключ)
</Button>
{bundle.sswanContent && bundle.sswanFilename ? (
<Button
variant="outline"
className="justify-start"
disabled={!canDownload}
onClick={() => {
if (!bundle.sswanContent || !bundle.sswanFilename) return
downloadText(bundle.sswanFilename, bundle.sswanContent)
toast.success(`Скачан ${bundle.sswanFilename}`)
}}
>
<DownloadIcon className="size-4" />
{bundle.sswanFilename} (strongSwan)
</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-auto rounded-md border bg-muted/40 p-3 text-[11px] leading-relaxed whitespace-pre-wrap">
{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 }