From 33212c71e4b0fa8543728a9b8e5068e18c526d7a Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Sat, 6 Jun 2026 01:02:16 -0700 Subject: [PATCH] =?UTF-8?q?=C2=A722=20S5:=20in-app=20create-project=20+=20?= =?UTF-8?q?global-directory=20empty=20states=20(@S5=20C3.1=E2=80=93C3.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the global-Owner create-project action and the role-aware deployment directory, completing slice S5 of the three-tier refactor (release v0.44.0, minor/non-breaking — no migration). Per docs/design/2026-06-05-three-tier-projects-collections.md Part E (S5), Part C.3 (C3.1–C3.2). Backend: - auth.can_create_project — global-Owner gate (deployment owner/admin or an explicit scope_type='global' Owner grant). - bot.create_project — provision the Gitea content repo (seed README so main exists), read+append+commit projects.yaml in the registry repo, audit-log (create_project). The bot stays the only git writer (§1). - POST /api/projects (api_deployment) — global-Owner gated; validates the id (slug, not 'default'), name, type, visibility, content_repo; commits via the bot, then re-mirrors the registry so projects + default collection rows flow from git (§22.2). GET /api/deployment gains viewer.can_create_project and default_project_readable. make_router now takes gitea + bot. Frontend: - api.createProject; DeploymentProvider surfaces viewer + defaultProjectReadable + refresh; DeploymentLanding redirects into the default only when readable (gated/absent default falls through to the directory, no 404 bounce). - Directory.jsx role-aware empty states (C3.1 "Create your first project" CTA; C3.2 "Nothing has been shared with you yet") + Owner-only "New project" control + CreateProjectModal. Tests: backend test_create_project_vertical.py (vertical + gates + the deployment empty-state signals); frontend Directory.test.jsx empty-state cases. Also: ignore the session-local .superpowers/ tooling dir. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 1 + CHANGELOG.md | 53 +++++ VERSION | 2 +- backend/app/api.py | 2 +- backend/app/api_deployment.py | 150 ++++++++++++- backend/app/auth.py | 19 ++ backend/app/bot.py | 76 +++++++ backend/tests/test_create_project_vertical.py | 202 ++++++++++++++++++ ...6-06-05-three-tier-projects-collections.md | 11 +- frontend/package.json | 2 +- frontend/src/App.jsx | 11 +- frontend/src/api.js | 18 ++ .../src/components/CreateProjectModal.jsx | 113 ++++++++++ frontend/src/components/Directory.jsx | 71 ++++-- frontend/src/components/Directory.test.jsx | 62 ++++-- frontend/src/context/DeploymentProvider.jsx | 42 ++-- 16 files changed, 779 insertions(+), 56 deletions(-) create mode 100644 backend/tests/test_create_project_vertical.py create mode 100644 frontend/src/components/CreateProjectModal.jsx diff --git a/.gitignore b/.gitignore index 34f5bd1..c0449cb 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ data/ # Claude Code (per-machine settings only; shared config under .claude/ is committed) .claude/settings.local.json +.superpowers/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d0863e9..ee9aea8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,59 @@ skip versions are the composition of each intervening adjacent release's steps in order — no A-to-B path is pre-computed beyond that. +## 0.44.0 — 2026-06-06 + +**Minor (non-breaking) — §22 three-tier refactor, slice S5: *in-app +create-project + the global directory.* A global Owner can now stand up a new +project end-to-end from the UI — the bot provisions a Gitea content repo and +commits the project to `projects.yaml`, and the registry mirror picks it up — and +the deployment directory shows a role-aware empty state. Purely additive over S4: +one new endpoint and UI, no schema migration, and no change to existing read or +write semantics. Completes acceptance scenarios `@S5` (C3.1–C3.2: the +global-directory empty states).** + +See [`docs/design/2026-06-05-three-tier-projects-collections.md`](./docs/design/2026-06-05-three-tier-projects-collections.md) +(Part E slice S5, Part C.3 scenarios C3.1–C3.2). With S5 the role-and-empty-state +focus of Parts B/C is complete; type modules, membership lifecycle, hardening, +and the SPEC merge remain S6. + +Added: + +- **In-app create-project (§22 S5 / §A.2)** — `POST /api/projects` (global-Owner + only) provisions a Gitea content repo under the deployment org (seeding a + `README.md` so `main` exists), commits a new project entry to the registry's + `projects.yaml`, then re-runs the registry mirror so the `projects` + default + `collections` rows flow from the registry (§22.2 keeps the registry the source + of truth). The bot remains the only Git writer (§1); the action is logged + (`create_project`) for the §6.5 trail. The body takes `project_id` (a slug, not + `default`), `name`, `type` (the initial/default collection's), optional + `visibility` (defaults to `public`), and an optional `content_repo` name + (defaults to `-content`). +- **Create-project gate** — `auth.can_create_project`: "+ New project" is a + global-Owner action — a deployment owner/admin, or a holder of an explicit + `scope_type='global'` Owner grant. A project- or collection-scope grant, or a + global RFC Contributor, cannot create projects (that role creates collections, + not projects). +- **Deployment-directory capability + redirect signals** — `GET /api/deployment` + now carries a `viewer` block (`can_create_project`) and + `default_project_readable` (whether the N=1 land-in-corpus redirect target is + reachable by this viewer). The frontend bounces into the default project only + when it is readable; a gated default (C3.2) or an absent default (C3.1, a + deployment with no projects) falls through to the directory's empty state + instead of a 404. +- **Role-aware global directory (§22 C3.1–C3.2)** — a global Owner landing on an + empty deployment directory sees a "Create your first project" CTA (opening the + create-project modal: id, name, type, visibility, optional content-repo); a + non-owner sees "Nothing has been shared with you yet" and no create action. The + directory also gains an Owner-only "New project" control when it is non-empty. + +Upgrade steps: + +1. No operator action is required. S5 adds one endpoint and UI only — there is no + migration and no change to existing authorization outcomes. A deployment that + was declaring projects directly in `projects.yaml` keeps working unchanged; + the new UI is an additional, equivalent way to commit the same registry entry. + ## 0.43.0 — 2026-06-06 **Minor (non-breaking) — §22 three-tier refactor, slice S4: *invitation diff --git a/VERSION b/VERSION index 8298bb0..a8ab6c9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.43.0 +0.44.0 diff --git a/backend/app/api.py b/backend/app/api.py index 34cce3c..78dceeb 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -153,7 +153,7 @@ def make_router( router.include_router(api_contributions.make_router()) # §22.9/§22.10 (M3): runtime deployment + per-project config (replaces # VITE_APP_NAME) + the old-URL 308 redirects. - router.include_router(api_deployment.make_router(config)) + router.include_router(api_deployment.make_router(config, gitea, bot)) router.include_router(api_collections.make_router(config, gitea, bot)) # §22 S4 (C.2): the scope-role invitation surface — Owners grant # {owner, contributor} at project/collection scope to existing accounts. diff --git a/backend/app/api_deployment.py b/backend/app/api_deployment.py index 2b7b1a2..93ca897 100644 --- a/backend/app/api_deployment.py +++ b/backend/app/api_deployment.py @@ -1,28 +1,60 @@ """§22.9 runtime deployment/project config (replaces VITE_APP_NAME) + §22.10 -old-URL 308 redirects. +old-URL 308 redirects + §22 S5 in-app create-project. -GET /api/deployment — the deployment name/tagline + the projects the caller can +GET /api/deployment — the deployment name/tagline + the projects the caller can see (§22.5: gated filtered by membership, unlisted omitted from enumeration), -plus the corpus-served `default_project_id` the M3-frontend guard keys on. -GET /api/projects/:id — one project's runtime config + optional theme overlay, +plus the corpus-served `default_project_id` the M3-frontend guard keys on, the +`viewer` capability block (S5: `can_create_project`), and +`default_project_readable` (whether the N=1 redirect target is reachable by this +viewer — drives the deployment-directory empty state vs the land-in-corpus +redirect). +POST /api/projects — §22 S5 create-project (global-Owner only). The bot +provisions a Gitea content repo and commits a project entry to `projects.yaml`; +the registry mirror then upserts the `projects` + default `collections` rows +(§22.2 keeps the registry the source of truth). +GET /api/projects/:id — one project's runtime config + optional theme overlay, gated behind the §22.5 read gate (404 for a non-member of a gated project). -GET /rfc/{slug}, /proposals/{n} — §22.10 server-side 308s onto the new +GET /rfc/{slug}, /proposals/{n} — §22.10 server-side 308s onto the new `/p//…` routes (the SPA no longer owns these paths; nginx proxies them to the backend instead of serving index.html). """ from __future__ import annotations import json +import re from typing import Any +import yaml from fastapi import APIRouter, HTTPException, Request from fastapi.responses import RedirectResponse +from pydantic import BaseModel -from . import auth, collections as collections_mod, db, projects as projects_mod +from . import ( + auth, + collections as collections_mod, + db, + projects as projects_mod, + registry as registry_mod, +) +from .bot import Bot from .config import Config +from .gitea import Gitea, GiteaError + +# A project id is a slug (the §22.2 registry key + the `/p//` path segment). +_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$") +# A Gitea repo name: alphanumeric start, then alphanumerics / `-` / `_` / `.`. +_REPO_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") -def make_router(config: Config) -> APIRouter: +class CreateProjectBody(BaseModel): + project_id: str + name: str + type: str + visibility: str | None = None + content_repo: str | None = None + + +def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter: router = APIRouter() @router.get("/api/deployment") @@ -51,6 +83,16 @@ def make_router(config: Config) -> APIRouter: for r in rows if r["id"] in visible ] + # §22 S5: the N=1 land-in-corpus redirect targets the default project, but + # only when this viewer can actually read it. A `gated` default (C3.2) or + # an absent default (C3.1, a deployment with no projects) is *not* a valid + # redirect target — the frontend then falls through to the deployment + # directory's role-aware empty state instead of bouncing into a 404. + default_id = projects_mod.resolved_default_id(config) + default_exists = db.conn().execute( + "SELECT 1 FROM projects WHERE id = ?", (default_id,) + ).fetchone() is not None + default_readable = default_exists and auth.can_read_project(viewer, default_id) return { "name": (dep["name"] if dep else "") or "", "tagline": (dep["tagline"] if dep else "") or "", @@ -58,8 +100,100 @@ def make_router(config: Config) -> APIRouter: # serves the corpus for (the default, until Plan B serves per # project). The frontend renders corpus routes only for this id and # shows a "content not yet served" placeholder for any other. - "default_project_id": projects_mod.resolved_default_id(config), + "default_project_id": default_id, + "default_project_readable": default_readable, "projects": projects, + # §22 S5 (C3.1/C3.2): role-aware deployment-directory affordances. + "viewer": {"can_create_project": auth.can_create_project(viewer)}, + } + + @router.post("/api/projects") + async def create_project(body: CreateProjectBody, request: Request) -> dict[str, Any]: + # §22 S5 / §A.2 / §B.1: "+ New project" is a global-Owner action. + user = auth.require_contributor(request) + if not auth.can_create_project(user): + raise HTTPException(403, "Only a global Owner may create projects") + pid = body.project_id.strip().lower() + if not _SLUG_RE.match(pid) or pid == "default": + raise HTTPException(422, "project id must be a slug and not 'default'") + name = (body.name or "").strip() + if not name: + raise HTTPException(422, "project name is required") + if body.type not in registry_mod.VALID_TYPES: + raise HTTPException(422, f"invalid type {body.type!r}") + # A project is created visible by default — the point of standing one up + # is for it to be seen; an Owner narrows it afterwards (or picks gated). + visibility = (body.visibility or "public").strip() + if visibility not in registry_mod.VALID_VISIBILITY: + raise HTTPException(422, f"invalid visibility {visibility!r}") + if db.conn().execute("SELECT 1 FROM projects WHERE id = ?", (pid,)).fetchone(): + raise HTTPException(409, f"project `{pid}` already exists") + content_repo = (body.content_repo or f"{pid}-content").strip() + if not _REPO_RE.match(content_repo): + raise HTTPException(422, f"invalid content repo name {content_repo!r}") + if await gitea.get_repo(config.gitea_org, content_repo) is not None: + raise HTTPException(409, f"repo `{content_repo}` already exists") + + # Read the current registry, append the project, recompose. Reads live in + # gitea.py and may be called anywhere; the bot owns the write back. + read = await gitea.read_file( + config.gitea_org, config.registry_repo, "projects.yaml", ref="main" + ) + if read is None: + raise HTTPException(409, "registry projects.yaml not found") + text, sha = read + try: + doc = yaml.safe_load(text) or {} + except yaml.YAMLError as e: + raise HTTPException(500, f"registry projects.yaml is not valid YAML: {e}") + if not isinstance(doc, dict): + raise HTTPException(500, "registry projects.yaml is malformed") + projects = doc.get("projects") + if not isinstance(projects, list): + projects = [] + if any(isinstance(p, dict) and str(p.get("id") or "") == pid for p in projects): + raise HTTPException(409, f"project `{pid}` already in the registry") + projects.append( + { + "id": pid, + "name": name, + "type": body.type, + "content_repo": content_repo, + "visibility": visibility, + } + ) + doc["projects"] = projects + new_text = yaml.safe_dump(doc, sort_keys=False) + readme_text = f"# {name}\n\nContent repository for project `{pid}`.\n" + + try: + await bot.create_project( + user.as_actor(), + org=config.gitea_org, + registry_repo=config.registry_repo, + content_repo=content_repo, + project_id=pid, + projects_yaml_new=new_text, + projects_yaml_sha=sha, + readme_text=readme_text, + ) + except GiteaError as e: + raise HTTPException(502, f"Gitea: {e.detail}") + + # §22.2: re-read the registry so the new entry becomes projects + + # default-collection rows. + await registry_mod.refresh_registry(config, gitea) + row = db.conn().execute( + "SELECT id, name, visibility FROM projects WHERE id = ?", (pid,) + ).fetchone() + if row is None: + raise HTTPException(500, "project committed but not mirrored") + cid = collections_mod.default_collection_id(pid) + return { + "id": row["id"], + "name": row["name"], + "visibility": row["visibility"], + "type": collections_mod.collection_type(cid), } @router.get("/api/projects/{project_id}") diff --git a/backend/app/auth.py b/backend/app/auth.py index 30e4b5d..0c70c45 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -682,6 +682,25 @@ def can_create_collection(user: SessionUser | None, project_id: str) -> bool: return row is not None +def can_create_project(user: SessionUser | None) -> bool: + """§22 S5 (§A.2 / §B.1): may the user create a new project? "+ New project" + is a **global-Owner** action — a deployment owner/admin (a global Owner per + §B.1) or a holder of an explicit `scope_type='global'` Owner grant. Creating + a project is deployment-level, so it is not reachable by a project- or + collection-scope grant nor by a global RFC Contributor (that role creates + collections, not projects). Subject to the §6 admission floor.""" + if user is None or user.permission_state != "granted": + return False + if user.role in _DEPLOYMENT_SUPERUSER_ROLES: + return True + row = db.conn().execute( + "SELECT 1 FROM memberships " + "WHERE user_id = ? AND scope_type = 'global' AND role = 'owner' LIMIT 1", + (user.user_id,), + ).fetchone() + return row is not None + + def can_invite_at_project(user: SessionUser | None, project_id: str) -> bool: """§22 S4 (C.2): may the user grant scope roles at this project (or at any collection within it)? Managing membership is an *Owner* capability whose diff --git a/backend/app/bot.py b/backend/app/bot.py index f985f23..f58d3df 100644 --- a/backend/app/bot.py +++ b/backend/app/bot.py @@ -198,6 +198,82 @@ class Bot: ) return created + async def create_project( + self, + actor: Actor, + *, + org: str, + registry_repo: str, + content_repo: str, + project_id: str, + projects_yaml_new: str, + projects_yaml_sha: str, + readme_text: str, + ) -> dict: + """§22 S5 (§A.2): stand up a new project. A global-Owner action wrapping + a bot write at two git sources: + + 1. **provision the content repo** — create `org/content_repo` if it + doesn't exist, then seed a `README.md` on `main` so the branch + exists (the contents API initialises the repo with that commit; the + corpus mirror and the propose path both need a `main` to write to). + 2. **register the project** — commit the caller-composed + `projects.yaml` (the existing doc with the new project appended) to + the registry repo's `main`. + + Like `create_collection`, this is a structural admin action committed + straight to main (no PR), like the registry config it feeds; the caller + then re-runs the registry mirror so the new `projects` + default + `collections` rows flow from the registry (§22.2 keeps the registry the + source of truth). Logs a `create_project` audit row for the §6.5 trail. + Returns the registry update_file result (carries the new commit sha).""" + ae = actor.email or f"{actor.gitea_login}@users.noreply" + existing = await self._gitea.get_repo(org, content_repo) + if existing is None: + await self._gitea.create_org_repo( + org, content_repo, description=f"Content repo for project {project_id}" + ) + # Seed a README if absent, which also establishes `main` on a freshly + # created (auto_init=False) repo — the contents API initialises the repo + # with that commit. Keyed on the README rather than the branch so it is + # idempotent and behaves identically whether the repo has a bare `main` + # or no branch at all. Mirrors `ensure_rfc_repo_seed`'s empty-repo seed. + readme = await self._gitea.get_contents(org, content_repo, "README.md", ref="main") + if readme is None: + await self._gitea.create_file( + org, + content_repo, + "README.md", + content=readme_text, + message=_stamp_single(f"chore: initialise content repo for {project_id}", actor), + branch="main", + author_name=actor.display_name, + author_email=ae, + ) + result = await self._gitea.update_file( + org, + registry_repo, + "projects.yaml", + content=projects_yaml_new, + sha=projects_yaml_sha, + message=_stamp_single(f"chore: create project {project_id}", actor), + branch="main", + author_name=actor.display_name, + author_email=ae, + ) + commit_sha = ( + result.get("commit", {}).get("sha") + or result.get("content", {}).get("sha") + or "" + ) + _log( + actor, + "create_project", + bot_commit_sha=commit_sha, + details={"project_id": project_id, "content_repo": content_repo}, + ) + return result + # ----- Meta repo: idea PRs (§9.1 / §9.2) ----- async def open_idea_pr( diff --git a/backend/tests/test_create_project_vertical.py b/backend/tests/test_create_project_vertical.py new file mode 100644 index 0000000..587c268 --- /dev/null +++ b/backend/tests/test_create_project_vertical.py @@ -0,0 +1,202 @@ +"""§22 S5 — create-project vertical: a global Owner POSTs `/api/projects`, the +bot provisions a Gitea content repo + commits the project to `projects.yaml`, and +the registry mirror upserts the `projects` + default `collections` rows (registry +stays the source of truth). Plus the deployment-directory empty-state signals +(`viewer.can_create_project`, `default_project_readable`) that drive C3.1/C3.2. +""" +from __future__ import annotations + +from fastapi.testclient import TestClient + +from app import db +from test_propose_vertical import ( # noqa: F401 + app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as, +) + + +def test_create_project_provisions_repo_commits_registry_and_mirrors(app_with_fake_gitea): + app, fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=1, login="ben", role="owner") + sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", + role="owner", email="ben@test") + r = client.post("/api/projects", + json={"project_id": "acme", "name": "Acme", "type": "bdd", + "visibility": "public"}) + assert r.status_code == 200, r.text + body = r.json() + assert body["id"] == "acme" + assert body["type"] == "bdd" + assert body["visibility"] == "public" + + # The bot provisioned the content repo (default name -content) and + # seeded a README so `main` exists. + assert ("wiggleverse", "acme-content") in fake.repos + readme = fake.files.get(("wiggleverse", "acme-content", "main", "README.md")) + assert readme is not None and "acme" in readme["content"] + + # The bot committed the new project into projects.yaml. + reg = fake.files.get(("wiggleverse", "registry", "main", "projects.yaml")) + assert reg is not None + assert "id: acme" in reg["content"] + assert "content_repo: acme-content" in reg["content"] + + # The registry refresh mirrored a projects row + its default collection. + prow = db.conn().execute( + "SELECT name, content_repo, visibility FROM projects WHERE id='acme'" + ).fetchone() + assert (prow["name"], prow["content_repo"], prow["visibility"]) == ( + "Acme", "acme-content", "public") + crow = db.conn().execute( + "SELECT type, project_id, subfolder FROM collections WHERE id='acme'" + ).fetchone() + assert (crow["type"], crow["project_id"], crow["subfolder"]) == ("bdd", "acme", "") + + # It is now visible in the deployment directory and readable. + ids = {p["id"] for p in client.get("/api/deployment").json()["projects"]} + assert "acme" in ids + assert client.get("/api/projects/acme").status_code == 200 + + # An audit row records the structural action with the global Owner actor. + act = db.conn().execute( + "SELECT actor_user_id FROM actions WHERE action_kind='create_project'" + ).fetchone() + assert act is not None and act["actor_user_id"] == 1 + + +def test_create_project_custom_content_repo_name(app_with_fake_gitea): + app, fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=1, login="ben", role="owner") + sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", + role="owner", email="ben@test") + r = client.post("/api/projects", + json={"project_id": "beta", "name": "Beta", "type": "document", + "content_repo": "beta-corpus"}) + assert r.status_code == 200, r.text + assert ("wiggleverse", "beta-corpus") in fake.repos + prow = db.conn().execute( + "SELECT content_repo FROM projects WHERE id='beta'" + ).fetchone() + assert prow["content_repo"] == "beta-corpus" + + +def test_create_project_requires_global_owner(app_with_fake_gitea): + # A plain deployment contributor is not a global Owner (C: + New project is a + # global-Owner action), even though they may create collections. + app, _ = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", + role="contributor", email="alice@test") + r = client.post("/api/projects", + json={"project_id": "x", "name": "X", "type": "bdd"}) + assert r.status_code == 403 + + +def test_create_project_global_owner_grant_permitted(app_with_fake_gitea): + # An explicit global-scope Owner grant (not a deployment owner/admin) may + # create projects. + app, _ = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=3, login="gina", role="contributor") + db.conn().execute( + "INSERT INTO memberships (scope_type, scope_id, user_id, role) " + "VALUES ('global', '*', 3, 'owner')" + ) + sign_in_as(client, user_id=3, gitea_login="gina", display_name="Gina", + role="contributor", email="gina@test") + r = client.post("/api/projects", + json={"project_id": "gproj", "name": "G", "type": "document"}) + assert r.status_code == 200, r.text + + +def test_create_project_anonymous_rejected(app_with_fake_gitea): + app, _ = app_with_fake_gitea + with TestClient(app) as client: + r = client.post("/api/projects", + json={"project_id": "x", "name": "X", "type": "bdd"}) + assert r.status_code in (401, 403) + + +def test_create_project_rejects_duplicate(app_with_fake_gitea): + app, _ = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=1, login="ben", role="owner") + sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", + role="owner", email="ben@test") + ok = client.post("/api/projects", + json={"project_id": "acme", "name": "Acme", "type": "bdd"}) + assert ok.status_code == 200, ok.text + dup = client.post("/api/projects", + json={"project_id": "acme", "name": "Acme 2", "type": "bdd"}) + assert dup.status_code == 409 + + +def test_create_project_rejects_reserved_default_id(app_with_fake_gitea): + app, _ = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=1, login="ben", role="owner") + sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", + role="owner", email="ben@test") + r = client.post("/api/projects", + json={"project_id": "default", "name": "X", "type": "bdd"}) + assert r.status_code == 422 + + +def test_create_project_rejects_bad_type(app_with_fake_gitea): + app, _ = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=1, login="ben", role="owner") + sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", + role="owner", email="ben@test") + r = client.post("/api/projects", + json={"project_id": "x", "name": "X", "type": "nonsense"}) + assert r.status_code == 422 + + +# --- C3.1 / C3.2: deployment-directory empty-state signals ------------------ + + +def test_deployment_owner_sees_create_project_capability(app_with_fake_gitea): + # C3.1: a global Owner is offered the create-project action. + app, _ = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=1, login="ben", role="owner") + sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") + body = client.get("/api/deployment").json() + assert body["viewer"]["can_create_project"] is True + + +def test_deployment_non_owner_no_create_capability(app_with_fake_gitea): + # C3.2: a granted account with no roles is not offered create-project. + app, _ = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="vee", role="contributor") + sign_in_as(client, user_id=2, gitea_login="vee", display_name="Vee", role="contributor") + body = client.get("/api/deployment").json() + assert body["viewer"]["can_create_project"] is False + + +def test_deployment_default_readable_when_default_is_public(app_with_fake_gitea): + # The seeded default project is public → readable → the N=1 redirect target + # is valid (land-in-corpus preserved). + app, _ = app_with_fake_gitea + with TestClient(app) as client: + body = client.get("/api/deployment").json() + assert body["default_project_readable"] is True + + +def test_deployment_default_not_readable_when_only_gated(app_with_fake_gitea): + # C3.2: the only project is gated; a granted non-member sees no visible + # projects AND default_project_readable False → the frontend renders the + # empty directory (no 404 bounce). + app, _ = app_with_fake_gitea + with TestClient(app) as client: + db.conn().execute("UPDATE projects SET visibility='gated' WHERE id='default'") + db.conn().execute("UPDATE collections SET visibility='gated' WHERE id='default'") + provision_user_row(user_id=2, login="vee", role="contributor") + sign_in_as(client, user_id=2, gitea_login="vee", display_name="Vee", role="contributor") + body = client.get("/api/deployment").json() + assert body["projects"] == [] + assert body["default_project_readable"] is False diff --git a/docs/design/2026-06-05-three-tier-projects-collections.md b/docs/design/2026-06-05-three-tier-projects-collections.md index 6498e6a..8817063 100644 --- a/docs/design/2026-06-05-three-tier-projects-collections.md +++ b/docs/design/2026-06-05-three-tier-projects-collections.md @@ -593,9 +593,16 @@ means the deployment runs and either gains a capability or provably loses none invites collaborators at the right scope from the UI. **Completes:** `@S4` (all of C.2 — invitation; plus the project/collection empty states C3.3–C3.5). -- **S5 — In-app create-project + the global directory.** The global-Owner +- **S5 — In-app create-project + the global directory.** *(Shipped v0.44.0.)* + The global-Owner **create-project** action (bot provisions a Gitea content repo + commits to - `projects.yaml`); the deployment directory empty states. **Usable end-state:** + `projects.yaml`); the deployment directory empty states. Modelled as a + global-Owner gate (`auth.can_create_project`: a deployment owner/admin or an + explicit `scope_type='global'` Owner grant) over `POST /api/projects`; the + content repo defaults to `-content` and is seeded with a `README.md` so + `main` exists. The deployment payload gains `viewer.can_create_project` + + `default_project_readable` so the directory renders the role-aware empty state + rather than bouncing into an unreadable/absent default. **Usable end-state:** a global Owner stands up a new project end-to-end from the UI. **Completes:** `@S5` (the global-directory empty states C3.1–C3.2). diff --git a/frontend/package.json b/frontend/package.json index 29152e4..e262972 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "rfc-app-frontend", "private": true, - "version": "0.43.0", + "version": "0.44.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 6bdf11b..aabfa33 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -448,16 +448,19 @@ function DeploymentLanding() { // OHM's "land in the corpus" UX is preserved; the directory appears only // when 2+ projects are visible to the caller. Visibility is per-caller // (§22.5), so the unlisted projects never count toward the directory. - const { projects, defaultProjectId, loading } = useDeployment() + const { projects, defaultProjectId, defaultProjectReadable, loading } = useDeployment() if (loading) { return
Loading…
} if (projects.length === 1) { return } - if (projects.length === 0 && defaultProjectId) { - // No enumerable projects but a default exists (e.g. an anon hitting a - // deployment whose only project is unlisted-but-default) — land there. + if (projects.length === 0 && defaultProjectReadable && defaultProjectId) { + // No enumerable projects but the default is readable by this viewer (e.g. an + // anon hitting a deployment whose only project is unlisted-but-default) — + // land there. §22 S5: when the default is gated to this viewer (C3.2) or + // absent (C3.1, no projects), we do NOT bounce into a 404 — we fall through + // to the directory's role-aware empty state below. return } return diff --git a/frontend/src/api.js b/frontend/src/api.js index 7a191e1..213a9c3 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -182,6 +182,24 @@ export async function getProject(projectId) { return jsonOrThrow(await fetch(`/api/projects/${projectId}`)) } +// §22 S5: create-project (global Owner). The backend provisions a Gitea content +// repo, commits the project to projects.yaml, re-mirrors the registry, and +// returns the new project { id, name, type, visibility }. +export async function createProject({ projectId, name, type, visibility, contentRepo }) { + const res = await fetch('/api/projects', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + project_id: projectId, + name, + type, + visibility: visibility || null, + content_repo: contentRepo || null, + }), + }) + return jsonOrThrow(res) +} + // §22.4 (Plan B) / §22 S2: per-collection serving. With a projectId + a // collectionId, read the collection-scoped routes; with only a projectId, the // project default-collection compat path; with neither, the unscoped path. diff --git a/frontend/src/components/CreateProjectModal.jsx b/frontend/src/components/CreateProjectModal.jsx new file mode 100644 index 0000000..e03091b --- /dev/null +++ b/frontend/src/components/CreateProjectModal.jsx @@ -0,0 +1,113 @@ +// CreateProjectModal.jsx — §22 S5. The create-project form the deployment +// directory's "Create your first project" / "New project" control opens, gated +// on the viewer's `can_create_project` capability (a global Owner action). +// +// The form lets the Owner choose a project id (a slug, not "default"), a display +// name, the type of its initial (default) collection, a visibility, and an +// optional content-repo name (defaulting to `-content`). The backend +// provisions the Gitea content repo, commits the project to projects.yaml, +// re-mirrors the registry, and returns the new project; on success the caller +// refreshes the directory (and the N=1 redirect then lands in the new project). + +import { useState } from 'react' +import { createProject } from '../api' + +const TYPE_OPTIONS = [ + { value: 'document', label: 'Document — prose RFCs' }, + { value: 'specification', label: 'Specification' }, + { value: 'bdd', label: 'BDD — behaviour scenarios' }, +] + +const VISIBILITY_OPTIONS = [ + { value: 'public', label: 'Public' }, + { value: 'unlisted', label: 'Unlisted (link-only)' }, + { value: 'gated', label: 'Gated (hidden from the public)' }, +] + +export default function CreateProjectModal({ onClose, onCreated }) { + const [projectId, setProjectId] = useState('') + const [name, setName] = useState('') + const [type, setType] = useState('document') + const [visibility, setVisibility] = useState('public') + const [contentRepo, setContentRepo] = useState('') + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + async function handleCreate(e) { + e.preventDefault() + const pid = projectId.trim().toLowerCase() + if (!pid || !name.trim()) return + setSubmitting(true) + setError(null) + try { + const project = await createProject({ + projectId: pid, + name: name.trim(), + type, + visibility, + contentRepo: contentRepo.trim() || null, + }) + onCreated?.(project) + } catch (err) { + setError(err.message || 'Failed to create the project.') + } finally { + setSubmitting(false) + } + } + + return ( +
{ if (e.target === e.currentTarget) onClose() }}> +
+
+

