feat(profile): render /.profile via the tables engine (access + create + diagnostics)

Retire the bespoke profile page. /.profile now renders through the shared
tables engine (header chrome incl. the profile menu) from a server-injected
context: the caller's "Effective access" — projects + admin subtrees — as
clickable rows (rowNav opens each), identity in the description, and an
apiActions "+ New project" (name → POST /.profile/projects, gated on
can_create_project; roles are added afterward by editing the project's
.zddc, which is now standing-editable). Super-admin diagnostics
(config/logs/whoami/effective-policy) stay discoverable as rows linking to
their unchanged endpoints — gated on IsSuperAdmin so a non-admin's context
never even names them.

Dropped as redundant/niche: the in-page theme picker (the header has the
theme button), the localStorage inspector, and the "editable .zddc" links
(those files are now standing-editable in browse).

Extends the generic apiActions layer (tables/js/api-actions.js) with
`fixed` constant fields (e.g. parent="/"), `required` field validation, and
`rowNav` clickable rows (capture-phase, so it beats the editor's per-cell
handlers). Rewrote TestServeProfileHTMLLayered to the new model (per-role
context correctness: no admin leak; super-admin diagnostics present) and
dropped the now-dead stripTemplates helper. Validated in a containerized
browser; full Go suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-06 16:07:56 -05:00
parent 2a05b7716c
commit d9256050d2
4 changed files with 201 additions and 90 deletions

View file

