Files
2026-05-27 07:34:34 +08:00

640 lines
27 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="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>凭证保险库</title>
<style>
:root {
--bg: #0d0d0f; --surface: #16161a; --border: #2a2a32;
--text: #e4e4e7; --muted: #71717a; --accent: #3b82f6;
--accent-h: #2563eb; --ok: #22c55e; --err: #ef4444;
--r: 8px; --font: "Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
--mono: Consolas,"Cascadia Code",monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: var(--font); background: var(--bg); color: var(--text); min-height: 100vh; }
.wrap { max-width: 1000px; margin: 0 auto; padding: 24px 16px 48px; }
.hidden { display: none !important; }
h1 { font-size: 1.35rem; }
.sub { color: var(--muted); font-size: 0.85rem; margin-top: 4px; }
.panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r); padding: 18px; margin-bottom: 18px; }
.panel h2 { font-size: 0.75rem; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin-bottom: 14px; }
label { display: block; font-size: 0.72rem; color: var(--muted); margin-bottom: 4px; }
input, select, textarea {
width: 100%; padding: 9px 11px; background: var(--bg); border: 1px solid var(--border);
border-radius: 6px; color: var(--text); font-size: 0.82rem;
}
input, textarea { font-family: var(--mono); }
textarea { min-height: 60px; resize: vertical; }
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); }
.grid { display: grid; gap: 10px; }
@media (min-width:700px) { .grid-3 { grid-template-columns: repeat(3,1fr); } .span-all { grid-column: 1/-1; } }
.btn {
display: inline-flex; align-items: center; justify-content: center;
padding: 8px 14px; border: none; border-radius: 6px; cursor: pointer;
font-size: 0.82rem; font-weight: 500; font-family: var(--font);
}
.btn-p { background: var(--accent); color: #fff; }
.btn-p:hover { background: var(--accent-h); }
.btn-g { background: transparent; color: var(--muted); border: 1px solid var(--border); }
.btn-g:hover { color: var(--text); }
.btn-s { padding: 4px 10px; font-size: 0.72rem; background: var(--bg); border: 1px solid var(--border); color: var(--muted); }
.btn-s:hover { border-color: var(--accent); color: var(--text); }
.btn-d { color: var(--err); background: transparent; font-size: 0.72rem; padding: 4px 8px; }
.btn-s.copied { color: var(--ok); border-color: var(--ok); }
.top { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
.top-actions { display: flex; gap: 8px; }
.search-row { display: flex; gap: 10px; align-items: flex-end; flex-wrap: wrap; }
.search-row > * { flex: 1; min-width: 140px; }
.search-row .btn { flex: 0 0 auto; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r); padding: 14px; margin-bottom: 10px; }
.card-h { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid var(--border); }
.badge { font-size: 0.65rem; padding: 2px 7px; border-radius: 4px; border: 1px solid var(--border); color: var(--accent); text-transform: uppercase; }
.row { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
.row-l { width: 110px; flex-shrink: 0; font-size: 0.72rem; color: var(--muted); }
.row-v { flex: 1; font-family: var(--mono); font-size: 0.8rem; word-break: break-all; }
.row a { color: var(--accent); text-decoration: none; }
.row a:hover { text-decoration: underline; }
.empty { text-align: center; padding: 40px; color: var(--muted); border: 1px dashed var(--border); border-radius: var(--r); }
.err { color: var(--err); font-size: 0.82rem; margin-top: 8px; }
.toast { position: fixed; bottom: 20px; right: 20px; padding: 10px 14px; background: var(--surface); border: 1px solid var(--ok); color: var(--ok); border-radius: 6px; opacity: 0; transition: .2s; z-index: 99; pointer-events: none; }
.toast.on { opacity: 1; }
.login-box { max-width: 380px; margin: 80px auto; }
.type-search { margin-bottom: 12px; }
.type-list { max-height: 200px; overflow-y: auto; border: 1px solid var(--border); border-radius: 6px; }
.type-item { padding: 8px 12px; cursor: pointer; font-size: 0.82rem; border-bottom: 1px solid var(--border); }
.type-item:hover, .type-item.on { background: var(--bg); color: var(--accent); }
.field-builder { border: 1px solid var(--border); border-radius: 6px; padding: 10px; margin-top: 8px; }
.fb-row { display: grid; grid-template-columns: 1fr 1fr 100px 60px 50px; gap: 6px; margin-bottom: 6px; align-items: end; }
@media (max-width:700px) { .fb-row { grid-template-columns: 1fr 1fr; } }
.toolbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; }
.chk { display: flex; align-items: center; gap: 6px; font-size: 0.82rem; color: var(--muted); cursor: pointer; }
.chk input { width: auto; }
</style>
</head>
<body>
<div id="viewLogin" class="wrap login-box">
<h1>凭证保险库</h1>
<p class="sub" style="margin-bottom:20px">登录后管理 · 浏览器不缓存明文</p>
<div class="panel">
<form id="loginForm">
<div style="margin-bottom:12px">
<label>用户名</label>
<input type="text" id="loginUser" autocomplete="username" required>
</div>
<div style="margin-bottom:16px">
<label>密码</label>
<input type="password" id="loginPass" autocomplete="current-password" required>
</div>
<button type="submit" class="btn btn-p" style="width:100%">登录</button>
<p id="loginErr" class="err hidden"></p>
</form>
</div>
</div>
<div id="viewApp" class="wrap hidden">
<div class="top">
<div>
<h1>凭证保险库</h1>
<p class="sub">加密存储 · 查询后显示 · 复制为明文</p>
</div>
<div class="top-actions">
<button type="button" class="btn btn-g" id="btnSettings">系统设置</button>
<button type="button" class="btn btn-g" id="btnLogout">退出</button>
</div>
</div>
<section class="panel">
<h2>添加凭证</h2>
<div class="type-search">
<label>凭证类型(可搜索)</label>
<input type="text" id="addTypeSearch" placeholder="搜索:交易所、邮箱、小红书…">
<div id="addTypeList" class="type-list hidden"></div>
</div>
<p id="addTypeSelected" class="sub" style="margin:8px 0">未选择类型</p>
<form id="addForm" class="grid grid-3" style="margin-top:12px"></form>
<div class="span-all" style="margin-top:12px">
<button type="button" class="btn btn-p" id="btnAddSubmit">添加</button>
<p id="addErr" class="err hidden"></p>
</div>
</section>
<section class="panel">
<h2>查询凭证</h2>
<div class="search-row">
<div>
<label>类型筛选</label>
<input type="text" id="queryTypeSearch" placeholder="搜索类型…">
<select id="queryType" class="hidden"></select>
</div>
<div id="queryExchangeWrap" class="hidden">
<label>交易所</label>
<select id="queryExchange">
<option value="binance">Binance</option>
<option value="okx">OKX</option>
<option value="gate">Gate</option>
</select>
</div>
<div>
<label>关键词(可选)</label>
<input type="text" id="queryQ" placeholder="名称、账号、邮箱…">
</div>
<button type="button" class="btn btn-p" id="btnQuery">确认</button>
</div>
</section>
<section>
<div class="toolbar">
<span id="listCount" class="sub">未查询</span>
<label class="chk"><input type="checkbox" id="maskToggle" checked> 隐藏敏感字段</label>
</div>
<div id="listBox"><div class="empty">选择类型并点击「确认」查看</div></div>
</section>
</div>
<div id="viewSettings" class="wrap hidden">
<div class="top">
<div><h1>系统设置</h1><p class="sub">登录凭据写入 .env · 自定义凭证类型</p></div>
<button type="button" class="btn btn-g" id="btnBackApp">返回</button>
</div>
<section class="panel">
<h2>登录账号(写入 .env</h2>
<form id="authForm" class="grid grid-3">
<div><label>新用户名</label><input type="text" id="newUser" required></div>
<div><label>新密码</label><input type="password" id="newPass" required minlength="6"></div>
<div><label>当前密码确认</label><input type="password" id="curPass" required></div>
<div class="span-all"><button type="submit" class="btn btn-p">保存登录设置</button></div>
</form>
<p id="authErr" class="err hidden"></p>
</section>
<section class="panel">
<h2>自定义类型(如小红书、抖音)</h2>
<div class="type-search">
<label>搜索已有自定义类型</label>
<input type="text" id="customSearch" placeholder="搜索…">
</div>
<div id="customList" style="margin:12px 0"></div>
<hr style="border:none;border-top:1px solid var(--border);margin:16px 0">
<h2 style="margin-bottom:10px">新增类型</h2>
<div class="grid grid-3">
<div><label>类型 ID(英文)</label><input type="text" id="newTypeId" placeholder="xiaohongshu"></div>
<div><label>显示名称</label><input type="text" id="newTypeLabel" placeholder="小红书"></div>
</div>
<div class="field-builder" id="fieldBuilder">
<p class="sub" style="margin-bottom:8px">字段列表</p>
<div id="fieldRows"></div>
<button type="button" class="btn btn-g" id="btnAddField" style="margin-top:8px">+ 添加字段</button>
</div>
<button type="button" class="btn btn-p" id="btnSaveType" style="margin-top:12px">保存自定义类型</button>
<p id="typeErr" class="err hidden"></p>
</section>
</div>
<div id="toast" class="toast"></div>
<script>
const SENSITIVE = new Set(["secret", "password"]);
const EX_LABEL = { binance: "Binance", okx: "OKX", gate: "Gate" };
let allTypes = [];
let selectedAddType = null;
let masked = true;
let queryActive = false;
let displayed = [];
const $ = (id) => document.getElementById(id);
async function api(path, opts = {}) {
const res = await fetch(path, {
credentials: "same-origin",
headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
...opts,
});
const data = res.headers.get("content-type")?.includes("json") ? await res.json() : null;
if (res.status === 401) {
showLogin();
throw new Error("未登录");
}
return { res, data };
}
function toast(msg) {
const t = $("toast");
t.textContent = msg;
t.classList.add("on");
clearTimeout(toast._t);
toast._t = setTimeout(() => t.classList.remove("on"), 1800);
}
function mask(v) {
if (!masked || v == null || v === "") return v ?? "";
return "•".repeat(Math.min(String(v).length, 24));
}
async function copyText(text, btn) {
try { await navigator.clipboard.writeText(text); } catch {
const ta = document.createElement("textarea");
ta.value = text; document.body.appendChild(ta); ta.select();
document.execCommand("copy"); document.body.removeChild(ta);
}
if (btn) { btn.classList.add("copied"); btn.textContent = "已复制"; setTimeout(() => { btn.classList.remove("copied"); btn.textContent = "复制"; }, 1200); }
toast("已复制");
}
function showLogin() {
$("viewLogin").classList.remove("hidden");
$("viewApp").classList.add("hidden");
$("viewSettings").classList.add("hidden");
}
function showApp() {
$("viewLogin").classList.add("hidden");
$("viewApp").classList.remove("hidden");
$("viewSettings").classList.add("hidden");
}
function showSettings() {
$("viewApp").classList.add("hidden");
$("viewSettings").classList.remove("hidden");
loadSettingsView();
}
function fieldVisible(fd, fields) {
const when = fd.when;
if (!when) return true;
return Object.entries(when).every(([k, v]) => fields[k] === v);
}
function renderTypePicker(listEl, searchEl, onPick, currentId) {
const q = searchEl.value.trim().toLowerCase();
const filtered = allTypes.filter(t =>
!q || t.label.toLowerCase().includes(q) || t.id.includes(q)
);
listEl.innerHTML = "";
if (!filtered.length) {
listEl.classList.add("hidden");
return;
}
listEl.classList.remove("hidden");
filtered.forEach(t => {
const div = document.createElement("div");
div.className = "type-item" + (t.id === currentId ? " on" : "");
div.textContent = t.label + (t.builtin ? "" : " (自定义)");
div.onclick = () => {
onPick(t);
listEl.classList.add("hidden");
searchEl.value = t.label;
};
listEl.appendChild(div);
});
}
function readFormState(typeDef) {
const state = {};
typeDef.fields.forEach(fd => {
const inp = $("fld_" + fd.key);
if (inp) state[fd.key] = inp.value;
});
return state;
}
function syncFormVisibility(typeDef) {
const state = readFormState(typeDef);
typeDef.fields.forEach(fd => {
const wrap = document.querySelector(`#addForm [data-key="${fd.key}"]`);
if (wrap) wrap.classList.toggle("hidden", !fieldVisible(fd, state));
});
}
function buildAddForm(typeDef) {
const form = $("addForm");
form.innerHTML = "";
if (!typeDef) return;
typeDef.fields.forEach(fd => {
const wrap = document.createElement("div");
wrap.dataset.key = fd.key;
const lab = document.createElement("label");
lab.textContent = fd.label + (fd.required ? " *" : "");
let input;
if (fd.type === "select") {
input = document.createElement("select");
(fd.options || []).forEach(o => {
const opt = document.createElement("option");
opt.value = o; opt.textContent = EX_LABEL[o] || o;
input.appendChild(opt);
});
} else {
input = document.createElement("input");
input.type = fd.type === "secret" ? "password" : "text";
if (fd.type === "url") input.placeholder = "https://";
if (fd.type === "email") input.placeholder = "user@mail.com";
}
input.id = "fld_" + fd.key;
const onUpd = () => syncFormVisibility(typeDef);
input.addEventListener("input", onUpd);
input.addEventListener("change", onUpd);
wrap.appendChild(lab);
wrap.appendChild(input);
form.appendChild(wrap);
});
syncFormVisibility(typeDef);
}
function collectAddFields(typeDef) {
const state = readFormState(typeDef);
const fields = {};
typeDef.fields.forEach(fd => {
if (!fieldVisible(fd, state)) return;
fields[fd.key] = (state[fd.key] || "").trim();
});
return fields;
}
function renderValue(fd, val) {
if (!val) return document.createTextNode("—");
if (fd.type === "url" && val.startsWith("http")) {
const a = document.createElement("a");
a.href = val; a.target = "_blank"; a.rel = "noopener";
a.textContent = mask(val);
return a;
}
if (fd.type === "email" && val.includes("@")) {
const a = document.createElement("a");
a.href = "mailto:" + val;
a.textContent = mask(val);
return a;
}
const span = document.createElement("span");
span.textContent = SENSITIVE.has(fd.type) || fd.key.includes("password") || fd.key.includes("secret") || fd.key === "webhook"
? mask(val) : mask(val);
return span;
}
function renderCard(rec) {
const typeDef = allTypes.find(t => t.id === rec.type_id);
const card = document.createElement("article");
card.className = "card";
const h = document.createElement("div");
h.className = "card-h";
const left = document.createElement("div");
left.innerHTML = `<span class="badge">${typeDef?.label || rec.type_id}</span> <strong style="margin-left:8px">${mask(rec.title)}</strong>`;
const del = document.createElement("button");
del.className = "btn btn-d"; del.textContent = "删除";
del.onclick = async () => {
if (!confirm("确定删除?")) return;
await api(`/api/credentials/${rec.id}`, { method: "DELETE" });
displayed = displayed.filter(r => r.id !== rec.id);
renderList();
toast("已删除");
};
h.appendChild(left); h.appendChild(del);
card.appendChild(h);
const fields = rec.fields || {};
(typeDef?.fields || Object.keys(fields).map(k => ({ key: k, label: k, type: "text" }))).forEach(fd => {
if (!fieldVisible(fd, fields)) return;
const val = fields[fd.key];
if (val === undefined || val === "") return;
const row = document.createElement("div");
row.className = "row";
row.innerHTML = `<span class="row-l">${fd.label}</span>`;
const v = document.createElement("div");
v.className = "row-v";
if (fd.key === "web_url" && val.startsWith("http")) {
const a = document.createElement("a");
a.href = val; a.target = "_blank"; a.textContent = mask(val);
v.appendChild(a);
} else if (fd.type === "url" || (fd.key === "url" && val)) {
const url = val.startsWith("http") ? val : "https://" + val;
const a = document.createElement("a");
a.href = url; a.target = "_blank"; a.textContent = mask(val);
v.appendChild(a);
} else if (fd.type === "email" || fd.key === "email") {
const a = document.createElement("a");
a.href = "mailto:" + val; a.textContent = mask(val);
v.appendChild(a);
} else {
v.appendChild(renderValue(fd, val));
}
row.appendChild(v);
const isSec = fd.type === "secret" || fd.key.includes("password") || fd.key === "webhook" || fd.key.includes("secret");
if (isSec || ["text","phone","select"].includes(fd.type)) {
const btn = document.createElement("button");
btn.type = "button"; btn.className = "btn btn-s"; btn.textContent = "复制";
btn.onclick = () => copyText(val, btn);
row.appendChild(btn);
}
card.appendChild(row);
});
return card;
}
function renderList() {
const box = $("listBox");
box.innerHTML = "";
if (!queryActive) {
box.innerHTML = '<div class="empty">选择类型并点击「确认」查看</div>';
$("listCount").textContent = "未查询";
return;
}
const t = allTypes.find(x => x.id === $("queryType").value);
$("listCount").textContent = `${t?.label || ""} · 共 ${displayed.length}`;
if (!displayed.length) {
box.innerHTML = '<div class="empty">无匹配记录</div>';
return;
}
displayed.forEach(r => box.appendChild(renderCard(r)));
}
async function loadTypes() {
const { data } = await api("/api/settings");
allTypes = [...data.builtin_types, ...data.custom_types];
const sel = $("queryType");
sel.innerHTML = allTypes.map(t => `<option value="${t.id}">${t.label}</option>`).join("");
$("newUser").value = data.username || "";
}
$("loginForm").onsubmit = async (e) => {
e.preventDefault();
$("loginErr").classList.add("hidden");
const { res, data } = await api("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username: $("loginUser").value, password: $("loginPass").value }),
});
if (!res.ok) {
$("loginErr").textContent = data?.error || "登录失败";
$("loginErr").classList.remove("hidden");
return;
}
$("loginPass").value = "";
await initApp();
showApp();
};
$("btnLogout").onclick = async () => {
await api("/api/auth/logout", { method: "POST" });
showLogin();
};
$("btnSettings").onclick = showSettings;
$("btnBackApp").onclick = showApp;
$("addTypeSearch").onfocus = () => renderTypePicker($("addTypeList"), $("addTypeSearch"), pickAddType, selectedAddType?.id);
$("addTypeSearch").oninput = () => renderTypePicker($("addTypeList"), $("addTypeSearch"), pickAddType, selectedAddType?.id);
function pickAddType(t) {
selectedAddType = t;
$("addTypeSelected").textContent = "已选:" + t.label;
buildAddForm(t);
}
$("btnAddSubmit").onclick = async () => {
$("addErr").classList.add("hidden");
if (!selectedAddType) { $("addErr").textContent = "请先选择类型"; $("addErr").classList.remove("hidden"); return; }
const fields = collectAddFields(selectedAddType);
const { res, data } = await api("/api/credentials", {
method: "POST",
body: JSON.stringify({ type_id: selectedAddType.id, fields }),
});
if (!res.ok) { $("addErr").textContent = data?.error || "添加失败"; $("addErr").classList.remove("hidden"); return; }
$("addForm").innerHTML = "";
selectedAddType = null;
$("addTypeSearch").value = "";
$("addTypeSelected").textContent = "未选择类型";
queryActive = false;
displayed = [];
masked = true;
$("maskToggle").checked = true;
renderList();
toast("已添加,请查询查看");
};
$("queryTypeSearch").oninput = () => {
const q = $("queryTypeSearch").value.toLowerCase();
const sel = $("queryType");
sel.classList.remove("hidden");
[...sel.options].forEach(o => {
o.hidden = q && !o.text.toLowerCase().includes(q) && !o.value.includes(q);
});
};
$("queryType").onchange = () => {
$("queryExchangeWrap").classList.toggle("hidden", $("queryType").value !== "exchange");
};
$("btnQuery").onclick = async () => {
const type_id = $("queryType").value;
const q = $("queryQ").value.trim();
let url = `/api/credentials?type_id=${encodeURIComponent(type_id)}`;
if (q) url += `&q=${encodeURIComponent(q)}`;
if (type_id === "exchange") url += `&exchange=${encodeURIComponent($("queryExchange").value)}`;
const { data } = await api(url);
displayed = data;
queryActive = true;
renderList();
};
$("maskToggle").onchange = () => { masked = $("maskToggle").checked; if (queryActive) renderList(); };
$("authForm").onsubmit = async (e) => {
e.preventDefault();
$("authErr").classList.add("hidden");
const { res, data } = await api("/api/settings/auth", {
method: "PUT",
body: JSON.stringify({
username: $("newUser").value,
new_password: $("newPass").value,
current_password: $("curPass").value,
}),
});
if (!res.ok) { $("authErr").textContent = data?.error || "保存失败"; $("authErr").classList.remove("hidden"); return; }
$("curPass").value = $("newPass").value = "";
toast("登录设置已写入 .env");
};
function addFieldRow() {
const row = document.createElement("div");
row.className = "fb-row";
row.innerHTML = `
<input placeholder="key" class="fb-key">
<input placeholder="显示名" class="fb-label">
<select class="fb-type"><option value="text">文本</option><option value="secret">敏感</option><option value="url">链接</option><option value="email">邮箱</option><option value="phone">手机</option></select>
<label class="chk"><input type="checkbox" class="fb-req">必填</label>
<button type="button" class="btn btn-d fb-del">删</button>`;
row.querySelector(".fb-del").onclick = () => row.remove();
$("fieldRows").appendChild(row);
}
$("btnAddField").onclick = addFieldRow;
$("btnSaveType").onclick = async () => {
$("typeErr").classList.add("hidden");
const fields = [...$("fieldRows").querySelectorAll(".fb-row")].map(r => ({
key: r.querySelector(".fb-key").value.trim().toLowerCase(),
label: r.querySelector(".fb-label").value.trim(),
type: r.querySelector(".fb-type").value,
required: r.querySelector(".fb-req").checked,
})).filter(f => f.key);
const { res, data } = await api("/api/settings/types", {
method: "POST",
body: JSON.stringify({
id: $("newTypeId").value.trim().toLowerCase(),
label: $("newTypeLabel").value.trim(),
fields,
}),
});
if (!res.ok) { $("typeErr").textContent = data?.error || "保存失败"; $("typeErr").classList.remove("hidden"); return; }
$("newTypeId").value = $("newTypeLabel").value = "";
$("fieldRows").innerHTML = "";
addFieldRow();
await loadTypes();
loadSettingsView();
toast("类型已添加");
};
function loadSettingsView() {
const q = $("customSearch").value.toLowerCase();
const customs = allTypes.filter(t => !t.builtin && (!q || t.label.toLowerCase().includes(q) || t.id.includes(q)));
$("customList").innerHTML = customs.length ? "" : '<p class="sub">暂无自定义类型</p>';
customs.forEach(t => {
const div = document.createElement("div");
div.className = "card";
div.innerHTML = `<strong>${t.label}</strong> <code style="color:var(--muted)">${t.id}</code> · ${t.fields.length} 个字段`;
const btn = document.createElement("button");
btn.className = "btn btn-d"; btn.textContent = "删除"; btn.style.marginTop = "8px";
btn.onclick = async () => {
if (!confirm(`删除类型 ${t.label} 及其所有记录?`)) return;
await api(`/api/settings/types/${t.id}`, { method: "DELETE" });
await loadTypes();
loadSettingsView();
toast("已删除");
};
div.appendChild(btn);
$("customList").appendChild(div);
});
}
$("customSearch").oninput = loadSettingsView;
async function initApp() {
await loadTypes();
$("queryExchangeWrap").classList.toggle("hidden", $("queryType").value !== "exchange");
if (!$("fieldRows").children.length) addFieldRow();
}
async function boot() {
const { data } = await api("/api/auth/status");
if (data.logged_in) {
await initApp();
showApp();
} else showLogin();
}
boot();
</script>
</body>
</html>