Files

362 lines
13 KiB
HTML
Raw Permalink Normal View History

2026-04-15 11:38:26 +03:00
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NanoRouter — Профиль</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<div class="header-left">
<svg class="logo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
<h1>NanoRouter</h1>
</div>
<div class="header-right">
</div>
</header>
<nav class="tab-nav">
<a href="/home.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
Главная
</a>
<a href="/ifaces.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
Интерфейсы
</a>
<a href="/dhcp.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
DHCP сервер
</a>
<a href="/clients.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<circle cx="9" cy="7" r="4"/><path d="M3 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75M21 21v-2a4 4 0 0 0-3-3.87"/>
</svg>
Клиенты
</a>
<a href="/firewall.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
<path d="M9 12l2 2 4-4"/>
</svg>
Файрвол
</a>
<a href="/proxy.html" class="tab-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<circle cx="12" cy="12" r="3"/>
<path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/>
</svg>
Прокси
</a>
<a href="/profile.html" class="tab-link active">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
Профиль
</a>
</nav>
<main class="profile-main">
<div id="defaultWarning" class="alert alert-error hidden" style="margin-bottom:20px">
<div>
<strong>Внимание!</strong> Используется пароль по умолчанию (admin:admin). Задайте собственный пароль в профиле для безопасности.
</div>
</div>
<div class="profile-grid">
<!-- Credentials -->
<div class="dash-card">
<h3 class="dash-card-title">Учётные данные</h3>
<form id="credForm" class="profile-form">
<div class="form-row">
<label>Логин</label>
<input type="text" id="profUsername" placeholder="admin" autocomplete="off">
</div>
<div class="form-row">
<label>Текущий пароль</label>
<input type="password" id="profOldPassword" placeholder="Текущий пароль" autocomplete="off">
</div>
<div class="form-row">
<label>Новый пароль</label>
<input type="password" id="profNewPassword" placeholder="Новый пароль" autocomplete="new-password">
</div>
<div class="form-row">
<label>Подтверждение пароля</label>
<input type="password" id="profNewPassword2" placeholder="Повторите новый пароль" autocomplete="new-password">
</div>
<button type="submit" class="btn btn-primary">Сохранить</button>
</form>
</div>
<!-- API Key -->
<div class="dash-card">
<h3 class="dash-card-title">API ключ</h3>
<div id="apiKeySection">
<div id="noApiKey" class="profile-no-key hidden">
<p class="profile-hint">API ключ не создан. Сгенерируйте его для доступа к API без авторизации через браузер.</p>
<button id="genApiKeyBtn" class="btn btn-primary">Сгенерировать API ключ</button>
</div>
<div id="hasApiKey" class="hidden">
<div class="api-key-display">
<div class="api-key-row">
<span class="api-key-label">Ключ:</span>
<code id="apiKeyValue" class="api-key-val"></code>
<button class="btn-icon" id="copyApiKey" title="Копировать">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</button>
</div>
<div class="api-key-row">
<span class="api-key-label">Префикс:</span>
<code id="apiKeyPrefix" class="api-key-val"></code>
</div>
</div>
<div class="profile-section-gap"></div>
<button id="revokeApiKeyBtn" class="btn btn-danger btn-sm">Отозвать ключ</button>
<div class="profile-section-gap"></div>
<div class="profile-api-info">
<h4>Использование API ключа</h4>
<p>Передайте ключ в заголовке <code>Authorization</code> в формате <code>Bearer</code>:</p>
<pre class="profile-code">curl -H "Authorization: Bearer &lt;ваш_ключ&gt;" \
http://router:8080/api/interfaces</pre>
<p>Все endpoints API доступны с этим ключом. Ключ одноразовый — после отзыва нужно сгенерировать новый.</p>
</div>
</div>
</div>
</div>
</div>
<!-- Logout -->
<div class="profile-logout">
<button id="logoutBtn" class="btn btn-ghost">Выйти из системы</button>
</div>
</main>
<div id="toast" class="toast hidden"></div>
<script>
(function() {
const $ = id => document.getElementById(id);
let toastTimer;
function showToast(msg, type) {
const t = $('toast');
t.textContent = msg;
t.className = 'toast ' + type;
t.classList.remove('hidden');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.add('hidden'), 3500);
}
async function api(method, path, body) {
const opts = { method, headers: body ? { 'Content-Type': 'application/json' } : {} };
if (body) opts.body = JSON.stringify(body);
const res = await fetch(path, opts);
const json = await res.json();
if (!res.ok || !json.success) throw new Error(json.error || 'HTTP ' + res.status);
return json.data;
}
async function sha256hex(str) {
const enc = new TextEncoder();
const data = enc.encode(str);
const hash = await crypto.subtle.digest('SHA-256', data);
return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join('');
}
async function hashPassword(password) {
let salt = 'nano-router-salt-v1';
for (let i = 0; i < 10000; i++) {
const data = salt + password + i.toString();
const hash = await sha256hex(data);
salt = hash.substring(0, 32);
}
const finalHash = await sha256hex(salt + password);
return finalHash;
}
async function loadProfile() {
try {
const data = await api('GET', '/api/auth/profile');
$('profUsername').value = data.username || 'admin';
if (data.default_password) {
$('defaultWarning').classList.remove('hidden');
} else {
$('defaultWarning').classList.add('hidden');
}
if (data.has_api_key) {
$('noApiKey').classList.add('hidden');
$('hasApiKey').classList.remove('hidden');
$('apiKeyPrefix').textContent = data.api_key_prefix || '';
} else {
$('noApiKey').classList.remove('hidden');
$('hasApiKey').classList.add('hidden');
}
} catch (e) {
showToast('Ошибка загрузки профиля: ' + e.message, 'error');
}
}
$('credForm').addEventListener('submit', async (e) => {
e.preventDefault();
const username = $('profUsername').value.trim();
const oldPassword = $('profOldPassword').value;
const newPassword = $('profNewPassword').value;
const newPassword2 = $('profNewPassword2').value;
if (newPassword && !oldPassword) {
showToast('Введите текущий пароль', 'error');
return;
}
if (newPassword && newPassword !== newPassword2) {
showToast('Пароли не совпадают', 'error');
return;
}
try {
const body = { username };
if (newPassword) {
body.old_password = oldPassword;
body.new_password = newPassword;
body.new_password2 = newPassword2;
}
const data = await api('POST', '/api/auth/profile', body);
showToast('Настройки сохранены', 'success');
$('profOldPassword').value = '';
$('profNewPassword').value = '';
$('profNewPassword2').value = '';
if (data.default_password) {
$('defaultWarning').classList.remove('hidden');
} else {
$('defaultWarning').classList.add('hidden');
}
} catch (err) {
showToast('Ошибка: ' + err.message, 'error');
}
});
$('genApiKeyBtn').addEventListener('click', async () => {
try {
const data = await api('POST', '/api/auth/api-key');
$('apiKeyValue').textContent = data.api_key;
$('apiKeyPrefix').textContent = data.api_key.substring(0, 8) + '...';
$('noApiKey').classList.add('hidden');
$('hasApiKey').classList.remove('hidden');
showToast('API ключ создан', 'success');
} catch (err) {
showToast('Ошибка: ' + err.message, 'error');
}
});
$('copyApiKey').addEventListener('click', () => {
const key = $('apiKeyValue').textContent;
if (key) {
navigator.clipboard.writeText(key).then(() => {
showToast('Ключ скопирован', 'success');
});
}
});
$('revokeApiKeyBtn').addEventListener('click', async () => {
if (!confirm('Отозвать API ключ? Это действие необратимо.')) return;
try {
await api('DELETE', '/api/auth/api-key');
$('noApiKey').classList.remove('hidden');
$('hasApiKey').classList.add('hidden');
showToast('API ключ отозван', 'success');
} catch (err) {
showToast('Ошибка: ' + err.message, 'error');
}
});
$('logoutBtn').addEventListener('click', async () => {
try {
await fetch('/api/auth/logout', { method: 'POST' });
} catch (_) {}
window.location.href = '/login.html';
});
loadProfile();
})();
</script>
<style>
.profile-main { padding: 28px; max-width: var(--max-w); margin: 0 auto; }
.profile-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
@media (max-width: 768px) {
.profile-grid { grid-template-columns: 1fr; }
}
.profile-form { display: flex; flex-direction: column; gap: 14px; padding: 0; }
.profile-no-key { text-align: center; padding: 20px 0; }
.profile-hint { color: var(--muted); font-size: .85rem; margin-bottom: 16px; line-height: 1.5; }
.profile-logout { margin-top: 24px; text-align: center; }
.profile-api-info {
background: rgba(0,0,0,0.2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 14px 18px;
font-size: .85rem;
line-height: 1.6;
color: var(--text-dim);
}
.profile-api-info h4 { color: var(--accent); margin: 0 0 8px; font-size: .9rem; }
.profile-api-info code {
font-family: "JetBrains Mono", monospace;
background: rgba(0,0,0,.3);
padding: 2px 6px;
border-radius: 4px;
font-size: .82rem;
}
.profile-code {
background: var(--bg-deep);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 14px;
font-family: "JetBrains Mono", monospace;
font-size: .82rem;
overflow-x: auto;
margin: 10px 0;
color: var(--text);
}
.api-key-display { display: flex; flex-direction: column; gap: 8px; }
.api-key-row { display: flex; align-items: center; gap: 10px; }
.api-key-label { font-size: .8rem; color: var(--muted); min-width: 60px; }
.api-key-val {
font-family: "JetBrains Mono", monospace;
font-size: .85rem;
color: var(--accent);
background: rgba(0,212,255,0.06);
padding: 6px 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
word-break: break-all;
user-select: all;
flex: 1;
}
.profile-section-gap { height: 16px; }
</style>
</body>
</html>