feat(hephaestus): BCH wallet as default login (3-option picker + Caddy redirect)
- login.css/wallet.js: replace 2-tab (generate/import) UX with 3 big options — Create a new wallet [easiest], Import / add a wallet, Connect a wallet — WizardConnect [most private] (Cashonize/Paytaca placeholder, "coming soon"). - Caddyfile (both hostnames): /user/login now 302→ /user/oauth2/hephaestus-wallet. Escape hatch: /user/login?password=1 falls through to Forgejo's password form, linked from the picker footer as "Prefer old-school? Sign in with username & password".
This commit is contained in:
parent
fc43382d48
commit
e67943ed00
3 changed files with 152 additions and 29 deletions
|
|
@ -23,21 +23,74 @@
|
|||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--text); min-height: 100%; }
|
||||
main {
|
||||
max-width: 520px;
|
||||
margin: 8vh auto;
|
||||
padding: 32px 28px;
|
||||
max-width: 560px;
|
||||
margin: 6vh auto;
|
||||
padding: 36px 32px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
}
|
||||
h1 { font-size: 22px; margin: 0 0 4px; }
|
||||
p.lede { color: var(--muted); margin: 0 0 24px; font-size: 14px; }
|
||||
.tab-row { display: flex; gap: 8px; margin-bottom: 20px; border-bottom: 1px solid var(--border); }
|
||||
.tab {
|
||||
padding: 10px 14px; cursor: pointer; background: transparent; border: none;
|
||||
color: var(--muted); font: inherit; border-bottom: 2px solid transparent;
|
||||
h1 { font-size: 22px; margin: 0 0 6px; line-height: 1.3; }
|
||||
h1 .acid { color: var(--accent); }
|
||||
h2 { font-size: 17px; margin: 0 0 6px; }
|
||||
p.lede { color: var(--muted); margin: 0 0 20px; font-size: 14px; }
|
||||
p.sub { color: var(--muted); margin: 0 0 12px; font-size: 13.5px; }
|
||||
|
||||
/* --- Wallet picker (three big options — Create / Import / Connect) --- */
|
||||
.wallet-picker { display: grid; gap: 10px; margin: 8px 0 18px; }
|
||||
.wallet-opt {
|
||||
display: grid; grid-template-columns: 40px 1fr; gap: 14px; align-items: start;
|
||||
padding: 14px 16px; background: var(--bg); border: 1px solid var(--border);
|
||||
border-radius: 10px; cursor: pointer; text-align: left; font: inherit; color: var(--text);
|
||||
transition: border-color .1s, background .1s;
|
||||
}
|
||||
.tab[aria-selected="true"] { color: var(--text); border-bottom-color: var(--accent); }
|
||||
.wallet-opt:hover { border-color: var(--accent); background: var(--card); }
|
||||
.wallet-opt .ico { font-size: 24px; line-height: 1; padding-top: 2px; }
|
||||
.wallet-opt .body { display: flex; flex-direction: column; gap: 4px; }
|
||||
.wallet-opt b { color: var(--text); font-size: 14.5px; font-weight: 600;
|
||||
display: inline-flex; align-items: center; gap: 8px; }
|
||||
.wallet-opt .sub { color: var(--muted); font-size: 13px; line-height: 1.5; }
|
||||
.wallet-opt .pill {
|
||||
display: inline-block; font-size: 10.5px; padding: 1px 8px; border-radius: 999px;
|
||||
background: rgba(255,140,0,.14); color: var(--accent); font-weight: 500;
|
||||
letter-spacing: 0.02em; text-transform: lowercase;
|
||||
}
|
||||
p.fine {
|
||||
color: var(--muted); font-size: 12.5px; margin: 4px 0 0; text-align: center;
|
||||
}
|
||||
p.fine a { color: var(--muted); }
|
||||
p.fine a:hover { color: var(--text); }
|
||||
|
||||
/* --- Selected flow panel (Create/Import/Connect) --- */
|
||||
.flow { display: flex; flex-direction: column; gap: 4px; }
|
||||
.flow > label { margin-top: 12px; }
|
||||
.flow .row {
|
||||
display: flex; gap: 10px; margin-top: 18px; align-items: center;
|
||||
}
|
||||
.flow .row .primary { flex: 1; margin-top: 0; }
|
||||
.flow .row .ghost {
|
||||
background: transparent; border: 1px solid var(--border); color: var(--muted);
|
||||
padding: 10px 14px; border-radius: 8px; font: inherit; font-size: 13.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.flow .row .ghost:hover { color: var(--text); border-color: var(--muted); }
|
||||
|
||||
/* --- WizardConnect coming-soon panel --- */
|
||||
.wc-supported { display: grid; gap: 8px; margin: 8px 0 14px; }
|
||||
.wc-wallet {
|
||||
display: grid; grid-template-columns: 1fr auto auto; gap: 12px; align-items: center;
|
||||
padding: 10px 14px; background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.wc-wallet b { color: var(--text); }
|
||||
.wc-wallet span { color: var(--dim, var(--muted)); font-size: 12px; }
|
||||
.wc-wallet a { color: var(--accent); text-decoration: none; font-size: 12.5px; }
|
||||
.wc-wallet a:hover { text-decoration: underline; }
|
||||
.note-soon {
|
||||
background: var(--bg); border: 1px dashed var(--border); border-left: 3px solid var(--accent);
|
||||
border-radius: 8px; padding: 12px 14px; color: var(--muted); font-size: 13px;
|
||||
}
|
||||
.note-soon b { color: var(--text); }
|
||||
label { display: block; font-size: 13px; color: var(--muted); margin: 12px 0 6px; }
|
||||
input, textarea {
|
||||
width: 100%; padding: 10px 12px; background: var(--bg);
|
||||
|
|
|
|||
|
|
@ -156,57 +156,94 @@ function renderTabs() {
|
|||
app.innerHTML = "";
|
||||
app.appendChild(el(`
|
||||
<div>
|
||||
<h1>Sign in to Hephaestus</h1>
|
||||
<h1>Sign in with your <span class="acid">Bitcoin Cash</span> wallet</h1>
|
||||
<p class="lede">No email. No password reset. Your wallet is your identity.</p>
|
||||
<div class="tab-row" role="tablist">
|
||||
<button class="tab" aria-selected="true" data-tab="generate">Generate new</button>
|
||||
<button class="tab" aria-selected="false" data-tab="import">Import existing</button>
|
||||
<div class="wallet-picker" id="picker">
|
||||
<button class="wallet-opt" data-choice="create">
|
||||
<span class="ico">✨</span>
|
||||
<span class="body">
|
||||
<b>Create a new wallet<span class="pill">easiest</span></b>
|
||||
<span class="sub">Made here in your browser. You get a recovery phrase to write down — it is the only key.</span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="wallet-opt" data-choice="import">
|
||||
<span class="ico">🔑</span>
|
||||
<span class="body">
|
||||
<b>Import / add a wallet</b>
|
||||
<span class="sub">Restore a wallet you already have from its 12- or 24-word recovery phrase.</span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="wallet-opt" data-choice="connect">
|
||||
<span class="ico">🪄</span>
|
||||
<span class="body">
|
||||
<b>Connect a wallet — WizardConnect<span class="pill">most private</span></b>
|
||||
<span class="sub">Cashonize 0.9+ or Paytaca. Your keys never leave your wallet.</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="fine">Prefer old-school? <a href="/user/login?password=1">Sign in with username & password →</a></p>
|
||||
<div id="panel"></div>
|
||||
</div>
|
||||
`));
|
||||
app.querySelectorAll(".tab").forEach((b) => {
|
||||
app.querySelectorAll(".wallet-opt").forEach((b) => {
|
||||
b.addEventListener("click", () => {
|
||||
app.querySelectorAll(".tab").forEach((x) =>
|
||||
x.setAttribute("aria-selected", x === b ? "true" : "false"));
|
||||
renderPanel(b.dataset.tab);
|
||||
// Hide the picker while a specific panel is open
|
||||
app.querySelector("#picker").classList.add("hidden");
|
||||
app.querySelector(".fine")?.classList.add("hidden");
|
||||
renderPanel(b.dataset.choice);
|
||||
});
|
||||
});
|
||||
renderPanel("generate");
|
||||
}
|
||||
|
||||
function backToPicker() {
|
||||
app.querySelector("#picker")?.classList.remove("hidden");
|
||||
app.querySelector(".fine")?.classList.remove("hidden");
|
||||
const panel = document.getElementById("panel");
|
||||
if (panel) panel.innerHTML = "";
|
||||
}
|
||||
|
||||
function renderPanel(kind) {
|
||||
const panel = document.getElementById("panel");
|
||||
panel.innerHTML = "";
|
||||
if (kind === "generate") {
|
||||
if (kind === "create") {
|
||||
const mnemonic = generateBip39Mnemonic();
|
||||
panel.appendChild(el(`
|
||||
<div>
|
||||
<label>Your new recovery phrase (write it down — this is the only way to recover your account)</label>
|
||||
<div class="flow">
|
||||
<h2>Your new recovery phrase</h2>
|
||||
<p class="sub">Write it down. This is the only way to recover your account — we can never help you reset it.</p>
|
||||
<div class="mnemonic-box">${mnemonic}</div>
|
||||
<label>Set a passphrase to encrypt this wallet in your browser</label>
|
||||
<input type="password" id="pw" autocomplete="new-password" placeholder="min 8 chars">
|
||||
<p class="small">The passphrase never leaves your device. If you lose both the phrase and the passphrase your account is gone — we cannot help.</p>
|
||||
<button class="primary" id="go">Create wallet & sign in</button>
|
||||
<p class="small">The passphrase never leaves your device. Lose both phrase and passphrase = account is gone.</p>
|
||||
<div class="row">
|
||||
<button class="primary" id="go">Create wallet & sign in</button>
|
||||
<button class="ghost" id="back">← Back</button>
|
||||
</div>
|
||||
<p class="warn hidden" id="err"></p>
|
||||
</div>
|
||||
`));
|
||||
panel.querySelector("#back").addEventListener("click", backToPicker);
|
||||
panel.querySelector("#go").addEventListener("click", async () => {
|
||||
const pw = panel.querySelector("#pw").value;
|
||||
if (pw.length < 8) return showErr(panel, "passphrase must be at least 8 chars");
|
||||
await onboardAndLogin(mnemonic, pw, panel);
|
||||
});
|
||||
} else {
|
||||
} else if (kind === "import") {
|
||||
panel.appendChild(el(`
|
||||
<div>
|
||||
<label>Paste your BIP-39 recovery phrase (12 or 24 words)</label>
|
||||
<textarea id="mn" spellcheck="false" autocomplete="off"></textarea>
|
||||
<div class="flow">
|
||||
<h2>Import a wallet</h2>
|
||||
<p class="sub">Paste your BIP-39 recovery phrase (12 or 24 words). Same phrase = same account.</p>
|
||||
<textarea id="mn" spellcheck="false" autocomplete="off" placeholder="word word word ..."></textarea>
|
||||
<label>Set a passphrase to encrypt this wallet in your browser</label>
|
||||
<input type="password" id="pw" autocomplete="new-password" placeholder="min 8 chars">
|
||||
<button class="primary" id="go">Import & sign in</button>
|
||||
<div class="row">
|
||||
<button class="primary" id="go">Import & sign in</button>
|
||||
<button class="ghost" id="back">← Back</button>
|
||||
</div>
|
||||
<p class="warn hidden" id="err"></p>
|
||||
</div>
|
||||
`));
|
||||
panel.querySelector("#back").addEventListener("click", backToPicker);
|
||||
panel.querySelector("#go").addEventListener("click", async () => {
|
||||
const mn = panel.querySelector("#mn").value.trim().replace(/\s+/g, " ");
|
||||
const pw = panel.querySelector("#pw").value;
|
||||
|
|
@ -215,6 +252,22 @@ function renderPanel(kind) {
|
|||
catch { return showErr(panel, "invalid recovery phrase"); }
|
||||
await onboardAndLogin(mn, pw, panel);
|
||||
});
|
||||
} else if (kind === "connect") {
|
||||
panel.appendChild(el(`
|
||||
<div class="flow">
|
||||
<h2>Connect a wallet</h2>
|
||||
<p class="sub">Sign the challenge with an external BCH wallet — your keys never leave it. Encrypted end-to-end over Nostr; the relay only sees ciphertext.</p>
|
||||
<div class="wc-supported">
|
||||
<div class="wc-wallet"><b>Cashonize</b><span>browser extension · v0.9+</span><a href="https://cashonize.com/" target="_blank" rel="noopener">install →</a></div>
|
||||
<div class="wc-wallet"><b>Paytaca</b><span>mobile · iOS + Android</span><a href="https://paytaca.com/" target="_blank" rel="noopener">install →</a></div>
|
||||
</div>
|
||||
<p class="note-soon"><b>Coming soon.</b> WizardConnect for message-signing is being wired in — for now, use Create or Import above (or the button below to jump back to the picker).</p>
|
||||
<div class="row">
|
||||
<button class="ghost" id="back">← Back to picker</button>
|
||||
</div>
|
||||
</div>
|
||||
`));
|
||||
panel.querySelector("#back").addEventListener("click", backToPicker);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,15 @@
|
|||
reverse_proxy auth-proxy:4000
|
||||
}
|
||||
|
||||
# Make BCH-wallet login the DEFAULT for /user/login. Old-school
|
||||
# username/password stays reachable via `/user/login?password=1`
|
||||
# (unmatched by this rule; falls through to Forgejo).
|
||||
@plain_login {
|
||||
path /user/login
|
||||
not query password=1
|
||||
}
|
||||
redir @plain_login /user/oauth2/hephaestus-wallet 302
|
||||
|
||||
handle {
|
||||
reverse_proxy forgejo:3000
|
||||
request_body {
|
||||
|
|
@ -43,6 +52,14 @@ hephaestus.x {
|
|||
reverse_proxy auth-proxy:4000
|
||||
}
|
||||
|
||||
# Same wallet-login-as-default as code.silentmode.st. Old-school
|
||||
# password login still reachable via /user/login?password=1.
|
||||
@plain_login {
|
||||
path /user/login
|
||||
not query password=1
|
||||
}
|
||||
redir @plain_login /user/oauth2/hephaestus-wallet 302
|
||||
|
||||
# Static-file-first, Forgejo-fallback. If /srv/hephaestus.x/{path} (or {path}/index.html)
|
||||
# exists, serve it. Otherwise the request falls through to Forgejo so all git/api/user
|
||||
# routes still work.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue