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:
parent
e59a2a01b5
commit
6ce9de33a0
2 changed files with 155 additions and 32 deletions
|
|
@ -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(); });
|
||||||
|
|
|
||||||
119
portal.html
119
portal.html
|
|
@ -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) => ({"&":"&","<":"<"
|
||||||
|
|
||||||
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");
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue