ZDDC/zddc/internal/handler/profilepage.go
ZDDC 2d114fcb96 refactor: unified listing protocol + form-editor retirement + admin elevation
Three coordinated changes that share the same files. Common theme:
convention beats exception. Where the codebase had a bespoke wire shape
or a special-case route, replace it with the generic shape every other
client already speaks.

== Listing protocol ==

GET / Accept: application/json used to dispatch to a bespoke
ServeProjectList handler returning {name, url, title} per project — a
shape that diverged from every other directory's listing.FileInfo
response. Now:

- listing.FileInfo gains an optional `title` field (read from each
  directory's own .zddc title:). Generic clients (landing, browse)
  read the same shape from every URL.
- appfs.ListDirectory emits a virtual `.zddc` entry (is_dir:false,
  virtual:true) when no on-disk file exists at that path and the
  caller asked for ?hidden=1. Opens an editable view of the cascade
  defaults; PUT-saving its bytes materialises a real file.
- The bespoke GET / JSON branch in cmd/zddc-server/main.go is gone.
  The bare-root landing serve is Accept-gated: HTML requests get the
  landing tool (project picker), JSON requests fall through to
  ServeDirectory and get the generic listing.
- landing's fetchProjects filters the new generic shape (is_dir,
  strip trailing slash) — same pattern fetchParties already used at
  /<project>/archive/.

== Form editor retirement ==

`<dir>/.zddc.html` was a server-rendered form for editing per-directory
.zddc files (~900 LOC across zddceditor.go, zddchandler.go, zddc_assets.go).
Browse's YAML/CodeMirror editor (with .zddc-schema lint) already edits
the same files via the generic file-API. Two ways to edit the same data
is exception, not convention.

- Delete zddceditor.go, zddchandler.go, zddc_assets.go and tests.
- `/<dir>/.zddc.html` → 302 redirect to `/<dir>/?file=.zddc` (browse
  opens the .zddc in its editor pane).
