// 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, useRef, useCallback } from 'react'; import QRCode from 'qrcode'; import { apiFetch, vpnKeyToConf, downloadFile, copyText, bytes, timeAgo, genLabel, type User, type PageProps, type AwgGen, } from '../../lib/shared'; import { IcoPlus, IcoRefresh, IcoQR, IcoTrash, IcoGlobe } from '../../components/icons'; import './users.css'; // Вкладка «Пользователи»: форма создания + таблица (десктоп) / карточки (мобильный) // + модалка QR. Сервер-бар сверху рендерит App. Свой стейт и поллинг — здесь. export default function UsersPage({ token, showMsg }: PageProps) { const [users, setUsers] = useState([]); const [newName, setNewName] = useState(''); const [qrModal, setQrModal] = useState<{ name: string; dataUrl: string; vpnKey: string } | null>(null); // Поколение, на котором сейчас работает интерфейс. Ключи, выданные на другом // поколении, уже не подключатся — их надо перевыпустить. const [serverGen, setServerGen] = useState('2'); const [upgrading, setUpgrading] = useState(false); const statsRef = useRef | null>(null); const stale = users.filter(u => u.key_gen !== serverGen); const loadStats = useCallback(async (tok: string) => { try { const data = await apiFetch('GET', '/api/users/stats', tok); setUsers(prev => prev.map(u => { const s = (data.users as User[] ?? []).find(x => x.name === u.name); return s ? { ...u, online: s.online, lastHandshake: s.lastHandshake, rx: s.rx, tx: s.tx } : u; })); } catch { /* silent */ } }, []); const loadUsers = useCallback(async (tok: string) => { try { const [data, health] = await Promise.all([ apiFetch('GET', '/api/users', tok), apiFetch('GET', '/health', tok), ]); setUsers(data.users ?? []); setServerGen(health.gen ?? '2'); await loadStats(tok); } catch { showMsg('Ошибка загрузки'); } }, [loadStats, showMsg]); // Пересобирает vpn:// ключи всех пользователей на текущих параметрах интерфейса. // IP и ключевая пара сохраняются, но клиентам нужно заново импортировать ключ. const reissueKeys = useCallback(async () => { if (!confirm( `Перевыпустить ключи (${stale.length} шт.) на поколении ${genLabel(serverGen)}?\n\n` + 'Старые ключи перестанут работать — всем придётся импортировать ключ заново.', )) return; try { const r = await apiFetch('POST', '/api/users/reissue', token); showMsg(`Перевыпущено: ${r.reissued} из ${r.total}`); await loadUsers(token); } catch { showMsg('Не удалось перевыпустить ключи'); } }, [stale.length, serverGen, token, loadUsers, showMsg]); // Перевод сервера с 2.0 на 3.1 без переустановки: awg-ctrl дописывает // 3.x-параметры в awg1.conf, перезапускает интерфейс (с откатом, если ядро // их не примет) и сразу перевыпускает ключи — старые собраны на // 2.0-параметрах и работать не будут. const upgradeGen = useCallback(async () => { if (!confirm( 'Перевести сервер на AmneziaWG 3.1?\n\n' + 'Интерфейс будет перезапущен, а все vpn:// ключи перевыпущены — ' + 'клиентам придётся импортировать ключ заново.', )) return; setUpgrading(true); try { const r = await apiFetch('POST', '/awg/upgrade', token); showMsg(`Сервер переведён на ${genLabel(r.gen)}, перевыпущено ключей: ${r.reissue?.reissued ?? 0}`); await loadUsers(token); } catch (e: any) { showMsg(e?.response?.data?.error ?? 'Не удалось перейти на 3.1'); } finally { setUpgrading(false); } }, [token, loadUsers, showMsg]); const createUser = useCallback(async () => { if (!newName) return; const u = await apiFetch('POST', '/api/users', token, { name: newName }); if (u.error) { showMsg(u.error); return; } setNewName(''); showMsg('Создан: ' + newName); await loadUsers(token); }, [newName, token, loadUsers, showMsg]); const deleteUser = useCallback(async (name: string) => { if (!confirm('Удалить ' + name + '?')) return; await apiFetch('DELETE', '/api/users/' + name, token); showMsg('Удалён: ' + name); await loadUsers(token); }, [token, loadUsers, showMsg]); const showQR = useCallback(async (name: string) => { try { const u = await apiFetch('POST', '/api/users/' + name, token); const vpnKey = u.vpn_key ?? ''; // errorCorrectionLevel 'L' (а не 'H'): ключ vpn:// длинный (~1.3 КБ), // при 'H' ёмкость QR падает до ~1273 байт и кодирование падает. const dataUrl = await QRCode.toDataURL(vpnKey, { width: 560, margin: 2, errorCorrectionLevel: 'L', }); setQrModal({ name, dataUrl, vpnKey }); } catch { showMsg('Не удалось сгенерировать QR-код'); } }, [token, showMsg]); // Загрузить пользователей и запустить поллинг статистики, пока вкладка открыта. useEffect(() => { if (!token) return; loadUsers(token); statsRef.current = setInterval(() => loadStats(token), 60_000); return () => { if (statsRef.current) clearInterval(statsRef.current); }; }, [token, loadUsers, loadStats]); return ( <>
setNewName(e.target.value)} onKeyDown={e => e.key === 'Enter' && createUser()} />
{serverGen !== '3.1' && ( )} {stale.length > 0 && ( )}
{users.length === 0 ? ( ) : users.map((u, i) => ( ))}
# Статус Имя IP ↓ rx ↑ tx
Нет пользователей
{i + 1} {u.online ? 'онлайн' : u.lastHandshake ? timeAgo(u.lastHandshake) + ' назад' : 'никогда'} {u.name} {u.key_gen !== serverGen && ( {genLabel(u.key_gen)} )} {u.ip} {bytes(u.rx)} {bytes(u.tx)}

{users.length} пользователей

{users.length === 0 ? (

Нет пользователей

) : users.map((u, i) => (
#{i + 1} {u.name} {u.key_gen !== serverGen && ( {genLabel(u.key_gen)} )}
{u.online ? 'онлайн' : u.lastHandshake ? timeAgo(u.lastHandshake) + ' назад' : 'никогда'}
{u.ip}
↓ rx {bytes(u.rx)}
↑ tx {bytes(u.tx)}
))}
{qrModal && (
setQrModal(null)}>
e.stopPropagation()}>

{qrModal.name}

QR { await copyText(qrModal.vpnKey); showMsg('Ключ скопирован'); }} />

Отсканируй в AmneziaVPN · нажми на QR чтобы скопировать

)} ); }