New project

+ +
+
+
+ + setProjectId(e.target.value)} + placeholder="e.g. ohm, acme" + autoFocus + required + /> + + setName(e.target.value)} + placeholder="e.g. Open Human Model" + required + /> + + + + + + setContentRepo(e.target.value)} + placeholder="defaults to -content" + /> +
+ + {error && {error}} +
+ +
+
+ +
+
+
+ ) +} diff --git a/frontend/src/components/Directory.jsx b/frontend/src/components/Directory.jsx index b63662f..e53b39e 100644 --- a/frontend/src/components/Directory.jsx +++ b/frontend/src/components/Directory.jsx @@ -1,30 +1,71 @@ // §22.10 (M3) — the deployment directory at `/`. Renders the caller-visible // projects (from /api/deployment) as cards linking into each project's -// `/p//` home. App's DeploymentLanding only mounts this when 2+ projects -// are visible; the N=1 case redirects straight into the single project so -// OHM's "land in the corpus" UX is preserved. +// `/p//` home. App's DeploymentLanding only mounts this when the N=1 +// land-in-corpus redirect does not apply (2+ visible projects, or no readable +// default) so OHM's "land in the corpus" UX is preserved. +// +// §22 S5 — role-aware empty states + the global-Owner create-project action. +// The deployment payload carries a `viewer` block; the directory reads it to +// render: +// * C3.1: a global Owner sees a "Create your first project" CTA (and a "New +// project" control when the directory is non-empty), opening the +// create-project modal (choose id, name, type, visibility). +// * C3.2: a non-owner (a granted account with no roles) sees the empty +// directory with no create action and a note that nothing is shared yet. +import { useState } from 'react' import { Link } from 'react-router-dom' import { useDeployment } from '../context/DeploymentProvider' import { entryNoun } from './ProjectLayout.jsx' +import CreateProjectModal from './CreateProjectModal.jsx' export default function Directory() { - const { name, tagline, projects } = useDeployment() + const { name, tagline, projects, viewer, refresh } = useDeployment() + const [createOpen, setCreateOpen] = useState(false) + const canCreate = !!viewer?.can_create_project + return (
-

