Files
alpine-router/public/profile.html
2026-04-15 11:38:26 +03:00

362 lines
13 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>