// Copyright (c) 2026 Ivan Vasilev // This source code is licensed under the MIT license found in the // LICENSE file in the root directory of this source tree. import { Fragment, useState, useEffect, useCallback } from 'react'; import QRCode from 'qrcode'; import { apiFetch, copyText, bytes, timeAgo, type NodeInfo, type PageProps } from '../../lib/shared'; import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings } from '../../components/icons'; import ServerPicker, { serverName } from '../../components/ServerPicker'; import './masterkeys.css'; // Вкладка «Мастер-ключи»: sen://-подписки для SenAWG. Мастер-ключ — это ссылка // плюс лимит устройств; устройства регистрируются сами (приватный ключ остаётся // на них) и здесь только отображаются и отзываются. Формат — docs/sen-link.md. // deleting — ключ удалён и уже не работает, но эти серверы ещё не подтвердили, что сняли его пиров. interface MasterKey { id: number; label: string; device_limit: number; devices: number; servers: number[]; created_at: number; deleting?: number[] } interface Device { id: number; device_id: string; device_name: string; platform: string; version: string; created_at: number; last_seen: number | null; rekey_requested: boolean; online: boolean; lastHandshake: number; rx: number; tx: number; servers_total: number; servers_ok: number; // на скольких серверах ключа уже стоит его текущий ключ } // Устройство ещё не на всех серверах ключа (нода не в сети при регистрации или смене ключа) — // панель догонит, когда нода появится. const syncLabel = (d: Device): string => d.servers_ok < d.servers_total ? ` · на ${d.servers_ok} из ${d.servers_total} серверов` : ''; // Платформа и версия клиента: «macOS-0.6.5»; без версии — просто платформа. const platformLabel = (d: Device): string => d.platform ? (d.version ? `${d.platform}-${d.version}` : d.platform) : '—'; const errText = (e: any): string => e?.response?.data?.error ?? 'Ошибка запроса'; export default function MasterKeysPage({ token, showMsg }: PageProps) { const [keys, setKeys] = useState([]); const [enabled, setEnabled] = useState(true); const [tls, setTls] = useState(false); const [nodes, setNodes] = useState([]); // серверы, которые можно включить в ключ const [label, setLabel] = useState(''); const [limit, setLimit] = useState('3'); const [devs, setDevs] = useState>({}); const [folded, setFolded] = useState>(new Set()); // свёрнутые узлы const [tools, setTools] = useState(null); // узел с открытыми настройками const [limitEdit, setLimitEdit] = useState(''); const [modal, setModal] = useState<{ label: string; link: string; tls: boolean; dataUrl: string } | null>(null); const load = useCallback(async () => { try { const d = await apiFetch('GET', '/ui/masterkeys', token); const list: MasterKey[] = d.keys ?? []; setKeys(list); setEnabled(!!d.enabled); setTls(!!d.tls); apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {}); const all = await Promise.all(list.filter(k => !k.deleting).map(k => apiFetch('GET', `/ui/masterkeys/${k.id}/devices`, token).then(r => [k.id, r.devices ?? []] as const))); setDevs(Object.fromEntries(all)); } catch { showMsg('Ошибка загрузки мастер-ключей'); } }, [token, showMsg]); // Действие над ключом/устройством: выполнить, показать сообщение, обновить данные. const act = useCallback(async (fn: () => Promise, ok: string) => { try { await fn(); showMsg(ok); await load(); } catch (e) { showMsg(errText(e)); } }, [load, showMsg]); const create = useCallback(async () => { if (!label.trim()) return; await act(async () => { // Новый ключ отдаёт все серверы, которые уже подключались; набор потом правится в настройках ключа. const ready = nodes.filter(n => !n.pending).map(n => n.id); await apiFetch('POST', '/ui/masterkeys', token, { label: label.trim(), device_limit: Number(limit), ...(ready.length ? { servers: ready } : {}), }); setLabel(''); }, 'Мастер-ключ создан'); }, [label, limit, nodes, token, act]); const serverNames = useCallback((ids: number[]) => ids.map(id => serverName(nodes, id)).join(', '), [nodes]); const remove = useCallback(async (k: MasterKey) => { if (!confirm(`Удалить «${k.label}»? Все ${k.devices} устройств потеряют доступ.`)) return; try { const r = await apiFetch('DELETE', `/ui/masterkeys/${k.id}`, token); const pending: number[] = r.pending ?? []; showMsg(pending.length ? `«${k.label}» отключён, ждём подтверждения: ${serverNames(pending)}` : 'Удалён: ' + k.label); await load(); } catch (e) { showMsg(errText(e)); } }, [token, load, showMsg, serverNames]); const fetchLink = useCallback(async (k: MasterKey) => { const r = await apiFetch('GET', `/ui/masterkeys/${k.id}/link`, token); return r as { link: string; tls: boolean }; }, [token]); const showQR = useCallback(async (k: MasterKey) => { try { const { link, tls: t } = await fetchLink(k); const dataUrl = await QRCode.toDataURL(link, { width: 560, margin: 2, errorCorrectionLevel: 'H' }); setModal({ label: k.label, link, tls: t, dataUrl }); } catch (e) { showMsg(errText(e)); } }, [fetchLink, showMsg]); const copyLink = useCallback(async (k: MasterKey) => { try { await copyText((await fetchLink(k)).link); showMsg('Ссылка скопирована'); } catch (e) { showMsg(errText(e)); } }, [fetchLink, showMsg]); const fold = (id: number) => setFolded(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; }); const openTools = (k: MasterKey) => { setLimitEdit(String(k.device_limit)); setTools(tools === k.id ? null : k.id); }; useEffect(() => { if (token) load(); }, [token, load]); // Пока какой-то ключ ждёт серверы, время от времени проверяем, не подтвердили ли они. const waiting = keys.some(k => k.deleting); useEffect(() => { if (!waiting) return; const t = setInterval(load, 15_000); return () => clearInterval(t); }, [waiting, load]); const deletingChip = (k: MasterKey) => ( Удаляется — ждёт: {serverNames(k.deleting ?? [])} ); const actions = (k: MasterKey) => (
); const toolbox = (k: MasterKey) => (
{/* Слева — настройки самого ключа */}

Настройки ключа

Сколько устройств может подключить ключ
setLimitEdit(e.target.value)} />
Ссылка Старая перестанет принимать новые устройства
Ключи устройств Клиенты сменят их при следующем опросе
{/* Справа — какие серверы отдаёт ключ: в config.servers[] каждого устройства. Снять галочку — пиры его устройств с этого сервера удалятся; поставить — добавятся. Последний не снимается. */}

