feat(sirius-x/portal): New wallet card, siriusProfile state, ?mode= deep-links

Closes the loop between the nav's profile dropdown and portal.html:

- Adds a 🆕 New wallet card that calls BuiltInWallet.create() to generate
  a fresh BIP-39 phrase in the browser. The phrase is shown once for the
  user to write down; sign-in only unlocks after they tick the 'I have
  written this down' acknowledgement. Copy button included.
- Sign-in (both New and Import paths) now writes localStorage.siriusProfile
  = { address, tokenAddress, signedInAt } so any page on the same origin
  can render 'signed in' state. Sign-out clears the key and also resets
  the New wallet card so a re-sign-in starts clean.
- Handles ?mode=new|import|wc from the profile dropdown's deep-links:
  scrolls the matching card into view and briefly outlines it in acid so
  the user knows which one they were sent to.

profile-menu.js:
- Re-renders the dropdown on every open() call rather than caching the
  first paint, so a sign-in that happens *after* the script's initial
  run (same tab: portal.html; other tabs: storage event) reflects in the
  nav without needing a full reload.
- Listens for the storage event (cross-tab) and a custom
  'siriusProfileChanged' event (same-tab) — portal.html fires that on
  every writeProfile/clearProfile call.
This commit is contained in:
Local Dev 2026-09-07 00:15:43 +02:00
parent e59a2a01b5
commit 6ce9de33a0
2 changed files with 155 additions and 32 deletions

View file

@ -16,13 +16,15 @@
if (!anchor) return; // page has no nav-portal button; nothing to do if (!anchor) return; // page has no nav-portal button; nothing to do
// Read profile state — portal.html sets this after successful sign-in with // Read profile state — portal.html sets this after successful sign-in with
// { address, tokenAddress, source: "seed" | "wc" }; clears on sign-out. // { address, tokenAddress, signedInAt }; clears on sign-out. The read has to
// run every time the button label refreshes or the menu opens, because
// sign-in can happen on this same page (portal.html) or in another tab
// *after* this script's initial run.
const readProfile = () => { const readProfile = () => {
try { return JSON.parse(localStorage.getItem("siriusProfile") || "null"); } catch { return null; } try { return JSON.parse(localStorage.getItem("siriusProfile") || "null"); } catch { return null; }
}; };
const profile = readProfile();
// Build wrapper + dropdown around the existing pill. // Build wrapper + button + (empty) menu around the existing pill.
const wrap = document.createElement("div"); const wrap = document.createElement("div");
wrap.className = "profile-wrap"; wrap.className = "profile-wrap";
const btn = document.createElement("button"); const btn = document.createElement("button");
@ -30,12 +32,17 @@
btn.className = anchor.className; // reuse .portal (and .here if present) btn.className = anchor.className; // reuse .portal (and .here if present)
btn.setAttribute("aria-haspopup", "menu"); btn.setAttribute("aria-haspopup", "menu");
btn.setAttribute("aria-expanded", "false"); btn.setAttribute("aria-expanded", "false");
btn.textContent = profile ? "🔑 " + shortAddr(profile.address) + " ▾" : "🔑 Wallet ▾";
const menu = document.createElement("div"); const menu = document.createElement("div");
menu.className = "profile-menu"; menu.className = "profile-menu";
menu.setAttribute("role", "menu"); menu.setAttribute("role", "menu");
menu.hidden = true; menu.hidden = true;
// Re-renders the button label AND the menu innerHTML from the current
// profile in localStorage. Called at load, on every open(), and whenever
// another tab updates the storage key.
function render() {
const profile = readProfile();
btn.textContent = profile ? "🔑 " + shortAddr(profile.address) + " ▾" : "🔑 Wallet ▾";
if (profile) { if (profile) {
menu.innerHTML = ` menu.innerHTML = `
<div class="profile-header"> <div class="profile-header">
@ -57,10 +64,19 @@
<a href="${PORTAL_URL}" role="menuitem">🔑 Open sign-in page </a> <a href="${PORTAL_URL}" role="menuitem">🔑 Open sign-in page </a>
`; `;
} }
}
render();
// Toggle + close-on-outside. // Toggle + close-on-outside. Re-render on open so a sign-in that happened
const open = () => { menu.hidden = false; btn.setAttribute("aria-expanded", "true"); }; // after page load (same tab: portal.html; other tab: storage event) shows
// up without a full reload.
const open = () => { render(); menu.hidden = false; btn.setAttribute("aria-expanded", "true"); };
const close = () => { menu.hidden = true; btn.setAttribute("aria-expanded", "false"); }; const close = () => { menu.hidden = true; btn.setAttribute("aria-expanded", "false"); };
// Storage events fire only in *other* tabs, but they cover the cross-tab case.
// Same-tab: portal.html dispatches "siriusProfileChanged" on window after
// it writes/clears the key; render on that too.
window.addEventListener("storage", (e) => { if (e.key === "siriusProfile") render(); });
window.addEventListener("siriusProfileChanged", render);
btn.addEventListener("click", (e) => { e.stopPropagation(); menu.hidden ? open() : close(); }); btn.addEventListener("click", (e) => { e.stopPropagation(); menu.hidden ? open() : close(); });
document.addEventListener("click", (e) => { if (!wrap.contains(e.target)) close(); }); document.addEventListener("click", (e) => { if (!wrap.contains(e.target)) close(); });
document.addEventListener("keydown", (e) => { if (e.key === "Escape") close(); }); document.addEventListener("keydown", (e) => { if (e.key === "Escape") close(); });

