// 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 { apiFetch, copyText, timeAgo, getServerId, type ApiKey, type NodeInfo, type PageProps } from '../../lib/shared'; import { IcoPlus, IcoTrash, IcoCopy, IcoSettings } from '../../components/icons'; import ServerPicker, { serverName } from '../../components/ServerPicker'; import './apikeys.css'; // Вкладка «API-ключи»: ключи внешнего API (/api/v1). Открытый ключ показывается // один раз при создании (в БД хранится только хэш). Сервер-бар сверху рендерит App. // Ключ видит набор серверов: на них он заводит vpn://-пользователей и в их пределах // управляет мастер-ключами. Создаётся на выбранном в сервер-баре сервере, набор // правится в настройках ключа. export default function ApiKeysPage({ token, showMsg }: PageProps) { const [keys, setKeys] = useState([]); const [nodes, setNodes] = useState([]); const [label, setLabel] = useState(''); const [created, setCreated] = useState<{ label: string; key: string } | null>(null); const [tools, setTools] = useState(null); // ключ с открытыми настройками const [picked, setPicked] = useState(null); // серверы нового ключа; null — только выбранный в сервер-баре const load = useCallback(async () => { try { const data = await apiFetch('GET', '/ui/apikeys', token); setKeys(data.keys ?? []); apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {}); } catch { showMsg('Ошибка загрузки ключей'); } }, [token, showMsg]); const setServers = useCallback(async (k: ApiKey, next: number[], n: NodeInfo, added: boolean) => { try { await apiFetch('PATCH', `/ui/apikeys/${k.id}`, token, { servers: next }); showMsg(added ? `Ключ видит «${serverName(nodes, n.id)}»` : `«${serverName(nodes, n.id)}» убран из ключа`); await load(); } catch (e: any) { showMsg(e?.response?.data?.error ?? 'Ошибка запроса'); } }, [token, nodes, load, showMsg]); const serversText = (k: ApiKey) => (k.servers ?? [k.server_id]).map(id => serverName(nodes, id)).join(', '); const toolbox = (k: ApiKey) => (

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

На них ключ заводит vpn://-пользователей (по умолчанию — на «{serverName(nodes, k.server_id)}», другой выбирается заголовком X-Server-Id) и управляет мастер-ключами, чьи серверы все отмечены здесь.

setServers(k, next, n, added)} />
); const createKey = useCallback(async () => { if (!label.trim()) return; const def = getServerId() ?? 0; const servers = picked ?? [def]; const r = await apiFetch('POST', '/ui/apikeys', token, { label: label.trim(), server_id: servers.includes(def) ? def : servers[0], servers }); if (r.error) { showMsg(r.error); return; } setLabel(''); setPicked(null); setCreated({ label: r.label, key: r.key }); await load(); }, [label, picked, token, load, showMsg]); const deleteKey = useCallback(async (id: number, lbl: string) => { if (!confirm('Удалить ключ «' + lbl + '»? Клиенты с ним потеряют доступ.')) return; await apiFetch('DELETE', '/ui/apikeys/' + id, token); showMsg('Ключ удалён: ' + lbl); await load(); }, [token, load, showMsg]); useEffect(() => { if (token) load(); }, [token, load]); return ( <> {/* Сервер-бар сверху рендерит App; новый ключ получает выбранный в нём сервер. */}
setLabel(e.target.value)} onKeyDown={e => e.key === 'Enter' && createKey()} />
Серверы нового ключа: setPicked(next)} />
{keys.length === 0 ? ( ) : keys.map((k, i) => ( {tools === k.id && } ))}
# Метка Ключ Серверы Создан Последний раз
Нет ключей
{i + 1} {k.label} {k.prefix} {serversText(k)} {new Date(k.created_at * 1000).toLocaleDateString()} {k.last_used ? timeAgo(k.last_used) + ' назад' : 'никогда'}
{toolbox(k)}
{/* Мобильный вид: таблица скрыта (.table-card), ключи — карточками */}

{keys.length} ключей

{keys.length === 0 ? (

Нет ключей

) : keys.map((k, i) => (
#{i + 1} {k.label}
{k.prefix} Серверы: {serversText(k)} {tools === k.id && toolbox(k)}
Создан {new Date(k.created_at * 1000).toLocaleDateString()} {k.last_used ? timeAgo(k.last_used) + ' назад' : 'не использован'}
))}
{created && (
setCreated(null)}>
e.stopPropagation()}>

Ключ «{created.label}» создан

Скопируй ключ сейчас — он показывается один раз и больше не будет доступен.

{created.key}
)} ); }