mirror of
https://github.com/maeneko/forgetting.git
synced 2026-08-25 16:24:25 +00:00
Initial commit
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import {
|
||||
apiFetch, vpnKeyToConf, downloadFile, copyText, bytes, timeAgo,
|
||||
type User, type PageProps,
|
||||
} 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<User[]>([]);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [qrModal, setQrModal] = useState<{ name: string; dataUrl: string; vpnKey: string } | null>(null);
|
||||
const statsRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
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 = await apiFetch('GET', '/api/users', tok);
|
||||
setUsers(data.users ?? []);
|
||||
await loadStats(tok);
|
||||
} catch {
|
||||
showMsg('Ошибка загрузки');
|
||||
}
|
||||
}, [loadStats, 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 (
|
||||
<>
|
||||
<div className="toolbar">
|
||||
<input
|
||||
className="field"
|
||||
placeholder="Имя пользователя"
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && createUser()}
|
||||
/>
|
||||
<div className="toolbar-btns">
|
||||
<button className="btn btn--primary" onClick={createUser}>
|
||||
<IcoPlus /> Создать
|
||||
</button>
|
||||
<button className="btn btn--tonal" onClick={() => loadUsers(token)}>
|
||||
<IcoRefresh /> Обновить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Статус</th>
|
||||
<th>Имя</th>
|
||||
<th>IP</th>
|
||||
<th>↓ rx</th>
|
||||
<th>↑ tx</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.length === 0 ? (
|
||||
<tr><td colSpan={7} className="empty">Нет пользователей</td></tr>
|
||||
) : users.map((u, i) => (
|
||||
<tr key={u.name}>
|
||||
<td className="td-num">{i + 1}</td>
|
||||
<td>
|
||||
<span className={`chip chip--${u.online ? 'online' : 'offline'}`}>
|
||||
<span className="chip-dot" />
|
||||
{u.online
|
||||
? 'онлайн'
|
||||
: u.lastHandshake
|
||||
? timeAgo(u.lastHandshake) + ' назад'
|
||||
: 'никогда'}
|
||||
</span>
|
||||
</td>
|
||||
<td>{u.name}</td>
|
||||
<td className="td-mono">{u.ip}</td>
|
||||
<td>{bytes(u.rx)}</td>
|
||||
<td>{bytes(u.tx)}</td>
|
||||
<td className="td-actions">
|
||||
<div className="actions">
|
||||
<button
|
||||
className="btn-icon"
|
||||
title="QR-код vpn://"
|
||||
onClick={() => showQR(u.name)}
|
||||
><IcoQR /></button>
|
||||
<button
|
||||
className="btn-icon btn-icon--danger"
|
||||
title="Удалить"
|
||||
onClick={() => deleteUser(u.name)}
|
||||
><IcoTrash /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="user-cards-list">
|
||||
<p className="users-count">{users.length} пользователей</p>
|
||||
{users.length === 0 ? (
|
||||
<p className="empty-cards">Нет пользователей</p>
|
||||
) : users.map((u, i) => (
|
||||
<div className="user-card" key={u.name}>
|
||||
<div className="user-card-header">
|
||||
<div className="user-card-title">
|
||||
<span className="user-card-num">#{i + 1}</span>
|
||||
<span className="user-card-name">{u.name}</span>
|
||||
</div>
|
||||
<span className={`chip chip--${u.online ? 'online' : 'offline'}`}>
|
||||
<span className="chip-dot" />
|
||||
{u.online
|
||||
? 'онлайн'
|
||||
: u.lastHandshake
|
||||
? timeAgo(u.lastHandshake) + ' назад'
|
||||
: 'никогда'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="user-card-ip">
|
||||
<IcoGlobe />
|
||||
<span className="user-card-ip-text">{u.ip}</span>
|
||||
</div>
|
||||
<div className="user-card-metrics">
|
||||
<div className="metric-block">
|
||||
<span className="metric-label">↓ rx</span>
|
||||
<span className="metric-value">{bytes(u.rx)}</span>
|
||||
</div>
|
||||
<div className="metric-block">
|
||||
<span className="metric-label">↑ tx</span>
|
||||
<span className="metric-value">{bytes(u.tx)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="user-card-actions">
|
||||
<button
|
||||
className="btn btn--outline"
|
||||
aria-label="QR-код"
|
||||
onClick={() => showQR(u.name)}
|
||||
><IcoQR /> QR</button>
|
||||
<button
|
||||
className="btn btn--danger btn--sq"
|
||||
aria-label="Удалить"
|
||||
onClick={() => deleteUser(u.name)}
|
||||
><IcoTrash /></button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{qrModal && (
|
||||
<div className="qr-backdrop" onClick={() => setQrModal(null)}>
|
||||
<div className="qr-card" onClick={e => e.stopPropagation()}>
|
||||
<p className="qr-name">{qrModal.name}</p>
|
||||
<img
|
||||
className="qr-img"
|
||||
src={qrModal.dataUrl}
|
||||
alt="QR"
|
||||
title="Нажми чтобы скопировать vpn:// ключ"
|
||||
onClick={async () => { await copyText(qrModal.vpnKey); showMsg('Ключ скопирован'); }}
|
||||
/>
|
||||
<p className="qr-hint">Отсканируй в AmneziaVPN · нажми на QR чтобы скопировать</p>
|
||||
<div className="qr-actions">
|
||||
<button className="btn btn--tonal" onClick={async () => {
|
||||
try {
|
||||
const conf = await vpnKeyToConf(qrModal.vpnKey);
|
||||
const safe = qrModal.name.replace(/[\/\\:*?"<>|]+/g, '_').trim() || 'awg';
|
||||
downloadFile(safe + '.conf', conf);
|
||||
showMsg('Конфиг скачан');
|
||||
} catch { showMsg('Не удалось декодировать ключ'); }
|
||||
}}>Скачать .conf</button>
|
||||
<button className="btn btn--tonal" onClick={() => setQrModal(null)}>Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "users",
|
||||
"label": "Пользователи",
|
||||
"icon": "users",
|
||||
"order": 1
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/* Стили вкладки «Пользователи». Общая дизайн-система (палитра, btn, chip, field,
|
||||
table-card, toolbar, layout) остаётся в App.css — здесь только уникальное вкладки. */
|
||||
|
||||
.user-cards-list { display: none; flex-direction: column; gap: 12px; }
|
||||
|
||||
.users-count { font-size: 12px; color: var(--neutral-text); }
|
||||
.empty-cards {
|
||||
font-size: 14px;
|
||||
color: var(--neutral-text);
|
||||
text-align: center;
|
||||
padding: 48px 0;
|
||||
}
|
||||
.user-card {
|
||||
background: var(--surface-bright);
|
||||
border: 1px solid var(--outline-variant);
|
||||
border-radius: var(--r-card);
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.user-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.user-card-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.user-card-num { font-size: 12px; color: var(--neutral-dot); flex-shrink: 0; }
|
||||
.user-card-name {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--on-surface);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.user-card-ip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--neutral-text);
|
||||
}
|
||||
.user-card-ip-text { font-family: var(--mono); font-size: 13px; }
|
||||
|
||||
.user-card-metrics { display: flex; gap: 8px; }
|
||||
.metric-block {
|
||||
flex: 1;
|
||||
background: var(--surface-container);
|
||||
border-radius: var(--r-table);
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.metric-label { font-size: 12px; color: var(--neutral-text); }
|
||||
.metric-value { font-family: var(--mono); font-size: 18px; font-weight: 500; color: var(--on-surface); }
|
||||
|
||||
.user-card-actions { display: flex; gap: 8px; }
|
||||
.user-card-actions .btn--outline { flex: 1; }
|
||||
|
||||
/* На мобильной таблица скрывается (.table-card в App.css), карточки показываются. */
|
||||
@media (max-width: 640px) {
|
||||
.user-cards-list { display: flex; }
|
||||
}
|
||||
Reference in New Issue
Block a user