- /.profile/zddc/* namespace deleted (REST API + assets sub-route).
- Profile page's "Editable .zddc files" list links to browse.
- ServeZddcFile's 405 message + virtual-body comment point at the
  browse URL instead of the dead form.

== Admin elevation (Principal model) ==

Sudo-style: admins are treated as normal users by default; opting into
admin powers is per-request and gated by a `zddc-elevate=1` cookie.

- zddc.Principal{Email, Elevated} replaces bare-email arguments on
  IsAdmin / IsSubtreeAdmin / CanEditZddc. The signature change makes
  the elevation gate compiler-enforced at every admin call site —
  audit-fragility is gone. The empty-email short-circuit is no longer
  load-bearing for elevation; Principal.gate() is the explicit check.
- handler.ACLMiddleware derives Elevated per request: bearer tokens
  are implicitly elevated (CLI clients can't toggle a cookie); browser
  sessions elevate only when zddc-elevate=1 is set. PrincipalFromContext(r)
  is the one-call-per-site bundling helper.
- Every admin-check call site updated to pass a Principal.
- /.auth/admin (forward_auth target for the dev-shell IDE) explicitly
  bypasses elevation with a synthetic-elevated Principal — different
  cookie scope than zddc-server origin, documented inline.
- AccessView gains CanElevate (elevation-independent "does this email
  have admin authority anywhere?") so the header toggle can render
  itself for an un-elevated admin who hasn't opted in yet.
- ServeProjectList is removed; ProjectInfo + EnumerateProjects stay
  for the profile page's server-rendered project list.
- MatchAppHTML stays — still used by main.go to route <dir>/<tool>.html
  URLs to the apps subsystem when no real file exists.
- Test helpers carry Elevated=true by default (matches the
  pre-elevation default; tests for the un-elevated gate use the
  explicit form).

Go tests pass across all 14 internal packages. Browse + every other
tool rebuilds clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:15:07 -05:00

570 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package handler
import (
"html/template"
"net/http"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// profileView is the data passed to the profile template's HTML shell.
//
// Only cheap-to-compute fields appear here — Email comes from the request
// context, IsSuperAdmin reads the root .zddc only (single file + ACL chain
// cache hit), and HasCustomCSS is a single stat call. Everything else
// (visible projects, admin subtrees, editable scaffolds) is fetched lazily
// by the page's JS via /.profile/access after first paint, so the slow
// .zddc tree walk doesn't block the initial render. See AccessView and
// enumerateAccess for the JSON contract the client renders against.
type profileView struct {
Email string
EmailHeader string
IsSuperAdmin bool
ProfilePathPrefix string
AssetsPathPrefix string
HasCustomCSS bool
}
// serveProfilePage renders the universal profile page at GET /.profile/.
// Reachable to anyone (anonymous included). The shell is intentionally
// minimal: identity card + theme + localStorage + super-admin diagnostic
// scaffolds (gated by the cheap IsSuperAdmin check) + a hidden
// <template id="tmpl-subtree-admin"> block. The client IIFE fetches
// /.profile/access on load and reveals the subtree-admin block when the
// caller has any subtree-admin scope. Pure non-admins receive no live
// admin form, button handler, or fetch URL — admin functionality only
// ever activates after enumerateAccess has confirmed the caller's scope.
func serveProfilePage(cfg config.Config, w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET")
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
email := EmailFromContext(r)
view := profileView{
Email: email,
EmailHeader: cfg.EmailHeader,
IsSuperAdmin: zddc.IsAdmin(cfg.Root, PrincipalFromContext(r)),
ProfilePathPrefix: ProfilePathPrefix,
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)
}
}
// 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,
// create-project parent choices) are populated by the IIFE below after a
// single fetch to /.profile/access. Subtree-admin scaffolds live inside a
// <template id="tmpl-subtree-admin"> so non-admins never receive the live
// form markup. Inline styles use the same custom-property naming as the
// editor so a future merge with shared/base.css stays trivial.
var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>zddc-server — profile</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root {
--bg: #fff; --bg-alt: #f7f7f8; --text: #222; --muted: #666;
--border: #d0d4dc; --primary: #2563eb; --primary-bg: #eff6ff;
--danger: #b00020; --warn: #b15c00; --ok: #0a7d2c;
--radius: 4px;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1a1c1f; --bg-alt: #23262b; --text: #e8e8ea; --muted: #a0a4ad;
--border: #353941; --primary: #60a5fa; --primary-bg: #1e293b;
--danger: #ff7080; --warn: #f5b056; --ok: #4ad27c;
}
}
[data-theme="light"] {
--bg: #fff; --bg-alt: #f7f7f8; --text: #222; --muted: #666;
--border: #d0d4dc; --primary: #2563eb; --primary-bg: #eff6ff;
--danger: #b00020; --warn: #b15c00; --ok: #0a7d2c;
}
[data-theme="dark"] {
--bg: #1a1c1f; --bg-alt: #23262b; --text: #e8e8ea; --muted: #a0a4ad;
--border: #353941; --primary: #60a5fa; --primary-bg: #1e293b;
--danger: #ff7080; --warn: #f5b056; --ok: #4ad27c;
}
body { font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; margin: 1.5rem; color: var(--text); background: var(--bg); max-width: 980px; }
h1 { margin: 0 0 .25rem; font-size: 1.4rem; }
h2 { margin: 1.5rem 0 .25rem; font-size: 1.05rem; }
.muted { color: var(--muted); }
.breadcrumb { color: var(--muted); margin-bottom: 1rem; }
.breadcrumb a { color: var(--primary); text-decoration: none; }
.breadcrumb a:hover { text-decoration: underline; }
.card { background: var(--bg-alt); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem 1.1rem; margin-bottom: 1rem; }
.card h2 { margin-top: 0; }
.card .help { color: var(--muted); font-size: .9em; margin: .3rem 0 .6rem; }
label { display: block; margin-bottom: .5rem; }
input[type="text"], select { width: 100%; max-width: 32rem; padding: .35rem .5rem; font: inherit; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--text); box-sizing: border-box; }
input[type="text"]:focus, select:focus { outline: 2px solid var(--primary); outline-offset: -1px; }
.row { display: flex; gap: .5rem; align-items: center; margin-bottom: .35rem; }
.row input[type="text"] { flex: 1; max-width: none; }
.row .err { color: var(--danger); font-size: .85em; margin-left: .5rem; }
button { font: inherit; padding: .35rem .85rem; cursor: pointer; border: 1px solid var(--border); background: var(--bg); color: var(--text); border-radius: var(--radius); }
button:hover { background: var(--primary-bg); }
button.primary { background: var(--primary); color: white; border-color: var(--primary); }
button.primary:hover { filter: brightness(1.1); }
button.danger { color: var(--danger); border-color: var(--danger); }
button.danger:hover { background: rgba(176, 0, 32, 0.06); }
table { width: 100%; border-collapse: collapse; font-size: .95em; }
th, td { text-align: left; padding: .35rem .5rem; border-bottom: 1px solid var(--border); }
th { color: var(--muted); font-weight: 500; }
td.value { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; word-break: break-all; }
td.numeric { text-align: right; font-variant-numeric: tabular-nums; }
ul.bare { list-style: none; padding: 0; margin: 0; }
ul.bare li { padding: .2rem 0; }
.badge { display: inline-block; padding: .1rem .5rem; border-radius: 999px; font-size: .8em; border: 1px solid var(--border); background: var(--bg); }
.badge.yes { background: var(--primary-bg); border-color: var(--primary); color: var(--primary); }
.ok-banner { background: var(--primary-bg); border: 1px solid var(--primary); border-radius: var(--radius); padding: .55rem .85rem; margin-bottom: 1rem; color: var(--text); }
pre { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: .5rem .7rem; margin: .35rem 0; overflow-x: auto; font-size: 12px; max-height: 20rem; }
pre.err { border-color: var(--danger); color: var(--danger); }
code { background: var(--bg); padding: 0 .25rem; border-radius: 2px; font-size: 12px; border: 1px solid var(--border); }
.theme-pick label { display: inline-flex; align-items: center; gap: .25rem; margin-right: 1rem; }
.ls-actions { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: .6rem; }
</style>
{{ if .HasCustomCSS }}<link rel="stylesheet" href="{{ .AssetsPathPrefix }}/custom.css">{{ end }}
</head>
<body>
<div class="breadcrumb"><a href="/">&larr; home</a> &nbsp;/&nbsp; <span>profile</span></div>
<h1>Your profile</h1>
<section class="card">
<h2>Identity</h2>
{{ if .Email }}
<p>Signed in as <code>{{ .Email }}</code>.</p>
{{ else }}
<p>Not signed in. The server reads identity from the <code>{{ .EmailHeader }}</code> header. If you expected to be authenticated, your reverse proxy or SSO gateway is not forwarding it.</p>
{{ end }}
<p class="muted">Configured email header: <code>{{ .EmailHeader }}</code></p>
</section>
<section class="card">
<h2>Effective access</h2>
<p>
Super-admin: {{ if .IsSuperAdmin }}<span class="badge yes">yes</span>{{ else }}<span class="badge">no</span>{{ end }}
</p>
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">Visible projects</h3>
<div id="projects-list"><p class="muted" id="projects-loading">loading…</p></div>
<div id="admin-subtrees-block" hidden>
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">Subtrees you administer</h3>
<div id="admin-subtrees-list"></div>
</div>
</section>
<section class="card">
<h2>Theme</h2>
<p class="help">Applies to every ZDDC tool you open in this browser. Stored in <code>localStorage["zddc-theme"]</code>.</p>
<div class="theme-pick">
<label><input type="radio" name="theme" value="auto"> auto (system)</label>
<label><input type="radio" name="theme" value="light"> light</label>
<label><input type="radio" name="theme" value="dark"> dark</label>
</div>
</section>
<section class="card">
<h2>Local storage</h2>
<p class="help">Browser-side state used by the ZDDC tools at this origin. The profile page can read and write it for you.</p>
<table id="ls-table"><thead><tr><th>Key</th><th>Value</th><th class="numeric">Bytes</th></tr></thead><tbody></tbody></table>
<div class="ls-actions">
<button type="button" id="ls-export">Export all (JSON)</button>
<button type="button" id="ls-import">Import from JSON…</button>
<input type="file" id="ls-import-file" accept="application/json,.json" hidden>
<button type="button" id="ls-clear" class="danger">Clear all</button>
</div>
</section>
<div id="subtree-admin-slot"></div>
<template id="tmpl-subtree-admin">
<section class="card">
<h2>Editable .zddc files</h2>
<p class="help">Open the form-based editor for any subtree you administer.</p>
<div id="editable-list"></div>
</section>
<section class="card">
<h2>Create new project folder</h2>
<p class="help">Creates a directory under the chosen parent. If you fill in any of title / allow / deny / admins, a starter <code>.zddc</code> is also written; otherwise the directory is empty and inherits ACL from its ancestors.</p>
<div id="cp-ok" class="ok-banner" hidden>Created.</div>
<form id="cp-form" autocomplete="off">
<label>Parent
<select name="parent" id="cp-parent"></select>
</label>
<label>Name
<input type="text" name="name" id="cp-name" maxlength="64" placeholder="e.g. Site-3" required>
<span class="err" id="cp-name-err"></span>
</label>
<label>Title (optional)
<input type="text" name="title" id="cp-title" maxlength="200">
</label>
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">ACL — Allow (optional)</h3>
<div class="list" data-field="acl.allow"></div>
<button type="button" class="add" data-target="acl.allow">+ Add allow rule</button>
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">ACL — Deny (optional)</h3>
<div class="list" data-field="acl.deny"></div>
<button type="button" class="add" data-target="acl.deny">+ Add deny rule</button>
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">Admins (optional)</h3>
<div class="list" data-field="admins"></div>
<button type="button" class="add" data-target="admins">+ Add admin</button>
<div style="margin-top: 1rem;">
<button type="submit" class="primary">Create</button>
</div>
</form>
</section>
</template>
{{ if .IsSuperAdmin }}
<section class="card">
<h2>Server config <button type="button" data-diag="config">refresh</button></h2>
<p class="help">Effective config from environment variables. Read-only.</p>
<pre id="diag-config">loading…</pre>
</section>
<section class="card">
<h2>Logs <button type="button" data-diag="logs">refresh</button>
<span class="muted">level: <select id="diag-level"><option value="">all</option><option value="debug">debug+</option><option value="info" selected>info+</option><option value="warn">warn+</option><option value="error">error</option></select></span>
</h2>
<pre id="diag-logs">loading…</pre>
</section>
<section class="card">
<h2>whoami <button type="button" data-diag="whoami">refresh</button></h2>
<p class="help">Headers as they arrived at the binary. Useful for debugging SSO header passthrough.</p>
<pre id="diag-whoami">loading…</pre>
</section>
{{ end }}
<script>
(function() {
var prefix = {{ .ProfilePathPrefix }};
var isSuper = {{ .IsSuperAdmin }};
function escText(s) {
var d = document.createElement("div");
d.textContent = s == null ? "" : String(s);
return d.innerHTML;
}
// ── Theme ──────────────────────────────────────────────────────────────
var THEME_KEY = "zddc-theme";
function applyTheme(v) {
if (v === "light" || v === "dark") {
document.documentElement.setAttribute("data-theme", v);
} else {
document.documentElement.removeAttribute("data-theme");
}
}
var current = (function() { try { return localStorage.getItem(THEME_KEY) || "auto"; } catch (e) { return "auto"; } })();
applyTheme(current);
document.querySelectorAll('input[name="theme"]').forEach(function(r) {
if (r.value === current) r.checked = true;
r.addEventListener("change", function() {
try { localStorage.setItem(THEME_KEY, r.value); } catch (e) {}
applyTheme(r.value);
});
});
// ── Local storage panel ────────────────────────────────────────────────
function bytes(s) { return new TextEncoder().encode(s).length; }
function renderLS() {
var tbody = document.querySelector("#ls-table tbody");
tbody.textContent = "";
var keys = [];
for (var i = 0; i < localStorage.length; i++) keys.push(localStorage.key(i));
keys.sort();
if (keys.length === 0) {
var tr = document.createElement("tr");
var td = document.createElement("td");
td.colSpan = 3; td.className = "muted"; td.textContent = "(empty)";
tr.appendChild(td); tbody.appendChild(tr);
return;
}
keys.forEach(function(k) {
var v = localStorage.getItem(k) || "";
var tr = document.createElement("tr");
var tdK = document.createElement("td"); tdK.textContent = k;
var tdV = document.createElement("td"); tdV.className = "value";
tdV.textContent = v.length > 200 ? v.slice(0, 200) + "…" : v;
var tdB = document.createElement("td"); tdB.className = "numeric"; tdB.textContent = bytes(v);
tr.appendChild(tdK); tr.appendChild(tdV); tr.appendChild(tdB);
tbody.appendChild(tr);
});
}
renderLS();
document.getElementById("ls-export").addEventListener("click", function() {
var dump = {};
for (var i = 0; i < localStorage.length; i++) {
var k = localStorage.key(i);
dump[k] = localStorage.getItem(k);
}
var blob = new Blob([JSON.stringify(dump, null, 2)], { type: "application/json" });
var a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "zddc-localstorage.json";
a.click();
URL.revokeObjectURL(a.href);
});
document.getElementById("ls-import").addEventListener("click", function() {
document.getElementById("ls-import-file").click();
});
document.getElementById("ls-import-file").addEventListener("change", function(ev) {
var file = ev.target.files && ev.target.files[0];
if (!file) return;
var reader = new FileReader();
reader.onload = function() {
try {
var parsed = JSON.parse(String(reader.result || ""));
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
alert("Import file must be a JSON object of string→string."); return;
}
Object.keys(parsed).forEach(function(k) {
var v = parsed[k];
if (typeof v === "string") localStorage.setItem(k, v);
});
renderLS();
alert("Imported " + Object.keys(parsed).length + " keys.");
} catch (e) {
alert("Import failed: " + e);
}
};
reader.readAsText(file);
ev.target.value = "";
});
document.getElementById("ls-clear").addEventListener("click", function() {
if (!confirm("Clear ALL localStorage at this origin? This includes theme and any saved presets.")) return;
localStorage.clear();
renderLS();
applyTheme("auto");
document.querySelectorAll('input[name="theme"]').forEach(function(r) { r.checked = (r.value === "auto"); });
});
// ── Lazy access view ──────────────────────────────────────────────────
// Fetch /.profile/access and populate the projects + admin-subtree
// sections after first paint. The slow .zddc tree walk happens here, off
// the request hot path. Subtree-admin scaffolds are cloned from the
// <template> only if the response shows the caller has any admin scope —
// pure non-admins never see live admin form markup.
function renderProjects(projects) {
var host = document.getElementById("projects-list");
if (!projects || projects.length === 0) {
host.innerHTML = '<p class="muted">No projects accessible.</p>';
return;
}
var html = '<ul class="bare">';
projects.forEach(function(p) {
var label = p.title ? escText(p.title) : escText(p.name);
html += '<li><a href="' + escText(p.url) + '">' + label + '</a> '
+ '<span class="muted">(' + escText(p.url) + ')</span></li>';
});
html += '</ul>';
host.innerHTML = html;
}
function renderAdminSubtrees(subtrees) {
if (!subtrees || subtrees.length === 0) return;
document.getElementById("admin-subtrees-block").hidden = false;
var host = document.getElementById("admin-subtrees-list");
var html = '<ul class="bare">';
subtrees.forEach(function(s) {
html += '<li><code>' + escText(s.path) + '</code>';
if (s.title) html += ' — ' + escText(s.title);
if (s.can_edit) {
html += ' <span class="muted">(editable)</span>';
} else {
html += ' <span class="muted">(read-only — you cannot edit the file granting your own authority)</span>';
}
html += '</li>';
});
html += '</ul>';
host.innerHTML = html;
}
function renderEditableList(parents, hasAnyAdminScope) {
var host = document.getElementById("editable-list");
if (!host) return;
if (!parents || parents.length === 0) {
host.innerHTML = '<p class="muted">No <code>.zddc</code> files within your edit authority. Subtree admins cannot edit the file that grants their own authority — only an admin from a higher level can.</p>';
return;
}
var html = '<ul class="bare">';
parents.forEach(function(p) {
var path = escText(p.path);
// Link to browse opening the .zddc in the YAML/CodeMirror
// editor (with .zddc-schema lint). Replaces the retired form-
// based editor at <prefix>/zddc/edit?path=; same data, one
// canonical edit surface.
var dirURL = path === '/' ? '/' : path + '/';
html += '<li><a href="' + dirURL + '?file=.zddc">'
+ '<code>' + path + '/.zddc</code></a>';
if (p.title) html += ' — ' + escText(p.title);
html += '</li>';
});
html += '</ul>';
host.innerHTML = html;
}
function populateParentChoices(adminSubtrees) {
var sel = document.getElementById("cp-parent");
if (!sel) return;
sel.innerHTML = "";
if (isSuper) {
var optRoot = document.createElement("option");
optRoot.value = "/"; optRoot.textContent = "/ (root)";
sel.appendChild(optRoot);
}
(adminSubtrees || []).forEach(function(s) {
var opt = document.createElement("option");
opt.value = s.path; opt.textContent = s.path;
sel.appendChild(opt);
});
}
function rowFor(field) {
var div = document.createElement("div"); div.className = "row";
var input = document.createElement("input");
input.type = "text"; input.dataset.field = field;
var del = document.createElement("button");
del.type = "button"; del.textContent = ""; del.className = "del";
div.appendChild(input); div.appendChild(del);
return div;
}
function collectList(field) {
var out = [];
document.querySelectorAll('#cp-form .list[data-field="' + field + '"] input').forEach(function(i) {
if (i.value.trim()) out.push(i.value.trim());
});
return out;
}
function wireCreateProjectForm() {
document.querySelectorAll("#cp-form button.add").forEach(function(btn) {
btn.addEventListener("click", function() {
var field = btn.dataset.target;
document.querySelector('#cp-form .list[data-field="' + field + '"]').appendChild(rowFor(field));
});
});
document.getElementById("cp-form").addEventListener("click", function(e) {
if (e.target && e.target.classList && e.target.classList.contains("del")) {
e.target.closest(".row").remove();
}
});
document.getElementById("cp-form").addEventListener("submit", function(ev) {
ev.preventDefault();
document.getElementById("cp-name-err").textContent = "";
document.getElementById("cp-ok").hidden = true;
var allow = collectList("acl.allow");
var deny = collectList("acl.deny");
var admins = collectList("admins");
var title = document.getElementById("cp-title").value.trim();
var body = {
parent: document.getElementById("cp-parent").value,
name: document.getElementById("cp-name").value.trim()
};
if (title) body.title = title;
if (allow.length || deny.length) body.acl = { allow: allow, deny: deny };
if (admins.length) body.admins = admins;
fetch(prefix + "/projects", {
method: "POST",
headers: { "Content-Type": "application/json", "Accept": "application/json" },
credentials: "same-origin",
body: JSON.stringify(body)
}).then(function(r) {
return r.text().then(function(t) { return { ok: r.ok, status: r.status, text: t }; });
}).then(function(res) {
if (res.ok) {
var parsed = {};
try { parsed = JSON.parse(res.text); } catch (e) {}
var ok = document.getElementById("cp-ok");
ok.hidden = false;
ok.textContent = "Created " + (parsed.path || "(unknown path)") + ". Reload to see it in the lists above.";
return;
}
try {
var p = JSON.parse(res.text);
if (p && p.errors && p.errors.length) {
document.getElementById("cp-name-err").textContent = p.errors.map(function(e) { return e.field + ": " + e.message; }).join("; ");
return;
}
} catch (e) {}
document.getElementById("cp-name-err").textContent = "HTTP " + res.status + ": " + res.text;
});
});
}
function instantiateAdminScaffold(view) {
if (!view.has_any_admin_scope) return;
var tmpl = document.getElementById("tmpl-subtree-admin");
if (!tmpl) return;
var slot = document.getElementById("subtree-admin-slot");
slot.appendChild(tmpl.content.cloneNode(true));
renderEditableList(view.editable_parent_choices, view.has_any_admin_scope);
populateParentChoices(view.admin_subtrees);
wireCreateProjectForm();
}
fetch(prefix + "/access", { headers: { Accept: "application/json" }, credentials: "same-origin" })
.then(function(r) { return r.ok ? r.json() : null; })
.then(function(view) {
if (!view) {
document.getElementById("projects-loading").textContent = "Could not load access view.";
return;
}
renderProjects(view.projects);
renderAdminSubtrees(view.admin_subtrees);
instantiateAdminScaffold(view);
})
.catch(function() {
document.getElementById("projects-loading").textContent = "Could not load access view.";
});
// ── Super-admin diagnostics ───────────────────────────────────────────
if (isSuper) {
function loadDiag(target, qs) {
var el = document.getElementById("diag-" + target);
el.classList.remove("err");
el.textContent = "loading…";
var url = prefix + "/" + target + (qs ? ("?" + qs) : "");
fetch(url, { headers: { Accept: "application/json" }, credentials: "same-origin" })
.then(function(r) { return r.text().then(function(t) { return { ok: r.ok, status: r.status, text: t }; }); })
.then(function(res) {
if (!res.ok) {
el.classList.add("err");
el.textContent = "HTTP " + res.status + "\n\n" + res.text;
return;
}
try { el.textContent = JSON.stringify(JSON.parse(res.text), null, 2); }
catch (e) { el.textContent = res.text; }
}).catch(function(err) { el.classList.add("err"); el.textContent = String(err); });
}
document.querySelectorAll("button[data-diag]").forEach(function(b) {
b.addEventListener("click", function() {
var t = b.dataset.diag;
var qs = t === "logs" ? ("level=" + (document.getElementById("diag-level").value || "")) : "";
loadDiag(t, qs);
});
});
var lvl = document.getElementById("diag-level");
if (lvl) lvl.addEventListener("change", function() { loadDiag("logs", "level=" + (lvl.value || "")); });
loadDiag("config");
loadDiag("logs", "level=info");
loadDiag("whoami");
}
})();
</script>
</body>
</html>
`))