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:
parent
05e37256b7
commit
fbe9d11f22
3 changed files with 157 additions and 41 deletions
|
|
@ -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 & 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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue