feat(profile): project-create — drop parent picker, add role groups, record creator

Projects are always created at the deployment root, so the "Parent" dropdown
(and populateParentChoices) is gone — the client always POSTs parent:"/".

The Create-new-project dialog now collects members for the four project roles
— admins, document controllers, project team, guests — as simple email lists.
Server-side, each non-empty list becomes a roles:<name> entry plus a base
acl.permissions grant (document_controller→rwcd, project_team→rwc, guest→r);
an explicit advanced acl.permissions entry for the same key still wins.

The new project's .zddc now always records the creator: zf.CreatedBy = creator
email, and the creator is always included in admins: (deduped, first) so they
administer their own project from birth.

Tests: creator recorded + roles/permissions seeded; explicit permission
overrides the role default. Existing create tests still pass (creator-in-admins
is compatible with the explicit-admins-list case).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-05 08:55:55 -05:00
parent 05e37256b7
commit fbe9d11f22
3 changed files with 157 additions and 41 deletions

View file

@ -199,12 +199,9 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
<template id="tmpl-create-project"> <template id="tmpl-create-project">
<section class="card"> <section class="card">
<h2>Create new project folder</h2> <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> <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> <div id="cp-ok" class="ok-banner" hidden>Created.</div>
<form id="cp-form" autocomplete="off"> <form id="cp-form" autocomplete="off">
<label>Parent
<select name="parent" id="cp-parent"></select>
</label>
<label>Name <label>Name
<input type="text" name="name" id="cp-name" maxlength="64" placeholder="e.g. Site-3" required> <input type="text" name="name" id="cp-name" maxlength="64" placeholder="e.g. Site-3" required>
<span class="err" id="cp-name-err"></span> <span class="err" id="cp-name-err"></span>
@ -212,13 +209,26 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
<label>Title (optional) <label>Title (optional)
<input type="text" name="title" id="cp-title" maxlength="200"> <input type="text" name="title" id="cp-title" maxlength="200">
</label> </label>
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">ACL Permissions (optional)</h3> <h3 style="font-size: 1em; margin: .8rem 0 .3rem;">Admins</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> <p class="help" style="margin: 0 0 .3rem;">Full control of the project (you are already an admin).</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> <div class="list" data-field="admins"></div>
<button type="button" class="add" data-target="admins">+ Add admin</button> <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;"> <div style="margin-top: 1rem;">
<button type="submit" class="primary">Create</button> <button type="submit" class="primary">Create</button>
</div> </div>
@ -417,26 +427,6 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
host.innerHTML = html; 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) { function rowFor(field) {
var div = document.createElement("div"); div.className = "row"; var div = document.createElement("div"); div.className = "row";
var input = document.createElement("input"); var input = document.createElement("input");
@ -493,14 +483,21 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
document.getElementById("cp-ok").hidden = true; document.getElementById("cp-ok").hidden = true;
var permissions = collectPermissions(); var permissions = collectPermissions();
var admins = collectList("admins"); 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(); var title = document.getElementById("cp-title").value.trim();
// Projects are always created at the deployment root (top level).
var body = { var body = {
parent: document.getElementById("cp-parent").value, parent: "/",
name: document.getElementById("cp-name").value.trim() name: document.getElementById("cp-name").value.trim()
}; };
if (title) body.title = title; if (title) body.title = title;
if (Object.keys(permissions).length) body.acl = { permissions: permissions }; if (Object.keys(permissions).length) body.acl = { permissions: permissions };
if (admins.length) body.admins = admins; 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", { fetch(prefix + "/projects", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", "Accept": "application/json" }, headers: { "Content-Type": "application/json", "Accept": "application/json" },
@ -547,7 +544,6 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
if (cpTmpl) { if (cpTmpl) {
var cpSlot = document.getElementById("create-project-slot"); var cpSlot = document.getElementById("create-project-slot");
cpSlot.appendChild(cpTmpl.content.cloneNode(true)); cpSlot.appendChild(cpTmpl.content.cloneNode(true));
populateParentChoices(view.admin_subtrees || []);
wireCreateProjectForm(); wireCreateProjectForm();
} }
} }

View file

