feat: mode-agnostic landing + HEAD-adaptive Sign up/Sign in (#435)#436
Conversation
Phase 3 refinement of #427. Builds on #433/#434. The original Phase 3 page was mode-specific: it inlined "Create a pod" and "Sign in" buttons based on the seed-time singleUser/idp flags. Two problems showed up in practice: 1. The seeded /index.html went stale on mode change. Once seeded by a --single-user run, every later run (any mode) served the same HTML because of skip-if-exists, so a multi-user restart still showed "Mode: single-user" and a non-functional Sign-in button. 2. "Sign in" linked to /idp, which 404s when IDP isn't enabled. The button always rendered (we worked it out at write time), so a mode change to no-IDP left the page promising something the server no longer offers. Refinement: render once, adapt at load. - The page is now mode-agnostic: same headline, same explainer, same primary "Get started" CTA pointing at jss.live/docs/getting-started/. - Sign up + Sign in anchors are emitted but `hidden` by default. An inline script does HEAD /idp/register on load: 200 → reveal both Sign up and Sign in (multi-user + IDP, open) 403 → reveal Sign in only (single-user — registration disabled) 404 / network error → leave both hidden (no IDP) Same seeded HTML keeps working when the operator switches modes. - The live server URL is filled in client-side from window.location.origin, so the page shows whichever host the visitor actually used (works behind reverse proxies, across localhost / 127.0.0.1 / LAN-IP, without re-rendering the file). - Tone tightened: no emoji headline, sentence case, brief paragraph about Solid for visitors landing on a public pod for the first time. Polished enough to leave as a public landing on an IDP / multi-user deployment without it looking like a first-run celebration screen. - Footer carries the GitHub link and a single-line customise hint. The seed model itself is unchanged: still skip-if-exists for /index.html, /.acl, and /index.html.acl. Operator-written files are preserved. The .acl seeds (relative './' / './index.html' from #434) keep an operator-uploaded /index.html publicly readable without the operator having to write WAC by hand. Tests: server-root.test.js rewritten for the new model. - 14 tests: same set of capabilities, refocused on the mode-agnostic behaviour (one render, all modes), the Get started link, hidden Sign-up/Sign-in anchors with the right data-cond attributes, the HEAD probe + live-URL scripts, version interpolation + escape, and the footer hints. - The previous mode-specific render assertions (Create a pod text, Personal-pod-for-name subtitle, $-pattern and {{token}} re-scan regressions) drop with the values they were guarding — none of those interpolated values exist in the new template. 813/813 tests pass. Refs #435.
Docusaurus 3 doesn't auto-generate a category index page, so /docs/getting-started/ 404s on jss.live. The canonical URL for the intro page is /docs/getting-started/introduction — point the landing's primary CTA there.
There was a problem hiding this comment.
Pull request overview
Refines the seeded server-root landing page so the public welcome page is less mode-specific and uses client-side detection for IDP sign-up/sign-in availability.
Changes:
- Reworks the landing page template with updated copy, styling, docs CTA, footer links, and inline scripts.
- Updates the renderer to remove mode-specific action rendering and keep only template substitutions for title/version/mode/features.
- Rewrites landing page tests around the new mode-agnostic copy and inline script presence.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
src/ui/server-root.html |
Replaces the landing page markup/styles and adds live URL plus HEAD-adaptive button scripts. |
src/ui/server-root.js |
Simplifies renderer context and removes server-side action/button generation. |
test/server-root.test.js |
Updates tests to assert the refreshed copy, docs link, hidden buttons, and inline scripts. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| .btn { | ||
| display: inline-block; | ||
| padding: 0.6rem 1.2rem; |
There was a problem hiding this comment.
Addressed in 694a5df: added [hidden] { display: none !important } so the UA-stylesheet [hidden] semantics win over the .btn class's display rule. New test pins the CSS rule down.
| const values = { | ||
| title: heading, | ||
| heading, | ||
| subtitle, | ||
| description, | ||
| actions: renderActions({ singleUser, idp }), | ||
| title: 'JSS Solid pod', | ||
| version: escape(version), | ||
| mode, | ||
| features |
There was a problem hiding this comment.
Addressed in 694a5df: dropped both Mode and Features from the seeded HTML — they would have gone stale on the next mode change because of skip-if-exists. The CLI banner already lists them at startup. Renderer simplified to just version. New test asserts neither pill appears in the seed.
| assert.match(html, /Sign in/); | ||
| }); | ||
|
|
||
| it('single-user + IDP shows Sign in only (no Create a pod)', () => { | ||
| const html = renderServerRoot({ version: '1.0.0', singleUser: true, idp: true, singleUserName: 'alice' }); | ||
| assert.doesNotMatch(html, /Create a pod/); | ||
| assert.match(html, /Sign in/); | ||
| }); | ||
|
|
||
| it('multi-user without IDP shows only the Docs link', () => { | ||
| const html = renderServerRoot({ version: '1.0.0', singleUser: false, idp: false }); | ||
| assert.doesNotMatch(html, /Create a pod/); | ||
| assert.doesNotMatch(html, /Sign in/); | ||
| assert.match(html, /Docs/); | ||
| it('includes the HEAD-adaptive script targeting /idp/register', () => { | ||
| const html = renderServerRoot({ version: '1.0.0' }); | ||
| assert.match(html, /fetch\(['"]\/idp\/register['"]/); | ||
| assert.match(html, /method:\s*['"]HEAD['"]/); | ||
| // The three documented branches: 200 → both, 403 → login only, |
There was a problem hiding this comment.
Addressed in 694a5df: extracted decideRevealForRegisterStatus(status) as a pure helper and added 5 unit tests covering 200 / 403 / 404 / 500 / undefined. The inline script implements the same matrix literally; the existing regex check on the script text pins the literals so they don't drift silently.
| */ | ||
| export function renderServerRoot(ctx = {}) { | ||
| const { version = 'unknown', singleUser = false, idp = false, singleUserName, enabled = {} } = ctx; | ||
| const { version = 'unknown', singleUser = false, enabled = {} } = ctx; |
There was a problem hiding this comment.
Addressed in 694a5df: dropped both Mode and Features from the seeded HTML — they would have gone stale on the next mode change because of skip-if-exists. The CLI banner already lists them at startup. Renderer simplified to just version. New test asserts neither pill appears in the seed.
| (function () { | ||
| fetch('/idp/register', { method: 'HEAD', cache: 'no-store' }) | ||
| .then(function (res) { | ||
| var register = document.querySelector('[data-cond="register"]'); | ||
| var login = document.querySelector('[data-cond="login"]'); | ||
| if (res.status === 200) { | ||
| if (register) register.hidden = false; | ||
| if (login) login.hidden = false; | ||
| } else if (res.status === 403) { | ||
| if (login) login.hidden = false; | ||
| } | ||
| // 404 / other / network error: leave both hidden | ||
| }) | ||
| .catch(function () { /* network error: leave both hidden */ }); |
There was a problem hiding this comment.
Addressed in 694a5df: extracted decideRevealForRegisterStatus(status) as a pure helper and added 5 unit tests covering 200 / 403 / 404 / 500 / undefined. The inline script implements the same matrix literally; the existing regex check on the script text pins the literals so they don't drift silently.
|
|
||
| {{actions}} | ||
| <div class="actions"> | ||
| <a href="https://jss.live/docs/getting-started/introduction" class="btn btn-primary">Get started →</a> |
There was a problem hiding this comment.
Updated the PR body to reflect /docs/getting-started/introduction (the canonical URL — Docusaurus 3 doesn't auto-generate a category index page, so /docs/getting-started/ would 404). Also dropped the docs-repo follow-up checkbox; introduction is a real, useful page already, and a friendlier 'first-run' walkthrough is being filed as a separate docs PR (this PR no longer blocks on it).
Three concrete fixes from the review thread.
1. .btn { display: inline-block } overrode the UA stylesheet's
[hidden] { display: none } (equal specificity, author rule wins
because it's later in the cascade). Sign up / Sign in would have
flashed visible before the HEAD probe finished. Add an explicit
[hidden] { display: none !important } so the attribute stays
authoritative; new test pins the rule down.
2. The mode pill and feature-pills row were rendered into the seeded
HTML at first start. Skip-if-exists then froze them — a mode change
later would have left the seeded copy showing the old mode and
feature flags even though the rest of the page is meant to adapt
without regeneration. Drop both from the seeded template; the CLI
banner already lists them at startup. The renderer drops the
listFeatures helper and the singleUser/enabled context inputs.
3. The HEAD-decision matrix was only verified by regex checks on the
inline script's text — a wrong-button regression for 200/403/404
would have slipped through. Extract the matrix into a pure helper,
`decideRevealForRegisterStatus(status)`, and unit-test the five
cases. The inline script implements the same matrix literally;
the regex test still pins the literals so they don't drift silently.
819/819 tests pass.
| <!-- Mode and enabled-feature pills used to live here, but the seeded | ||
| HTML can't reflect mode changes after first start (skip-if-exists). | ||
| The CLI banner already lists both at startup; surface them via a | ||
| runtime probe in a follow-up if needed. See #436 review. --> |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
src/ui/server-root.html:106
- Using an origin-root path for the HEAD probe breaks deployments mounted behind a reverse-proxy path prefix (the seeded ACLs explicitly use
./to support that case). If the page is served from/jss/, this probes/idp/registerinstead of/jss/idp/register, so Sign up/Sign in stay hidden even when the IdP is available; use a page-relative URL or derive the base from the current document.
fetch('/idp/register', { method: 'HEAD', cache: 'no-store' })
| <a href="/idp/register" class="btn btn-secondary" data-cond="register" hidden>Sign up</a> | ||
| <a href="/idp" class="btn btn-secondary" data-cond="login" hidden>Sign in</a> |
There was a problem hiding this comment.
Addressed in b6ad396: switched the Sign up / Sign in anchor hrefs and the HEAD probe URL to page-relative (./idp/register, ./idp). They now resolve against document.baseURI, so a JSS instance behind a reverse proxy at /jss/ sends visitors to /jss/idp/register instead of escaping to the proxy origin. Same rationale as the relative ACL targets from #428. Tests assert both the new ./ form and the absence of the old absolute form.
Address Copilot pickup on #436. The Sign up / Sign in anchor hrefs and the inline HEAD probe used origin-root absolute paths (/idp...). Behind a reverse proxy at e.g. https://example/jss/, these would escape the mount point and hit the proxy origin (/idp) instead of the JSS-mounted server (/jss/idp). Switch all three to page-relative (./idp...). Anchor hrefs and the fetch() URL both resolve against document.baseURI, so they stay inside whatever path prefix the server is mounted under. Same rationale as the relative ACL targets from #428. Tests assert both the new ./ form and the absence of the old absolute form (so a regression to /idp would fail loud). 819/819 tests pass.
| // whichever hostname/port they hit — works behind reverse proxies | ||
| // and across localhost / 127.0.0.1 / LAN-IP without re-rendering | ||
| // the file). | ||
| (function () { | ||
| var el = document.getElementById('server-url'); | ||
| if (!el) return; | ||
| el.textContent = window.location.origin; | ||
| el.href = window.location.origin + '/'; |
There was a problem hiding this comment.
Addressed in 5447f65: switched to new URL('./', window.location.href).href, which resolves to the document's directory and preserves any reverse-proxy path prefix the server is mounted under. A JSS instance at https://example.com/jss/ now displays and links to https://example.com/jss/, not the proxy origin. Same path-prefix-safe treatment as the IdP links and ACL targets in the rest of this PR. Test asserts both the new pattern and the absence of the old origin-only form.
Address Copilot pickup on #436. window.location.origin drops any reverse-proxy path prefix the server is mounted under: a JSS instance at https://example.com/jss/ would have displayed (and linked to) 'https://example.com/' for the live-URL line, escaping the mount. Switch to new URL('./', window.location.href).href, which resolves to the directory of the current document — i.e. the JSS server root, prefix included. Same path-prefix-safe treatment as the IdP links and ACL targets in the rest of this PR. Test asserts both presence of the new pattern and absence of the origin-only form. 819/819 tests pass.
| <div class="subtitle">{{subtitle}}</div> | ||
| <p>{{description}}</p> | ||
| <h1>Welcome</h1> | ||
| <p class="lede">Your JSS Solid pod is running at <a id="server-url" href="/">this server</a>.</p> |
| title: 'JSS Solid pod', | ||
| version: escape(version) |
Two more Copilot pickups on #436. 1. The version was still seed-time. Same skip-if-exists trap as the mode and feature pills already dropped — an upgrade wouldn't refresh the displayed version. Operators relying on it would silently see the old number forever. Drop the version row entirely; the CLI banner already shows it at startup, and `npm view` is one shell command away. With version gone, the info box has nothing left to show; remove the whole .info block plus its CSS. The renderer no longer has any dynamic values to substitute — collapses to a static readFileSync. Drop the template-substitution machinery (single-pass replacer) and the escape() helper that only existed to sanitise version. 2. The live-URL anchor's fallback href was "/" (origin-root absolute). If the inline script doesn't run (CSP, JS disabled, error before it executes), clicking the link would escape any reverse-proxy mount prefix. Switch to "./" so the fallback stays inside the mount, matching every other URL in this template. Tests: dropped the version interpolation + injection-escape tests (no longer applicable — no version is rendered, no values substituted). Renamed the byte-equality test to make the static-HTML guarantee explicit. Added a regression test asserting the page-relative fallback href. 819/819 tests pass.
| // HEAD-adaptive Sign up / Sign in. Reveals each button based on what | ||
| // the IDP currently exposes — the same seeded HTML works for | ||
| // single-user, multi-user-with-IDP, and no-IDP modes without | ||
| // regeneration on mode change. | ||
| // 200 → registration open: show both Sign up and Sign in | ||
| // 403 → IDP enabled but registration disabled (single-user mode): | ||
| // Sign in only | ||
| // 404 / network error → no IDP: hide both | ||
| (function () { | ||
| // Relative URL resolves against document base, same as the | ||
| // anchor hrefs above — preserves the path-prefix mount point. | ||
| fetch('./idp/register', { method: 'HEAD', cache: 'no-store' }) | ||
| .then(function (res) { | ||
| var register = document.querySelector('[data-cond="register"]'); | ||
| var login = document.querySelector('[data-cond="login"]'); | ||
| if (res.status === 200) { | ||
| if (register) register.hidden = false; | ||
| if (login) login.hidden = false; | ||
| } else if (res.status === 403) { | ||
| if (login) login.hidden = false; | ||
| } | ||
| // 404 / other / network error: leave both hidden | ||
| }) | ||
| .catch(function () { /* network error: leave both hidden */ }); |
There was a problem hiding this comment.
Real concern, but it requires an operator who both runs with --no-idp AND explicitly PUTs content to /idp/register — unlikely combination, and worst case is bad UX (revealed buttons leading to a 404), not a security issue. A robust fix (reserve the /idp/* namespace at the routing layer so LDP can never shadow it, or add a dedicated discovery endpoint) is meaningfully bigger than the rest of #435 — better to land this PR and iterate. Filed as #439 for follow-up.
Summary
Refines the Phase 3 landing page from #433/#434 so it adapts to the server's current state at load time instead of being frozen at seed time.
HEAD /idp/register:200→ reveal both (multi-user + IDP, registration open)403→ reveal Sign in only (single-user — registration intentionally disabled)404/ network error → leave both hidden (no IDP)window.location.origin— adapts to whichever host the visitor used (works behind reverse proxies, acrosslocalhost/127.0.0.1/ LAN-IP) without re-rendering.https://jss.live/docs/getting-started/introduction(the canonical first page in the existing Getting Started section — Docusaurus 3 doesn't auto-generate a category index, so/docs/getting-started/would 404).The seed model itself is unchanged — still skip-if-exists for
/index.html,/.acl,/index.html.acl. Operator-written files preserved. The./-relative.aclseeds from #434 still mean an operator-uploaded/index.htmlis publicly readable without them having to write WAC by hand.Why
Two problems with the previous Phase 3 landing showed up the first time we tried switching modes on a real install:
--single-userran first, every later run (any mode) served the same HTML because of skip-if-exists. A subsequent restart in another mode still showed "Mode: single-user" and a non-functional Sign-in button./idp, which 404s when IDP isn't enabled. The button always rendered (worked out at write time), so the page kept promising something the server no longer offered after a mode change.Copilot review surfaced two more during this PR's iteration:
.btnclass'sdisplay: inline-blockoverrode the UA stylesheet's[hidden] { display: none }(equal specificity, author rule wins because it comes later). Sign up / Sign in would have flashed visible before the HEAD probe finished. Fixed with an explicit[hidden] { display: none !important }rule.Tests
test/server-root.test.jsrewritten for the new model. 20 tests:/serves seeded HTML, public-read on/index.html, seeded ACLs use./relative form (feat: default landing page + ACL at server root (#433, supersedes #303) #434 regression)./index.htmlpreserved + served as-is.singleUserflag, no Mode/Features pills baked in, Get started CTA at/docs/getting-started/introduction, hidden Sign-up/Sign-in anchors with the rightdata-condattributes,[hidden] { display: none !important }CSS rule present, HEAD probe script presence + status literals, live-URL script, version interpolation + escape, footer GitHub link + customise hint.decideRevealForRegisterStatus(status)helper covering 200 / 403 / 404 / 500 / undefined.819/819 tests pass.
Test plan
npm test— 819/819 passing locally[hidden]override, the absence of stale Mode/Features pills in the seed, and the docs-CTA URL..aclportability regression from feat: default landing page + ACL at server root (#433, supersedes #303) #434 still passing.Out of scope
/idp/loginpage (NSS-style direct credential form). That's the only way to give "Sign in" a destination when no OIDC client app is in the picture; deferred to its own feature.JavaScriptSolidServer/docs. The CTA currently lands on Introduction (a real, useful page); a more guided post-install page is being filed as a separate, independent docs PR.Closes #435. Phase 3 refinement of #427.