§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:
@@ -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 `<id>-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 (
|
||||
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="modal" style={{ maxWidth: 560 }}>
|
||||
<div className="modal-header">
|
||||
<h2>New project</h2>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<form onSubmit={handleCreate} className="invitations-form">
|
||||
<label htmlFor="proj-id">Project id</label>
|
||||
<input
|
||||
id="proj-id"
|
||||
value={projectId}
|
||||
onChange={e => setProjectId(e.target.value)}
|
||||
placeholder="e.g. ohm, acme"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<label htmlFor="proj-name" style={{ marginTop: 10 }}>Display name</label>
|
||||
<input
|
||||
id="proj-name"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="e.g. Open Human Model"
|
||||
required
|
||||
/>
|
||||
<label htmlFor="proj-type" style={{ marginTop: 10 }}>Initial collection type</label>
|
||||
<select id="proj-type" value={type} onChange={e => setType(e.target.value)}>
|
||||
{TYPE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<label htmlFor="proj-vis" style={{ marginTop: 10 }}>Visibility</label>
|
||||
<select id="proj-vis" value={visibility} onChange={e => setVisibility(e.target.value)}>
|
||||
{VISIBILITY_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<label htmlFor="proj-repo" style={{ marginTop: 10 }}>Content repo (optional)</label>
|
||||
<input
|
||||
id="proj-repo"
|
||||
value={contentRepo}
|
||||
onChange={e => setContentRepo(e.target.value)}
|
||||
placeholder="defaults to <id>-content"
|
||||
/>
|
||||
<div style={{ marginTop: 12, display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<button type="submit" className="btn-primary" disabled={submitting}>
|
||||
{submitting ? 'Creating…' : 'Create project'}
|
||||
</button>
|
||||
{error && <span style={{ color: '#c33' }}>{error}</span>}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn-link" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user