@ -5,6 +5,7 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy" "codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
@ -29,6 +30,41 @@ type projectCreateRequest struct {
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
ACL *zddc.ACLRules `json:"acl,omitempty"` ACL *zddc.ACLRules `json:"acl,omitempty"`
Admins []string `json:"admins,omitempty"` Admins []string `json:"admins,omitempty"`
// Role groups: member email lists for the conventional project roles.
// Each non-empty list becomes a roles:<name> entry plus a base
// acl.permissions grant (document_controller→rwcd, project_team→rwc,
// guest→r). An explicit ACL permission for the same key wins.
DocumentControllers []string `json:"document_controllers,omitempty"`
ProjectTeam []string `json:"project_team,omitempty"`
Guests []string `json:"guests,omitempty"`
}
// projectRoleGrants maps each role group to its name + base verb grant, in a
// stable order. The single source of truth for the create flow's role seeding.
var projectRoleGrants = []struct {
name string
verbs string
pick func(projectCreateRequest) []string
}{
{"document_controller", "rwcd", func(r projectCreateRequest) []string { return r.DocumentControllers }},
{"project_team", "rwc", func(r projectCreateRequest) []string { return r.ProjectTeam }},
{"guest", "r", func(r projectCreateRequest) []string { return r.Guests }},
}
// dedupeStrings trims, drops empties, and removes duplicates (first-wins),
// preserving order.
func dedupeStrings(in []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(in))
for _, s := range in {
s = strings.TrimSpace(s)
if s == "" || seen[s] {
continue
}
seen[s] = true
out = append(out, s)
}
return out
} }
// projectCreateResponse is the success payload. // projectCreateResponse is the success payload.
@ -108,21 +144,43 @@ func serveProfileProjectsCreate(cfg config.Config, w http.ResponseWriter, r *htt
return return
} }
// Always seed a starter .zddc — the creator becomes subtree admin of // Always seed a starter .zddc. The creator administers their new project
// their new project. Caller can also pass title / ACL / extra // and is RECORDED as its creator (created_by, audit). Caller can also
// admins on top. // pass title / ACL / role groups / extra admins on top.
admins := req.Admins
if len(admins) == 0 && p.Email != "" {
admins = []string{p.Email}
}
var zf zddc.ZddcFile var zf zddc.ZddcFile
zf.Title = req.Title zf.Title = req.Title
zf.CreatedBy = p.Email
if req.ACL != nil { if req.ACL != nil {
zf.ACL = *req.ACL zf.ACL = *req.ACL
} }
zf.Admins = admins // Creator is always an admin (deduped, first), then any extra admins.
wantsZddc := len(zf.Admins) > 0 || zf.Title != "" || zf.Admins = dedupeStrings(append([]string{p.Email}, req.Admins...))
(req.ACL != nil && len(req.ACL.Permissions) > 0)
// Role groups → named roles + conventional base verb grants. Each
// non-empty member list defines a role; an explicit acl.permissions
// entry for the same key (passed via the advanced ACL field) wins.
for _, g := range projectRoleGrants {
members := dedupeStrings(g.pick(req))
if len(members) == 0 {
continue
}
if zf.Roles == nil {
zf.Roles = map[string]zddc.Role{}
}
zf.Roles[g.name] = zddc.Role{Members: members}
if zf.ACL.Permissions == nil {
zf.ACL.Permissions = map[string]string{}
}
if _, ok := zf.ACL.Permissions[g.name]; !ok {
zf.ACL.Permissions[g.name] = g.verbs
}
}
// We always record the creator, so a .zddc is essentially always
// written; the guard only skips the rare anonymous-creator case with
// no other content.
wantsZddc := zf.CreatedBy != "" || len(zf.Admins) > 0 || zf.Title != "" ||
len(zf.Roles) > 0 || len(zf.ACL.Permissions) > 0
if wantsZddc { if wantsZddc {
if errs := zddc.ValidateFile(zf); len(errs) > 0 { if errs := zddc.ValidateFile(zf); len(errs) > 0 {
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")

View file

@ -119,3 +119,65 @@ func TestProjectCreate_DuplicateNameRejected(t *testing.T) {
t.Errorf("status=%d, want 409", rec.Code) t.Errorf("status=%d, want 409", rec.Code)
} }
} }
// The creator is recorded in created_by (+ made admin), and the role-group
// member lists become roles{} with conventional base ACL permission grants.
func TestProjectCreate_RecordsCreatorAndRoles(t *testing.T) {
cfg, root := projectCreateFixture(t)
body, _ := json.Marshal(map[string]any{
"parent": "/",
"name": "RoleProj",
"document_controllers": []string{"dc@example.com"},
"project_team": []string{"t1@example.com", "t2@example.com"},
"guests": []string{"guest@example.com"},
})
rec := doProjectCreate(cfg, "alice@example.com", false, body)
if rec.Code != http.StatusCreated {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
zddc.InvalidateCache(root)
zf, err := zddc.ParseFile(filepath.Join(root, "RoleProj", ".zddc"))
if err != nil {
t.Fatalf("read new .zddc: %v", err)
}
if zf.CreatedBy != "alice@example.com" {
t.Errorf("CreatedBy=%q, want alice@example.com", zf.CreatedBy)
}
if len(zf.Admins) != 1 || zf.Admins[0] != "alice@example.com" {
t.Errorf("Admins=%v, want [alice@example.com]", zf.Admins)
}
if r, ok := zf.Roles["document_controller"]; !ok || len(r.Members) != 1 || r.Members[0] != "dc@example.com" {
t.Errorf("document_controller role=%v", zf.Roles["document_controller"])
}
if r, ok := zf.Roles["project_team"]; !ok || len(r.Members) != 2 {
t.Errorf("project_team role=%v", zf.Roles["project_team"])
}
if r, ok := zf.Roles["guest"]; !ok || len(r.Members) != 1 {
t.Errorf("guest role=%v", zf.Roles["guest"])
}
for role, verbs := range map[string]string{"document_controller": "rwcd", "project_team": "rwc", "guest": "r"} {
if zf.ACL.Permissions[role] != verbs {
t.Errorf("permission %s=%q, want %q", role, zf.ACL.Permissions[role], verbs)
}
}
}
// An explicit acl.permissions entry for a role wins over the default grant.
func TestProjectCreate_ExplicitPermissionOverridesRoleGrant(t *testing.T) {
cfg, root := projectCreateFixture(t)
body, _ := json.Marshal(map[string]any{
"parent": "/",
"name": "OverrideProj",
"project_team": []string{"t@example.com"},
"acl": map[string]any{"permissions": map[string]string{"project_team": "r"}},
})
rec := doProjectCreate(cfg, "alice@example.com", false, body)
if rec.Code != http.StatusCreated {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
zddc.InvalidateCache(root)
zf, _ := zddc.ParseFile(filepath.Join(root, "OverrideProj", ".zddc"))
if zf.ACL.Permissions["project_team"] != "r" {
t.Errorf("explicit permission should win: got %q want r", zf.ACL.Permissions["project_team"])
}
}