362 lines
13 KiB
HTML
362 lines
13 KiB
HTML
<!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 <ваш_ключ>" \
|
||
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> |