mirror of
https://github.com/andrey271192/amnezia_web-PRO.git
synced 2026-09-21 14:51:59 +00:00
docs: publish install/uninstall scripts, README, FUNDING, support footer
Add curl-one-liner install with generated password file; uninstall flags for image/data/src; optional ALLOW_DEFAULT_PASSWORD; footer mirroring GitHub/Boosty/Telegram layout. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
381
public/app.js
Normal file
381
public/app.js
Normal file
@@ -0,0 +1,381 @@
|
||||
const loginGate = document.querySelector("#login-gate");
|
||||
const appRoot = document.querySelector("#app-root");
|
||||
const loginForm = document.querySelector("#login-form");
|
||||
const loginPassword = document.querySelector("#login-password");
|
||||
const loginError = document.querySelector("#login-error");
|
||||
|
||||
const logoutBtn = document.querySelector("#logout");
|
||||
const refreshBtn = document.querySelector("#refresh");
|
||||
const rowsEl = document.querySelector("#rows");
|
||||
const statusEl = document.querySelector("#status");
|
||||
const peerCountEl = document.querySelector("#peer-count");
|
||||
const wgShowEl = document.querySelector("#wg-show");
|
||||
|
||||
const pwForm = document.querySelector("#pw-form");
|
||||
const pwCurrent = document.querySelector("#pw-current");
|
||||
const pwNew = document.querySelector("#pw-new");
|
||||
const pwNew2 = document.querySelector("#pw-new2");
|
||||
const pwMsg = document.querySelector("#pw-msg");
|
||||
|
||||
const dtDialog = document.querySelector("#disconnect-dt-dialog");
|
||||
const dtTitle = document.querySelector("#dt-dialog-title");
|
||||
const dtClientEl = document.querySelector("#dt-dialog-client");
|
||||
const dtInput = document.querySelector("#dt-dialog-input");
|
||||
const dtCancel = document.querySelector("#dt-dialog-cancel");
|
||||
const dtOk = document.querySelector("#dt-dialog-ok");
|
||||
|
||||
let dtMode = "disable";
|
||||
/** @type {{ clientId: string, name: string } | null} */
|
||||
let dtClient = null;
|
||||
|
||||
function isoToDatetimeLocal(iso) {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function datetimeLocalToIso(localVal) {
|
||||
if (!localVal || !String(localVal).trim()) {
|
||||
throw new Error("Укажите дату и время");
|
||||
}
|
||||
const d = new Date(localVal);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
throw new Error("Некорректная дата");
|
||||
}
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
function openDisableDialog(c) {
|
||||
dtMode = "disable";
|
||||
dtClient = c;
|
||||
dtTitle.textContent = "Выключить клиента";
|
||||
dtClientEl.textContent = c.name;
|
||||
dtOk.textContent = "Выключить";
|
||||
dtInput.value = isoToDatetimeLocal(new Date().toISOString());
|
||||
dtDialog.showModal();
|
||||
}
|
||||
|
||||
function openEditDisconnectDialog(c) {
|
||||
dtMode = "edit";
|
||||
dtClient = c;
|
||||
dtTitle.textContent = "Дата последнего отключения";
|
||||
dtClientEl.textContent = c.name;
|
||||
dtOk.textContent = "Сохранить";
|
||||
const iso =
|
||||
c.lastDisconnectedAt || (!c.activeInConf && c.disabledAt) || new Date().toISOString();
|
||||
dtInput.value = isoToDatetimeLocal(iso);
|
||||
dtDialog.showModal();
|
||||
}
|
||||
|
||||
dtCancel.addEventListener("click", () => {
|
||||
dtDialog.close();
|
||||
dtClient = null;
|
||||
});
|
||||
|
||||
dtOk.addEventListener("click", async () => {
|
||||
if (!dtClient) return;
|
||||
let iso;
|
||||
try {
|
||||
iso = datetimeLocalToIso(dtInput.value);
|
||||
} catch (e) {
|
||||
setStatus(String(e.message || e), true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (dtMode === "disable") {
|
||||
setStatus("Выполняю…", false);
|
||||
await api("/api/clients/disable", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ clientId: dtClient.clientId, disconnectedAt: iso }),
|
||||
});
|
||||
} else {
|
||||
setStatus("Сохраняю дату…", false);
|
||||
await api("/api/clients/disconnect-date", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ clientId: dtClient.clientId, disconnectedAt: iso }),
|
||||
});
|
||||
}
|
||||
dtDialog.close();
|
||||
dtClient = null;
|
||||
setStatus("Готово.", false);
|
||||
await loadClients();
|
||||
} catch (e) {
|
||||
setStatus(String(e.message || e), true);
|
||||
}
|
||||
});
|
||||
|
||||
function showLogin() {
|
||||
loginGate.classList.remove("hidden");
|
||||
loginGate.setAttribute("aria-hidden", "false");
|
||||
appRoot.classList.add("hidden");
|
||||
}
|
||||
|
||||
function showApp() {
|
||||
loginGate.classList.add("hidden");
|
||||
loginGate.setAttribute("aria-hidden", "true");
|
||||
appRoot.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function setStatus(text, isErr) {
|
||||
statusEl.textContent = text || "";
|
||||
statusEl.classList.toggle("err", Boolean(isErr));
|
||||
}
|
||||
|
||||
function setPwMsg(text, isErr) {
|
||||
pwMsg.textContent = text || "";
|
||||
pwMsg.classList.toggle("err", Boolean(isErr));
|
||||
}
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
const res = await fetch(path, {
|
||||
...opts,
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...opts.headers,
|
||||
},
|
||||
});
|
||||
const text = await res.text();
|
||||
let data;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
data = { raw: text };
|
||||
}
|
||||
if (!res.ok) {
|
||||
const msg = data.error || data.raw || res.statusText;
|
||||
throw new Error(msg);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function checkSession() {
|
||||
try {
|
||||
await api("/api/session");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
loginForm.addEventListener("submit", async (ev) => {
|
||||
ev.preventDefault();
|
||||
loginError.textContent = "";
|
||||
try {
|
||||
await api("/api/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password: loginPassword.value }),
|
||||
});
|
||||
loginPassword.value = "";
|
||||
showApp();
|
||||
await loadClients();
|
||||
} catch (e) {
|
||||
loginError.textContent = String(e.message || e);
|
||||
}
|
||||
});
|
||||
|
||||
logoutBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await api("/api/logout", { method: "POST", body: JSON.stringify({}) });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
showLogin();
|
||||
loginPassword.focus();
|
||||
});
|
||||
|
||||
refreshBtn.addEventListener("click", () => {
|
||||
loadClients();
|
||||
});
|
||||
|
||||
const dtRu = new Intl.DateTimeFormat("ru-RU", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
});
|
||||
|
||||
function formatLastDisconnect(c) {
|
||||
const iso =
|
||||
c.lastDisconnectedAt ||
|
||||
(!c.activeInConf && c.disabledAt) ||
|
||||
null;
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
return dtRu.format(d);
|
||||
}
|
||||
|
||||
pwForm.addEventListener("submit", async (ev) => {
|
||||
ev.preventDefault();
|
||||
setPwMsg("", false);
|
||||
if (pwNew.value !== pwNew2.value) {
|
||||
setPwMsg("Новый пароль и повтор не совпадают.", true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await api("/api/change-password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
currentPassword: pwCurrent.value,
|
||||
newPassword: pwNew.value,
|
||||
}),
|
||||
});
|
||||
pwCurrent.value = "";
|
||||
pwNew.value = "";
|
||||
pwNew2.value = "";
|
||||
setPwMsg(data.message || "Готово.", false);
|
||||
showLogin();
|
||||
loginPassword.focus();
|
||||
} catch (e) {
|
||||
setPwMsg(String(e.message || e), true);
|
||||
}
|
||||
});
|
||||
|
||||
function renderRows(clients) {
|
||||
rowsEl.innerHTML = "";
|
||||
clients.forEach((c) => {
|
||||
const tr = document.createElement("tr");
|
||||
|
||||
const nameTd = document.createElement("td");
|
||||
const nameWrap = document.createElement("div");
|
||||
nameWrap.className = "name-cell";
|
||||
const strong = document.createElement("strong");
|
||||
strong.textContent = c.name;
|
||||
const renameWrap = document.createElement("div");
|
||||
renameWrap.className = "rename-inline";
|
||||
renameWrap.appendChild(
|
||||
btn("Переименовать", "btn small ghost", () => void renameClient(c))
|
||||
);
|
||||
nameWrap.append(strong, renameWrap);
|
||||
nameTd.appendChild(nameWrap);
|
||||
|
||||
const ipTd = document.createElement("td");
|
||||
ipTd.innerHTML = `<span class="ip">${escapeHtml(c.allowedIps || "—")}</span>`;
|
||||
|
||||
const stTd = document.createElement("td");
|
||||
const badge = document.createElement("span");
|
||||
badge.className = `badge ${c.activeInConf ? "on" : "off"}`;
|
||||
badge.textContent = c.activeInConf ? "В туннеле" : "Выключен";
|
||||
stTd.appendChild(badge);
|
||||
|
||||
const offTd = document.createElement("td");
|
||||
offTd.className = "date-cell";
|
||||
const dateLine = document.createElement("div");
|
||||
dateLine.textContent = formatLastDisconnect(c);
|
||||
const dtWrap = document.createElement("div");
|
||||
dtWrap.className = "rename-inline";
|
||||
dtWrap.appendChild(
|
||||
btn("Задать дату", "btn small ghost", () => openEditDisconnectDialog(c))
|
||||
);
|
||||
offTd.append(dateLine, dtWrap);
|
||||
|
||||
const actTd = document.createElement("td");
|
||||
actTd.className = "actions";
|
||||
|
||||
if (c.activeInConf) {
|
||||
actTd.appendChild(btn("Выключить", "btn small ghost", () => openDisableDialog(c)));
|
||||
} else {
|
||||
actTd.appendChild(
|
||||
btn("Включить", "btn small primary", () => mutate("/api/clients/enable", c.clientId))
|
||||
);
|
||||
}
|
||||
actTd.appendChild(btn("Удалить", "btn small warn", () => confirmDelete(c.name, c.clientId)));
|
||||
|
||||
tr.append(nameTd, ipTd, stTd, offTd, actTd);
|
||||
rowsEl.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function btn(label, cls, onClick) {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = cls;
|
||||
b.textContent = label;
|
||||
b.addEventListener("click", onClick);
|
||||
return b;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
async function renameClient(c) {
|
||||
const next = prompt(`Новое имя для «${c.name}»:`, c.name);
|
||||
if (next === null) return;
|
||||
const trimmed = next.trim().replace(/\s+/g, " ");
|
||||
if (!trimmed) {
|
||||
setStatus("Имя не может быть пустым", true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setStatus("Сохраняю имя…", false);
|
||||
await api("/api/clients/rename", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ clientId: c.clientId, name: trimmed }),
|
||||
});
|
||||
setStatus("Готово.", false);
|
||||
await loadClients();
|
||||
} catch (e) {
|
||||
setStatus(String(e.message || e), true);
|
||||
}
|
||||
}
|
||||
|
||||
async function mutate(path, clientId) {
|
||||
try {
|
||||
setStatus("Выполняю…", false);
|
||||
await api(path, { method: "POST", body: JSON.stringify({ clientId }) });
|
||||
setStatus("Готово.", false);
|
||||
await loadClients();
|
||||
} catch (e) {
|
||||
setStatus(String(e.message || e), true);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete(name, clientId) {
|
||||
const ok = confirm(
|
||||
`Удалить клиента «${name}»? Конфиг из приложения Amnezia перестанет совпадать с сервером.`
|
||||
);
|
||||
if (!ok) return;
|
||||
await mutate("/api/clients/delete", clientId);
|
||||
}
|
||||
|
||||
async function loadClients() {
|
||||
try {
|
||||
setStatus("Загрузка…", false);
|
||||
const data = await api("/api/clients");
|
||||
peerCountEl.textContent = `${data.clients.length} в таблице · ${data.peerCount} peer в awg0.conf`;
|
||||
wgShowEl.textContent = data.wgShow || "";
|
||||
renderRows(data.clients);
|
||||
setStatus("", false);
|
||||
} catch (e) {
|
||||
const msg = String(e.message || e);
|
||||
if (msg.includes("Unauthorized")) {
|
||||
showLogin();
|
||||
setStatus("", false);
|
||||
loginError.textContent = "Сессия истекла — войдите снова.";
|
||||
return;
|
||||
}
|
||||
setStatus(msg, true);
|
||||
rowsEl.innerHTML = "";
|
||||
wgShowEl.textContent = "";
|
||||
peerCountEl.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
const ok = await checkSession();
|
||||
if (ok) {
|
||||
showApp();
|
||||
await loadClients();
|
||||
} else {
|
||||
showLogin();
|
||||
loginPassword.focus();
|
||||
}
|
||||
}
|
||||
|
||||
boot();
|
||||
120
public/index.html
Normal file
120
public/index.html
Normal file
@@ -0,0 +1,120 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>AmneziaWG — клиенты</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;600;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body class="page">
|
||||
<div id="login-gate" class="gate hidden" aria-hidden="true">
|
||||
<div class="gate-card">
|
||||
<p class="eyebrow">Панель сервера</p>
|
||||
<h1>Вход</h1>
|
||||
<p class="sub">Введите пароль администратора панели.</p>
|
||||
<form id="login-form">
|
||||
<label for="login-password">Пароль</label>
|
||||
<input id="login-password" type="password" autocomplete="current-password" required>
|
||||
<button type="submit" class="btn primary full">Войти</button>
|
||||
<p id="login-error" class="status err" role="alert"></p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="app-root" class="shell hidden">
|
||||
<header class="top">
|
||||
<div>
|
||||
<p class="eyebrow">Панель сервера</p>
|
||||
<h1>Пользователи AmneziaWG</h1>
|
||||
<p class="sub">Дату отключения вы задаёте сами: при нажатии «Выключить» или кнопкой «Задать дату» в колонке «Последнее отключение». Время берётся как локальное на вашем компьютере.</p>
|
||||
</div>
|
||||
<div class="token-box">
|
||||
<div class="session-actions">
|
||||
<button type="button" id="logout" class="btn ghost full">Выйти</button>
|
||||
</div>
|
||||
<details class="pw-change">
|
||||
<summary>Сменить пароль</summary>
|
||||
<form id="pw-form">
|
||||
<label for="pw-current">Текущий пароль</label>
|
||||
<input id="pw-current" type="password" autocomplete="current-password" required>
|
||||
<label for="pw-new">Новый пароль</label>
|
||||
<input id="pw-new" type="password" autocomplete="new-password" required minlength="8">
|
||||
<label for="pw-new2">Повтор нового пароля</label>
|
||||
<input id="pw-new2" type="password" autocomplete="new-password" required minlength="8">
|
||||
<button type="submit" class="btn primary full">Сохранить новый пароль</button>
|
||||
<p id="pw-msg" class="status" role="status"></p>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="toolbar">
|
||||
<div class="pill"><span class="dot ok"></span><span id="proto-label">Протокол: AmneziaWG</span></div>
|
||||
<button type="button" id="refresh" class="btn primary">Обновить</button>
|
||||
</section>
|
||||
|
||||
<p id="status" class="status" role="status"></p>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Пользователи</h2>
|
||||
<span class="muted" id="peer-count"></span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Имя</th>
|
||||
<th>IP</th>
|
||||
<th>Статус</th>
|
||||
<th>Последнее отключение</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<details class="raw">
|
||||
<summary>Вывод awg show (отладка)</summary>
|
||||
<pre id="wg-show"></pre>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<footer class="support-footer">
|
||||
<div class="support-line">
|
||||
<strong class="support-title">Amnezia Admin WebUI</strong>
|
||||
<span class="support-sep" aria-hidden="true">·</span>
|
||||
<a href="https://github.com/andrey271192/amnezia-admin" target="_blank" rel="noopener noreferrer">⭐ GitHub</a>
|
||||
<span class="support-sep" aria-hidden="true">·</span>
|
||||
<a href="https://boosty.to/lot_andrey" target="_blank" rel="noopener noreferrer">💖 Boosty</a>
|
||||
<span class="support-sep" aria-hidden="true">·</span>
|
||||
<a href="https://github.com/andrey271192/amnezia-admin/blob/main/README.md#support-links" target="_blank" rel="noopener noreferrer">💳 Ozon СБП</a>
|
||||
<span class="support-sep" aria-hidden="true">·</span>
|
||||
<a href="https://t.me/lot_andrey" target="_blank" rel="noopener noreferrer">✉️ Telegram @lot_andrey</a>
|
||||
</div>
|
||||
<p class="support-blurb">
|
||||
Поддержать проект — поставь звезду на GitHub или донат. Связаться с автором — Telegram.
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
<dialog id="disconnect-dt-dialog" class="dt-dialog">
|
||||
<div class="dt-dialog-inner">
|
||||
<h3 id="dt-dialog-title">Дата отключения</h3>
|
||||
<p class="muted" id="dt-dialog-client"></p>
|
||||
<label for="dt-dialog-input">Дата и время</label>
|
||||
<input type="datetime-local" id="dt-dialog-input" step="60">
|
||||
<div class="dt-dialog-actions">
|
||||
<button type="button" class="btn ghost" id="dt-dialog-cancel">Отмена</button>
|
||||
<button type="button" class="btn primary" id="dt-dialog-ok">Подтвердить</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script src="/app.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
501
public/styles.css
Normal file
501
public/styles.css
Normal file
@@ -0,0 +1,501 @@
|
||||
:root {
|
||||
--bg: #070b10;
|
||||
--card: #111722;
|
||||
--line: rgba(255, 255, 255, 0.08);
|
||||
--text: #f4f7ff;
|
||||
--muted: #94a3b8;
|
||||
--accent: #7dd3fc;
|
||||
--danger: #fb7185;
|
||||
--ok: #4ade80;
|
||||
--shadow: 0 24px 80px rgba(0, 0, 0, 0.45);
|
||||
font-family: "DM Sans", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body.page {
|
||||
margin: 0;
|
||||
background: radial-gradient(circle at 20% 20%, rgba(125, 211, 252, 0.08), transparent 35%),
|
||||
radial-gradient(circle at 80% 0%, rgba(94, 234, 212, 0.06), transparent 40%),
|
||||
var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.shell {
|
||||
flex: 1 0 auto;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: clamp(1.5rem, 4vw, 2.5rem) clamp(1rem, 3vw, 1.5rem) 3rem;
|
||||
}
|
||||
|
||||
.top {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@media (min-width: 880px) {
|
||||
.top {
|
||||
grid-template-columns: 2fr 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.72rem;
|
||||
color: var(--accent);
|
||||
margin: 0 0 0.35rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: clamp(1.6rem, 4vw, 2rem);
|
||||
}
|
||||
|
||||
.sub {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
max-width: 62ch;
|
||||
}
|
||||
|
||||
.token-box {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 1rem;
|
||||
background: #0c121b;
|
||||
}
|
||||
|
||||
.token-box label {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.token-hint {
|
||||
margin: 0 0 0.65rem;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.token-hint code.inline {
|
||||
font-family: "JetBrains Mono", ui-monospace, monospace;
|
||||
font-size: 0.78rem;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.gate {
|
||||
flex: 1;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.gate-card {
|
||||
width: min(420px, 100%);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 1.35rem 1.25rem 1.5rem;
|
||||
background: var(--card);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.gate-card h1 {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.gate-card .sub {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.gate-card label {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.gate-card input {
|
||||
width: 100%;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
background: #0a0f16;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.btn.full {
|
||||
width: 100%;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.session-actions {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.pw-change {
|
||||
margin-top: 0.25rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.pw-change summary {
|
||||
cursor: pointer;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pw-change form {
|
||||
margin-top: 0.75rem;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.pw-change label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.pw-change input {
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
background: #0a0f16;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.token-box input {
|
||||
width: 100%;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
background: #0a0f16;
|
||||
color: var(--text);
|
||||
font-family: "JetBrains Mono", ui-monospace, monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
margin-top: 1.25rem;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.45rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.dot.ok {
|
||||
background: var(--ok);
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 11px;
|
||||
padding: 0.55rem 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: linear-gradient(135deg, #38bdf8, #22d3ee);
|
||||
color: #041018;
|
||||
}
|
||||
|
||||
.btn.ghost {
|
||||
border-color: var(--line);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.token-box .btn.ghost {
|
||||
margin-top: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rename-inline .btn.small {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.btn.warn {
|
||||
border-color: rgba(251, 113, 133, 0.35);
|
||||
background: rgba(251, 113, 133, 0.08);
|
||||
color: #fecdd3;
|
||||
}
|
||||
|
||||
.btn.small {
|
||||
padding: 0.35rem 0.65rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
min-height: 1.25rem;
|
||||
color: var(--muted);
|
||||
margin: 0.75rem 0 0;
|
||||
}
|
||||
|
||||
.status.err {
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.panel {
|
||||
margin-top: 1rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
background: var(--card);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.1rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.panel-head h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0.85rem 1rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.name-cell strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.name-cell .rename-inline {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.date-cell {
|
||||
font-size: 0.88rem;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ip {
|
||||
font-family: "JetBrains Mono", ui-monospace, monospace;
|
||||
font-size: 0.85rem;
|
||||
color: #dbeafe;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.25rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.82rem;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.badge.on {
|
||||
border-color: rgba(74, 222, 128, 0.35);
|
||||
color: #bbf7d0;
|
||||
background: rgba(74, 222, 128, 0.08);
|
||||
}
|
||||
|
||||
.badge.off {
|
||||
border-color: rgba(148, 163, 184, 0.35);
|
||||
color: #e2e8f0;
|
||||
background: rgba(148, 163, 184, 0.06);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.raw {
|
||||
margin-top: 1.25rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.raw summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.raw pre {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
background: #0a0f16;
|
||||
border: 1px solid var(--line);
|
||||
padding: 0.75rem;
|
||||
border-radius: 12px;
|
||||
color: #cbd5f5;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.dt-dialog {
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
padding: 0;
|
||||
background: var(--card);
|
||||
color: var(--text);
|
||||
max-width: min(420px, 92vw);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.dt-dialog::backdrop {
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.dt-dialog-inner {
|
||||
padding: 1.25rem 1.35rem 1.35rem;
|
||||
}
|
||||
|
||||
.dt-dialog-inner h3 {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.dt-dialog-inner label {
|
||||
display: block;
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted);
|
||||
margin: 0.75rem 0 0.35rem;
|
||||
}
|
||||
|
||||
.dt-dialog-inner input[type="datetime-local"] {
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
background: #0a0f16;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.dt-dialog-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dt-dialog-actions .btn {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.support-footer {
|
||||
flex-shrink: 0;
|
||||
margin-top: auto;
|
||||
padding: 1.25rem clamp(1rem, 3vw, 1.5rem) 1.5rem;
|
||||
border-top: 1px solid var(--line);
|
||||
background: rgba(10, 15, 22, 0.92);
|
||||
}
|
||||
|
||||
.support-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem 0.5rem;
|
||||
font-size: 0.92rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.support-title {
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.support-sep {
|
||||
opacity: 0.55;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.support-line a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.support-line a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.support-blurb {
|
||||
margin: 0.65rem 0 0;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
color: var(--muted);
|
||||
max-width: 62rem;
|
||||
}
|
||||
Reference in New Issue
Block a user