The sign-in page's headings were sentence case and named the domain: "Join src.rest", "Add a passkey", then "You are fox on src.rest" and "For fox on src.rest" under it. Now each step is a ui.panel whose head strip names it in lowercase chrome, with the forge's name: sign in, join forge, add a passkey for fox, signed in as fox. The passkey step says who it is for once, in its head strip. ssh commands on the page still show the real host, to copy.
- commit
- 81106ae94f4e75d932621ed91f53a611282893e3
- parent
- 38873d01038d33e362cc24c6ba0c4dbf0733a257
- author
- cat <admin@dev.site>
- ci
- ✓ passed in 88s, signed by cat
- kind
- refactor risk score 1.7 of 3, Jev's estimate
- review
- #21 A home page of profiles, recent and topics; motd for admins; /login/ in panels
ci attestation
forge-attestation 1 type ci commit 81106ae94f4e75d932621ed91f53a611282893e3 tree 01dc277be89b666d7a4c6433d70f948a208c7428 script .forge/ci 2dbcda6e044dc4b05503abde8369e2528413d3c7 result pass exit 0 started 2026-10-19T04:45:18Z duration 88s runner (not shown here; refs/notes/ci has it) system Darwin 25.5.0 arm64 env host log sha256:3659b3efaa745bd918db98a6bc5bfa5dccc1ed2d750e3e415aa0364eae88ab36 283 lines |pushed wip/i18n |3181f9e running .forge/ci: greet.sh: say what it is | | sh: /bin/sh | | ok greet |3181f9e pass in 0s, signed |pushed main |remote: review #4: /dami/greet/reviews/4/ |pushed wip/i18n |a1a3f5b running .forge/ci: wave at the world | | sh: /bin/sh | | FAIL greet : got 'hello, world o/', want 'hello, world' |a1a3f5b fail in 0s, signed |forge: 1 commit(s) did not pass |remote: review #6: /dami/greet/reviews/6/ |pushed ana/wave |210c93e running .forge/ci: update the test for the wave | | sh: /bin/sh | | ok greet |210c93e pass in 0s, signed |remote: review #6: /dami/greet/reviews/6/ |pushed ana/wave |warning: You appear to have cloned an empty repository. |site home: deployed 29f863c -> /sites/ana/dotfiles/home/ |created ana/notes |remote: review #1: /ana/notes/reviews/1/ |pushed ana/todo |landing #1 A todo list: ana/todo -> main |868b468 running .forge/ci: web, server and Python versions of greet | | sh: /bin/sh | | ok greet |868b468 pass in 0s, signed |pushed main |471d6d4 running .forge/ci: messages: notes for the later regions | | sh: /bin/sh | | ok greet |471d6d4 pass in 0s, signed |remote: review #7: /dami/greet/reviews/7/ |pushed dami/regions |created dami/forge |warning: You appear to have cloned an empty repository. |site guide: mkdir -p guide/fonts && cp site/assets/fonts/*.woff2 guide/fonts/ |site guide: deployed 4faed11 -> /sites/dami/forge/guide/ |(node:83757) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built ana/dotfiles in 0s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.869GhQ/wt/demo/root/.forge/www |(node:83916) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built ana/notes in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.869GhQ/wt/demo/root/.forge/www |(node:84084) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built dami/forge in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.869GhQ/wt/demo/root/.forge/www |(node:84245) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built dami/greet in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.869GhQ/wt/demo/root/.forge/www |(node:84999) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built - in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.869GhQ/wt/demo/root/.forge/www |235 of 235 pages render the same without 11ty |guide: 8 files, links ok |ok
Signature: good, cat
| M | lib/ | 2 | +- |
| M | site/ | 44 | +++++++++++++++--------------- |
| M | site/ | 3 | +- |
| M | test/ | 2 | +- |
| A | test/ | 33 | +++++++++++++++++++++++ |
@@ -280,7 +280,7 @@ export function privateHandler(cfg: PrivateConfig): http.RequestListener {
280280 const who = gate.bySession(cookieValue(req.headers.cookie));281281 const html = render('login.njk', {282282 p: { title: `Sign in - ${cfg.name ?? 'forge'}` }, user: who?.user ?? '', csrf: who?.secret ? csrfToken(who.secret) : '',283- signal: who ? signalJson(who.user) : '', next: safeNext(url.searchParams.get('next')), ready: !!login, host: rp?.id ?? '',283+ signal: who ? signalJson(who.user) : '', next: safeNext(url.searchParams.get('next')), ready: !!login,284284 });285285 res.writeHead(200, { ...PAGE_HEADERS, ...cookie(who) }).end(html);286286 }@@ -2,8 +2,10 @@
22 sign in with a passkey, add one from the link that ssh <host> passkey33 prints (/login/#enroll=<code>; login.ts does both), join from the link44 forge user invite prints (/login/#invite=<code>: an SSH key, then a5- passkey), and, signed in, sign out. Data: forge, p (title), user (or ''), csrf, signal (JSON or ''),6- next, ready (sign-in is set up: FORGE_BASE_URL), host. -#}5+ passkey), and, signed in, sign out. Each step is a panel named in6+ lowercase for the forge (FORGE_NAME), not its domain; ssh commands show7+ the real host, to copy. Data: forge, p (title), user (or ''), csrf,8+ signal (JSON or ''), next, ready (sign-in is set up: FORGE_BASE_URL). -#}79 {% extends "base.njk" %}810 {% import "ui.njk" as ui %}911 {% block scripts %}@@ -14,44 +16,42 @@
1416 {% endblock %}1517 {% block content %}1618 {% css %}17-/* the sign-in page */19+/* the sign-in page: a panel for each step, the status and the help under them */1820 .login { max-width: 40em; }19-.login h1 { font-size: var(--text-xl); margin: 0 0 var(--space-3); }20-.login-status { min-height: 1.4em; }21+.login-status { min-height: 1.4em; margin: var(--space-3) 0 0; }2122 .login-status.bad { color: var(--fail); }2223 .login form { display: inline; }2324 .login .input { max-width: 36em; }2425 {% endcss %}25-<div class="panel login" id="login" data-next="{{ next }}"{% if signal %} data-signal="{{ signal }}"{% endif %}><div class="panel-body">26+<div class="login" id="login" data-next="{{ next }}"{% if signal %} data-signal="{{ signal }}"{% endif %}>2627 {% if user %}27-<h1>Signed in as {{ user }}</h1>28+{% set head %}<h2>signed in as <b>{{ user }}</b></h2>{% endset %}29+{% call ui.panel(head) %}<div class="panel-body">2830 <p>You see the pages of the private repos you can read: <a href="/private/">your private repos</a>.</p>2931 <form method="post" action="/login/logout"><input type="hidden" name="csrf" value="{{ csrf }}">{{ ui.button('sign out', false, 'id="signout"', 'submit') }}</form>32+</div>{% endcall %}3033 {% elif not ready %}31-<h1>Sign in</h1>34+{% call ui.panel('<h2>sign in</h2>') %}<div class="panel-body">3235 <p>Sign-in isn't set up here: the forge needs <code>FORGE_BASE_URL</code>, the origin passkeys belong to.</p>36+</div>{% endcall %}3337 {% else %}34-<div id="signin-part">35-<h1>Sign in</h1>38+{% call ui.panel('<h2>sign in</h2>', '', 'id="signin-part"') %}<div class="panel-body">3639 <p>Private repos show their pages to their readers. Sign in with a passkey you have added to see them.</p>3740 <p>{{ ui.button('sign in with a passkey', true, 'id="signin"') }}</p>38-</div>39-<div id="invite-part" hidden>40-<h1>Join {{ host }}</h1>41-<div id="invite-form">41+</div>{% endcall %}42+{% set head %}<h2>join {{ forge.config.name }}</h2>{% endset %}43+{% call ui.panel(head, '', 'id="invite-part" hidden') %}<div class="panel-body" id="invite-form">4244 <p>You are invited as <b id="invite-user">you</b><span id="invite-grant"></span>. Your SSH key is how you get in: paste the public half, the one line of a <code>.pub</code> file such as <code>~/.ssh/id_ed25519.pub</code>. None yet? <code>ssh-keygen -t ed25519</code> makes one.</p>4345 <p>{{ ui.textarea('ssh public key', 'ssh-ed25519 AAAA... you@laptop', 'id="invite-key" rows="3" spellcheck="false" autocomplete="off" autocapitalize="off" maxlength="8192"', true) }}</p>4446 <p>{{ ui.button('join', true, 'id="invite-join"') }}</p>45-</div>46-</div>47-<div id="enroll-part" hidden>48-<h1>Add a passkey</h1>49-<p id="joined" hidden>You are <b id="joined-user">in</b> on {{ host }}: <code>ssh {{ forge.config.ssh }} help</code> says what you can do over ssh. A passkey signs you in on the web as well, to see the pages of private repos you may read. Your browser, phone or password manager keeps it. Not now? <code>ssh {{ forge.config.ssh }} passkey</code> prints a link for one later.</p>50-<p id="enroll-for">For <b id="enroll-user">you</b> on {{ host }}. Your browser, phone or password manager keeps it, and signs you in with it from now on.</p>47+</div>{% endcall %}48+{% call ui.panel('<h2>add a passkey for <b id="enroll-user">you</b></h2>', '', 'id="enroll-part" hidden') %}<div class="panel-body">49+<p id="joined" hidden>You are in: <code>ssh {{ forge.config.ssh }} help</code> says what you can do over ssh. A passkey signs you in on the web as well, to see the pages of private repos you may read. Not now? <code>ssh {{ forge.config.ssh }} passkey</code> prints a link for one later.</p>50+<p>Your browser, phone or password manager keeps it, and signs you in with it from now on.</p>5151 <p>{{ ui.button('add a passkey', true, 'id="enroll"') }}</p>52-</div>52+</div>{% endcall %}5353 <p class="login-status" id="login-status" role="status"></p>5454 <p class="desc" id="login-help">No passkey yet, or lost them all? <code>ssh {{ forge.config.ssh }} passkey</code> prints a link that adds one: your ssh key is how you get in.</p>5555 {% endif %}56-</div></div>56+</div>5757 {% endblock %}@@ -141,9 +141,8 @@ interface WebAuthn {
141141 history.replaceState(null, '', '/login/');142142 say('');143143 document.getElementById('invite-part')!.hidden = true;144- document.getElementById('joined-user')!.textContent = String(done.user);144+ document.getElementById('enroll-user')!.textContent = String(done.user);145145 document.getElementById('joined')!.hidden = false;146- document.getElementById('enroll-for')!.hidden = true;147146 if (wa.browserSupportsWebAuthn()) startEnroll(String(done.enroll));148147 else {149148 document.getElementById('enroll-part')!.hidden = false;@@ -26,7 +26,7 @@ http -s -D "$T/h" -o "$T/login" -H "Cookie: __Host-forge=$c" "$PRIV/login/"
2626 lacks "$T/h" '^Set-Cookie'2727 has "$T/h" "^Content-Security-Policy: default-src 'self'"2828 has "$T/h" '^Cache-Control: private, no-store'29-has "$T/login" 'Signed in as dami'29+has "$T/login" '<h2>signed in as <b>dami</b></h2>'3030 signal=$(sed -n 's/.*data-signal="\([^"]*\)".*/\1/p' "$T/login" | sed 's/"/"/g')3131 for k in one two; do3232 id=$(node -e 'console.log(JSON.parse(require("fs").readFileSync(process.argv[1])).id)' "$T/$k.key")@@ -0,0 +1,33 @@
1+# /login/'s steps (sign in, join from an invite, add a passkey, signed in)2+# are panels whose head strips are lowercase chrome naming the forge by its3+# name, never its domain; ssh commands on the page show the real host, to4+# copy. The passkey step names who it is for once, in its head strip.5+. "$(dirname "$0")/../lib.sh"6+user dami7+export FORGE_BASE_URL=https://forge.test FORGE_SSH=git@forge.test8+private_server9+# heads file: the head strips of the page, one per line.10+heads() { grep -o '<header class="panel-head">.*</header>' "$1" | sed 's/<[^>]*>//g'; }11+http -s "$PRIV/login/" >"$T/page"12+eq "$(heads "$T/page")" "sign in13+join forge14+add a passkey for you"15+has "$T/page" '<section class="panel" id="invite-part" hidden><header class="panel-head"><h2>join forge</h2></header>'16+has "$T/page" '<section class="panel" id="enroll-part" hidden><header class="panel-head"><h2>add a passkey for <b id="enroll-user">you</b></h2></header>'17+has "$T/page" '<code>ssh git@forge\.test passkey</code>'18+lacks "$T/page" 'For <b|on forge\.test'19+heads "$T/page" | grep -q 'forge\.test' && fail "a heading names the domain"20+heads "$T/page" | grep -q '[A-Z]' && fail "a heading has capitals"21+22+code=$(ssh_as dami passkey | sed -n 's/^ .*#enroll=//p')23+ok node "$T_HOME/test/authenticator.ts" enroll "$PRIV" "$code" "$T/dami.key"24+c=$(node "$T_HOME/test/authenticator.ts" login "$PRIV" "$T/dami.key")25+http -s -H "Cookie: __Host-forge=$c" "$PRIV/login/" >"$T/in"26+eq "$(heads "$T/in")" "signed in as dami"27+28+# Without sign-in set up, still a panel.29+stop_servers30+unset FORGE_BASE_URL31+private_server32+http -s "$PRIV/login/" >"$T/off"33+eq "$(heads "$T/off")" "sign in"