The profile page links to /.profile/{config,logs,whoami}, which returned raw
JSON — so a browser click landed on raw JSON. Render them through the tables
engine instead (header chrome + sortable/filterable columns), content-
negotiated: browsers (Accept: text/html) get the table; scripts (Accept:
application/json) still get the unchanged JSON. New serveDiagTable helper +
kvRow/kvColumns: logs → time/level/message/detail rows (newest first);
config + whoami → Field/Value rows. Dropped the deep effective-policy row
from the profile table (kept JSON-only, not linked).
Extends api-actions.js with a `readOnly` context flag so a server-injected
read-only table (no apiActions) still hides the file-model toolbar buttons
(+ Add row / Save). Export CSV stays.
Completes the bespoke-server-page → tables-engine consolidation: tokens,
profile, and the three admin diagnostics now all render declaratively with
shared chrome; per-role gating stays server-side (diagnostics are elevated-
super-admin only). Full Go suite green; verified in a containerized browser.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
698 lines
31 KiB
Go
698 lines
31 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
|
||
}
|
||
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), "")
|
||
rows := []map[string]interface{}{}
|
||
for _, proj := range view.Projects {
|
||
rows = append(rows, map[string]interface{}{
|
||
"url": proj.URL,
|
||
"editable": false,
|
||
"data": map[string]interface{}{"name": proj.Name, "title": proj.Title, "kind": "project"},
|
||
})
|
||
}
|
||
for _, sub := range view.AdminSubtrees {
|
||
rows = append(rows, map[string]interface{}{
|
||
"url": sub.Path,
|
||
"editable": false,
|
||
"data": map[string]interface{}{"name": sub.Path, "title": sub.Title, "kind": "admin"},
|
||
})
|
||
}
|
||
// Super-admin diagnostics: keep config/logs/whoami discoverable as rows
|
||
// (the endpoints are unchanged; only the bespoke links moved here). Gated
|
||
// on IsSuperAdmin so a non-admin's context never names them.
|
||
if view.IsSuperAdmin {
|
||
for _, d := range []struct{ name, url string }{
|
||
{"Server config", ProfilePathPrefix + "/config"},
|
||
{"Server logs", ProfilePathPrefix + "/logs"},
|
||
{"Whoami (request headers)", ProfilePathPrefix + "/whoami"},
|
||
} {
|
||
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="/">← 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 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 & 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>
|
||
`))
|