Серверы ключа

act(() => apiFetch('PATCH', `/ui/masterkeys/${k.id}`, token, { servers: next }), added ? `«${serverName(nodes, n.id)}» добавлен в ключ` : `«${serverName(nodes, n.id)}» убран из ключа`)} />

Новые серверы добавляются кнопкой «Добавить сервер» над вкладкой.

); // Полоска занятых слотов: числа по краям внутри; цвет по заполнению. const meter = (k: MasterKey, n: number) => (
= k.device_limit ? ' mk-meter--full' : n * 3 >= k.device_limit * 2 ? ' mk-meter--warn' : ''}`} role="progressbar" aria-label="Устройства" aria-valuemin={0} aria-valuemax={k.device_limit} aria-valuenow={n} title={`Устройств: ${n} из ${k.device_limit}`}> {n}{k.device_limit}
{/* тот же текст поверх заливки — светлый, чтобы читался на ней */}
); const deviceActions = (d: Device) => (
); const caret = (k: MasterKey) => ( ); const deviceNode = (k: MasterKey, d: Device) => (
  • {d.device_name || 'Устройство'} {d.device_id} {platformLabel(d)} · был {d.last_seen ? timeAgo(d.last_seen) + ' назад' : 'никогда'} {d.rekey_requested ? ' · ждёт смены ключа' : ''}{syncLabel(d)}
    {d.online ? 'Онлайн' : 'Офлайн'}
    ↓{bytes(d.rx)} ↑{bytes(d.tx)}
    {deviceActions(d)}
  • ); return ( <> {!enabled && (

    Подписка не настроена на сервере: нет ключа подписи или SUB_PORT. Ссылки выдавать нельзя.

    )} {enabled && !tls && (

    Подписка работает без TLS: ответы подписаны, но secret ссылки и конфиг видны в сети.

    )}
    setLabel(e.target.value)} onKeyDown={e => e.key === 'Enter' && create()} /> setLimit(e.target.value)} />
    {/* Десктоп: таблица, иерархия — строка мастер-ключа и устройства под ней */}
    {keys.length === 0 ? ( ) : keys.map(k => { if (k.deleting) return ( ); const list = devs[k.id] ?? []; const free = Math.max(0, k.device_limit - list.length); const open = !folded.has(k.id); return ( {tools === k.id && } {open && list.map((d, i) => ( ))} {open && free > 0 && ( )} ); })}
    ИмяID устройстваПлатформаСтатус ↓ rx↑ txБылУстройства
    Нет мастер-ключей
    {k.label} {deletingChip(k)}
    {caret(k)}{k.label} {meter(k, list.length)} {actions(k)}
    {toolbox(k)}
    {d.device_name || 'Устройство'} {d.device_id} {platformLabel(d)} {d.online ? 'Онлайн' : 'Офлайн'} {bytes(d.rx)} {bytes(d.tx)} {d.last_seen ? timeAgo(d.last_seen) + ' назад' : 'никогда'} {d.rekey_requested && · ждёт смены ключа} {syncLabel(d) && {syncLabel(d)}} {deviceActions(d)}
    Свободно: {free}
    {/* Мобильный: таблица скрыта (.table-card), то же — деревом карточек */} {keys.length === 0 ?

    Нет мастер-ключей

    : (
      {keys.map(k => { if (k.deleting) return (
    • {k.label} {deletingChip(k)}
    • ); const list = devs[k.id] ?? []; const free = Math.max(0, k.device_limit - list.length); const isFolded = folded.has(k.id); return (
    • {caret(k)} {k.label} {meter(k, list.length)} {actions(k)}
      {tools === k.id && toolbox(k)} {!isFolded && (
        {list.map(d => deviceNode(k, d))} {free > 0 && (
      • Свободно: {free}
      • )}
      )}
    • ); })}
    )} {modal && (
    setModal(null)}>
    e.stopPropagation()}>

    {modal.label}

    QR { await copyText(modal.link); showMsg('Ссылка скопирована'); }} />

    Отсканируй в SenAWG · нажми на QR чтобы скопировать{modal.tls ? '' : ' · без TLS'}

    )} ); }