Add project creation from team dashboard and update seed data

- Add project creation modal to TeamDashboardPage with team_id assignment
- Update createProject API function to accept optional team_id
- Update seed data to create a "Demo Team" and assign all projects to it
- Admin user is added as team owner when present
This commit is contained in:
Mondo Diaz
2026-01-28 00:02:53 +00:00
parent a1bf38de04
commit 053d45add1
5 changed files with 210 additions and 12 deletions

View File

@@ -38,10 +38,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- TeamContext provider for managing current team selection - TeamContext provider for managing current team selection
- TeamSelector dropdown component (persists selection in localStorage) - TeamSelector dropdown component (persists selection in localStorage)
- Teams list page at `/teams` - Teams list page at `/teams`
- Team dashboard page at `/teams/{slug}` - Team dashboard page at `/teams/{slug}` with inline project creation
- Team settings page at `/teams/{slug}/settings` - Team settings page at `/teams/{slug}/settings`
- Team members page at `/teams/{slug}/members` - Team members page at `/teams/{slug}/members`
- Teams navigation link in header (authenticated users only) - Teams navigation link in header (authenticated users only)
- Updated seed data to create a "Demo Team" and assign all seed projects to it
- Added TypeScript types and API client functions for teams - Added TypeScript types and API client functions for teams
- Added integration tests for team CRUD, membership, and project operations - Added integration tests for team CRUD, membership, and project operations
- Added unit tests for TeamAuthorizationService - Added unit tests for TeamAuthorizationService

View File

@@ -5,7 +5,7 @@ import hashlib
import logging import logging
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .models import Project, Package, Artifact, Tag, Upload, PackageVersion, ArtifactDependency from .models import Project, Package, Artifact, Tag, Upload, PackageVersion, ArtifactDependency, Team, TeamMembership, User
from .storage import get_storage from .storage import get_storage
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -149,6 +149,33 @@ def seed_database(db: Session) -> None:
logger.info("Seeding database with test data...") logger.info("Seeding database with test data...")
storage = get_storage() storage = get_storage()
# Find or use admin user for team ownership
admin_user = db.query(User).filter(User.username == "admin").first()
team_owner_username = admin_user.username if admin_user else "seed-user"
# Create a demo team
demo_team = Team(
name="Demo Team",
slug="demo-team",
description="A demonstration team with sample projects",
created_by=team_owner_username,
)
db.add(demo_team)
db.flush()
# Add admin user as team owner if they exist
if admin_user:
membership = TeamMembership(
team_id=demo_team.id,
user_id=admin_user.id,
role="owner",
invited_by=team_owner_username,
)
db.add(membership)
db.flush()
logger.info(f"Created team: {demo_team.name} ({demo_team.slug})")
# Create projects and packages # Create projects and packages
project_map = {} project_map = {}
package_map = {} package_map = {}
@@ -158,7 +185,8 @@ def seed_database(db: Session) -> None:
name=project_data["name"], name=project_data["name"],
description=project_data["description"], description=project_data["description"],
is_public=project_data["is_public"], is_public=project_data["is_public"],
created_by="seed-user", created_by=team_owner_username,
team_id=demo_team.id, # Assign to demo team
) )
db.add(project) db.add(project)
db.flush() # Get the ID db.flush() # Get the ID
@@ -174,7 +202,7 @@ def seed_database(db: Session) -> None:
db.flush() db.flush()
package_map[(project_data["name"], package_data["name"])] = package package_map[(project_data["name"], package_data["name"])] = package
logger.info(f"Created {len(project_map)} projects and {len(package_map)} packages") logger.info(f"Created {len(project_map)} projects and {len(package_map)} packages (assigned to {demo_team.slug})")
# Create artifacts, tags, and versions # Create artifacts, tags, and versions
artifact_count = 0 artifact_count = 0
@@ -212,7 +240,7 @@ def seed_database(db: Session) -> None:
size=size, size=size,
content_type=artifact_data["content_type"], content_type=artifact_data["content_type"],
original_name=artifact_data["filename"], original_name=artifact_data["filename"],
created_by="seed-user", created_by=team_owner_username,
s3_key=s3_key, s3_key=s3_key,
ref_count=ref_count, ref_count=ref_count,
) )
@@ -235,7 +263,7 @@ def seed_database(db: Session) -> None:
artifact_id=sha256_hash, artifact_id=sha256_hash,
version=artifact_data["version"], version=artifact_data["version"],
version_source="explicit", version_source="explicit",
created_by="seed-user", created_by=team_owner_username,
) )
db.add(version) db.add(version)
version_count += 1 version_count += 1
@@ -246,7 +274,7 @@ def seed_database(db: Session) -> None:
package_id=package.id, package_id=package.id,
name=tag_name, name=tag_name,
artifact_id=sha256_hash, artifact_id=sha256_hash,
created_by="seed-user", created_by=team_owner_username,
) )
db.add(tag) db.add(tag)
tag_count += 1 tag_count += 1

View File