@ -49,9 +49,10 @@
var inputs = {};
(c.fields || []).forEach(function (f) {
var lab = el('label', { class: 'api-modal__field' });
lab.appendChild(el('span', null, f.label || f.name));
lab.appendChild(el('span', null, (f.label || f.name) + (f.required ? ' *' : '')));
var inp = el('input', { type: f.type || 'text' });
if (f.placeholder) inp.setAttribute('placeholder', f.placeholder);
if (f.required) inp.required = true;
inputs[f.name] = inp;
lab.appendChild(inp);
form.appendChild(lab);
@ -76,6 +77,12 @@
form.addEventListener('submit', function (e) {
e.preventDefault();
err.hidden = true;
var missing = (c.fields || []).filter(function (f) { return f.required && !inputs[f.name].value.trim(); });
if (missing.length) {
err.textContent = 'Required: ' + missing.map(function (f) { return f.label || f.name; }).join(', ');
err.hidden = false;
return;
}
var body = {};
(c.fields || []).forEach(function (f) {
var v = inputs[f.name].value.trim();
@ -83,6 +90,9 @@
// Date fields → RFC3339 so the Go time.Time decoder accepts them.
body[f.name] = (f.type === 'date') ? new Date(v + 'T00:00:00').toISOString() : v;
});
// Constant fields the server requires but the user doesn't set
// (e.g. project create's parent="/").
if (c.fixed) Object.keys(c.fixed).forEach(function (k) { body[k] = c.fixed[k]; });
submit.disabled = true;
fetch(c.url, {
method: 'POST',
@ -175,12 +185,40 @@
});
}
// Per-row navigation: clicking a row opens its data-url (the project /
// subtree it represents) — used by the profile "Effective access" table.
// Clicks on inner controls (buttons/links/inputs) are left alone.
function ensureRowNav() {
var tbody = document.querySelector('#table-root tbody');
if (!tbody) return;
var trs = tbody.querySelectorAll('tr');
for (var i = 0; i < trs.length; i++) {
var tr = trs[i];
if (tr.getAttribute('data-nav') === '1') continue;
var url = tr.getAttribute('data-url');
if (!url) continue;
tr.setAttribute('data-nav', '1');
tr.style.cursor = 'pointer';
(function (target) {
// Capture phase: fire before the tables editor's per-cell
// click handlers (which would otherwise swallow the click on
// read-only rows). Inner controls (buttons/links/inputs) still
// opt out.
tr.addEventListener('click', function (e) {
if (e.target.closest('button, a, input')) return;
window.location.href = target;
}, true);
})(url);
}
}
function tick() {
var c = cfg();
if (!c) return;
hideNative();
if (c.create) mountCreate(c.create);
if (c.deleteRow) ensureRowDelete(c.deleteRow);
if (c.rowNav) ensureRowNav();
}
function start() {

View file

@ -235,32 +235,6 @@ func TestServeProfileLogsLevelFilter(t *testing.T) {
}
}
// stripTemplates removes every <template ...>...</template> block from the
// HTML body so substring assertions check only ACTIVE markup — i.e. live
// DOM content the user (and their browser) actually sees, as opposed to
// inert content that JS may clone in based on a later access fetch.
//
// Naive but sufficient for the controlled output of profileTemplate (the
// template tags are unnested and well-formed). If the page ever grows
// nested templates, swap this for an html.Tokenizer-based pass.
func stripTemplates(body string) string {
var b strings.Builder
for {
i := strings.Index(body, "<template")
if i < 0 {
b.WriteString(body)
return b.String()
}
b.WriteString(body[:i])
j := strings.Index(body[i:], "</template>")
if j < 0 {
// Unterminated <template> — bail; whatever's left is suspect.
return b.String()
}
body = body[i+j+len("</template>"):]
}
}
// TestServeProfileHTMLLayered pins the page-render contract after the
// lazy-load refactor:
//
@ -306,85 +280,53 @@ func TestServeProfileHTMLLayered(t *testing.T) {
return rec.Body.String()
}
// Anonymous: identity says "Not signed in", no live admin markup, no
// diagnostics. The <template> still ships inertly so any caller could
// hydrate it after a successful /access fetch — but a non-admin's
// /access response carries empty AdminSubtrees and the JS skips
// instantiation. The active-markup check below proves the live DOM is
// admin-clean regardless.
// The page now renders through the shared tables engine with a server-
// injected #table-context (no bespoke scaffolds). The per-role contract:
// the context must never NAME a capability the caller lacks — super-admin
// diagnostics (config/logs/whoami) appear only for a super-admin, so a
// non-admin's bytes can't even reference them.
diag := ProfilePathPrefix + "/config"
// Anonymous: "Not signed in" identity, no diagnostics.
anon := render("")
if !strings.Contains(anon, "Not signed in") {
t.Errorf("anonymous body missing 'Not signed in'")
}
anonActive := stripTemplates(anon)
for _, marker := range []string{
`<form id="cp-form"`,
`id="diag-config"`,
`id="diag-logs"`,
`id="diag-whoami"`,
"Server config",
} {
if strings.Contains(anonActive, marker) {
t.Errorf("anonymous active markup unexpectedly contains admin marker %q", marker)
}
if !strings.Contains(anon, `id="table-context"`) {
t.Errorf("profile page not rendered via the tables engine")
}
// Inert <template> SHOULD ship — admins (and only admins) hydrate it.
if !strings.Contains(anon, `<template id="tmpl-subtree-admin">`) {
t.Errorf("anonymous body missing inert subtree-admin <template>")
if strings.Contains(anon, diag) {
t.Errorf("anonymous body leaks super-admin diagnostics (%q)", diag)
}
// Non-admin (carol): email shown, no diagnostics.
nonAdmin := render("carol@example.com")
if !strings.Contains(nonAdmin, "carol@example.com") {
t.Errorf("non-admin body missing email")
}
nonAdminActive := stripTemplates(nonAdmin)
for _, marker := range []string{
`<form id="cp-form"`,
`id="diag-config"`,
"Server config",
} {
if strings.Contains(nonAdminActive, marker) {
t.Errorf("non-admin active markup unexpectedly contains admin marker %q", marker)
}
if strings.Contains(nonAdmin, diag) {
t.Errorf("non-admin body leaks super-admin diagnostics")
}
// Subtree-admin (bob) gets the same shell as a non-admin — the
// scaffold lives in the <template> and JS hydrates it after fetching
// /.profile/access. The server-side render no longer differentiates
// these two roles, so its byte-output should match a non-admin's.
// Subtree-admin (bob): administers projects/, but is NOT a root super-
// admin — still no diagnostics.
subtree := render("bob@example.com")
subtreeActive := stripTemplates(subtree)
for _, marker := range []string{
`<form id="cp-form"`,
`id="diag-config"`,
"Server config",
} {
if strings.Contains(subtreeActive, marker) {
t.Errorf("subtree-admin active markup unexpectedly contains admin marker %q (these are JS-hydrated)", marker)
}
}
if !strings.Contains(subtree, `<template id="tmpl-subtree-admin">`) {
t.Errorf("subtree-admin body missing the <template> the IIFE will hydrate")
if strings.Contains(subtree, diag) {
t.Errorf("subtree-admin body leaks super-admin diagnostics")
}
// Super-admin: diagnostics scaffold is rendered inline (cheap to
// gate), AND the subtree-admin <template> still ships for the IIFE to
// hydrate Editable + Create sections.
// Super-admin (alice): diagnostics are discoverable as rows linking to
// the (unchanged) endpoints.
super := render("alice@example.com")
superActive := stripTemplates(super)
for _, marker := range []string{
"Server config",
`id="diag-config"`,
`id="diag-logs"`,
`id="diag-whoami"`,
for _, link := range []string{
ProfilePathPrefix + "/config",
ProfilePathPrefix + "/logs",
ProfilePathPrefix + "/whoami",
} {
if !strings.Contains(superActive, marker) {
t.Errorf("super-admin active markup missing %q", marker)
if !strings.Contains(super, link) {
t.Errorf("super-admin profile missing diagnostic link %q", link)
}
}
if !strings.Contains(super, `<template id="tmpl-subtree-admin">`) {
t.Errorf("super-admin body missing subtree-admin <template> (still needs to hydrate Editable + Create)")
}
}
func TestServeProfileAccessJSON(t *testing.T) {

View file

@ -41,6 +41,24 @@ func serveProfilePage(cfg config.Config, w http.ResponseWriter, r *http.Request)
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
// Render "Effective access" (projects + admin subtrees) + Create project
// through the shared tables engine — header chrome + declarative columns,
// no bespoke page. The redundant/niche sections of the old page are
// dropped: theme (now the header's theme button), the localStorage tool,
// and the "editable .zddc" links (those files are now standing-editable in
// browse). Falls back to the legacy template if the tables renderer isn't
// built into this binary.
tablesHTML := EmbeddedTablesHTML()
if len(tablesHTML) > 0 {
if injected, err := injectTableContextObj(tablesHTML, buildProfileTableContext(cfg, r)); err == nil {
_, _ = w.Write(injected)
return
}
}
email := EmailFromContext(r)
view := profileView{
Email: email,
@ -50,13 +68,88 @@ func serveProfilePage(cfg config.Config, w http.ResponseWriter, r *http.Request)
AssetsPathPrefix: profileAssetsPathPrefix,
HasCustomCSS: hasCustomProfileCSS(cfg.Root),
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
if err := profileTemplate.Execute(w, view); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// buildProfileTableContext assembles the #table-context for the profile page:
// the caller's accessible scopes (projects + admin subtrees) as clickable
// rows, identity in the description, and an apiActions block wiring "+ New
// project" to POST /.profile/projects (only when the caller can create one).
func buildProfileTableContext(cfg config.Config, r *http.Request) map[string]interface{} {
view := enumerateAccess(r.Context(), DeciderFromContext(r), cfg, PrincipalFromContext(r), "")
rows := []map[string]interface{}{}
for _, proj := range view.Projects {
rows = append(rows, map[string]interface{}{
"url": proj.URL,
"editable": false,
"data": map[string]interface{}{"name": proj.Name, "title": proj.Title, "kind": "project"},
})
}
for _, sub := range view.AdminSubtrees {
rows = append(rows, map[string]interface{}{
"url": sub.Path,
"editable": false,
"data": map[string]interface{}{"name": sub.Path, "title": sub.Title, "kind": "admin"},
})
}
// Super-admin diagnostics: keep config/logs/whoami discoverable as rows
// (the endpoints are unchanged; only the bespoke links moved here). Gated
// on IsSuperAdmin so a non-admin's context never names them.
if view.IsSuperAdmin {
for _, d := range []struct{ name, url string }{
{"Server config", ProfilePathPrefix + "/config"},
{"Server logs", ProfilePathPrefix + "/logs"},
{"Whoami (request headers)", ProfilePathPrefix + "/whoami"},
{"Effective policy", ProfilePathPrefix + "/effective-policy"},
} {
rows = append(rows, map[string]interface{}{
"url": d.url,
"editable": false,
"data": map[string]interface{}{"name": d.name, "title": "", "kind": "server"},
})
}
}
desc := "Signed in as " + view.Email
if view.Email == "" {
desc = "Not signed in — the server reads identity from the " + cfg.EmailHeader + " header."
} else if view.IsSuperAdmin {
desc += " · super admin"
}
col := func(field, title, width string) map[string]interface{} {
c := map[string]interface{}{"field": field, "title": title}
if width != "" {
c["width"] = width
}
return c
}
apiActions := map[string]interface{}{"rowNav": true}
if view.CanCreateProject {
apiActions["create"] = map[string]interface{}{
"url": ProfilePathPrefix + "/projects",
"title": "New project",
"fixed": map[string]interface{}{"parent": "/"},
"fields": []map[string]interface{}{
{"name": "name", "label": "Folder name", "placeholder": "e.g. Site-3", "required": true},
{"name": "title", "label": "Title (optional)"},
},
}
}
return map[string]interface{}{
"title": "Profile",
"description": desc,
"addable": false,
"columns": []map[string]interface{}{
col("name", "Project", ""),
col("title", "Title", ""),
col("kind", "Type", "8em"),
},
"rows": rows,
"apiActions": apiActions,
}
}
// profileTemplate is the html/template for the profile page. The shell is
// rendered server-side from cheap-only data (identity + IsSuperAdmin); the
// expensive bits (visible projects, admin subtrees, editable .zddc files,

View file

@ -1632,7 +1632,7 @@ body.is-elevated::after {
</svg>
<div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-dev · 2026-06-06 20:06:06 · 76087c8-dirty</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-dev · 2026-06-06 21:07:11 · 2a05b77-dirty</span></span>
</div>
</div>
<div class="header-right">
@ -7092,9 +7092,10 @@ body.is-elevated::after {
var inputs = {};
(c.fields || []).forEach(function (f) {
var lab = el('label', { class: 'api-modal__field' });
lab.appendChild(el('span', null, f.label || f.name));
lab.appendChild(el('span', null, (f.label || f.name) + (f.required ? ' *' : '')));
var inp = el('input', { type: f.type || 'text' });
if (f.placeholder) inp.setAttribute('placeholder', f.placeholder);
if (f.required) inp.required = true;
inputs[f.name] = inp;
lab.appendChild(inp);
form.appendChild(lab);
@ -7119,6 +7120,12 @@ body.is-elevated::after {
form.addEventListener('submit', function (e) {
e.preventDefault();
err.hidden = true;
var missing = (c.fields || []).filter(function (f) { return f.required && !inputs[f.name].value.trim(); });
if (missing.length) {
err.textContent = 'Required: ' + missing.map(function (f) { return f.label || f.name; }).join(', ');
err.hidden = false;
return;
}
var body = {};
(c.fields || []).forEach(function (f) {
var v = inputs[f.name].value.trim();
@ -7126,6 +7133,9 @@ body.is-elevated::after {
// Date fields → RFC3339 so the Go time.Time decoder accepts them.
body[f.name] = (f.type === 'date') ? new Date(v + 'T00:00:00').toISOString() : v;
});
// Constant fields the server requires but the user doesn't set
// (e.g. project create's parent="/").
if (c.fixed) Object.keys(c.fixed).forEach(function (k) { body[k] = c.fixed[k]; });
submit.disabled = true;
fetch(c.url, {
method: 'POST',
@ -7218,12 +7228,40 @@ body.is-elevated::after {
});
}
// Per-row navigation: clicking a row opens its data-url (the project /
// subtree it represents) — used by the profile "Effective access" table.
// Clicks on inner controls (buttons/links/inputs) are left alone.
function ensureRowNav() {
var tbody = document.querySelector('#table-root tbody');
if (!tbody) return;
var trs = tbody.querySelectorAll('tr');
for (var i = 0; i < trs.length; i++) {
var tr = trs[i];
if (tr.getAttribute('data-nav') === '1') continue;
var url = tr.getAttribute('data-url');
if (!url) continue;
tr.setAttribute('data-nav', '1');
tr.style.cursor = 'pointer';
(function (target) {
// Capture phase: fire before the tables editor's per-cell
// click handlers (which would otherwise swallow the click on
// read-only rows). Inner controls (buttons/links/inputs) still
// opt out.
tr.addEventListener('click', function (e) {
if (e.target.closest('button, a, input')) return;
window.location.href = target;
}, true);
})(url);
}
}
function tick() {
var c = cfg();
if (!c) return;
hideNative();
if (c.create) mountCreate(c.create);
if (c.deleteRow) ensureRowDelete(c.deleteRow);
if (c.rowNav) ensureRowNav();
}
function start() {