§22 S5: in-app create-project + global-directory empty states (@S5 C3.1–C3.2)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||
<DeploymentContext.Provider value={state}>
|
||||
<DeploymentContext.Provider value={{ ...state, refresh }}>
|
||||
{children}
|
||||
</DeploymentContext.Provider>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user