@@ -166,7 +166,7 @@ export async function listProjectsSimple(params: ListParams = {}): Promise<Proje
return data.items; return data.items;
} }
export async function createProject(data: { name: string; description?: string; is_public?: boolean }): Promise<Project> { export async function createProject(data: { name: string; description?: string; is_public?: boolean; team_id?: string }): Promise<Project> {
const response = await fetch(`${API_BASE}/projects`, { const response = await fetch(`${API_BASE}/projects`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },

View File

@@ -221,3 +221,100 @@
.btn-secondary:hover { .btn-secondary:hover {
background: var(--color-bg-tertiary); background: var(--color-bg-tertiary);
} }
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal-content {
background: var(--color-bg);
border-radius: var(--radius-lg);
padding: 1.5rem;
width: 100%;
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
}
.modal-content h2 {
margin: 0 0 1.5rem;
font-size: 1.25rem;
}
/* Form */
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
font-size: 0.875rem;
}
.form-group input[type="text"],
.form-group textarea {
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.875rem;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-alpha);
}
.form-group textarea {
resize: vertical;
min-height: 80px;
}
.checkbox-group label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.checkbox-group input[type="checkbox"] {
width: 1rem;
height: 1rem;
}
.form-hint {
display: block;
font-size: 0.8125rem;
color: var(--color-text-muted);
margin-top: 0.375rem;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1.5rem;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.empty-state .btn {
margin-top: 1rem;
}

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Link, useParams, useNavigate } from 'react-router-dom'; import { Link, useParams, useNavigate } from 'react-router-dom';
import { TeamDetail, Project, PaginatedResponse } from '../types'; import { TeamDetail, Project, PaginatedResponse } from '../types';
import { getTeam, listTeamProjects } from '../api'; import { getTeam, listTeamProjects, createProject } from '../api';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { Badge } from '../components/Badge'; import { Badge } from '../components/Badge';
import { Breadcrumb } from '../components/Breadcrumb'; import { Breadcrumb } from '../components/Breadcrumb';
@@ -15,6 +15,9 @@ function TeamDashboardPage() {
const [projects, setProjects] = useState<PaginatedResponse<Project> | null>(null); const [projects, setProjects] = useState<PaginatedResponse<Project> | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showProjectForm, setShowProjectForm] = useState(false);
const [newProject, setNewProject] = useState({ name: '', description: '', is_public: true });
const [creating, setCreating] = useState(false);
const loadTeamData = useCallback(async () => { const loadTeamData = useCallback(async () => {
if (!slug) return; if (!slug) return;
@@ -38,6 +41,22 @@ function TeamDashboardPage() {
loadTeamData(); loadTeamData();
}, [loadTeamData]); }, [loadTeamData]);
async function handleCreateProject(e: React.FormEvent) {
e.preventDefault();
if (!team) return;
try {
setCreating(true);
const project = await createProject({ ...newProject, team_id: team.id });
setNewProject({ name: '', description: '', is_public: true });
setShowProjectForm(false);
navigate(`/project/${project.name}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create project');
} finally {
setCreating(false);
}
}
if (loading) { if (loading) {
return ( return (
<div className="team-dashboard"> <div className="team-dashboard">
@@ -124,13 +143,64 @@ function TeamDashboardPage() {
</div> </div>
)} )}
{showProjectForm && (
<div className="modal-overlay" onClick={() => setShowProjectForm(false)}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<h2>Create New Project</h2>
<form onSubmit={handleCreateProject}>
<div className="form-group">
<label htmlFor="project-name">Project Name</label>
<input
id="project-name"
type="text"
value={newProject.name}
onChange={e => setNewProject({ ...newProject, name: e.target.value })}
placeholder="my-project"
required
autoFocus
/>
</div>
<div className="form-group">
<label htmlFor="project-description">Description (optional)</label>
<textarea
id="project-description"
value={newProject.description}
onChange={e => setNewProject({ ...newProject, description: e.target.value })}
placeholder="What is this project for?"
rows={3}
/>
</div>
<div className="form-group checkbox-group">
<label>
<input
type="checkbox"
checked={newProject.is_public}
onChange={e => setNewProject({ ...newProject, is_public: e.target.checked })}
/>
Public project
</label>
<span className="form-hint">Public projects are visible to everyone</span>
</div>
<div className="form-actions">
<button type="button" className="btn btn-secondary" onClick={() => setShowProjectForm(false)}>
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={creating}>
{creating ? 'Creating...' : 'Create Project'}
</button>
</div>
</form>
</div>
</div>
)}
<div className="team-section"> <div className="team-section">
<div className="section-header"> <div className="section-header">
<h2>Projects</h2> <h2>Projects</h2>
{isAdminOrOwner && ( {isAdminOrOwner && (
<Link to="/" className="btn btn-primary btn-sm"> <button className="btn btn-primary btn-sm" onClick={() => setShowProjectForm(true)}>
+ New Project + New Project
</Link> </button>
)} )}
</div> </div>
@@ -138,7 +208,9 @@ function TeamDashboardPage() {
<div className="empty-state"> <div className="empty-state">
<p>No projects in this team yet.</p> <p>No projects in this team yet.</p>
{isAdminOrOwner && ( {isAdminOrOwner && (
<p className="empty-hint">Create a project and assign it to this team to get started.</p> <button className="btn btn-primary" onClick={() => setShowProjectForm(true)}>
Create Project
</button>
)} )}
</div> </div>
) : ( ) : (