feat(sirius-x): admin TLD hide/unhide + tabbed sign-in modal (4 direct actions)
Two features:
1. Operator can hide TLDs from the public /api/tlds listing so hidden
TLDs stop appearing in name-search UIs. On-chain registrations
under a hidden TLD keep resolving — this is a UX filter, not
enforcement.
Gateway (public-gateway.mjs):
- Persistent HIDDEN_TLDS set backed by hidden-tlds.json next to
the service script
- GET /api/tld-visibility -> {hidden:[...]} (public)
- POST /api/tld-visibility -> updates the list (operator-gated by
Bearer BNS_OPERATOR_TOKEN env var; if unset, all writes refused
so we default-deny)
- /api/tlds filters out HIDDEN_TLDS; add ?include_hidden=1 to see
everything (used by the admin panel to show all rows)
- Operator token installed via systemd override on the VPS
Admin panel:
- New 'Operator token' card at the top of the TLD-registry section;
token stored in sessionStorage (not localStorage) so a full
browser close forgets it
- Each TLD row got a 'Hidden from public' checkbox that POSTs on
toggle and refreshes the table; failures roll back the checkbox
and surface the error next to the token field
2. Wallet dropdown restored to 4 direct actions
(Unlock / Create a wallet / Import a wallet / WizardConnect) and
the shared mint/sign-in modal grew a tab strip so users can switch
between the four wallet actions from any step without going back
to a choice screen.
register-flow.js:
- renderTabs(active) prepended to stepCreate/stepImport/stepUnlock/
stepExternal when signInOnly is set. Unlock tab only appears
when a saved wallet exists.
- Delegated click handler on the sheet routes tab clicks to the
matching step; switching away from a live WC session tears it
down first so we don't leak WebSockets.
profile-menu.js:
- Restored 4-item onboarding menu (Create/Import/WC plus Unlock
when saved). Each item is a direct entry point; the tabbed modal
lets the user pivot to any other option without closing.
Cache-buster bumped on all 8 sirius-x pages to ?v=20260908tabs.
This commit is contained in:
parent
e19226cbbb
commit
109e9e7351
9 changed files with 167 additions and 34 deletions
100
admin/index.html
100
admin/index.html
|
|
@ -150,6 +150,17 @@
|
|||
|
||||
<section id="registry">
|
||||
<h2>TLD registry</h2>
|
||||
<p class="tag" style="max-width:none;font-size:.92rem">Every TLD registered on the beacon. Operators
|
||||
(signed-in) can toggle a TLD's visibility on the public /api/tlds listing — hidden TLDs stop
|
||||
appearing in the name-search UIs, but on-chain registrations under them keep resolving.</p>
|
||||
<div class="card" id="op-token-card" style="margin-top:.8rem">
|
||||
<label>Operator token <span class="field-hint">stored per browser tab, not sent anywhere except the /api/tld-visibility POST</span></label>
|
||||
<input type="password" id="op-token" placeholder="paste BNS_OPERATOR_TOKEN">
|
||||
<div class="row">
|
||||
<button class="btn ghost small" id="op-token-save">Save token</button>
|
||||
<span id="op-token-msg" class="dim"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status" id="registry-status">Loading current registry…</div>
|
||||
<div id="registry-table"></div>
|
||||
</section>
|
||||
|
|
@ -222,7 +233,26 @@ async function enterAdmin() {
|
|||
}
|
||||
}
|
||||
|
||||
// ---------- TLD registry (always visible, signed-in or not) ----------
|
||||
// ---------- operator token ----------
|
||||
// Stored in sessionStorage so refreshing the tab keeps the token for the
|
||||
// session but a full browser close forgets it. Not localStorage — the
|
||||
// weaker persistence is the right default for a POST-write credential.
|
||||
const opToken = () => { try { return sessionStorage.getItem("bnsOperatorToken") || ""; } catch { return ""; } };
|
||||
function setOpTokenMsg(text, ok) {
|
||||
const el = $("op-token-msg"); el.textContent = text;
|
||||
el.style.color = ok === true ? "var(--ok)" : ok === false ? "var(--taken)" : "var(--dim)";
|
||||
}
|
||||
$("op-token").value = opToken();
|
||||
$("op-token-save").addEventListener("click", () => {
|
||||
try { sessionStorage.setItem("bnsOperatorToken", $("op-token").value.trim()); } catch {}
|
||||
setOpTokenMsg(opToken() ? "saved for this session" : "cleared", opToken() ? true : null);
|
||||
});
|
||||
|
||||
// ---------- TLD registry with hide/unhide ----------
|
||||
// Fetches BOTH /api/tlds?include_hidden=1 (so we see the hidden ones the
|
||||
// public listing filters out) and the current /api/tld-visibility. Renders
|
||||
// one row per TLD with a checkbox for the hidden flag — flipping it POSTs
|
||||
// to /api/tld-visibility with the operator token.
|
||||
async function loadRegistry() {
|
||||
const status = $("registry-status");
|
||||
const table = $("registry-table");
|
||||
|
|
@ -230,22 +260,64 @@ async function loadRegistry() {
|
|||
status.textContent = "Loading current registry from the chain…";
|
||||
table.innerHTML = "";
|
||||
try {
|
||||
const r = await fetch("https://navigate.st/api/tlds");
|
||||
if (!r.ok) throw new Error("API " + r.status);
|
||||
const body = await r.json();
|
||||
// /api/tlds returns { source, root, count, legend, tlds: [{tld, certificate:{category, owner, regHeight,...}, records}] }
|
||||
const tlds = body.tlds || [];
|
||||
const [rTlds, rVis] = await Promise.all([
|
||||
fetch("https://silentmode.st/api/tlds?include_hidden=1"),
|
||||
fetch("https://silentmode.st/api/tld-visibility"),
|
||||
]);
|
||||
if (!rTlds.ok) throw new Error("api/tlds " + rTlds.status);
|
||||
if (!rVis.ok) throw new Error("api/tld-visibility " + rVis.status);
|
||||
const body = await rTlds.json();
|
||||
const vis = await rVis.json();
|
||||
const hidden = new Set(vis.hidden || []);
|
||||
const tlds = (body.tlds || []).sort((a, b) => a.tld.localeCompare(b.tld));
|
||||
status.className = "status ok";
|
||||
status.textContent = `${tlds.length} TLDs on the beacon` + (body.root ? ` · root ${body.root.slice(0, 16)}…` : "");
|
||||
let html = '<table class="tlds"><thead><tr><th>TLD</th><th>Category</th><th>Records</th><th>Height</th></tr></thead><tbody>';
|
||||
for (const t of tlds.sort((a, b) => a.tld.localeCompare(b.tld))) {
|
||||
const cat = (t.certificate?.category || t.category || "").slice(0, 16);
|
||||
const recs = t.records && Object.keys(t.records).length ? JSON.stringify(t.records) : "";
|
||||
const h = t.certificate?.regHeight ?? t.regHeight ?? "";
|
||||
html += `<tr><td class="tld">.${esc(t.tld)}</td><td class="cat">${esc(cat)}${cat ? "…" : ""}</td><td class="records" title="${esc(recs)}">${esc(recs)}</td><td class="cat">${esc(String(h))}</td></tr>`;
|
||||
status.textContent = `${tlds.length} TLDs on the beacon · ${hidden.size} hidden from public listing`;
|
||||
let html = '<table class="tlds"><thead><tr><th>TLD</th><th>Names</th><th>Advertised</th><th>Hidden from public</th></tr></thead><tbody>';
|
||||
for (const t of tlds) {
|
||||
const isHidden = hidden.has(t.tld);
|
||||
html += `<tr>
|
||||
<td class="tld">.${esc(t.tld)}</td>
|
||||
<td class="cat">${t.registered_count ?? 0}</td>
|
||||
<td class="cat">${t.in_advertised ? "yes" : ""}</td>
|
||||
<td><label class="row" style="gap:6px;margin:0;font-size:12.5px;color:var(--mut)">
|
||||
<input type="checkbox" data-hide-tld="${esc(t.tld)}" ${isHidden ? "checked" : ""}>
|
||||
${isHidden ? '<span class="badge b-mem">hidden</span>' : '<span class="dim">public</span>'}
|
||||
</label></td>
|
||||
</tr>`;
|
||||
}
|
||||
html += "</tbody></table>";
|
||||
table.innerHTML = html;
|
||||
// Wire visibility checkboxes.
|
||||
table.querySelectorAll("input[data-hide-tld]").forEach((cb) => {
|
||||
cb.addEventListener("change", async (e) => {
|
||||
const tld = e.target.getAttribute("data-hide-tld");
|
||||
const hide = e.target.checked;
|
||||
if (!opToken()) {
|
||||
e.target.checked = !hide;
|
||||
setOpTokenMsg("paste your operator token first — required for /api/tld-visibility writes", false);
|
||||
return;
|
||||
}
|
||||
e.target.disabled = true;
|
||||
try {
|
||||
const r = await fetch("https://silentmode.st/api/tld-visibility", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", authorization: "Bearer " + opToken() },
|
||||
body: JSON.stringify({ tld, hide }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const j = await r.json().catch(() => ({}));
|
||||
throw new Error(j.error || "http " + r.status);
|
||||
}
|
||||
setOpTokenMsg(`.${tld} is now ${hide ? "hidden" : "public"}`, true);
|
||||
setTimeout(loadRegistry, 400); // refresh row states + counts
|
||||
} catch (err) {
|
||||
e.target.checked = !hide;
|
||||
setOpTokenMsg("update failed: " + err.message, false);
|
||||
} finally {
|
||||
e.target.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
status.className = "status err";
|
||||
status.textContent = "Could not load registry: " + (e.message || e);
|
||||
|
|
@ -301,6 +373,6 @@ $("mint-btn").addEventListener("click", async () => {
|
|||
});
|
||||
</script>
|
||||
<script defer src="../js/site-footer.js?v=20260907rel"></script>
|
||||
<script defer src="../js/profile-menu.js?v=20260908choose"></script>
|
||||
<script defer src="../js/profile-menu.js?v=20260908tabs"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -219,6 +219,6 @@
|
|||
<footer id="site-footer"></footer>
|
||||
|
||||
<script defer src="../js/site-footer.js?v=20260907rel"></script>
|
||||
<script defer src="../js/profile-menu.js?v=20260908choose"></script>
|
||||
<script defer src="../js/profile-menu.js?v=20260908tabs"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -425,6 +425,6 @@ process.exit(0);"</pre>
|
|||
<footer id="site-footer"></footer>
|
||||
|
||||
<script defer src="../js/site-footer.js?v=20260907rel"></script>
|
||||
<script defer src="../js/profile-menu.js?v=20260908choose"></script>
|
||||
<script defer src="../js/profile-menu.js?v=20260908tabs"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -421,9 +421,9 @@ process.exit(0);"</pre>
|
|||
<script type="importmap">
|
||||
{ "imports": { "@bitauth/libauth": "https://silentmode.st/js/libauth.js" } }
|
||||
</script>
|
||||
<script type="module" src="./js/register-flow.js?v=20260908choose"></script>
|
||||
<script type="module" src="./js/register-flow.js?v=20260908tabs"></script>
|
||||
|
||||
<script defer src="./js/site-footer.js?v=20260907rel"></script>
|
||||
<script defer src="./js/profile-menu.js?v=20260908choose"></script>
|
||||
<script defer src="./js/profile-menu.js?v=20260908tabs"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -90,19 +90,26 @@
|
|||
<hr>
|
||||
` : "";
|
||||
const headerHint = hasSaved
|
||||
? `<span class="profile-hint">Locked wallet on this device</span><span class="profile-sub">Unlock or pick another option</span>`
|
||||
: `<span class="profile-hint">No wallet yet</span><span class="profile-sub">Sign in or create one</span>`;
|
||||
// One primary action ("Sign in or create") — opens the shared modal at
|
||||
// the wallet-choice step. The modal shows all four options
|
||||
// (Unlock/Create/Import/WizardConnect) as clear cards; clicking one
|
||||
// reveals the form for that path. Choice first, form second — no
|
||||
// inline forms surprising the user.
|
||||
? `<span class="profile-hint">Locked wallet on this device</span><span class="profile-sub">Unlock or pick another action</span>`
|
||||
: `<span class="profile-hint">No wallet yet</span><span class="profile-sub">Pick how to sign in</span>`;
|
||||
// Four direct actions — each opens the shared modal at the matching
|
||||
// step (Unlock/Create/Import/WizardConnect) with tabs across the top
|
||||
// of the modal so the user can switch between them without going back
|
||||
// to a landing screen.
|
||||
menu.innerHTML = `
|
||||
<div class="profile-header">${headerHint}</div>
|
||||
${unlockItem}
|
||||
<a href="#" role="menuitem" data-action="choose">
|
||||
<span class="mi-ic">🔑</span>
|
||||
<span class="mi-body"><b>Sign in or create wallet</b><span class="mi-hint">pick from create · import · WizardConnect</span></span>
|
||||
<a href="#" role="menuitem" data-action="new">
|
||||
<span class="mi-ic">🆕</span>
|
||||
<span class="mi-body"><b>Create a wallet</b><span class="mi-hint">generate a fresh 12-word phrase (sign up)</span></span>
|
||||
</a>
|
||||
<a href="#" role="menuitem" data-action="import">
|
||||
<span class="mi-ic">📥</span>
|
||||
<span class="mi-body"><b>Import a wallet</b><span class="mi-hint">seed phrase you already have (sign in)</span></span>
|
||||
</a>
|
||||
<a href="#" role="menuitem" data-action="wc">
|
||||
<span class="mi-ic">🔗</span>
|
||||
<span class="mi-body"><b>WizardConnect</b><span class="mi-hint">Cashonize / Paytaca via QR</span></span>
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
|
@ -153,7 +160,7 @@
|
|||
location.reload();
|
||||
return;
|
||||
}
|
||||
if (["choose", "new", "import", "wc", "unlock"].includes(action)) {
|
||||
if (["new", "import", "wc", "unlock"].includes(action)) {
|
||||
close();
|
||||
const savedLabel = btn.querySelector(".wallet-label")?.textContent;
|
||||
const walletLabel = btn.querySelector(".wallet-label");
|
||||
|
|
|
|||
|
|
@ -76,6 +76,19 @@ const sats = (v) => Number(v).toLocaleString("en-US");
|
|||
#sirius-register-modal .wopt span{color:var(--mut,#8b98a9);font-size:13px}
|
||||
#sirius-register-modal .chk{display:flex;gap:9px;align-items:flex-start;margin-top:14px;font-size:13.5px;color:var(--mut,#8b98a9)}
|
||||
#sirius-register-modal .chk input{margin-top:3px}
|
||||
#sirius-register-modal .signin-tabs{
|
||||
display:flex;gap:2px;margin:-8px -12px 18px;padding:0 4px 0;
|
||||
border-bottom:1px solid var(--line,rgba(255,255,255,.09));overflow-x:auto
|
||||
}
|
||||
#sirius-register-modal .signin-tabs button{
|
||||
background:none;border:none;color:var(--mut,#8b98a9);font-family:inherit;font-size:12.5px;
|
||||
padding:10px 12px;cursor:pointer;border-bottom:2px solid transparent;
|
||||
white-space:nowrap;letter-spacing:.2px;transition:color .1s,border-color .1s
|
||||
}
|
||||
#sirius-register-modal .signin-tabs button:hover{color:var(--ink,#e7eaf1)}
|
||||
#sirius-register-modal .signin-tabs button.active{
|
||||
color:var(--acid,#d6ff3d);border-bottom-color:var(--acid,#d6ff3d)
|
||||
}
|
||||
#sirius-register-modal .btn{display:inline-block;text-decoration:none;padding:10px 18px;border-radius:10px;
|
||||
background:#4b7bec;color:#fff;font-size:14px;border:none;cursor:pointer;font-family:inherit}
|
||||
#sirius-register-modal .btn.ghost{background:transparent;border:1px solid var(--line,rgba(255,255,255,.09));color:var(--ink,#e7eaf1)}
|
||||
|
|
@ -155,6 +168,43 @@ $("sirius-reg-close").onclick = close;
|
|||
modal.addEventListener("click", (e) => { if (e.target === modal) close(); });
|
||||
|
||||
function render(html) { sheet.innerHTML = html; }
|
||||
|
||||
// Tab bar for sign-in mode — lets the user jump between the four wallet
|
||||
// actions without going back to a wallet-choice screen. Prepended to the
|
||||
// sheet content by each sign-in step. Only shown in signInOnly mode.
|
||||
function renderTabs(active) {
|
||||
if (!state.signInOnly) return "";
|
||||
const saved = BNS.BuiltInWallet.exists();
|
||||
const tabs = [];
|
||||
if (saved) tabs.push(["unlock", "🔓 Unlock", "Wallet stored on this device"]);
|
||||
tabs.push(["new", "🆕 Create", "Sign up · fresh phrase"]);
|
||||
tabs.push(["import", "📥 Import", "Sign in · seed you have"]);
|
||||
tabs.push(["wc", "🔗 WizardConnect", "Cashonize / Paytaca"]);
|
||||
return `
|
||||
<div class="signin-tabs" role="tablist">
|
||||
${tabs.map(([id, label, hint]) => `
|
||||
<button role="tab" data-tab="${id}" class="${id === active ? "active" : ""}"
|
||||
title="${esc(hint)}">${label}</button>
|
||||
`).join("")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
// Wire tab clicks — event delegation on the sheet so freshly-rendered
|
||||
// tabs pick up the handler without rewiring per step. Switching away from
|
||||
// a live WC session tears it down first so we don't leak connections.
|
||||
sheet.addEventListener("click", async (e) => {
|
||||
const t = e.target.closest("[data-tab]");
|
||||
if (!t) return;
|
||||
const which = t.dataset.tab;
|
||||
if (which !== "wc" && state.session) {
|
||||
try { await state.session.disconnect(); } catch {}
|
||||
state.session = null;
|
||||
}
|
||||
if (which === "unlock") stepUnlock();
|
||||
else if (which === "new") stepCreate();
|
||||
else if (which === "import") stepImport();
|
||||
else if (which === "wc") stepExternal();
|
||||
});
|
||||
function err(e) {
|
||||
sheet.querySelectorAll(".err").forEach((n) => n.remove());
|
||||
const box = document.createElement("div");
|
||||
|
|
@ -256,7 +306,7 @@ function stepWallet() {
|
|||
// ---------- WizardConnect (external wallet) ----------
|
||||
async function stepExternal() {
|
||||
const label = "WizardConnect";
|
||||
render(`<h3>Connect with ${esc(label)}</h3><p class="sub">Starting the connection…</p>`);
|
||||
render(`${renderTabs("wc")}<h3>Connect with ${esc(label)}</h3><p class="sub">Starting the connection…</p>`);
|
||||
let session;
|
||||
try {
|
||||
const mod = await import("https://silentmode.st/js/wizardconnect.js");
|
||||
|
|
@ -280,6 +330,7 @@ async function stepExternal() {
|
|||
|
||||
const uri = session.qrUri ?? session.uri;
|
||||
render(`
|
||||
${renderTabs("wc")}
|
||||
<h3>Open your wallet</h3>
|
||||
<p class="sub">Scan or paste this into ${esc(label)}-capable wallet, then approve the connection.</p>
|
||||
<div class="addr"><code id="uri">${esc(uri)}</code><button class="btn ghost" id="copy">Copy</button></div>
|
||||
|
|
@ -380,6 +431,7 @@ function stepCreate() {
|
|||
// with no chosen name, so "Back" should just close the modal in that case.
|
||||
const goBack = state.signInOnly ? close : stepWallet;
|
||||
render(`
|
||||
${renderTabs("new")}
|
||||
<h3>Create your wallet</h3>
|
||||
<p class="sub">Generated in this browser. The phrase is never sent anywhere — not to us, not to anyone.</p>
|
||||
<label for="pw">Password (encrypts the wallet on this device)</label>
|
||||
|
|
@ -427,6 +479,7 @@ function stepPhrase() {
|
|||
function stepImport() {
|
||||
const goBack = state.signInOnly ? close : stepWallet;
|
||||
render(`
|
||||
${renderTabs("import")}
|
||||
<h3>Import a recovery phrase</h3>
|
||||
<p class="sub">12 words, separated by spaces. It stays in this browser.</p>
|
||||
<label for="mn">Recovery phrase</label>
|
||||
|
|
@ -451,6 +504,7 @@ function stepImport() {
|
|||
function stepUnlock() {
|
||||
const goBack = state.signInOnly ? close : stepWallet;
|
||||
render(`
|
||||
${renderTabs("unlock")}
|
||||
<h3>Unlock your wallet</h3>
|
||||
<p class="sub">Decrypts the wallet stored in this browser.</p>
|
||||
<label for="pw">Password</label>
|
||||
|
|
|
|||
|
|
@ -812,6 +812,6 @@ $("tld-submit").addEventListener("click", async () => {
|
|||
updateTldPrice();
|
||||
</script>
|
||||
<script defer src="./js/site-footer.js?v=20260907rel"></script>
|
||||
<script defer src="./js/profile-menu.js?v=20260908choose"></script>
|
||||
<script defer src="./js/profile-menu.js?v=20260908tabs"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -170,6 +170,6 @@ sha256sum <downloaded-file>.exe</pre>
|
|||
<footer id="site-footer"></footer>
|
||||
|
||||
<script defer src="../js/site-footer.js?v=20260907rel"></script>
|
||||
<script defer src="../js/profile-menu.js?v=20260908choose"></script>
|
||||
<script defer src="../js/profile-menu.js?v=20260908tabs"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
4
tld.html
4
tld.html
|
|
@ -249,8 +249,8 @@
|
|||
})();
|
||||
</script>
|
||||
|
||||
<script type="module" src="./js/register-flow.js?v=20260908choose"></script>
|
||||
<script type="module" src="./js/register-flow.js?v=20260908tabs"></script>
|
||||
<script defer src="./js/site-footer.js?v=20260907rel"></script>
|
||||
<script defer src="./js/profile-menu.js?v=20260908choose"></script>
|
||||
<script defer src="./js/profile-menu.js?v=20260908tabs"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue