ZDDC/zddc/internal/handler/profilepage.go
2026-06-11 13:32:31 -05:00

710 lines
32 KiB
Go
Raw Permalink 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"
"strings"
"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
}
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,
EmailHeader: cfg.EmailHeader,
IsSuperAdmin: zddc.IsAdmin(cfg.Root, PrincipalFromContext(r)),
ProfilePathPrefix: ProfilePathPrefix,
AssetsPathPrefix: profileAssetsPathPrefix,
HasCustomCSS: hasCustomProfileCSS(cfg.Root),
}
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), "")
// Clicking a project/subtree row opens its .zddc INFO FORM (title, roles,
// admins, …) in the browse editor — not the project's files. The browse
// ?file=.zddc deep link selects + previews that dir's .zddc, which renders
// as the schema-driven form (real or a virtual placeholder). dir_tool at
// these paths is browse, so the trailing-slash URL loads the shell.
zddcFormURL := func(dirURL string) string {
if !strings.HasSuffix(dirURL, "/") {
dirURL += "/"
}
return dirURL + "?file=.zddc"
}
rows := []map[string]interface{}{}
for _, proj := range view.Projects {
rows = append(rows, map[string]interface{}{
"url": zddcFormURL(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": zddcFormURL(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"},
} {
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,
// 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 top-level project folder. Your email is recorded as the project's creator and added to its admins automatically. Assign members to the project roles below — one email (or role pattern) per row.</p>
<div id="cp-ok" class="ok-banner" hidden>Created.</div>
<form id="cp-form" autocomplete="off">
<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;">Admins</h3>
<p class="help" style="margin: 0 0 .3rem;">Full control of the project (you are already an admin).</p>
<div class="list" data-field="admins"></div>
<button type="button" class="add" data-target="admins">+ Add admin</button>
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">Document controllers</h3>
<p class="help" style="margin: 0 0 .3rem;">Manage filing &amp; records — read / write / create / delete.</p>
<div class="list" data-field="document_controllers"></div>
<button type="button" class="add" data-target="document_controllers">+ Add document controller</button>
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">Project team</h3>
<p class="help" style="margin: 0 0 .3rem;">Contribute documents — read / write / create.</p>
<div class="list" data-field="project_team"></div>
<button type="button" class="add" data-target="project_team">+ Add team member</button>
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">Guests</h3>
<p class="help" style="margin: 0 0 .3rem;">Read-only access.</p>
<div class="list" data-field="guests"></div>
<button type="button" class="add" data-target="guests">+ Add guest</button>
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">Advanced — 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. Overrides the role grants above for the same pattern.</p>
<div class="list" data-field="acl.permissions"></div>
<button type="button" class="add" data-target="acl.permissions">+ Add permission</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 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 dcs = collectList("document_controllers");
var team = collectList("project_team");
var guests = collectList("guests");
var title = document.getElementById("cp-title").value.trim();
// Projects are always created at the deployment root (top level).
var body = {
parent: "/",
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;
if (dcs.length) body.document_controllers = dcs;
if (team.length) body.project_team = team;
if (guests.length) body.guests = guests;
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));
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>
`))