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>
610 lines
27 KiB
Go
610 lines
27 KiB
Go
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="/">← home</a> / <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>
|
||
`))
|