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">
<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>
<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>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>
@ -212,13 +209,26 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
<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>
<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>
@ -417,26 +427,6 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
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");
@ -493,14 +483,21 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
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: document.getElementById("cp-parent").value,
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" },
@ -547,7 +544,6 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
if (cpTmpl) {
var cpSlot = document.getElementById("create-project-slot");
cpSlot.appendChild(cpTmpl.content.cloneNode(true));
populateParentChoices(view.admin_subtrees || []);
wireCreateProjectForm();
}
}

View file

@ -5,6 +5,7 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
@ -29,6 +30,41 @@ type projectCreateRequest struct {
Title string `json:"title,omitempty"`
ACL *zddc.ACLRules `json:"acl,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.
@ -108,21 +144,43 @@ func serveProfileProjectsCreate(cfg config.Config, w http.ResponseWriter, r *htt
return
}
// Always seed a starter .zddc — the creator becomes subtree admin of
// their new project. Caller can also pass title / ACL / extra
// admins on top.
admins := req.Admins
if len(admins) == 0 && p.Email != "" {
admins = []string{p.Email}
}
// Always seed a starter .zddc. The creator administers their new project
// and is RECORDED as its creator (created_by, audit). Caller can also
// pass title / ACL / role groups / extra admins on top.
var zf zddc.ZddcFile
zf.Title = req.Title
zf.CreatedBy = p.Email
if req.ACL != nil {
zf.ACL = *req.ACL
}
zf.Admins = admins
wantsZddc := len(zf.Admins) > 0 || zf.Title != "" ||
(req.ACL != nil && len(req.ACL.Permissions) > 0)
// Creator is always an admin (deduped, first), then any extra admins.
zf.Admins = dedupeStrings(append([]string{p.Email}, req.Admins...))
// 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 errs := zddc.ValidateFile(zf); len(errs) > 0 {
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)
}
}
// 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"])
}
}