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:
Local Dev 2026-08-31 17:50:49 +02:00
parent fc43382d48
commit e67943ed00
3 changed files with 152 additions and 29 deletions

View file

@ -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);

View file

@ -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 &amp; 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 &amp; 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 &amp; 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);
}
}

View file

@ -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.