ZDDC/zddc/internal/handler/profilepage.go
ZDDC f196205622 refactor(audit): pre-release cleanup pass
Single audit pass that removes pre-release back-compat, consolidates the
admin-policy decider, and fixes the .zddc write path.

Field removal — acl.allow / acl.deny:
- Drop ACLRules.Allow / Deny struct fields and mergeLegacyACL().
- Remove walker / lookups / validate / decider branches that read them.
- Migrate every test fixture (YAML strings and ACLRules struct literals)
  to acl.permissions: { principal → verb-set }.
- Rewrite both bundled Rego policies (access.rego, access_federal.rego)
  to traverse level.acl.permissions; rewrite parity-test helpers.
- Update create-project form (profile page) to collect permissions
  instead of allow/deny lists.

Admin decider consolidation:
- Delete zddc.CanEditZddc — strict-ancestor rule retired. Subtree admins
  own their own .zddc; the policy decider's IsActiveAdmin short-circuit
  is the single bypass site.
- Migrate tablehandler.ServeTable to AllowActionFromChainP — closes the
  same Forbidden bug already fixed for /browse.html.
- Drop AccessView.EditableParentChoices and treeEntry.CanEdit (always
  true after the retirement). Profile page renders AdminSubtrees
  directly for both lists.
- Drop the excludeLeaf parameter from AdminLevelInChain /
  IsAdminForChain — no production caller passed true.

Dead code removed:
- policy.AllowWriteFromChain (zero production callers, zero tests).
- zddc.AllowedWithChain (zero production callers; tests deleted).

ModeStrict retirement — federal posture is OPA-only:
- Delete cascade_mode.go / cascade_mode_test.go and the ModeStrict
  branches in cascade.go and acl.go.
- Drop --cascade-mode flag, CascadeMode config field, and the
  InternalDecider.Mode field.
- Drop the mode parameter from every cascade helper:
  GrantedVerbsAtLevel, AllowedAction, EffectiveVerbs,
  EffectiveVerbsRange, RoleMembers, MatchesPrincipal,
  MatchingPrincipals, WormZoneGrant, PolicyChain.VisibleStart.
- Strip cascade_mode from /.profile/config and
  /.profile/effective-policy responses.
- Refresh README / ARCHITECTURE.md to describe federal posture as
  "deploy OPA with access_federal.rego" (NIST AC-6); the bundled Rego
  is the parent-deny-is-absolute variant. The in-process Go evaluator
  implements only the commercial cascade.

Legacy redirects + .admin.css fallback:
- Drop /<dir>/.zddc.html → ?file=.zddc redirect and its test.
- Drop ?zip=1 retired comment + legacy test (handled by the
  .zip virtual-URL path; covered by TestServeSubtreeZip).
- Drop .admin.css fallback in profile_assets.go — only .profile.css now.
- Refresh stale "retired" / "back-compat" / "legacy" comment markers.

.zddc write path fix:
- Dispatcher: route only GET/HEAD on .zddc URLs to ServeZddcFile; carve
  .zddc out of the dot-prefix guard so PUT/DELETE/POST reach
  ServeFileAPI. Before this, .zddc writes 405'd at ServeZddcFile and
  the YAML editor's save flow had no live path.
- ServeFileAPI.resolveTargetPath: same .zddc-leaf carve-out so the file
  API accepts the path; intermediate dot dirs (.zddc.d/) stay reserved.
- Listing: compute Writable per-file with ActionAdmin for .zddc
  (matches the file API's gate) instead of ActionWrite for everything.
- Virtual .zddc placeholder: compute Writable via the same
  parentActiveAdmin || ActionAdmin path. Was always false before.
- browse YAML editor canSave: exempt virtual .zddc — the synthetic
  body is designed to materialize on PUT.

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

610 lines
27 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>
<div id="create-project-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>
</template>
<template id="tmpl-create-project">
<section class="card">
<h2>Create new project folder</h2>
<p class="help">Creates a directory under the chosen parent. Your email is added to admins automatically so you administer the new project; you can also fill title / ACL / additional admins below.</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 — Permissions (optional)</h3>
<p class="help" style="margin: 0 0 .3rem;">Pattern (email or role) → verbs (drawn from <code>r w c d a</code>). Empty verbs = explicit deny.</p>
<div class="list" data-field="acl.permissions"></div>
<button type="button" class="add" data-target="acl.permissions">+ Add permission</button>
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">Additional 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 }};
// canCreateProject is hydrated from /.profile/access (the JSON
// refresh) — server-rendered HTML only knows IsSuperAdmin. Default
// to isSuper so the UI doesn't flicker between paint and fetch for
// super-admins; the JSON view overrides for non-admin grantees.
var canCreateProject = isSuper;
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);
html += '</li>';
});
html += '</ul>';
host.innerHTML = html;
}
function renderEditableList(parents) {
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.</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).
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 = "";
// Root is offered whenever the caller can create projects there —
// super-admin (full bypass) or cascade-granted "c" at the root.
// The server's can_create_project flag means both, since it runs
// the same decider gate the endpoint uses.
if (isSuper || canCreateProject) {
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 permRowFor() {
var div = document.createElement("div"); div.className = "row";
var pat = document.createElement("input");
pat.type = "text"; pat.dataset.role = "pattern"; pat.placeholder = "pattern (email or role)";
var verbs = document.createElement("input");
verbs.type = "text"; verbs.dataset.role = "verbs"; verbs.placeholder = "verbs (rwcda) — empty = deny";
verbs.style.maxWidth = "10em";
var del = document.createElement("button");
del.type = "button"; del.textContent = ""; del.className = "del";
div.appendChild(pat); div.appendChild(verbs); 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 collectPermissions() {
var out = {};
document.querySelectorAll('#cp-form .list[data-field="acl.permissions"] .row').forEach(function(row) {
var pat = row.querySelector('input[data-role="pattern"]').value.trim();
if (!pat) return;
out[pat] = row.querySelector('input[data-role="verbs"]').value.trim();
});
return out;
}
function wireCreateProjectForm() {
document.querySelectorAll("#cp-form button.add").forEach(function(btn) {
btn.addEventListener("click", function() {
var field = btn.dataset.target;
var host = document.querySelector('#cp-form .list[data-field="' + field + '"]');
host.appendChild(field === "acl.permissions" ? permRowFor() : 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 permissions = collectPermissions();
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 (Object.keys(permissions).length) body.acl = { permissions: permissions };
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) {
var tmpl = document.getElementById("tmpl-subtree-admin");
if (tmpl) {
var slot = document.getElementById("subtree-admin-slot");
slot.appendChild(tmpl.content.cloneNode(true));
renderEditableList(view.admin_subtrees);
}
}
// Create-project mounts independently on the can_create_project
// gate — non-admins who hold "c" at root via cascade grant get the
// form too. Parent-selector seeds from admin_subtrees when those
// exist, otherwise just root.
if (view.can_create_project) {
var cpTmpl = document.getElementById("tmpl-create-project");
if (cpTmpl) {
var cpSlot = document.getElementById("create-project-slot");
cpSlot.appendChild(cpTmpl.content.cloneNode(true));
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;
}
// Hydrate the server-computed flag so populateParentChoices and
// any visibility check after this point sees the live value.
canCreateProject = !!view.can_create_project;
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>
`))