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
Co-authored-by: Cursor <cursoragent@cursor.com>
194 lines
7.4 KiB
TypeScript
194 lines
7.4 KiB
TypeScript
"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 }
|