diff --git a/CHANGELOG.md b/CHANGELOG.md index 502e86f..02d33df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 - TeamSelector dropdown component (persists selection in localStorage) - 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 members page at `/teams/{slug}/members` - 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 integration tests for team CRUD, membership, and project operations - Added unit tests for TeamAuthorizationService diff --git a/backend/app/seed.py b/backend/app/seed.py index ed1a29d..fdd3c07 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -5,7 +5,7 @@ import hashlib import logging 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 logger = logging.getLogger(__name__) @@ -149,6 +149,33 @@ def seed_database(db: Session) -> None: logger.info("Seeding database with test data...") 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 project_map = {} package_map = {} @@ -158,7 +185,8 @@ def seed_database(db: Session) -> None: name=project_data["name"], description=project_data["description"], 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.flush() # Get the ID @@ -174,7 +202,7 @@ def seed_database(db: Session) -> None: db.flush() 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 artifact_count = 0 @@ -212,7 +240,7 @@ def seed_database(db: Session) -> None: size=size, content_type=artifact_data["content_type"], original_name=artifact_data["filename"], - created_by="seed-user", + created_by=team_owner_username, s3_key=s3_key, ref_count=ref_count, ) @@ -235,7 +263,7 @@ def seed_database(db: Session) -> None: artifact_id=sha256_hash, version=artifact_data["version"], version_source="explicit", - created_by="seed-user", + created_by=team_owner_username, ) db.add(version) version_count += 1 @@ -246,7 +274,7 @@ def seed_database(db: Session) -> None: package_id=package.id, name=tag_name, artifact_id=sha256_hash, - created_by="seed-user", + created_by=team_owner_username, ) db.add(tag) tag_count += 1 diff --git a/frontend/src/api.ts b/frontend/src/api.ts index b78b7a1..6260fa7 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -166,7 +166,7 @@ export async function listProjectsSimple(params: ListParams = {}): Promise { +export async function createProject(data: { name: string; description?: string; is_public?: boolean; team_id?: string }): Promise { const response = await fetch(`${API_BASE}/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/frontend/src/pages/TeamDashboardPage.css b/frontend/src/pages/TeamDashboardPage.css index c784988..5493f08 100644 --- a/frontend/src/pages/TeamDashboardPage.css +++ b/frontend/src/pages/TeamDashboardPage.css @@ -221,3 +221,100 @@ .btn-secondary:hover { 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; +} diff --git a/frontend/src/pages/TeamDashboardPage.tsx b/frontend/src/pages/TeamDashboardPage.tsx index f37b55e..a1e00db 100644 --- a/frontend/src/pages/TeamDashboardPage.tsx +++ b/frontend/src/pages/TeamDashboardPage.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import { Link, useParams, useNavigate } from 'react-router-dom'; import { TeamDetail, Project, PaginatedResponse } from '../types'; -import { getTeam, listTeamProjects } from '../api'; +import { getTeam, listTeamProjects, createProject } from '../api'; import { useAuth } from '../contexts/AuthContext'; import { Badge } from '../components/Badge'; import { Breadcrumb } from '../components/Breadcrumb'; @@ -15,6 +15,9 @@ function TeamDashboardPage() { const [projects, setProjects] = useState | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [showProjectForm, setShowProjectForm] = useState(false); + const [newProject, setNewProject] = useState({ name: '', description: '', is_public: true }); + const [creating, setCreating] = useState(false); const loadTeamData = useCallback(async () => { if (!slug) return; @@ -38,6 +41,22 @@ function TeamDashboardPage() { 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) { return (
@@ -124,13 +143,64 @@ function TeamDashboardPage() {
)} + {showProjectForm && ( +
setShowProjectForm(false)}> +
e.stopPropagation()}> +

Create New Project

+
+
+ + setNewProject({ ...newProject, name: e.target.value })} + placeholder="my-project" + required + autoFocus + /> +
+
+ +