{name}

+
+

{name}

+
+ {canCreate && projects.length > 0 && ( + + )} +
+
{tagline &&

{tagline}

} -
    - {projects.map(p => ( -
  • - - {p.name} - {entryNoun(p.type)}s - -
  • - ))} -
+ {projects.length === 0 ? ( + // C3.1 / C3.2: role-keyed empty state. + canCreate ? ( +
+

No projects yet.

+ +
+ ) : ( +

Nothing has been shared with you yet.

+ ) + ) : ( +
    + {projects.map(p => ( +
  • + + {p.name} + {entryNoun(p.type)}s + +
  • + ))} +
+ )}
+ {createOpen && ( + setCreateOpen(false)} + onCreated={() => { setCreateOpen(false); refresh?.() }} + /> + )}
) } diff --git a/frontend/src/components/Directory.test.jsx b/frontend/src/components/Directory.test.jsx index 3aff06c..2de13a5 100644 --- a/frontend/src/components/Directory.test.jsx +++ b/frontend/src/components/Directory.test.jsx @@ -5,24 +5,34 @@ import { MemoryRouter } from 'react-router-dom' // Mocked first so Directory (and ProjectLayout, which it imports entryNoun // from) resolve the deployment hook and api without a live fetch. -let mockProjects = [] -vi.mock('../api', () => ({ getProject: vi.fn() })) +let mockDeployment = {} +vi.mock('../api', () => ({ getProject: vi.fn(), createProject: vi.fn() })) vi.mock('../context/DeploymentProvider', () => ({ - useDeployment: () => ({ name: 'Wiggleverse', tagline: 'a directory of projects', projects: mockProjects, defaultProjectId: null }), + useDeployment: () => mockDeployment, })) import Directory from './Directory.jsx' -function renderDir(projects) { - mockProjects = projects +function renderDir(overrides = {}) { + mockDeployment = { + name: 'Wiggleverse', + tagline: 'a directory of projects', + projects: [], + viewer: null, + defaultProjectId: null, + refresh: vi.fn(), + ...overrides, + } return render() } describe('Directory', () => { it('renders a card per visible project with the type-driven noun + link', () => { - renderDir([ - { id: 'ohm', name: 'Open Human Model', type: 'document', visibility: 'public' }, - { id: 'ecomm', name: 'Ecomm', type: 'bdd', visibility: 'public' }, - ]) + renderDir({ + projects: [ + { id: 'ohm', name: 'Open Human Model', type: 'document', visibility: 'public' }, + { id: 'ecomm', name: 'Ecomm', type: 'bdd', visibility: 'public' }, + ], + }) const ohm = screen.getByText('Open Human Model').closest('a') expect(ohm).toHaveAttribute('href', '/p/ohm/') const ecomm = screen.getByText('Ecomm').closest('a') @@ -33,13 +43,41 @@ describe('Directory', () => { }) it('renders the deployment name and tagline', () => { - renderDir([{ id: 'ohm', name: 'OHM', type: 'document', visibility: 'public' }]) + renderDir({ projects: [{ id: 'ohm', name: 'OHM', type: 'document', visibility: 'public' }] }) expect(screen.getByText('Wiggleverse')).toBeInTheDocument() expect(screen.getByText('a directory of projects')).toBeInTheDocument() }) it('renders an empty list without crashing when no projects are visible', () => { - renderDir([]) + renderDir({ projects: [] }) expect(screen.getByText('Wiggleverse')).toBeInTheDocument() }) -}) \ No newline at end of file + + // §22 S5 — role-aware empty states (C3.1 / C3.2). + it('shows a "Create your first project" CTA to a global Owner on an empty directory', () => { + renderDir({ projects: [], viewer: { can_create_project: true } }) + expect(screen.getByText('Create your first project')).toBeInTheDocument() + }) + + it('shows a "nothing shared with you yet" note to a non-owner on an empty directory', () => { + renderDir({ projects: [], viewer: { can_create_project: false } }) + expect(screen.getByText(/Nothing has been shared with you yet/i)).toBeInTheDocument() + expect(screen.queryByText('Create your first project')).not.toBeInTheDocument() + }) + + it('offers a "New project" control to an Owner when the directory is non-empty', () => { + renderDir({ + projects: [{ id: 'ohm', name: 'OHM', type: 'document', visibility: 'public' }], + viewer: { can_create_project: true }, + }) + expect(screen.getByText('New project')).toBeInTheDocument() + }) + + it('does not offer a create control to a non-owner when the directory is non-empty', () => { + renderDir({ + projects: [{ id: 'ohm', name: 'OHM', type: 'document', visibility: 'public' }], + viewer: { can_create_project: false }, + }) + expect(screen.queryByText('New project')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/context/DeploymentProvider.jsx b/frontend/src/context/DeploymentProvider.jsx index 7332d7b..824a475 100644 --- a/frontend/src/context/DeploymentProvider.jsx +++ b/frontend/src/context/DeploymentProvider.jsx @@ -2,22 +2,30 @@ // VITE_APP_NAME. Fetches GET /api/deployment once on boot and provides it to // the whole tree: // -// { name, tagline, defaultProjectId, projects[], loading } +// { name, tagline, defaultProjectId, defaultProjectReadable, projects[], +// viewer, loading, refresh } // // `projects` is the per-caller-visible set (§22.5: public + member-gated; // unlisted omitted). `defaultProjectId` is the corpus-served project the -// M3-frontend guard keys on. During the pre-fetch paint, consumers fall back -// to the neutral brandTitle() default ('RFC') — never a hardcoded deployment -// name. -import { createContext, useContext, useEffect, useState } from 'react' +// M3-frontend guard keys on. `defaultProjectReadable` (§22 S5) is whether that +// redirect target is reachable by this viewer — the deployment landing only +// bounces into it when true, else it renders the role-aware directory empty +// state. `viewer` carries the §22 S5 capability flags (`can_create_project`). +// `refresh` re-fetches the payload (e.g. after creating a project). During the +// pre-fetch paint, consumers fall back to the neutral brandTitle() default +// ('RFC') — never a hardcoded deployment name. +import { createContext, useContext, useCallback, useEffect, useState } from 'react' import { getDeployment } from '../api' const DeploymentContext = createContext({ name: '', tagline: '', defaultProjectId: null, + defaultProjectReadable: false, projects: [], + viewer: null, loading: true, + refresh: () => {}, }) export function useDeployment() { @@ -29,33 +37,43 @@ export function DeploymentProvider({ children }) { name: '', tagline: '', defaultProjectId: null, + defaultProjectReadable: false, projects: [], + viewer: null, loading: true, }) - useEffect(() => { - let cancelled = false - getDeployment() + const load = useCallback((cancelledRef) => { + return getDeployment() .then(d => { - if (cancelled) return + if (cancelledRef?.cancelled) return setState({ name: d.name || '', tagline: d.tagline || '', defaultProjectId: d.default_project_id || null, + defaultProjectReadable: !!d.default_project_readable, projects: Array.isArray(d.projects) ? d.projects : [], + viewer: d.viewer || null, loading: false, }) }) .catch(() => { // A failed deployment fetch must not wedge the app: fall back to the // neutral brand and an empty directory so the shell still paints. - if (!cancelled) setState(s => ({ ...s, loading: false })) + if (!cancelledRef?.cancelled) setState(s => ({ ...s, loading: false })) }) - return () => { cancelled = true } }, []) + useEffect(() => { + const ref = { cancelled: false } + load(ref) + return () => { ref.cancelled = true } + }, [load]) + + const refresh = useCallback(() => load(), [load]) + return ( - + {children} )