View file

@ -137,8 +137,30 @@
<p class="muted">Pick one. Nothing is transmitted — resolution is a read-only chain query, <p class="muted">Pick one. Nothing is transmitted — resolution is a read-only chain query,
and signing (for record edits) happens locally in the browser or in your external wallet.</p> and signing (for record edits) happens locally in the browser or in your external wallet.</p>
<div class="authbox" style="margin-top:14px"> <div class="authbox" style="margin-top:14px">
<div class="card"> <div class="card" id="card-new">
<h3>Import a seed phrase</h3> <h3>🆕 New wallet</h3>
<p>Generate a fresh 12-word BIP-39 phrase in your browser. Write it down before continuing —
it never leaves this page and there is nothing to reset if you lose it.</p>
<div class="row">
<button class="btn acid" id="new-btn">Generate a phrase</button>
</div>
<div id="new-phrase" class="hidden" style="margin-top:14px">
<label>Your new recovery phrase</label>
<textarea id="new-seed" readonly style="font-family:ui-monospace,monospace;font-size:14px"></textarea>
<label style="margin-top:10px;display:flex;gap:9px;align-items:flex-start;font-size:13.5px">
<input type="checkbox" id="new-ack" style="margin-top:3px">
<span>I have written this phrase down somewhere safe. I understand it is the only way
to recover this wallet and cannot be reset.</span>
</label>
<div class="row">
<button class="btn acid" id="new-signin-btn" disabled>Sign in with new wallet →</button>
<button class="btn ghost" id="new-copy-btn">Copy phrase</button>
</div>
</div>
<div id="new-err" class="err hidden"></div>
</div>
<div class="card" id="card-import">
<h3>📥 Import a seed phrase</h3>
<p>Paste your 12- or 24-word BIP-39 recovery phrase. It stays in memory only, wiped when you <p>Paste your 12- or 24-word BIP-39 recovery phrase. It stays in memory only, wiped when you
close the tab or sign out.</p> close the tab or sign out.</p>
<label for="seed">Recovery phrase</label> <label for="seed">Recovery phrase</label>
@ -149,8 +171,8 @@
</div> </div>
<div id="signin-err" class="err hidden"></div> <div id="signin-err" class="err hidden"></div>
</div> </div>
<div class="card"> <div class="card" id="card-wc">
<h3>Connect an external wallet</h3> <h3>🔗 Connect an external wallet</h3>
<p>Use Cashonize 0.9+ or Paytaca via WizardConnect. The wallet signs record edits — the seed <p>Use Cashonize 0.9+ or Paytaca via WizardConnect. The wallet signs record edits — the seed
never touches this page.</p> never touches this page.</p>
<div class="row"> <div class="row">
@ -158,7 +180,8 @@
<span class="badge b-warn" style="margin-left:6px">soon</span></button> <span class="badge b-warn" style="margin-left:6px">soon</span></button>
</div> </div>
<p class="dim" style="margin-top:8px">Portal-side WizardConnect wiring is in progress; the <p class="dim" style="margin-top:8px">Portal-side WizardConnect wiring is in progress; the
register.html flow already ships the WizardConnect adapter, and the portal will reuse it.</p> <a href="./register.html">register.html</a> flow already ships the WizardConnect adapter, and
the portal will reuse it.</p>
</div> </div>
</div> </div>
<div class="status" style="margin-top:14px"> <div class="status" style="margin-top:14px">
@ -270,6 +293,27 @@ const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({"&":"&amp;","<":"&lt;"
let wallet = null; let wallet = null;
// Persist signed-in state so the profile dropdown (profile-menu.js) can flip
// from "onboarding" to "signed in" and any tab on the same origin (sirius.x)
// reflects the current session. The wallet keys themselves never leave this
// page — localStorage only holds a small address-only breadcrumb, no seed.
function writeProfile(w) {
try {
localStorage.setItem("siriusProfile", JSON.stringify({
address: w.address,
tokenAddress: w.tokenAddress,
signedInAt: Date.now(),
}));
} catch {}
// profile-menu.js listens for this to re-render the nav dropdown in the
// same tab (the "storage" event only fires in *other* tabs).
window.dispatchEvent(new Event("siriusProfileChanged"));
}
function clearProfile() {
try { localStorage.removeItem("siriusProfile"); } catch {}
window.dispatchEvent(new Event("siriusProfileChanged"));
}
// ---------- sign in ---------- // ---------- sign in ----------
$("signin-btn").addEventListener("click", async () => { $("signin-btn").addEventListener("click", async () => {
const btn = $("signin-btn"); const btn = $("signin-btn");
@ -286,20 +330,83 @@ $("signin-btn").addEventListener("click", async () => {
btn.disabled = false; btn.textContent = "Sign in →"; btn.disabled = false; btn.textContent = "Sign in →";
return; return;
} }
// Wipe the textarea immediately — the seed is now in the wallet object only.
$("seed").value = ""; $("seed").value = "";
btn.disabled = false; btn.textContent = "Sign in →"; btn.disabled = false; btn.textContent = "Sign in →";
writeProfile(wallet);
await enterPortal();
});
// ---------- new wallet ----------
// Generates a fresh BIP-39 phrase in the browser via BuiltInWallet.create().
// The phrase is shown once for the user to write down; sign-in only unlocks
// after they acknowledge they have saved it. From that point on the flow is
// identical to import (BuiltInWallet in memory, no persistence of the seed).
$("new-btn").addEventListener("click", async () => {
const btn = $("new-btn");
const errBox = $("new-err");
errBox.classList.add("hidden");
btn.disabled = true; btn.textContent = "Generating…";
let fresh;
try { fresh = await BNS.BuiltInWallet.create(); }
catch (e) {
errBox.textContent = "Could not generate a phrase: " + (e.message || e);
errBox.classList.remove("hidden");
btn.disabled = false; btn.textContent = "Generate a phrase";
return;
}
wallet = fresh;
$("new-seed").value = fresh.mnemonic;
$("new-phrase").classList.remove("hidden");
btn.disabled = true; btn.textContent = "Phrase generated";
});
$("new-ack").addEventListener("change", (e) => {
$("new-signin-btn").disabled = !e.target.checked;
});
$("new-copy-btn").addEventListener("click", () => {
navigator.clipboard?.writeText($("new-seed").value);
});
$("new-signin-btn").addEventListener("click", async () => {
if (!wallet) return;
// Wipe the visible phrase immediately — it's in the wallet object now.
$("new-seed").value = "";
$("new-phrase").classList.add("hidden");
writeProfile(wallet);
await enterPortal(); await enterPortal();
}); });
$("signout-btn").addEventListener("click", () => { $("signout-btn").addEventListener("click", () => {
wallet = null; wallet = null;
clearProfile();
$("me").classList.add("hidden"); $("me").classList.add("hidden");
$("signin").classList.remove("hidden"); $("signin").classList.remove("hidden");
$("names-list").innerHTML = ""; $("names-list").innerHTML = "";
$("names-status").textContent = "Loading names from the chain…"; $("names-status").textContent = "Loading names from the chain…";
// Reset the new-wallet card so a subsequent sign-in starts clean.
$("new-btn").disabled = false; $("new-btn").textContent = "Generate a phrase";
$("new-phrase").classList.add("hidden");
$("new-ack").checked = false;
$("new-signin-btn").disabled = true;
}); });
// ---------- ?mode= deep-link ----------
// The profile dropdown links to portal.html?mode=new|import|wc — bring the
// matching card into view and highlight its border briefly so the user knows
// which one they were sent to.
(function handleMode() {
const mode = new URLSearchParams(location.search).get("mode");
if (!mode) return;
const id = { new: "card-new", import: "card-import", wc: "card-wc" }[mode];
const el = id && document.getElementById(id);
if (!el) return;
el.style.borderColor = "var(--acid)";
el.style.boxShadow = "0 0 0 3px rgba(214,255,61,.15)";
setTimeout(() => el.scrollIntoView({ behavior: "smooth", block: "center" }), 50);
setTimeout(() => { el.style.borderColor = ""; el.style.boxShadow = ""; }, 3000);
})();
async function enterPortal() { async function enterPortal() {
$("signin").classList.add("hidden"); $("signin").classList.add("hidden");
$("me").classList.remove("hidden"); $("me").classList.remove("hidden");