diff --git a/zddc/internal/handler/profilehandler.go b/zddc/internal/handler/profilehandler.go index 6adf970..6df7dab 100644 --- a/zddc/internal/handler/profilehandler.go +++ b/zddc/internal/handler/profilehandler.go @@ -148,6 +148,12 @@ type AccessView struct { IsSuperAdmin bool `json:"is_super_admin"` HasAnyAdminScope bool `json:"has_any_admin_scope"` CanElevate bool `json:"can_elevate"` + // CanCreateProject is true when the caller is authorized to mkdir a + // new top-level project — either via the root .zddc granting `c` to + // their email/role, or via super-admin authority (elevated). Drives + // the visibility of the profile page's "+ New project" form so the + // UI doesn't dangle an affordance the server would 404. + CanCreateProject bool `json:"can_create_project"` Projects []ProjectInfo `json:"projects"` AdminSubtrees []treeEntry `json:"admin_subtrees"` EditableParentChoices []treeEntry `json:"editable_parent_choices"` @@ -173,6 +179,13 @@ func enumerateAccess(ctx context.Context, decider policy.Decider, cfg config.Con // Drives the header elevation toggle's visibility — an un- // elevated admin still needs to see the toggle they'd flip. view.CanElevate = zddc.HasAnyAdminGrant(cfg.Root, p.Email) + // CanCreateProject mirrors the gate in serveProfileProjectsCreate — + // same decider call, same authority, no daylight between the UI + // affordance and the endpoint. + if rootChain, perr := zddc.EffectivePolicy(cfg.Root, cfg.Root); perr == nil { + allowed, _ := policy.AllowActionFromChainP(ctx, decider, rootChain, p, "/", policy.ActionCreate) + view.CanCreateProject = allowed + } for _, t := range view.AdminSubtrees { if t.CanEdit { view.EditableParentChoices = append(view.EditableParentChoices, t) diff --git a/zddc/internal/handler/profilepage.go b/zddc/internal/handler/profilepage.go index be27ab3..1c5df28 100644 --- a/zddc/internal/handler/profilepage.go +++ b/zddc/internal/handler/profilepage.go @@ -186,6 +186,7 @@ var profileTemplate = template.Must(template.New("profile").Parse(`
+
+