Manage master keys through the external API
API keys now see a set of servers instead of one, and /api/v1 gains the master-key operations the panel already had. - api_key_servers holds each key's servers (migrated from server_id, which stays the default server for /api/v1/users). /users follows X-Server-Id within the set; a server outside it is 404. PATCH /ui/apikeys/:id changes the set without reissuing the key. - /api/v1/masterkeys: create (returns the sen:// link), list, get with link and devices, patch label/limit/servers, rotate, rekey, delete, and nested device routes (unlink, rekey, PSK). A key sees a master key only if all of its servers are in the key's set, so it cannot delete or re-point service on a server it cannot see; anything else is 404, servers outside the set are 403. Creating refuses with 503 before writing anything if the subscription cannot issue links. - sub.ts: the panel routes and the API share one service layer, the panel's behaviour is unchanged. - Node deletion: owners check in beforeDelete (no writes, a later hook may still veto) and clean up in afterDelete. Previously a forced delete could release a waiting master key and then be vetoed. A node that is the only server of an API key is refused; other keys just lose it. - Panel: server checkboxes are a shared ServerPicker, used by the master key settings and the new API key settings row; the API keys table shows each key's servers. - README: the API section covers server sets and master keys.
This commit is contained in:
@@ -75,3 +75,18 @@ LICENSE file in the root directory of this source tree.
|
||||
@media (max-width: 640px) {
|
||||
.apikey-cards-list { display: flex; }
|
||||
}
|
||||
|
||||
/* Настройки ключа: какие серверы он видит */
|
||||
.apikey-tr-tools td { padding-top: 0; }
|
||||
.apikey-tools {
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
background: var(--surface-bright);
|
||||
border: 1px solid var(--outline-variant);
|
||||
border-radius: 16px;
|
||||
padding: 12px 16px 14px;
|
||||
margin: 6px 0 8px;
|
||||
}
|
||||
.apikey-tools-title { margin: 0; font-size: 13px; font-weight: 500; color: var(--label); }
|
||||
.apikey-tools-hint { margin: 0; font-size: 12px; color: var(--neutral-text); }
|
||||
.apikey-card-btns { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
.apikey-card-servers { font-size: 12px; color: var(--neutral-text); }
|
||||
|
||||
@@ -1,29 +1,57 @@
|
||||
// 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 { useState, useEffect, useCallback } from 'react';
|
||||
import { apiFetch, copyText, timeAgo, getServerId, type ApiKey, type PageProps } from '../../lib/shared';
|
||||
import { IcoPlus, IcoTrash, IcoCopy } from '../../components/icons';
|
||||
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.
|
||||
// TODO multi-server: сейчас сервер один (server_id = 0). Когда появится список
|
||||
// серверов — ключ будет привязываться к выбранному в сервер-баре.
|
||||
// Ключ видит набор серверов: на них он заводит vpn://-пользователей и в их пределах
|
||||
// управляет мастер-ключами. Создаётся на выбранном в сервер-баре сервере, набор
|
||||
// правится в настройках ключа.
|
||||
export default function ApiKeysPage({ token, showMsg }: PageProps) {
|
||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||
const [nodes, setNodes] = useState<NodeInfo[]>([]);
|
||||
const [label, setLabel] = useState('');
|
||||
const [created, setCreated] = useState<{ label: string; key: string } | null>(null);
|
||||
const [tools, setTools] = useState<number | 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) => (
|
||||
<section className="apikey-tools">
|
||||
<h4 className="apikey-tools-title">Серверы ключа</h4>
|
||||
<p className="apikey-tools-hint">
|
||||
На них ключ заводит vpn://-пользователей (по умолчанию — на «{serverName(nodes, k.server_id)}», другой
|
||||
выбирается заголовком X-Server-Id) и управляет мастер-ключами, чьи серверы все отмечены здесь.
|
||||
</p>
|
||||
<ServerPicker nodes={nodes} selected={k.servers ?? [k.server_id]} onChange={(next, n, added) => setServers(k, next, n, added)} />
|
||||
</section>
|
||||
);
|
||||
|
||||
const createKey = useCallback(async () => {
|
||||
if (!label.trim()) return;
|
||||
const r = await apiFetch('POST', '/ui/apikeys', token, { label: label.trim(), server_id: getServerId() ?? 0 });
|
||||
@@ -44,8 +72,7 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Сервер-бар сверху рендерит App; к выбранному в нём серверу и привязывается ключ.
|
||||
TODO multi-server: server_id берётся из выбранной карточки (пока 0). */}
|
||||
{/* Сервер-бар сверху рендерит App; новый ключ получает выбранный в нём сервер. */}
|
||||
<div className="toolbar">
|
||||
<input
|
||||
className="field"
|
||||
@@ -68,6 +95,7 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
|
||||
<th>#</th>
|
||||
<th>Метка</th>
|
||||
<th>Ключ</th>
|
||||
<th>Серверы</th>
|
||||
<th>Создан</th>
|
||||
<th>Последний раз</th>
|
||||
<th></th>
|
||||
@@ -75,24 +103,36 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
|
||||
</thead>
|
||||
<tbody>
|
||||
{keys.length === 0 ? (
|
||||
<tr><td colSpan={6} className="empty">Нет ключей</td></tr>
|
||||
<tr><td colSpan={7} className="empty">Нет ключей</td></tr>
|
||||
) : keys.map((k, i) => (
|
||||
<tr key={k.id}>
|
||||
<td className="td-num">{i + 1}</td>
|
||||
<td>{k.label}</td>
|
||||
<td className="td-mono">{k.prefix}</td>
|
||||
<td>{new Date(k.created_at * 1000).toLocaleDateString()}</td>
|
||||
<td>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'никогда'}</td>
|
||||
<td className="td-actions">
|
||||
<div className="actions">
|
||||
<button
|
||||
className="btn-icon btn-icon--danger"
|
||||
title="Удалить ключ"
|
||||
onClick={() => deleteKey(k.id, k.label)}
|
||||
><IcoTrash /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<Fragment key={k.id}>
|
||||
<tr>
|
||||
<td className="td-num">{i + 1}</td>
|
||||
<td>{k.label}</td>
|
||||
<td className="td-mono">{k.prefix}</td>
|
||||
<td>{serversText(k)}</td>
|
||||
<td>{new Date(k.created_at * 1000).toLocaleDateString()}</td>
|
||||
<td>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'никогда'}</td>
|
||||
<td className="td-actions">
|
||||
<div className="actions">
|
||||
<button
|
||||
className="btn-icon"
|
||||
title="Серверы ключа"
|
||||
aria-label="Серверы ключа"
|
||||
aria-expanded={tools === k.id}
|
||||
onClick={() => setTools(tools === k.id ? null : k.id)}
|
||||
><IcoSettings /></button>
|
||||
<button
|
||||
className="btn-icon btn-icon--danger"
|
||||
title="Удалить ключ"
|
||||
aria-label="Удалить ключ"
|
||||
onClick={() => deleteKey(k.id, k.label)}
|
||||
><IcoTrash /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{tools === k.id && <tr className="apikey-tr-tools"><td colSpan={7}>{toolbox(k)}</td></tr>}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -110,13 +150,23 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
|
||||
<span className="apikey-card-num">#{i + 1}</span>
|
||||
<span className="apikey-card-label">{k.label}</span>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn--danger btn--sq"
|
||||
aria-label="Удалить ключ"
|
||||
onClick={() => deleteKey(k.id, k.label)}
|
||||
><IcoTrash /></button>
|
||||
<div className="apikey-card-btns">
|
||||
<button
|
||||
className="btn btn--tonal btn--sq"
|
||||
aria-label="Серверы ключа"
|
||||
aria-expanded={tools === k.id}
|
||||
onClick={() => setTools(tools === k.id ? null : k.id)}
|
||||
><IcoSettings /></button>
|
||||
<button
|
||||
className="btn btn--danger btn--sq"
|
||||
aria-label="Удалить ключ"
|
||||
onClick={() => deleteKey(k.id, k.label)}
|
||||
><IcoTrash /></button>
|
||||
</div>
|
||||
</div>
|
||||
<code className="apikey-card-prefix">{k.prefix}</code>
|
||||
<span className="apikey-card-servers">Серверы: {serversText(k)}</span>
|
||||
{tools === k.id && toolbox(k)}
|
||||
<div className="apikey-card-meta">
|
||||
<span>Создан {new Date(k.created_at * 1000).toLocaleDateString()}</span>
|
||||
<span>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'не использован'}</span>
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
// 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, genLabel, type NodeInfo, type PageProps } from '../../lib/shared';
|
||||
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. Мастер-ключ — это ссылка
|
||||
@@ -72,8 +73,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
}, 'Мастер-ключ создан');
|
||||
}, [label, limit, nodes, token, act]);
|
||||
|
||||
const serverNames = useCallback((ids: number[]) =>
|
||||
ids.map(id => nodes.find(n => n.id === id)?.name ?? `#${id}`).join(', '), [nodes]);
|
||||
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;
|
||||
@@ -182,27 +182,9 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
пиры его устройств с этого сервера удалятся; поставить — добавятся. Последний не снимается. */}
|
||||
<section className="mk-box">
|
||||
<h4 className="mk-box-title">Серверы ключа</h4>
|
||||
{nodes.map(n => {
|
||||
const on = k.servers.includes(n.id);
|
||||
const last = on && k.servers.length === 1;
|
||||
const h = n.health;
|
||||
return (
|
||||
<label className={`mk-server${last ? ' mk-server--locked' : ''}`} key={n.id}>
|
||||
<input type="checkbox" checked={on} disabled={last}
|
||||
onChange={() => {
|
||||
const next = on ? k.servers.filter(id => id !== n.id) : [...k.servers, n.id];
|
||||
act(() => apiFetch('PATCH', `/ui/masterkeys/${k.id}`, token, { servers: next }),
|
||||
on ? `«${h?.server || n.name}» убран из ключа` : `«${h?.server || n.name}» добавлен в ключ`);
|
||||
}} />
|
||||
<span className={`chip chip--${n.online ? 'online' : 'offline'} mk-server-dot`} title={n.online ? 'В сети' : 'Нет связи'}>
|
||||
<span className="chip-dot" />
|
||||
</span>
|
||||
<span className="mk-server-name">{h?.server || n.name}</span>
|
||||
{h?.ip && <code className="mk-server-ip">{h.ip}</code>}
|
||||
{h?.gen && <span className="mk-server-gen">{genLabel(h.gen)}</span>}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
<ServerPicker nodes={nodes} selected={k.servers} onChange={(next, n, added) =>
|
||||
act(() => apiFetch('PATCH', `/ui/masterkeys/${k.id}`, token, { servers: next }),
|
||||
added ? `«${serverName(nodes, n.id)}» добавлен в ключ` : `«${serverName(nodes, n.id)}» убран из ключа`)} />
|
||||
<p className="mk-setting-hint">Новые серверы добавляются кнопкой «Добавить сервер» над вкладкой.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -149,24 +149,6 @@ LICENSE file in the root directory of this source tree.
|
||||
.mk-meter--full { --meter-fill: var(--error-dot); --meter-on: #fff; } /* лимит — красный */
|
||||
:root[data-theme="dark"] .mk-meter--full { --meter-on: #2a2118; }
|
||||
|
||||
/* серверы, отдаваемые по ключу (заглушка) */
|
||||
.mk-server {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
background: var(--surface-container);
|
||||
border-radius: 12px;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
.mk-server input { accent-color: var(--primary); margin: 0; }
|
||||
.mk-server-name { font-weight: 500; }
|
||||
.mk-server-ip, .mk-server-gen { font-size: 12px; color: var(--neutral-text); }
|
||||
.mk-server-ip { font-family: var(--mono); margin-left: auto; }
|
||||
.mk-server { cursor: pointer; }
|
||||
.mk-server:hover { background: var(--state-primary); }
|
||||
.mk-server--locked { cursor: default; }
|
||||
.mk-server--locked:hover { background: var(--surface-container); }
|
||||
.mk-server-dot { padding: 0; background: none; }
|
||||
.mk-box .tip-wrap { width: 100%; }
|
||||
|
||||
/* дети: вертикальная линия слева и «ножка» к каждому узлу */
|
||||
|
||||
Reference in New Issue
Block a user