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:
parent
2a05b7716c
commit
d9256050d2
4 changed files with 201 additions and 90 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in a new issue