6 Commits

Author SHA1 Message Date
Mondo Diaz
9df50d0963 Add migration tracking with smart error detection
- Add _schema_migrations table to track applied migrations
- Each migration has a unique name and checksum
- Migrations are only run once (tracked by name)
- Checksum changes are detected and logged as warnings
- Smart error detection distinguishes "already applied" errors from real failures
- Real errors now fail hard with clear error messages
- Safe PostgreSQL error codes (42P07, 42701, 42710, 42P16) are recognized
- Fix semver migration to generate UUID for id column
2026-01-28 21:05:45 +00:00
Mondo Diaz
c60a7ba1ab Add rollback after failed migration to allow subsequent migrations to run
When a migration fails, the transaction is left in a failed state. Without
rollback, all subsequent migrations fail with "current transaction is aborted".
This was preventing the team_id column from being added to projects.
2026-01-28 20:27:46 +00:00
Mondo Diaz
aed48bb4a2 Merge branch 'fix/teams-migration-runtime-v2' into 'main'
Add teams migration to runtime migrations

See merge request esv/bsf/bsf-integration/orchard/orchard-mvp!50
2026-01-28 14:19:35 -06:00
Mondo Diaz
0e67ebf94f Add teams migration to runtime migrations 2026-01-28 14:19:35 -06:00
Mondo Diaz
0a69910e8b Merge branch 'feature/multi-tenancy-teams' into 'main'
Add multi-tenancy with Teams feature

Closes #88, #89, #90, #91, #92, #93, #94, #95, #96, #97, #98, #99, #100, #101, #102, #103, and #104

See merge request esv/bsf/bsf-integration/orchard/orchard-mvp!48
2026-01-28 12:50:58 -06:00
Mondo Diaz
576791d19e Add multi-tenancy with Teams feature 2026-01-28 12:50:58 -06:00
24 changed files with 1757 additions and 812 deletions

View File

@@ -44,7 +44,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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 - 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
- Access management now shows team-based permissions alongside explicit permissions
- Team-based access displayed as read-only with "Source" column indicating origin
- Team members with access show team slug and role
- Added integration tests for team CRUD, membership, and project operations - Added integration tests for team CRUD, membership, and project operations
- Redesigned teams portal with modern card-based layout
- Card grid view with team avatar, name, slug, role badge, and stats
- Stats bar showing total teams, owned teams, and total projects
- Search functionality for filtering teams (appears when >3 teams)
- Empty states for no teams and no search results
- Added user autocomplete component for team member invitations
- `GET /api/v1/users/search` endpoint for username prefix search
- Dropdown shows matching users as you type
- Keyboard navigation support (arrow keys, enter, escape)
- Debounced search to reduce API calls
- Added unit tests for TeamAuthorizationService - Added unit tests for TeamAuthorizationService
- Added `ORCHARD_ADMIN_PASSWORD` environment variable to configure initial admin password (#87) - Added `ORCHARD_ADMIN_PASSWORD` environment variable to configure initial admin password (#87)
- When set, admin user is created with the specified password (no password change required) - When set, admin user is created with the specified password (no password change required)
@@ -92,6 +105,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added pre-test stage reset to ensure known environment state before integration tests (#54) - Added pre-test stage reset to ensure known environment state before integration tests (#54)
- Upload endpoint now accepts optional `ensure` file parameter for declaring dependencies - Upload endpoint now accepts optional `ensure` file parameter for declaring dependencies
- Updated upload API documentation with ensure file format and examples - Updated upload API documentation with ensure file format and examples
- Converted teams list and team projects to use DataTable component for consistent styling
- Centered team members and team settings page content
- Added orchard logo icon and dot separator to footer
### Fixed
- Fixed dark theme styling for team pages - modals, forms, and dropdowns now use correct theme variables
- Fixed UserAutocomplete and TeamSelector dropdown backgrounds for dark theme
## [0.5.1] - 2026-01-23 ## [0.5.1] - 2026-01-23
### Changed ### Changed

View File

@@ -1,10 +1,11 @@
from sqlalchemy import create_engine, text, event from sqlalchemy import create_engine, text, event
from sqlalchemy.orm import sessionmaker, Session from sqlalchemy.orm import sessionmaker, Session
from sqlalchemy.pool import QueuePool from sqlalchemy.pool import QueuePool
from typing import Generator from typing import Generator, NamedTuple
from contextlib import contextmanager from contextlib import contextmanager
import logging import logging
import time import time
import hashlib
from .config import get_settings from .config import get_settings
from .models import Base from .models import Base
@@ -12,6 +13,21 @@ from .models import Base
settings = get_settings() settings = get_settings()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class Migration(NamedTuple):
"""A database migration with a unique name and SQL to execute."""
name: str
sql: str
# PostgreSQL error codes that indicate "already exists" - safe to skip
SAFE_PG_ERROR_CODES = {
"42P07", # duplicate_table
"42701", # duplicate_column
"42710", # duplicate_object (index, constraint, etc.)
"42P16", # invalid_table_definition (e.g., column already exists)
}
# Build connect_args with query timeout if configured # Build connect_args with query timeout if configured
connect_args = {} connect_args = {}
if settings.database_query_timeout > 0: if settings.database_query_timeout > 0:
@@ -65,235 +81,397 @@ def init_db():
_run_migrations() _run_migrations()
def _ensure_migrations_table(conn) -> None:
"""Create the migrations tracking table if it doesn't exist."""
conn.execute(text("""
CREATE TABLE IF NOT EXISTS _schema_migrations (
name VARCHAR(255) PRIMARY KEY,
checksum VARCHAR(64) NOT NULL,
applied_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
"""))
conn.commit()
def _get_applied_migrations(conn) -> dict[str, str]:
"""Get all applied migrations and their checksums."""
result = conn.execute(text(
"SELECT name, checksum FROM _schema_migrations"
))
return {row[0]: row[1] for row in result}
def _compute_checksum(sql: str) -> str:
"""Compute a checksum for migration SQL to detect changes."""
return hashlib.sha256(sql.strip().encode()).hexdigest()[:16]
def _is_safe_error(exception: Exception) -> bool:
"""Check if the error indicates the migration was already applied."""
# Check for psycopg2 errors with pgcode attribute
original = getattr(exception, "orig", None)
if original is not None:
pgcode = getattr(original, "pgcode", None)
if pgcode in SAFE_PG_ERROR_CODES:
return True
# Fallback: check error message for common "already exists" patterns
error_str = str(exception).lower()
safe_patterns = [
"already exists",
"duplicate key",
"relation .* already exists",
"column .* already exists",
]
return any(pattern in error_str for pattern in safe_patterns)
def _record_migration(conn, name: str, checksum: str) -> None:
"""Record a migration as applied."""
conn.execute(text(
"INSERT INTO _schema_migrations (name, checksum) VALUES (:name, :checksum)"
), {"name": name, "checksum": checksum})
conn.commit()
def _run_migrations(): def _run_migrations():
"""Run manual migrations for schema updates""" """Run manual migrations for schema updates with tracking and error detection."""
migrations = [ migrations = [
# Add format_metadata column to artifacts table Migration(
""" name="001_add_format_metadata",
DO $$ sql="""
BEGIN DO $$
IF NOT EXISTS ( BEGIN
SELECT 1 FROM information_schema.columns IF NOT EXISTS (
WHERE table_name = 'artifacts' AND column_name = 'format_metadata' SELECT 1 FROM information_schema.columns
) THEN WHERE table_name = 'artifacts' AND column_name = 'format_metadata'
ALTER TABLE artifacts ADD COLUMN format_metadata JSONB DEFAULT '{}'; ) THEN
END IF; ALTER TABLE artifacts ADD COLUMN format_metadata JSONB DEFAULT '{}';
END $$;
""",
# Add format column to packages table
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'packages' AND column_name = 'format'
) THEN
ALTER TABLE packages ADD COLUMN format VARCHAR(50) DEFAULT 'generic' NOT NULL;
CREATE INDEX IF NOT EXISTS idx_packages_format ON packages(format);
END IF;
END $$;
""",
# Add platform column to packages table
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'packages' AND column_name = 'platform'
) THEN
ALTER TABLE packages ADD COLUMN platform VARCHAR(50) DEFAULT 'any' NOT NULL;
CREATE INDEX IF NOT EXISTS idx_packages_platform ON packages(platform);
END IF;
END $$;
""",
# Add ref_count index and constraints for artifacts
"""
DO $$
BEGIN
-- Add ref_count index
IF NOT EXISTS (
SELECT 1 FROM pg_indexes WHERE indexname = 'idx_artifacts_ref_count'
) THEN
CREATE INDEX idx_artifacts_ref_count ON artifacts(ref_count);
END IF;
-- Add ref_count >= 0 constraint
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'check_ref_count_non_negative'
) THEN
ALTER TABLE artifacts ADD CONSTRAINT check_ref_count_non_negative CHECK (ref_count >= 0);
END IF;
END $$;
""",
# Add composite indexes for packages and tags
"""
DO $$
BEGIN
-- Composite index for package lookup by project and name
IF NOT EXISTS (
SELECT 1 FROM pg_indexes WHERE indexname = 'idx_packages_project_name'
) THEN
CREATE UNIQUE INDEX idx_packages_project_name ON packages(project_id, name);
END IF;
-- Composite index for tag lookup by package and name
IF NOT EXISTS (
SELECT 1 FROM pg_indexes WHERE indexname = 'idx_tags_package_name'
) THEN
CREATE UNIQUE INDEX idx_tags_package_name ON tags(package_id, name);
END IF;
-- Composite index for recent tags queries
IF NOT EXISTS (
SELECT 1 FROM pg_indexes WHERE indexname = 'idx_tags_package_created_at'
) THEN
CREATE INDEX idx_tags_package_created_at ON tags(package_id, created_at);
END IF;
END $$;
""",
# Add package_versions indexes and triggers (007_package_versions.sql)
"""
DO $$
BEGIN
-- Create indexes for package_versions if table exists
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'package_versions') THEN
-- Indexes for common queries
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_package_versions_package_id') THEN
CREATE INDEX idx_package_versions_package_id ON package_versions(package_id);
END IF; END IF;
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_package_versions_artifact_id') THEN END $$;
CREATE INDEX idx_package_versions_artifact_id ON package_versions(artifact_id); """,
),
Migration(
name="002_add_package_format",
sql="""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'packages' AND column_name = 'format'
) THEN
ALTER TABLE packages ADD COLUMN format VARCHAR(50) DEFAULT 'generic' NOT NULL;
CREATE INDEX IF NOT EXISTS idx_packages_format ON packages(format);
END IF; END IF;
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_package_versions_package_version') THEN END $$;
CREATE INDEX idx_package_versions_package_version ON package_versions(package_id, version); """,
),
Migration(
name="003_add_package_platform",
sql="""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'packages' AND column_name = 'platform'
) THEN
ALTER TABLE packages ADD COLUMN platform VARCHAR(50) DEFAULT 'any' NOT NULL;
CREATE INDEX IF NOT EXISTS idx_packages_platform ON packages(platform);
END IF; END IF;
END IF; END $$;
END $$; """,
""", ),
# Create ref_count trigger functions for tags (ensures triggers exist even if initial migration wasn't run) Migration(
""" name="004_add_ref_count_index_constraint",
CREATE OR REPLACE FUNCTION increment_artifact_ref_count() sql="""
RETURNS TRIGGER AS $$ DO $$
BEGIN BEGIN
UPDATE artifacts SET ref_count = ref_count + 1 WHERE id = NEW.artifact_id; IF NOT EXISTS (
RETURN NEW; SELECT 1 FROM pg_indexes WHERE indexname = 'idx_artifacts_ref_count'
END; ) THEN
$$ LANGUAGE plpgsql; CREATE INDEX idx_artifacts_ref_count ON artifacts(ref_count);
""", END IF;
"""
CREATE OR REPLACE FUNCTION decrement_artifact_ref_count() IF NOT EXISTS (
RETURNS TRIGGER AS $$ SELECT 1 FROM pg_constraint WHERE conname = 'check_ref_count_non_negative'
BEGIN ) THEN
UPDATE artifacts SET ref_count = ref_count - 1 WHERE id = OLD.artifact_id; ALTER TABLE artifacts ADD CONSTRAINT check_ref_count_non_negative CHECK (ref_count >= 0);
RETURN OLD; END IF;
END; END $$;
$$ LANGUAGE plpgsql; """,
""", ),
""" Migration(
CREATE OR REPLACE FUNCTION update_artifact_ref_count() name="005_add_composite_indexes",
RETURNS TRIGGER AS $$ sql="""
BEGIN DO $$
IF OLD.artifact_id != NEW.artifact_id THEN BEGIN
UPDATE artifacts SET ref_count = ref_count - 1 WHERE id = OLD.artifact_id; IF NOT EXISTS (
SELECT 1 FROM pg_indexes WHERE indexname = 'idx_packages_project_name'
) THEN
CREATE UNIQUE INDEX idx_packages_project_name ON packages(project_id, name);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_indexes WHERE indexname = 'idx_tags_package_name'
) THEN
CREATE UNIQUE INDEX idx_tags_package_name ON tags(package_id, name);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_indexes WHERE indexname = 'idx_tags_package_created_at'
) THEN
CREATE INDEX idx_tags_package_created_at ON tags(package_id, created_at);
END IF;
END $$;
""",
),
Migration(
name="006_add_package_versions_indexes",
sql="""
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'package_versions') THEN
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_package_versions_package_id') THEN
CREATE INDEX idx_package_versions_package_id ON package_versions(package_id);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_package_versions_artifact_id') THEN
CREATE INDEX idx_package_versions_artifact_id ON package_versions(artifact_id);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_package_versions_package_version') THEN
CREATE INDEX idx_package_versions_package_version ON package_versions(package_id, version);
END IF;
END IF;
END $$;
""",
),
Migration(
name="007_create_ref_count_trigger_functions",
sql="""
CREATE OR REPLACE FUNCTION increment_artifact_ref_count()
RETURNS TRIGGER AS $$
BEGIN
UPDATE artifacts SET ref_count = ref_count + 1 WHERE id = NEW.artifact_id; UPDATE artifacts SET ref_count = ref_count + 1 WHERE id = NEW.artifact_id;
END IF; RETURN NEW;
RETURN NEW; END;
END; $$ LANGUAGE plpgsql;
$$ LANGUAGE plpgsql;
""",
# Create triggers for tags ref_count management
"""
DO $$
BEGIN
-- Drop and recreate triggers to ensure they're current
DROP TRIGGER IF EXISTS tags_ref_count_insert_trigger ON tags;
CREATE TRIGGER tags_ref_count_insert_trigger
AFTER INSERT ON tags
FOR EACH ROW
EXECUTE FUNCTION increment_artifact_ref_count();
DROP TRIGGER IF EXISTS tags_ref_count_delete_trigger ON tags; CREATE OR REPLACE FUNCTION decrement_artifact_ref_count()
CREATE TRIGGER tags_ref_count_delete_trigger RETURNS TRIGGER AS $$
AFTER DELETE ON tags BEGIN
FOR EACH ROW UPDATE artifacts SET ref_count = ref_count - 1 WHERE id = OLD.artifact_id;
EXECUTE FUNCTION decrement_artifact_ref_count(); RETURN OLD;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS tags_ref_count_update_trigger ON tags; CREATE OR REPLACE FUNCTION update_artifact_ref_count()
CREATE TRIGGER tags_ref_count_update_trigger RETURNS TRIGGER AS $$
AFTER UPDATE ON tags BEGIN
FOR EACH ROW IF OLD.artifact_id != NEW.artifact_id THEN
WHEN (OLD.artifact_id IS DISTINCT FROM NEW.artifact_id) UPDATE artifacts SET ref_count = ref_count - 1 WHERE id = OLD.artifact_id;
EXECUTE FUNCTION update_artifact_ref_count(); UPDATE artifacts SET ref_count = ref_count + 1 WHERE id = NEW.artifact_id;
END $$; END IF;
""", RETURN NEW;
# Create ref_count trigger functions for package_versions END;
""" $$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION increment_version_ref_count() """,
RETURNS TRIGGER AS $$ ),
BEGIN Migration(
UPDATE artifacts SET ref_count = ref_count + 1 WHERE id = NEW.artifact_id; name="008_create_tags_ref_count_triggers",
RETURN NEW; sql="""
END; DO $$
$$ LANGUAGE plpgsql; BEGIN
""", DROP TRIGGER IF EXISTS tags_ref_count_insert_trigger ON tags;
""" CREATE TRIGGER tags_ref_count_insert_trigger
CREATE OR REPLACE FUNCTION decrement_version_ref_count() AFTER INSERT ON tags
RETURNS TRIGGER AS $$
BEGIN
UPDATE artifacts SET ref_count = ref_count - 1 WHERE id = OLD.artifact_id;
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
""",
# Create triggers for package_versions ref_count
"""
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'package_versions') THEN
-- Drop and recreate triggers to ensure they're current
DROP TRIGGER IF EXISTS package_versions_ref_count_insert ON package_versions;
CREATE TRIGGER package_versions_ref_count_insert
AFTER INSERT ON package_versions
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION increment_version_ref_count(); EXECUTE FUNCTION increment_artifact_ref_count();
DROP TRIGGER IF EXISTS package_versions_ref_count_delete ON package_versions; DROP TRIGGER IF EXISTS tags_ref_count_delete_trigger ON tags;
CREATE TRIGGER package_versions_ref_count_delete CREATE TRIGGER tags_ref_count_delete_trigger
AFTER DELETE ON package_versions AFTER DELETE ON tags
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION decrement_version_ref_count(); EXECUTE FUNCTION decrement_artifact_ref_count();
END IF;
END $$; DROP TRIGGER IF EXISTS tags_ref_count_update_trigger ON tags;
""", CREATE TRIGGER tags_ref_count_update_trigger
# Migrate existing semver tags to package_versions AFTER UPDATE ON tags
r""" FOR EACH ROW
DO $$ WHEN (OLD.artifact_id IS DISTINCT FROM NEW.artifact_id)
BEGIN EXECUTE FUNCTION update_artifact_ref_count();
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'package_versions') THEN END $$;
-- Migrate tags that look like versions (v1.0.0, 1.2.3, 2.0.0-beta, etc.) """,
INSERT INTO package_versions (package_id, artifact_id, version, version_source, created_by, created_at) ),
SELECT Migration(
t.package_id, name="009_create_version_ref_count_functions",
t.artifact_id, sql="""
CASE WHEN t.name LIKE 'v%' THEN substring(t.name from 2) ELSE t.name END, CREATE OR REPLACE FUNCTION increment_version_ref_count()
'migrated_from_tag', RETURNS TRIGGER AS $$
t.created_by, BEGIN
t.created_at UPDATE artifacts SET ref_count = ref_count + 1 WHERE id = NEW.artifact_id;
FROM tags t RETURN NEW;
WHERE t.name ~ '^v?[0-9]+\.[0-9]+(\.[0-9]+)?([-.][a-zA-Z0-9]+)?$' END;
ON CONFLICT (package_id, version) DO NOTHING; $$ LANGUAGE plpgsql;
END IF;
END $$; CREATE OR REPLACE FUNCTION decrement_version_ref_count()
""", RETURNS TRIGGER AS $$
BEGIN
UPDATE artifacts SET ref_count = ref_count - 1 WHERE id = OLD.artifact_id;
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
""",
),
Migration(
name="010_create_package_versions_triggers",
sql="""
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'package_versions') THEN
DROP TRIGGER IF EXISTS package_versions_ref_count_insert ON package_versions;
CREATE TRIGGER package_versions_ref_count_insert
AFTER INSERT ON package_versions
FOR EACH ROW
EXECUTE FUNCTION increment_version_ref_count();
DROP TRIGGER IF EXISTS package_versions_ref_count_delete ON package_versions;
CREATE TRIGGER package_versions_ref_count_delete
AFTER DELETE ON package_versions
FOR EACH ROW
EXECUTE FUNCTION decrement_version_ref_count();
END IF;
END $$;
""",
),
Migration(
name="011_migrate_semver_tags_to_versions",
sql=r"""
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'package_versions') THEN
INSERT INTO package_versions (id, package_id, artifact_id, version, version_source, created_by, created_at)
SELECT
gen_random_uuid(),
t.package_id,
t.artifact_id,
CASE WHEN t.name LIKE 'v%' THEN substring(t.name from 2) ELSE t.name END,
'migrated_from_tag',
t.created_by,
t.created_at
FROM tags t
WHERE t.name ~ '^v?[0-9]+\.[0-9]+(\.[0-9]+)?([-.][a-zA-Z0-9]+)?$'
ON CONFLICT (package_id, version) DO NOTHING;
END IF;
END $$;
""",
),
Migration(
name="012_create_teams_table",
sql="""
CREATE TABLE IF NOT EXISTS teams (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE,
description TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
created_by VARCHAR(255) NOT NULL,
settings JSONB DEFAULT '{}'
);
""",
),
Migration(
name="013_create_team_memberships_table",
sql="""
CREATE TABLE IF NOT EXISTS team_memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(50) NOT NULL DEFAULT 'member',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
invited_by VARCHAR(255),
CONSTRAINT team_memberships_unique UNIQUE (team_id, user_id),
CONSTRAINT team_memberships_role_check CHECK (role IN ('owner', 'admin', 'member'))
);
""",
),
Migration(
name="014_add_team_id_to_projects",
sql="""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'projects' AND column_name = 'team_id'
) THEN
ALTER TABLE projects ADD COLUMN team_id UUID REFERENCES teams(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_projects_team_id ON projects(team_id);
END IF;
END $$;
""",
),
Migration(
name="015_add_teams_indexes",
sql="""
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_teams_slug') THEN
CREATE INDEX idx_teams_slug ON teams(slug);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_teams_created_by') THEN
CREATE INDEX idx_teams_created_by ON teams(created_by);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_team_memberships_team_id') THEN
CREATE INDEX idx_team_memberships_team_id ON team_memberships(team_id);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_team_memberships_user_id') THEN
CREATE INDEX idx_team_memberships_user_id ON team_memberships(user_id);
END IF;
END $$;
""",
),
] ]
with engine.connect() as conn: with engine.connect() as conn:
# Ensure migrations tracking table exists
_ensure_migrations_table(conn)
# Get already-applied migrations
applied = _get_applied_migrations(conn)
for migration in migrations: for migration in migrations:
checksum = _compute_checksum(migration.sql)
# Check if migration was already applied
if migration.name in applied:
stored_checksum = applied[migration.name]
if stored_checksum != checksum:
logger.warning(
f"Migration '{migration.name}' has changed since it was applied! "
f"Stored checksum: {stored_checksum}, current: {checksum}"
)
continue
# Run the migration
try: try:
conn.execute(text(migration)) logger.info(f"Running migration: {migration.name}")
conn.execute(text(migration.sql))
conn.commit() conn.commit()
_record_migration(conn, migration.name, checksum)
logger.info(f"Migration '{migration.name}' applied successfully")
except Exception as e: except Exception as e:
logger.warning(f"Migration failed (may already be applied): {e}") conn.rollback()
if _is_safe_error(e):
# Migration was already applied (schema already exists)
logger.info(
f"Migration '{migration.name}' already applied (schema exists), recording as complete"
)
_record_migration(conn, migration.name, checksum)
else:
# Real error - fail hard
logger.error(f"Migration '{migration.name}' failed: {e}")
raise RuntimeError(
f"Migration '{migration.name}' failed with error: {e}"
) from e
def get_db() -> Generator[Session, None, None]: def get_db() -> Generator[Session, None, None]:

View File

@@ -1093,6 +1093,43 @@ def oidc_callback(
return response return response
# --- User Search Routes (for autocomplete) ---
@router.get("/api/v1/users/search")
def search_users(
q: str = Query(..., min_length=1, description="Search query for username"),
limit: int = Query(default=10, ge=1, le=50, description="Maximum results"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Search for users by username prefix.
Returns basic user info for autocomplete (no email for privacy).
Any authenticated user can search.
"""
search_pattern = f"{q.lower()}%"
users = (
db.query(User)
.filter(
func.lower(User.username).like(search_pattern),
User.is_active == True,
)
.order_by(User.username)
.limit(limit)
.all()
)
return [
{
"id": str(u.id),
"username": u.username,
"is_admin": u.is_admin,
}
for u in users
]
# --- Admin User Management Routes --- # --- Admin User Management Routes ---
@@ -1795,14 +1832,63 @@ def list_project_permissions(
): ):
""" """
List all access permissions for a project. List all access permissions for a project.
Includes both explicit permissions and team-based access.
Requires admin access to the project. Requires admin access to the project.
""" """
project = check_project_access(db, project_name, current_user, "admin") project = check_project_access(db, project_name, current_user, "admin")
auth_service = AuthorizationService(db) auth_service = AuthorizationService(db)
permissions = auth_service.list_project_permissions(str(project.id)) explicit_permissions = auth_service.list_project_permissions(str(project.id))
return permissions # Convert to response format with source field
result = []
for perm in explicit_permissions:
result.append(AccessPermissionResponse(
id=perm.id,
project_id=perm.project_id,
user_id=perm.user_id,
level=perm.level,
created_at=perm.created_at,
expires_at=perm.expires_at,
source="explicit",
))
# Add team-based access if project belongs to a team
if project.team_id:
team = db.query(Team).filter(Team.id == project.team_id).first()
if team:
memberships = (
db.query(TeamMembership)
.join(User, TeamMembership.user_id == User.id)
.filter(TeamMembership.team_id == project.team_id)
.all()
)
# Track users who already have explicit permissions
explicit_users = {p.user_id for p in result}
for membership in memberships:
user = db.query(User).filter(User.id == membership.user_id).first()
if user and user.username not in explicit_users:
# Map team role to project access level
if membership.role in ("owner", "admin"):
level = "admin"
else:
level = "read"
result.append(AccessPermissionResponse(
id=membership.id, # Use membership ID
project_id=project.id,
user_id=user.username,
level=level,
created_at=membership.created_at,
expires_at=None,
source="team",
team_slug=team.slug,
team_role=membership.role,
))
return result
@router.post( @router.post(

View File

@@ -911,6 +911,9 @@ class AccessPermissionResponse(BaseModel):
level: str level: str
created_at: datetime created_at: datetime
expires_at: Optional[datetime] expires_at: Optional[datetime]
source: Optional[str] = "explicit" # "explicit" or "team"
team_slug: Optional[str] = None # Team slug if source is "team"
team_role: Optional[str] = None # Team role if source is "team"
class Config: class Config:
from_attributes = True from_attributes = True

View File

@@ -7,6 +7,7 @@ from sqlalchemy.orm import Session
from .models import Project, Package, Artifact, Tag, Upload, PackageVersion, ArtifactDependency, Team, TeamMembership, User from .models import Project, Package, Artifact, Tag, Upload, PackageVersion, ArtifactDependency, Team, TeamMembership, User
from .storage import get_storage from .storage import get_storage
from .auth import hash_password
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -176,6 +177,53 @@ def seed_database(db: Session) -> None:
logger.info(f"Created team: {demo_team.name} ({demo_team.slug})") logger.info(f"Created team: {demo_team.name} ({demo_team.slug})")
# Create test users with various roles
test_users = [
{"username": "alice", "email": "alice@example.com", "role": "admin"},
{"username": "bob", "email": "bob@example.com", "role": "admin"},
{"username": "charlie", "email": "charlie@example.com", "role": "member"},
{"username": "diana", "email": "diana@example.com", "role": "member"},
{"username": "eve", "email": "eve@example.com", "role": "member"},
{"username": "frank", "email": None, "role": "member"},
]
for user_data in test_users:
# Check if user already exists
existing_user = db.query(User).filter(User.username == user_data["username"]).first()
if existing_user:
test_user = existing_user
else:
# Create the user with password same as username
test_user = User(
username=user_data["username"],
email=user_data["email"],
password_hash=hash_password(user_data["username"]),
is_admin=False,
is_active=True,
must_change_password=False,
)
db.add(test_user)
db.flush()
logger.info(f"Created test user: {user_data['username']}")
# Add to demo team with specified role
existing_membership = db.query(TeamMembership).filter(
TeamMembership.team_id == demo_team.id,
TeamMembership.user_id == test_user.id,
).first()
if not existing_membership:
membership = TeamMembership(
team_id=demo_team.id,
user_id=test_user.id,
role=user_data["role"],
invited_by=team_owner_username,
)
db.add(membership)
logger.info(f"Added {user_data['username']} to {demo_team.slug} as {user_data['role']}")
db.flush()
# Create projects and packages # Create projects and packages
project_map = {} project_map = {}
package_map = {} package_map = {}

View File

@@ -668,3 +668,17 @@ export async function listTeamProjects(
}); });
return handleResponse<PaginatedResponse<Project>>(response); return handleResponse<PaginatedResponse<Project>>(response);
} }
// User search (for autocomplete)
export interface UserSearchResult {
id: string;
username: string;
is_admin: boolean;
}
export async function searchUsers(query: string, limit: number = 10): Promise<UserSearchResult[]> {
const response = await fetch(`${API_BASE}/users/search?q=${encodeURIComponent(query)}&limit=${limit}`, {
credentials: 'include',
});
return handleResponse<UserSearchResult[]>(response);
}

View File

@@ -114,3 +114,32 @@
font-size: 0.875rem; font-size: 0.875rem;
color: var(--text-primary); color: var(--text-primary);
} }
/* Access source styling */
.access-source {
display: inline-block;
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
.access-source--explicit {
background: var(--bg-tertiary);
color: var(--text-secondary);
}
.access-source--team {
background: var(--color-info-bg, #e3f2fd);
color: var(--color-info, #1976d2);
}
/* Team access row styling */
.team-access-row {
background: var(--bg-secondary, #fafafa);
}
.team-access-row td.actions .text-muted {
font-size: 0.8125rem;
font-style: italic;
}

View File

@@ -208,85 +208,104 @@ export function AccessManagement({ projectName }: AccessManagementProps) {
<tr> <tr>
<th>User</th> <th>User</th>
<th>Access Level</th> <th>Access Level</th>
<th>Source</th>
<th>Granted</th> <th>Granted</th>
<th>Expires</th> <th>Expires</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{permissions.map((p) => ( {permissions.map((p) => {
<tr key={p.id}> const isTeamBased = p.source === 'team';
<td>{p.user_id}</td> return (
<td> <tr key={p.id} className={isTeamBased ? 'team-access-row' : ''}>
{editingUser === p.user_id ? ( <td>{p.user_id}</td>
<select <td>
value={editLevel} {editingUser === p.user_id && !isTeamBased ? (
onChange={(e) => setEditLevel(e.target.value as AccessLevel)} <select
disabled={submitting} value={editLevel}
> onChange={(e) => setEditLevel(e.target.value as AccessLevel)}
<option value="read">Read</option>
<option value="write">Write</option>
<option value="admin">Admin</option>
</select>
) : (
<span className={`access-badge access-badge--${p.level}`}>
{p.level}
</span>
)}
</td>
<td>{new Date(p.created_at).toLocaleDateString()}</td>
<td>
{editingUser === p.user_id ? (
<input
type="date"
value={editExpiresAt}
onChange={(e) => setEditExpiresAt(e.target.value)}
disabled={submitting}
min={new Date().toISOString().split('T')[0]}
/>
) : (
formatExpiration(p.expires_at)
)}
</td>
<td className="actions">
{editingUser === p.user_id ? (
<>
<button
className="btn btn-sm btn-primary"
onClick={() => handleUpdate(p.user_id)}
disabled={submitting} disabled={submitting}
> >
Save <option value="read">Read</option>
</button> <option value="write">Write</option>
<button <option value="admin">Admin</option>
className="btn btn-sm" </select>
onClick={cancelEdit} ) : (
<span className={`access-badge access-badge--${p.level}`}>
{p.level}
</span>
)}
</td>
<td>
{isTeamBased ? (
<span className="access-source access-source--team" title={`Team role: ${p.team_role}`}>
Team: {p.team_slug}
</span>
) : (
<span className="access-source access-source--explicit">
Explicit
</span>
)}
</td>
<td>{new Date(p.created_at).toLocaleDateString()}</td>
<td>
{editingUser === p.user_id && !isTeamBased ? (
<input
type="date"
value={editExpiresAt}
onChange={(e) => setEditExpiresAt(e.target.value)}
disabled={submitting} disabled={submitting}
> min={new Date().toISOString().split('T')[0]}
Cancel />
</button> ) : (
</> formatExpiration(p.expires_at)
) : ( )}
<> </td>
<button <td className="actions">
className="btn btn-sm" {isTeamBased ? (
onClick={() => startEdit(p)} <span className="text-muted" title="Manage access via team settings">
disabled={submitting} Via team
> </span>
Edit ) : editingUser === p.user_id ? (
</button> <>
<button <button
className="btn btn-sm btn-danger" className="btn btn-sm btn-primary"
onClick={() => handleRevoke(p.user_id)} onClick={() => handleUpdate(p.user_id)}
disabled={submitting} disabled={submitting}
> >
Revoke Save
</button> </button>
</> <button
)} className="btn btn-sm"
</td> onClick={cancelEdit}
</tr> disabled={submitting}
))} >
Cancel
</button>
</>
) : (
<>
<button
className="btn btn-sm"
onClick={() => startEdit(p)}
disabled={submitting}
>
Edit
</button>
<button
className="btn btn-sm btn-danger"
onClick={() => handleRevoke(p.user_id)}
disabled={submitting}
>
Revoke
</button>
</>
)}
</td>
</tr>
);
})}
</tbody> </tbody>
</table> </table>
)} )}

View File

@@ -284,7 +284,11 @@
.footer-brand { .footer-brand {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 8px;
}
.footer-icon {
color: var(--accent-primary);
} }
.footer-logo { .footer-logo {
@@ -292,6 +296,10 @@
color: var(--text-primary); color: var(--text-primary);
} }
.footer-separator {
color: var(--text-muted);
}
.footer-tagline { .footer-tagline {
color: var(--text-secondary); color: var(--text-secondary);
font-size: 0.875rem; font-size: 0.875rem;

View File

@@ -2,6 +2,8 @@ import { ReactNode, useState, useRef, useEffect } from 'react';
import { Link, NavLink, useLocation, useNavigate } from 'react-router-dom'; import { Link, NavLink, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { GlobalSearch } from './GlobalSearch'; import { GlobalSearch } from './GlobalSearch';
import { listTeams } from '../api';
import { TeamDetail } from '../types';
import './Layout.css'; import './Layout.css';
interface LayoutProps { interface LayoutProps {
@@ -13,8 +15,22 @@ function Layout({ children }: LayoutProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { user, loading, logout } = useAuth(); const { user, loading, logout } = useAuth();
const [showUserMenu, setShowUserMenu] = useState(false); const [showUserMenu, setShowUserMenu] = useState(false);
const [userTeams, setUserTeams] = useState<TeamDetail[]>([]);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
// Fetch user's teams
useEffect(() => {
if (user) {
listTeams({ limit: 10 }).then(data => {
setUserTeams(data.items);
}).catch(() => {
setUserTeams([]);
});
} else {
setUserTeams([]);
}
}, [user]);
// Close menu when clicking outside // Close menu when clicking outside
useEffect(() => { useEffect(() => {
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: MouseEvent) {
@@ -77,15 +93,18 @@ function Layout({ children }: LayoutProps) {
</svg> </svg>
Dashboard Dashboard
</Link> </Link>
{user && ( {user && userTeams.length > 0 && (
<Link to="/teams" className={location.pathname.startsWith('/teams') ? 'active' : ''}> <Link
to={userTeams.length === 1 ? `/teams/${userTeams[0].slug}` : '/teams'}
className={location.pathname.startsWith('/teams') ? 'active' : ''}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/> <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/> <circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/> <path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/> <path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg> </svg>
Teams {userTeams.length === 1 ? 'Team' : 'Teams'}
</Link> </Link>
)} )}
<a href="/docs" className="nav-link-muted"> <a href="/docs" className="nav-link-muted">
@@ -199,7 +218,17 @@ function Layout({ children }: LayoutProps) {
<footer className="footer"> <footer className="footer">
<div className="container footer-content"> <div className="container footer-content">
<div className="footer-brand"> <div className="footer-brand">
<svg className="footer-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 14 Q6 8 3 8 Q6 4 6 4 Q6 4 9 8 Q6 8 6 14" fill="currentColor" opacity="0.6"/>
<rect x="5.25" y="13" width="1.5" height="4" fill="currentColor" opacity="0.6"/>
<path d="M12 12 Q12 5 8 5 Q12 1 12 1 Q12 1 16 5 Q12 5 12 12" fill="currentColor"/>
<rect x="11.25" y="11" width="1.5" height="5" fill="currentColor"/>
<path d="M18 14 Q18 8 15 8 Q18 4 18 4 Q18 4 21 8 Q18 8 18 14" fill="currentColor" opacity="0.6"/>
<rect x="17.25" y="13" width="1.5" height="4" fill="currentColor" opacity="0.6"/>
<ellipse cx="12" cy="19" rx="9" ry="1.5" fill="currentColor" opacity="0.3"/>
</svg>
<span className="footer-logo">Orchard</span> <span className="footer-logo">Orchard</span>
<span className="footer-separator">·</span>
<span className="footer-tagline">Content-Addressable Storage</span> <span className="footer-tagline">Content-Addressable Storage</span>
</div> </div>
<div className="footer-links"> <div className="footer-links">

View File

@@ -7,10 +7,10 @@
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
padding: 0.375rem 0.75rem; padding: 0.375rem 0.75rem;
background: var(--color-bg-secondary); background: var(--bg-secondary);
border: 1px solid var(--color-border); border: 1px solid var(--border-primary);
border-radius: var(--radius-md); border-radius: var(--radius-md);
color: var(--color-text); color: var(--text-primary);
font-size: 0.875rem; font-size: 0.875rem;
cursor: pointer; cursor: pointer;
transition: all 0.15s ease; transition: all 0.15s ease;
@@ -18,8 +18,8 @@
} }
.team-selector-trigger:hover:not(:disabled) { .team-selector-trigger:hover:not(:disabled) {
background: var(--color-bg-tertiary); background: var(--bg-tertiary);
border-color: var(--color-border-hover); border-color: var(--border-secondary);
} }
.team-selector-trigger:disabled { .team-selector-trigger:disabled {
@@ -51,8 +51,8 @@
right: 0; right: 0;
min-width: 240px; min-width: 240px;
margin-top: 0.25rem; margin-top: 0.25rem;
background: var(--color-bg); background: var(--bg-secondary);
border: 1px solid var(--color-border); border: 1px solid var(--border-primary);
border-radius: var(--radius-md); border-radius: var(--radius-md);
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
z-index: 100; z-index: 100;
@@ -62,7 +62,7 @@
.team-selector-empty { .team-selector-empty {
padding: 1rem; padding: 1rem;
text-align: center; text-align: center;
color: var(--color-text-muted); color: var(--text-muted);
} }
.team-selector-empty p { .team-selector-empty p {
@@ -71,7 +71,7 @@
} }
.team-selector-create-link { .team-selector-create-link {
color: var(--color-primary); color: var(--accent-primary);
font-size: 0.875rem; font-size: 0.875rem;
text-decoration: none; text-decoration: none;
} }
@@ -96,7 +96,7 @@
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
background: none; background: none;
border: none; border: none;
color: var(--color-text); color: var(--text-primary);
font-size: 0.875rem; font-size: 0.875rem;
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
@@ -104,11 +104,11 @@
} }
.team-selector-item:hover { .team-selector-item:hover {
background: var(--color-bg-secondary); background: var(--bg-hover);
} }
.team-selector-item.selected { .team-selector-item.selected {
background: var(--color-primary-bg); background: rgba(16, 185, 129, 0.1);
} }
.team-selector-item-info { .team-selector-item-info {
@@ -127,7 +127,7 @@
.team-selector-item-meta { .team-selector-item-meta {
display: block; display: block;
font-size: 0.75rem; font-size: 0.75rem;
color: var(--color-text-muted); color: var(--text-muted);
} }
.team-selector-item-role { .team-selector-item-role {
@@ -140,24 +140,24 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border-top: 1px solid var(--color-border); border-top: 1px solid var(--border-primary);
background: var(--color-bg-secondary); background: var(--bg-tertiary);
} }
.team-selector-link { .team-selector-link {
font-size: 0.8125rem; font-size: 0.8125rem;
color: var(--color-text-muted); color: var(--text-muted);
text-decoration: none; text-decoration: none;
} }
.team-selector-link:hover { .team-selector-link:hover {
color: var(--color-text); color: var(--text-primary);
} }
.team-selector-link-primary { .team-selector-link-primary {
color: var(--color-primary); color: var(--accent-primary);
} }
.team-selector-link-primary:hover { .team-selector-link-primary:hover {
color: var(--color-primary-hover); color: var(--accent-primary-hover);
} }

View File

@@ -0,0 +1,105 @@
.user-autocomplete {
position: relative;
width: 100%;
}
.user-autocomplete__input-wrapper {
position: relative;
}
.user-autocomplete__input {
width: 100%;
padding: 0.625rem 2.5rem 0.625rem 0.75rem;
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 0.875rem;
}
.user-autocomplete__input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2);
}
.user-autocomplete__spinner {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
border: 2px solid var(--border-primary);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: translateY(-50%) rotate(360deg); }
}
.user-autocomplete__dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 4px;
padding: 0.25rem;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: 100;
max-height: 240px;
overflow-y: auto;
list-style: none;
}
.user-autocomplete__option {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 0.1s;
}
.user-autocomplete__option:hover,
.user-autocomplete__option.selected {
background: var(--bg-hover);
}
.user-autocomplete__avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--accent-primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.875rem;
flex-shrink: 0;
}
.user-autocomplete__user-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.user-autocomplete__username {
font-weight: 500;
color: var(--text-primary);
}
.user-autocomplete__admin-badge {
font-size: 0.6875rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.025em;
}

View File

@@ -0,0 +1,171 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { searchUsers, UserSearchResult } from '../api';
import './UserAutocomplete.css';
interface UserAutocompleteProps {
value: string;
onChange: (username: string) => void;
placeholder?: string;
disabled?: boolean;
autoFocus?: boolean;
}
export function UserAutocomplete({
value,
onChange,
placeholder = 'Search users...',
disabled = false,
autoFocus = false,
}: UserAutocompleteProps) {
const [query, setQuery] = useState(value);
const [results, setResults] = useState<UserSearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
// Search for users with debounce
const doSearch = useCallback(async (searchQuery: string) => {
if (searchQuery.length < 1) {
setResults([]);
setIsOpen(false);
return;
}
setLoading(true);
try {
const users = await searchUsers(searchQuery);
setResults(users);
setIsOpen(users.length > 0);
setSelectedIndex(-1);
} catch {
setResults([]);
setIsOpen(false);
} finally {
setLoading(false);
}
}, []);
// Handle input change with debounce
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setQuery(newValue);
onChange(newValue); // Update parent immediately for form validation
// Debounce the search
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
doSearch(newValue);
}, 200);
};
// Handle selecting a user
const handleSelect = (user: UserSearchResult) => {
setQuery(user.username);
onChange(user.username);
setIsOpen(false);
setResults([]);
inputRef.current?.focus();
};
// Handle keyboard navigation
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!isOpen) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex(prev => (prev < results.length - 1 ? prev + 1 : prev));
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex(prev => (prev > 0 ? prev - 1 : -1));
break;
case 'Enter':
e.preventDefault();
if (selectedIndex >= 0 && results[selectedIndex]) {
handleSelect(results[selectedIndex]);
}
break;
case 'Escape':
setIsOpen(false);
break;
}
};
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Sync external value changes
useEffect(() => {
setQuery(value);
}, [value]);
// Cleanup debounce on unmount
useEffect(() => {
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, []);
return (
<div className="user-autocomplete" ref={containerRef}>
<div className="user-autocomplete__input-wrapper">
<input
ref={inputRef}
type="text"
value={query}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={() => query.length >= 1 && results.length > 0 && setIsOpen(true)}
placeholder={placeholder}
disabled={disabled}
autoFocus={autoFocus}
autoComplete="off"
className="user-autocomplete__input"
/>
{loading && (
<div className="user-autocomplete__spinner" />
)}
</div>
{isOpen && results.length > 0 && (
<ul className="user-autocomplete__dropdown">
{results.map((user, index) => (
<li
key={user.id}
className={`user-autocomplete__option ${index === selectedIndex ? 'selected' : ''}`}
onClick={() => handleSelect(user)}
onMouseEnter={() => setSelectedIndex(index)}
>
<div className="user-autocomplete__avatar">
{user.username.charAt(0).toUpperCase()}
</div>
<div className="user-autocomplete__user-info">
<span className="user-autocomplete__username">{user.username}</span>
{user.is_admin && (
<span className="user-autocomplete__admin-badge">Admin</span>
)}
</div>
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -179,16 +179,18 @@ function Home() {
</form> </form>
)} )}
<div className="list-controls"> {user && (
<FilterDropdown <div className="list-controls">
label="Visibility" <FilterDropdown
options={VISIBILITY_OPTIONS} label="Visibility"
value={visibility} options={VISIBILITY_OPTIONS}
onChange={handleVisibilityChange} value={visibility}
/> onChange={handleVisibilityChange}
</div> />
</div>
)}
{hasActiveFilters && ( {user && hasActiveFilters && (
<FilterChipGroup onClearAll={clearFilters}> <FilterChipGroup onClearAll={clearFilters}>
{visibility && ( {visibility && (
<FilterChip <FilterChip

View File

@@ -211,7 +211,7 @@ function ProjectPage() {
</div> </div>
</div> </div>
<div className="page-header__actions"> <div className="page-header__actions">
{canAdmin && ( {canAdmin && !project.team_id && (
<button <button
className="btn btn-secondary" className="btn btn-secondary"
onClick={() => navigate(`/project/${projectName}/settings`)} onClick={() => navigate(`/project/${projectName}/settings`)}

View File

@@ -10,7 +10,6 @@ import {
ForbiddenError, ForbiddenError,
} from '../api'; } from '../api';
import { Breadcrumb } from '../components/Breadcrumb'; import { Breadcrumb } from '../components/Breadcrumb';
import { AccessManagement } from '../components/AccessManagement';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import './ProjectSettingsPage.css'; import './ProjectSettingsPage.css';
@@ -236,9 +235,6 @@ function ProjectSettingsPage() {
</form> </form>
</div> </div>
{/* Access Management Section */}
<AccessManagement projectName={projectName!} />
{/* Danger Zone Section */} {/* Danger Zone Section */}
<div className="project-settings-danger-zone"> <div className="project-settings-danger-zone">
<h2>Danger Zone</h2> <h2>Danger Zone</h2>

View File

@@ -3,10 +3,18 @@
} }
.team-header { .team-header {
margin-bottom: 1.5rem; display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1.5rem;
margin-bottom: 2rem;
} }
.team-header-info { .team-header-left {
flex: 1;
}
.team-header-title {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
@@ -15,57 +23,26 @@
.team-header h1 { .team-header h1 {
margin: 0; margin: 0;
font-size: 1.75rem; font-size: 1.5rem;
} font-weight: 600;
.team-description {
margin: 0 0 0.5rem;
color: var(--color-text-secondary);
font-size: 1rem;
max-width: 600px;
}
.team-meta {
display: flex;
align-items: center;
gap: 1rem;
} }
.team-slug { .team-slug {
font-size: 0.875rem; font-size: 0.875rem;
color: var(--color-text-muted); color: var(--text-muted);
} }
.team-stats { .team-description {
margin: 0 0 0.5rem;
color: var(--text-secondary);
font-size: 0.9375rem;
max-width: 600px;
}
.team-header-actions {
display: flex; display: flex;
gap: 1rem; gap: 0.5rem;
margin-bottom: 1.5rem; flex-shrink: 0;
}
.stat-card {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 1rem 1.5rem;
min-width: 120px;
}
.stat-value {
font-size: 1.5rem;
font-weight: 600;
color: var(--color-text);
}
.stat-label {
font-size: 0.8125rem;
color: var(--color-text-muted);
margin-top: 0.25rem;
}
.team-actions {
display: flex;
gap: 0.75rem;
margin-bottom: 2rem;
} }
.team-section { .team-section {
@@ -84,54 +61,23 @@
font-size: 1.25rem; font-size: 1.25rem;
} }
.projects-grid { /* Table utility classes */
display: grid; .text-muted {
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); color: var(--text-muted);
gap: 1rem;
} }
.project-card { .btn-ghost {
background: var(--color-bg-secondary); background: transparent;
border: 1px solid var(--color-border); color: var(--text-muted);
border-radius: var(--radius-md); border: none;
padding: 1rem; padding: 0.375rem;
cursor: pointer; cursor: pointer;
transition: all 0.15s ease; border-radius: var(--radius-sm);
} }
.project-card:hover { .btn-ghost:hover {
border-color: var(--color-border-hover); background: var(--bg-tertiary);
box-shadow: var(--shadow-sm); color: var(--text-primary);
}
.project-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.project-card-header h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.project-card-description {
margin: 0 0 0.75rem;
font-size: 0.875rem;
color: var(--color-text-secondary);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.project-card-meta {
font-size: 0.8125rem;
color: var(--color-text-muted);
} }
.section-footer { .section-footer {
@@ -141,7 +87,7 @@
.view-all-link { .view-all-link {
font-size: 0.875rem; font-size: 0.875rem;
color: var(--color-primary); color: var(--accent-primary);
text-decoration: none; text-decoration: none;
} }
@@ -162,16 +108,16 @@
.error-state p { .error-state p {
margin: 0 0 1.5rem; margin: 0 0 1.5rem;
color: var(--color-text-muted); color: var(--text-muted);
} }
.empty-state { .empty-state {
text-align: center; text-align: center;
padding: 2rem; padding: 2rem;
background: var(--color-bg-secondary); background: var(--bg-secondary);
border: 1px dashed var(--color-border); border: 1px dashed var(--border-primary);
border-radius: var(--radius-md); border-radius: var(--radius-md);
color: var(--color-text-muted); color: var(--text-muted);
} }
.empty-state p { .empty-state p {
@@ -204,29 +150,29 @@
} }
.btn-primary { .btn-primary {
background: var(--color-primary); background: var(--accent-primary);
color: white; color: white;
} }
.btn-primary:hover { .btn-primary:hover {
background: var(--color-primary-hover); background: var(--accent-primary-hover);
} }
.btn-secondary { .btn-secondary {
background: var(--color-bg-secondary); background: var(--bg-tertiary);
color: var(--color-text); color: var(--text-primary);
border: 1px solid var(--color-border); border: 1px solid var(--border-primary);
} }
.btn-secondary:hover { .btn-secondary:hover {
background: var(--color-bg-tertiary); background: var(--bg-hover);
} }
/* Modal */ /* Modal */
.modal-overlay { .modal-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.7);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -235,18 +181,21 @@
} }
.modal-content { .modal-content {
background: var(--color-bg); background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 1.5rem; padding: 1.5rem;
width: 100%; width: 100%;
max-width: 480px; max-width: 480px;
max-height: 90vh; max-height: 90vh;
box-shadow: var(--shadow-lg);
overflow-y: auto; overflow-y: auto;
} }
.modal-content h2 { .modal-content h2 {
margin: 0 0 1.5rem; margin: 0 0 1.5rem;
font-size: 1.25rem; font-size: 1.25rem;
color: var(--text-primary);
} }
/* Form */ /* Form */
@@ -259,24 +208,25 @@
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
font-weight: 500; font-weight: 500;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--text-primary);
} }
.form-group input[type="text"], .form-group input[type="text"],
.form-group textarea { .form-group textarea {
width: 100%; width: 100%;
padding: 0.625rem 0.75rem; padding: 0.625rem 0.75rem;
border: 1px solid var(--color-border); border: 1px solid var(--border-primary);
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: var(--color-bg); background: var(--bg-tertiary);
color: var(--color-text); color: var(--text-primary);
font-size: 0.875rem; font-size: 0.875rem;
} }
.form-group input:focus, .form-group input:focus,
.form-group textarea:focus { .form-group textarea:focus {
outline: none; outline: none;
border-color: var(--color-primary); border-color: var(--accent-primary);
box-shadow: 0 0 0 3px var(--color-primary-alpha); box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2);
} }
.form-group textarea { .form-group textarea {
@@ -299,7 +249,7 @@
.form-hint { .form-hint {
display: block; display: block;
font-size: 0.8125rem; font-size: 0.8125rem;
color: var(--color-text-muted); color: var(--text-muted);
margin-top: 0.375rem; margin-top: 0.375rem;
} }

View File

@@ -5,6 +5,7 @@ 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';
import { DataTable } from '../components/DataTable';
import './TeamDashboardPage.css'; import './TeamDashboardPage.css';
function TeamDashboardPage() { function TeamDashboardPage() {
@@ -95,54 +96,42 @@ function TeamDashboardPage() {
/> />
<div className="team-header"> <div className="team-header">
<div className="team-header-info"> <div className="team-header-left">
<h1>{team.name}</h1> <div className="team-header-title">
{team.user_role && ( <h1>{team.name}</h1>
<Badge variant={roleVariants[team.user_role] || 'default'}> {team.user_role && (
{team.user_role} <Badge variant={roleVariants[team.user_role] || 'default'}>
</Badge> {team.user_role}
</Badge>
)}
<span className="team-slug">@{team.slug}</span>
</div>
{team.description && (
<p className="team-description">{team.description}</p>
)} )}
</div> </div>
{team.description && ( {isAdminOrOwner && (
<p className="team-description">{team.description}</p> <div className="team-header-actions">
<Link to={`/teams/${slug}/members`} className="btn btn-secondary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
Members
</Link>
<Link to={`/teams/${slug}/settings`} className="btn btn-secondary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
Settings
</Link>
</div>
)} )}
<div className="team-meta">
<span className="team-slug">@{team.slug}</span>
</div>
</div> </div>
<div className="team-stats">
<div className="stat-card">
<div className="stat-value">{team.project_count}</div>
<div className="stat-label">Projects</div>
</div>
<div className="stat-card">
<div className="stat-value">{team.member_count}</div>
<div className="stat-label">Members</div>
</div>
</div>
{isAdminOrOwner && (
<div className="team-actions">
<Link to={`/teams/${slug}/settings`} className="btn btn-secondary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
Settings
</Link>
<Link to={`/teams/${slug}/members`} className="btn btn-secondary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
Members
</Link>
</div>
)}
{showProjectForm && ( {showProjectForm && (
<div className="modal-overlay" onClick={() => setShowProjectForm(false)}> <div className="modal-overlay" onClick={() => setShowProjectForm(false)}>
<div className="modal-content" onClick={e => e.stopPropagation()}> <div className="modal-content" onClick={e => e.stopPropagation()}>
@@ -214,28 +203,65 @@ function TeamDashboardPage() {
)} )}
</div> </div>
) : ( ) : (
<div className="projects-grid"> <DataTable
{projects?.items.map(project => ( data={projects?.items || []}
<div keyExtractor={(project) => project.id}
key={project.id} onRowClick={(project) => navigate(`/project/${project.name}`)}
className="project-card" columns={[
onClick={() => navigate(`/project/${project.name}`)} {
> key: 'name',
<div className="project-card-header"> header: 'Name',
<h3>{project.name}</h3> render: (project) => (
<Link
to={`/project/${project.name}`}
className="cell-name"
onClick={(e) => e.stopPropagation()}
>
{project.name}
</Link>
),
},
{
key: 'description',
header: 'Description',
className: 'cell-description',
render: (project) => project.description || <span className="text-muted"></span>,
},
{
key: 'visibility',
header: 'Visibility',
render: (project) => (
<Badge variant={project.is_public ? 'public' : 'private'}> <Badge variant={project.is_public ? 'public' : 'private'}>
{project.is_public ? 'Public' : 'Private'} {project.is_public ? 'Public' : 'Private'}
</Badge> </Badge>
</div> ),
{project.description && ( },
<p className="project-card-description">{project.description}</p> {
)} key: 'created_by',
<div className="project-card-meta"> header: 'Created By',
<span>Created by {project.created_by}</span> render: (project) => <span className="text-muted">{project.created_by}</span>,
</div> },
</div> ...(isAdminOrOwner ? [{
))} key: 'actions',
</div> header: '',
render: (project: Project) => (
<button
className="btn btn-sm btn-ghost"
onClick={(e) => {
e.stopPropagation();
navigate(`/project/${project.name}/settings`);
}}
title="Settings"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
),
}] : []),
]}
/>
)} )}
{projects && projects.pagination.total > 10 && ( {projects && projects.pagination.total > 10 && (

View File

@@ -1,6 +1,7 @@
.team-members { .team-members {
padding: 1.5rem 0; padding: 1.5rem 0;
max-width: 800px; max-width: 800px;
margin: 0 auto;
} }
.page-header { .page-header {
@@ -16,41 +17,18 @@
font-size: 1.75rem; font-size: 1.75rem;
} }
/* Members list */ /* Member cell in table */
.members-list { .member-cell {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.member-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
gap: 1rem;
}
.member-card.current-user {
background: var(--color-primary-bg);
border-color: var(--color-primary-border, var(--color-border));
}
.member-info {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
min-width: 0;
} }
.member-avatar { .member-avatar {
width: 40px; width: 40px;
height: 40px; height: 40px;
border-radius: 50%; border-radius: 50%;
background: var(--color-primary); background: var(--accent-primary);
color: white; color: white;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -76,37 +54,34 @@
.you-badge { .you-badge {
font-size: 0.75rem; font-size: 0.75rem;
font-weight: normal; font-weight: normal;
color: var(--color-text-muted); color: var(--text-muted);
} }
.member-email { .member-email {
font-size: 0.8125rem; font-size: 0.8125rem;
color: var(--color-text-muted); color: var(--text-muted);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.member-actions { .text-muted {
display: flex; color: var(--text-muted);
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
} }
.role-select { .role-select {
padding: 0.375rem 0.75rem; padding: 0.375rem 0.75rem;
border: 1px solid var(--color-border); border: 1px solid var(--border-primary);
border-radius: var(--radius-md); border-radius: var(--radius-md);
font-size: 0.875rem; font-size: 0.875rem;
background: var(--color-bg); background: var(--bg-tertiary);
color: var(--color-text); color: var(--text-primary);
cursor: pointer; cursor: pointer;
} }
.role-select:focus { .role-select:focus {
outline: none; outline: none;
border-color: var(--color-primary); border-color: var(--accent-primary);
} }
/* Messages */ /* Messages */
@@ -116,10 +91,10 @@
justify-content: space-between; justify-content: space-between;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
background: var(--color-error-bg, #fef2f2); background: var(--error-bg);
border: 1px solid var(--color-error-border, #fecaca); border: 1px solid var(--error);
border-radius: var(--radius-md); border-radius: var(--radius-md);
color: var(--color-error, #dc2626); color: var(--error);
font-size: 0.875rem; font-size: 0.875rem;
} }
@@ -146,7 +121,7 @@
.error-state p { .error-state p {
margin: 0 0 1.5rem; margin: 0 0 1.5rem;
color: var(--color-text-muted); color: var(--text-muted);
} }
/* Modal */ /* Modal */
@@ -156,7 +131,7 @@
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.7);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -165,17 +140,19 @@
} }
.modal-content { .modal-content {
background: var(--color-bg); background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 1.5rem; padding: 1.5rem;
width: 100%; width: 100%;
max-width: 400px; max-width: 400px;
box-shadow: var(--shadow-xl); box-shadow: var(--shadow-lg);
} }
.modal-content h2 { .modal-content h2 {
margin: 0 0 1.5rem; margin: 0 0 1.5rem;
font-size: 1.25rem; font-size: 1.25rem;
color: var(--text-primary);
} }
/* Form */ /* Form */
@@ -188,24 +165,25 @@
margin-bottom: 0.375rem; margin-bottom: 0.375rem;
font-weight: 500; font-weight: 500;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--text-primary);
} }
.form-group input, .form-group input,
.form-group select { .form-group select {
width: 100%; width: 100%;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border); border: 1px solid var(--border-primary);
border-radius: var(--radius-md); border-radius: var(--radius-md);
font-size: 0.9375rem; font-size: 0.9375rem;
background: var(--color-bg); background: var(--bg-tertiary);
color: var(--color-text); color: var(--text-primary);
} }
.form-group input:focus, .form-group input:focus,
.form-group select:focus { .form-group select:focus {
outline: none; outline: none;
border-color: var(--color-primary); border-color: var(--accent-primary);
box-shadow: 0 0 0 2px var(--color-primary-bg); box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2);
} }
.form-actions { .form-actions {
@@ -236,22 +214,22 @@
} }
.btn-primary { .btn-primary {
background: var(--color-primary); background: var(--accent-primary);
color: white; color: white;
} }
.btn-primary:hover:not(:disabled) { .btn-primary:hover:not(:disabled) {
background: var(--color-primary-hover); background: var(--accent-primary-hover);
} }
.btn-secondary { .btn-secondary {
background: var(--color-bg-secondary); background: var(--bg-tertiary);
color: var(--color-text); color: var(--text-primary);
border: 1px solid var(--color-border); border: 1px solid var(--border-primary);
} }
.btn-secondary:hover:not(:disabled) { .btn-secondary:hover:not(:disabled) {
background: var(--color-bg-tertiary); background: var(--bg-hover);
} }
.btn-icon { .btn-icon {
@@ -260,10 +238,10 @@
.btn-danger-ghost { .btn-danger-ghost {
background: transparent; background: transparent;
color: var(--color-text-muted); color: var(--text-muted);
} }
.btn-danger-ghost:hover:not(:disabled) { .btn-danger-ghost:hover:not(:disabled) {
background: var(--color-error-bg, #fef2f2); background: var(--error-bg);
color: var(--color-error, #dc2626); color: var(--error);
} }

View File

@@ -11,6 +11,8 @@ import {
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';
import { DataTable } from '../components/DataTable';
import { UserAutocomplete } from '../components/UserAutocomplete';
import './TeamMembersPage.css'; import './TeamMembersPage.css';
function TeamMembersPage() { function TeamMembersPage() {
@@ -166,13 +168,10 @@ function TeamMembersPage() {
<form onSubmit={handleAddMember}> <form onSubmit={handleAddMember}>
<div className="form-group"> <div className="form-group">
<label htmlFor="username">Username</label> <label htmlFor="username">Username</label>
<input <UserAutocomplete
id="username"
type="text"
value={newMember.username} value={newMember.username}
onChange={e => setNewMember({ ...newMember, username: e.target.value })} onChange={(username) => setNewMember({ ...newMember, username })}
placeholder="Enter username" placeholder="Search for a user..."
required
autoFocus autoFocus
/> />
</div> </div>
@@ -203,34 +202,49 @@ function TeamMembersPage() {
</div> </div>
)} )}
<div className="members-list"> <DataTable
{members.map(member => { data={members}
const isCurrentUser = user?.username === member.username; keyExtractor={(member) => member.id}
const canModify = isAdmin && !isCurrentUser && (isOwner || member.role !== 'owner'); emptyMessage="No members in this team yet."
columns={[
{
key: 'member',
header: 'Member',
render: (member) => {
const isCurrentUser = user?.username === member.username;
return (
<div className="member-cell">
<div className="member-avatar">
{member.username.charAt(0).toUpperCase()}
</div>
<div className="member-details">
<span className="member-username">
{member.username}
{isCurrentUser && <span className="you-badge">(you)</span>}
</span>
{member.email && (
<span className="member-email">{member.email}</span>
)}
</div>
</div>
);
},
},
{
key: 'role',
header: 'Role',
render: (member) => {
const isCurrentUser = user?.username === member.username;
const canModify = isAdmin && !isCurrentUser && (isOwner || member.role !== 'owner');
return ( if (canModify) {
<div key={member.id} className={`member-card ${isCurrentUser ? 'current-user' : ''}`}> return (
<div className="member-info">
<div className="member-avatar">
{member.username.charAt(0).toUpperCase()}
</div>
<div className="member-details">
<span className="member-username">
{member.username}
{isCurrentUser && <span className="you-badge">(you)</span>}
</span>
{member.email && (
<span className="member-email">{member.email}</span>
)}
</div>
</div>
<div className="member-actions">
{canModify ? (
<select <select
value={member.role} value={member.role}
onChange={e => handleRoleChange(member.username, e.target.value as TeamRole)} onChange={e => handleRoleChange(member.username, e.target.value as TeamRole)}
disabled={editingMember === member.username} disabled={editingMember === member.username}
className="role-select" className="role-select"
onClick={e => e.stopPropagation()}
> >
{roles.map(role => ( {roles.map(role => (
<option <option
@@ -242,30 +256,54 @@ function TeamMembersPage() {
</option> </option>
))} ))}
</select> </select>
) : ( );
<Badge variant={roleVariants[member.role] || 'default'}> }
{member.role} return (
</Badge> <Badge variant={roleVariants[member.role] || 'default'}>
)} {member.role}
{canModify && ( </Badge>
<button );
className="btn btn-icon btn-danger-ghost" },
onClick={() => handleRemoveMember(member.username)} },
disabled={removingMember === member.username} {
title="Remove member" key: 'joined',
> header: 'Joined',
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> render: (member) => (
<path d="M3 6h18"/> <span className="text-muted">
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/> {new Date(member.created_at).toLocaleDateString()}
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/> </span>
</svg> ),
</button> },
)} ...(isAdmin ? [{
</div> key: 'actions',
</div> header: '',
); render: (member: TeamMember) => {
})} const isCurrentUser = user?.username === member.username;
</div> const canModify = isAdmin && !isCurrentUser && (isOwner || member.role !== 'owner');
if (!canModify) return null;
return (
<button
className="btn btn-icon btn-danger-ghost"
onClick={(e) => {
e.stopPropagation();
handleRemoveMember(member.username);
}}
disabled={removingMember === member.username}
title="Remove member"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 6h18"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/>
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
);
},
}] : []),
]}
/>
</div> </div>
); );
} }

View File

@@ -1,6 +1,7 @@
.team-settings { .team-settings {
padding: 1.5rem 0; padding: 1.5rem 0;
max-width: 640px; max-width: 640px;
margin: 0 auto;
} }
.team-settings h1 { .team-settings h1 {
@@ -13,8 +14,8 @@
} }
.form-section { .form-section {
background: var(--color-bg-secondary); background: var(--bg-secondary);
border: 1px solid var(--color-border); border: 1px solid var(--border-primary);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 1.5rem; padding: 1.5rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
@@ -23,6 +24,7 @@
.form-section h2 { .form-section h2 {
margin: 0 0 1rem; margin: 0 0 1rem;
font-size: 1.125rem; font-size: 1.125rem;
color: var(--text-primary);
} }
.form-group { .form-group {
@@ -34,29 +36,30 @@
margin-bottom: 0.375rem; margin-bottom: 0.375rem;
font-weight: 500; font-weight: 500;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--text-primary);
} }
.form-group input, .form-group input,
.form-group textarea { .form-group textarea {
width: 100%; width: 100%;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border); border: 1px solid var(--border-primary);
border-radius: var(--radius-md); border-radius: var(--radius-md);
font-size: 0.9375rem; font-size: 0.9375rem;
background: var(--color-bg); background: var(--bg-tertiary);
color: var(--color-text); color: var(--text-primary);
} }
.form-group input:focus, .form-group input:focus,
.form-group textarea:focus { .form-group textarea:focus {
outline: none; outline: none;
border-color: var(--color-primary); border-color: var(--accent-primary);
box-shadow: 0 0 0 2px var(--color-primary-bg); box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2);
} }
.input-disabled { .input-disabled {
background: var(--color-bg-tertiary) !important; background: var(--bg-elevated) !important;
color: var(--color-text-muted) !important; color: var(--text-muted) !important;
cursor: not-allowed; cursor: not-allowed;
} }
@@ -64,23 +67,23 @@
display: block; display: block;
margin-top: 0.25rem; margin-top: 0.25rem;
font-size: 0.8125rem; font-size: 0.8125rem;
color: var(--color-text-muted); color: var(--text-muted);
} }
/* Danger zone */ /* Danger zone */
.danger-zone { .danger-zone {
border-color: var(--color-error-border, #fecaca); border-color: var(--error);
background: var(--color-error-bg, #fef2f2); background: var(--error-bg);
} }
.danger-zone h2 { .danger-zone h2 {
color: var(--color-error, #dc2626); color: var(--error);
} }
.danger-warning { .danger-warning {
margin: 0 0 1rem; margin: 0 0 1rem;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--color-text-secondary); color: var(--text-secondary);
} }
/* Messages */ /* Messages */
@@ -90,10 +93,10 @@
justify-content: space-between; justify-content: space-between;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
background: var(--color-error-bg, #fef2f2); background: var(--error-bg);
border: 1px solid var(--color-error-border, #fecaca); border: 1px solid var(--error);
border-radius: var(--radius-md); border-radius: var(--radius-md);
color: var(--color-error, #dc2626); color: var(--error);
font-size: 0.875rem; font-size: 0.875rem;
} }
@@ -110,10 +113,10 @@
.success-message { .success-message {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
background: var(--color-success-bg, #f0fdf4); background: var(--success-bg);
border: 1px solid var(--color-success-border, #86efac); border: 1px solid var(--success);
border-radius: var(--radius-md); border-radius: var(--radius-md);
color: var(--color-success, #16a34a); color: var(--success);
font-size: 0.875rem; font-size: 0.875rem;
} }
@@ -130,7 +133,7 @@
.error-state p { .error-state p {
margin: 0 0 1.5rem; margin: 0 0 1.5rem;
color: var(--color-text-muted); color: var(--text-muted);
} }
/* Modal */ /* Modal */
@@ -140,7 +143,7 @@
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.7);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -149,33 +152,36 @@
} }
.modal-content { .modal-content {
background: var(--color-bg); background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 1.5rem; padding: 1.5rem;
width: 100%; width: 100%;
max-width: 400px; max-width: 400px;
box-shadow: var(--shadow-xl); box-shadow: var(--shadow-lg);
} }
.modal-content h2 { .modal-content h2 {
margin: 0 0 1rem; margin: 0 0 1rem;
font-size: 1.25rem; font-size: 1.25rem;
color: var(--color-error, #dc2626); color: var(--error);
} }
.modal-content p { .modal-content p {
margin: 0 0 1rem; margin: 0 0 1rem;
font-size: 0.9375rem; font-size: 0.9375rem;
color: var(--color-text-secondary); color: var(--text-secondary);
} }
.delete-confirm-input { .delete-confirm-input {
width: 100%; width: 100%;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border); border: 1px solid var(--border-primary);
border-radius: var(--radius-md); border-radius: var(--radius-md);
font-size: 0.9375rem; font-size: 0.9375rem;
margin-bottom: 1rem; margin-bottom: 1rem;
background: var(--bg-tertiary);
color: var(--text-primary);
} }
.form-actions { .form-actions {
@@ -205,26 +211,26 @@
} }
.btn-primary { .btn-primary {
background: var(--color-primary); background: var(--accent-primary);
color: white; color: white;
} }
.btn-primary:hover:not(:disabled) { .btn-primary:hover:not(:disabled) {
background: var(--color-primary-hover); background: var(--accent-primary-hover);
} }
.btn-secondary { .btn-secondary {
background: var(--color-bg-secondary); background: var(--bg-tertiary);
color: var(--color-text); color: var(--text-primary);
border: 1px solid var(--color-border); border: 1px solid var(--border-primary);
} }
.btn-secondary:hover:not(:disabled) { .btn-secondary:hover:not(:disabled) {
background: var(--color-bg-tertiary); background: var(--bg-hover);
} }
.btn-danger { .btn-danger {
background: var(--color-error, #dc2626); background: var(--error);
color: white; color: white;
} }

View File

@@ -1,102 +1,95 @@
.teams-page { .teams-page {
padding: 1.5rem 0; padding: 1.5rem 0;
max-width: 1200px;
margin: 0 auto;
} }
.page-header { /* Header */
.teams-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: center;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
gap: 1rem; gap: 1rem;
} }
.page-header h1 { .teams-header h1 {
margin: 0; margin: 0;
font-size: 1.75rem; font-size: 1.5rem;
font-weight: 600;
} }
.page-subtitle { /* Search */
margin: 0.25rem 0 0; .teams-search {
color: var(--color-text-muted); position: relative;
font-size: 0.9375rem; margin-bottom: 1.5rem;
} }
.team-name-cell { .teams-search__icon {
display: flex; position: absolute;
flex-direction: column; left: 0.875rem;
gap: 0.125rem; top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
pointer-events: none;
} }
.team-name-link { .teams-search__input {
font-weight: 500; width: 100%;
color: var(--color-text); padding: 0.625rem 2.5rem 0.625rem 2.75rem;
text-decoration: none; border: 1px solid var(--border-primary);
} border-radius: var(--radius-md);
background: var(--bg-primary);
.team-name-link:hover { color: var(--text-primary);
color: var(--color-primary);
}
.team-slug {
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.team-description {
color: var(--color-text-secondary);
font-size: 0.875rem; font-size: 0.875rem;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
/* Empty state */ .teams-search__input:focus {
.empty-state { outline: none;
text-align: center; border-color: var(--accent-primary);
padding: 4rem 2rem; box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2);
background: var(--color-bg-secondary);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border);
} }
.empty-state svg { .teams-search__input::placeholder {
color: var(--color-text-muted); color: var(--text-muted);
margin-bottom: 1rem;
} }
.empty-state h2 { .teams-search__clear {
margin: 0 0 0.5rem; position: absolute;
font-size: 1.25rem; right: 0.5rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
padding: 0.375rem;
cursor: pointer;
color: var(--text-muted);
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
} }
.empty-state p { .teams-search__clear:hover {
margin: 0 0 1.5rem; color: var(--text-primary);
color: var(--color-text-muted); background: var(--bg-secondary);
} }
/* Loading state */ /* Error */
.loading-state { .teams-error {
text-align: center;
padding: 4rem 2rem;
color: var(--color-text-muted);
}
/* Error message */
.error-message {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
background: var(--color-error-bg, #fef2f2); background: var(--error-bg);
border: 1px solid var(--color-error-border, #fecaca); border: 1px solid var(--error);
border-radius: var(--radius-md); border-radius: var(--radius-md);
color: var(--color-error, #dc2626); color: var(--error);
font-size: 0.875rem; font-size: 0.875rem;
} }
.error-dismiss { .teams-error__dismiss {
background: none; background: none;
border: none; border: none;
font-size: 1.25rem; font-size: 1.25rem;
@@ -106,6 +99,88 @@
line-height: 1; line-height: 1;
} }
/* Loading */
.teams-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 4rem 2rem;
color: var(--text-muted);
}
.teams-loading__spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border-primary);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: teams-spin 0.8s linear infinite;
}
@keyframes teams-spin {
to { transform: rotate(360deg); }
}
/* Empty State */
.teams-empty-state {
text-align: center;
padding: 4rem 2rem;
background: var(--bg-secondary);
border-radius: var(--radius-lg);
border: 1px solid var(--border-primary);
}
.teams-empty-icon {
color: var(--text-muted);
margin-bottom: 1rem;
}
.teams-empty-state h2 {
margin: 0 0 0.5rem;
font-size: 1.25rem;
}
.teams-empty-state p {
margin: 0 0 1.5rem;
color: var(--text-muted);
}
/* Table cell styles */
.team-name-cell {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.team-name-link {
font-weight: 500;
color: var(--text-primary);
text-decoration: none;
}
.team-name-link:hover {
color: var(--accent-primary);
}
.team-slug {
font-size: 0.8125rem;
color: var(--text-muted);
}
.team-description-cell {
color: var(--text-secondary);
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.text-muted {
color: var(--text-muted);
}
/* Modal */ /* Modal */
.modal-overlay { .modal-overlay {
position: fixed; position: fixed;
@@ -113,7 +188,7 @@
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.7);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -122,17 +197,47 @@
} }
.modal-content { .modal-content {
background: var(--color-bg); background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 1.5rem;
width: 100%; width: 100%;
max-width: 480px; max-width: 480px;
box-shadow: var(--shadow-xl); box-shadow: var(--shadow-lg);
overflow: hidden;
} }
.modal-content h2 { .modal-header {
margin: 0 0 1.5rem; display: flex;
font-size: 1.25rem; justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border-primary);
}
.modal-header h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
}
.modal-close {
background: none;
border: none;
padding: 0.25rem;
cursor: pointer;
color: var(--text-muted);
display: flex;
border-radius: var(--radius-sm);
}
.modal-close:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
.modal-content form {
padding: 1.5rem;
} }
/* Form */ /* Form */
@@ -145,31 +250,58 @@
margin-bottom: 0.375rem; margin-bottom: 0.375rem;
font-weight: 500; font-weight: 500;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--text-primary);
}
.form-group .optional {
font-weight: 400;
color: var(--text-muted);
} }
.form-group input, .form-group input,
.form-group textarea { .form-group textarea {
width: 100%; width: 100%;
padding: 0.5rem 0.75rem; padding: 0.625rem 0.75rem;
border: 1px solid var(--color-border); border: 1px solid var(--border-primary);
border-radius: var(--radius-md); border-radius: var(--radius-md);
font-size: 0.9375rem; font-size: 0.875rem;
background: var(--color-bg); background: var(--bg-tertiary);
color: var(--color-text); color: var(--text-primary);
} }
.form-group input:focus, .form-group input:focus,
.form-group textarea:focus { .form-group textarea:focus {
outline: none; outline: none;
border-color: var(--color-primary); border-color: var(--accent-primary);
box-shadow: 0 0 0 2px var(--color-primary-bg); box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2);
}
.input-with-prefix {
display: flex;
align-items: stretch;
}
.input-prefix {
display: flex;
align-items: center;
padding: 0 0.75rem;
background: var(--bg-elevated);
border: 1px solid var(--border-primary);
border-right: none;
border-radius: var(--radius-md) 0 0 var(--radius-md);
color: var(--text-muted);
font-size: 0.875rem;
}
.input-with-prefix input {
border-radius: 0 var(--radius-md) var(--radius-md) 0;
} }
.form-hint { .form-hint {
display: block; display: block;
margin-top: 0.25rem; margin-top: 0.25rem;
font-size: 0.8125rem; font-size: 0.75rem;
color: var(--color-text-muted); color: var(--text-muted);
} }
.form-actions { .form-actions {
@@ -177,6 +309,8 @@
justify-content: flex-end; justify-content: flex-end;
gap: 0.75rem; gap: 0.75rem;
margin-top: 1.5rem; margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border-primary);
} }
/* Buttons */ /* Buttons */
@@ -199,20 +333,44 @@
} }
.btn-primary { .btn-primary {
background: var(--color-primary); background: var(--accent-primary);
color: white; color: white;
} }
.btn-primary:hover:not(:disabled) { .btn-primary:hover:not(:disabled) {
background: var(--color-primary-hover); background: var(--accent-primary-hover);
} }
.btn-secondary { .btn-secondary {
background: var(--color-bg-secondary); background: var(--bg-tertiary);
color: var(--color-text); color: var(--text-primary);
border: 1px solid var(--color-border); border: 1px solid var(--border-primary);
} }
.btn-secondary:hover:not(:disabled) { .btn-secondary:hover:not(:disabled) {
background: var(--color-bg-tertiary); background: var(--bg-hover);
}
/* Responsive */
@media (max-width: 640px) {
.teams-header {
flex-direction: column;
align-items: stretch;
}
.teams-header .btn {
justify-content: center;
}
.teams-stats {
justify-content: space-around;
}
.teams-table-container {
overflow-x: auto;
}
.teams-table {
min-width: 600px;
}
} }

View File

@@ -17,6 +17,7 @@ function TeamsPage() {
const [newTeam, setNewTeam] = useState<TeamCreate>({ name: '', slug: '', description: '' }); const [newTeam, setNewTeam] = useState<TeamCreate>({ name: '', slug: '', description: '' });
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [slugManuallySet, setSlugManuallySet] = useState(false); const [slugManuallySet, setSlugManuallySet] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const loadTeams = useCallback(async () => { const loadTeams = useCallback(async () => {
try { try {
@@ -65,16 +66,39 @@ function TeamsPage() {
} }
} }
const roleVariants: Record<string, 'success' | 'info' | 'default'> = { const closeModal = () => {
owner: 'success', setShowForm(false);
admin: 'info', setNewTeam({ name: '', slug: '', description: '' });
member: 'default', setSlugManuallySet(false);
};
// Filter teams by search
const filteredTeams = teamsData?.items.filter(team =>
team.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
team.slug.toLowerCase().includes(searchQuery.toLowerCase()) ||
(team.description?.toLowerCase().includes(searchQuery.toLowerCase()))
) || [];
const totalTeams = teamsData?.items.length || 0;
const roleConfig: Record<string, { variant: 'success' | 'info' | 'default'; label: string }> = {
owner: { variant: 'success', label: 'Owner' },
admin: { variant: 'info', label: 'Admin' },
member: { variant: 'default', label: 'Member' },
}; };
if (!user) { if (!user) {
return ( return (
<div className="teams-page"> <div className="teams-page">
<div className="empty-state"> <div className="teams-empty-state">
<div className="teams-empty-icon">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
</div>
<h2>Sign in to view your teams</h2> <h2>Sign in to view your teams</h2>
<p>Teams help you organize projects and collaborate with others.</p> <p>Teams help you organize projects and collaborate with others.</p>
<Link to="/login" className="btn btn-primary">Sign In</Link> <Link to="/login" className="btn btn-primary">Sign In</Link>
@@ -83,76 +107,65 @@ function TeamsPage() {
); );
} }
const columns = [
{
key: 'name',
header: 'Team',
render: (team: TeamDetail) => (
<div className="team-name-cell">
<Link to={`/teams/${team.slug}`} className="team-name-link">
{team.name}
</Link>
<span className="team-slug">@{team.slug}</span>
</div>
),
},
{
key: 'description',
header: 'Description',
render: (team: TeamDetail) => (
<span className="team-description">{team.description || '-'}</span>
),
},
{
key: 'role',
header: 'Your Role',
render: (team: TeamDetail) => (
team.user_role ? (
<Badge variant={roleVariants[team.user_role] || 'default'}>
{team.user_role}
</Badge>
) : null
),
},
{
key: 'members',
header: 'Members',
render: (team: TeamDetail) => team.member_count,
},
{
key: 'projects',
header: 'Projects',
render: (team: TeamDetail) => team.project_count,
},
];
return ( return (
<div className="teams-page"> <div className="teams-page">
<div className="page-header"> {/* Header */}
<div> <div className="teams-header">
<h1>Teams</h1> <h1>Teams</h1>
<p className="page-subtitle">Organize projects and collaborate with others</p>
</div>
<button className="btn btn-primary" onClick={() => setShowForm(true)}> <button className="btn btn-primary" onClick={() => setShowForm(true)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19" /> <line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" /> <line x1="5" y1="12" x2="19" y2="12" />
</svg> </svg>
New Team Create Team
</button> </button>
</div> </div>
{error && ( {/* Search */}
<div className="error-message"> {!loading && totalTeams > 3 && (
{error} <div className="teams-search">
<button onClick={() => setError(null)} className="error-dismiss">&times;</button> <svg className="teams-search__icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
type="text"
placeholder="Search teams..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="teams-search__input"
/>
{searchQuery && (
<button className="teams-search__clear" onClick={() => setSearchQuery('')}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
)}
</div> </div>
)} )}
{error && (
<div className="teams-error">
{error}
<button onClick={() => setError(null)} className="teams-error__dismiss">&times;</button>
</div>
)}
{/* Create Team Modal */}
{showForm && ( {showForm && (
<div className="modal-overlay" onClick={() => setShowForm(false)}> <div className="modal-overlay" onClick={closeModal}>
<div className="modal-content" onClick={e => e.stopPropagation()}> <div className="modal-content" onClick={e => e.stopPropagation()}>
<h2>Create New Team</h2> <div className="modal-header">
<h2>Create New Team</h2>
<button className="modal-close" onClick={closeModal}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<form onSubmit={handleCreateTeam}> <form onSubmit={handleCreateTeam}>
<div className="form-group"> <div className="form-group">
<label htmlFor="team-name">Team Name</label> <label htmlFor="team-name">Team Name</label>
@@ -161,27 +174,30 @@ function TeamsPage() {
type="text" type="text"
value={newTeam.name} value={newTeam.name}
onChange={e => handleNameChange(e.target.value)} onChange={e => handleNameChange(e.target.value)}
placeholder="My Team" placeholder="Engineering"
required required
autoFocus autoFocus
/> />
</div> </div>
<div className="form-group"> <div className="form-group">
<label htmlFor="team-slug">Slug</label> <label htmlFor="team-slug">URL Slug</label>
<input <div className="input-with-prefix">
id="team-slug" <span className="input-prefix">@</span>
type="text" <input
value={newTeam.slug} id="team-slug"
onChange={e => handleSlugChange(e.target.value)} type="text"
placeholder="my-team" value={newTeam.slug}
pattern="^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$" onChange={e => handleSlugChange(e.target.value)}
title="Lowercase letters, numbers, and hyphens only" placeholder="engineering"
required pattern="^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$"
/> title="Lowercase letters, numbers, and hyphens only"
<span className="form-hint">Lowercase letters, numbers, and hyphens only</span> required
/>
</div>
<span className="form-hint">Used in URLs. Lowercase letters, numbers, and hyphens.</span>
</div> </div>
<div className="form-group"> <div className="form-group">
<label htmlFor="team-description">Description (optional)</label> <label htmlFor="team-description">Description <span className="optional">(optional)</span></label>
<textarea <textarea
id="team-description" id="team-description"
value={newTeam.description} value={newTeam.description}
@@ -191,7 +207,7 @@ function TeamsPage() {
/> />
</div> </div>
<div className="form-actions"> <div className="form-actions">
<button type="button" className="btn btn-secondary" onClick={() => setShowForm(false)}> <button type="button" className="btn btn-secondary" onClick={closeModal}>
Cancel Cancel
</button> </button>
<button type="submit" className="btn btn-primary" disabled={creating}> <button type="submit" className="btn btn-primary" disabled={creating}>
@@ -203,28 +219,88 @@ function TeamsPage() {
</div> </div>
)} )}
{/* Content */}
{loading ? ( {loading ? (
<div className="loading-state">Loading teams...</div> <div className="teams-loading">
) : teamsData?.items.length === 0 ? ( <div className="teams-loading__spinner" />
<div className="empty-state"> <span>Loading teams...</span>
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"> </div>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/> ) : filteredTeams.length === 0 ? (
<circle cx="9" cy="7" r="4"/> <div className="teams-empty-state">
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/> <div className="teams-empty-icon">
<path d="M16 3.13a4 4 0 0 1 0 7.75"/> <svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
</svg> <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<h2>No teams yet</h2> <circle cx="9" cy="7" r="4"/>
<p>Create your first team to start organizing your projects.</p> <path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<button className="btn btn-primary" onClick={() => setShowForm(true)}> <path d="M16 3.13a4 4 0 0 1 0 7.75"/>
Create Team </svg>
</button> </div>
{searchQuery ? (
<>
<h2>No teams found</h2>
<p>No teams match "{searchQuery}"</p>
<button className="btn btn-secondary" onClick={() => setSearchQuery('')}>
Clear search
</button>
</>
) : (
<>
<h2>No teams yet</h2>
<p>Create your first team to start organizing your projects.</p>
<button className="btn btn-primary" onClick={() => setShowForm(true)}>
Create Team
</button>
</>
)}
</div> </div>
) : ( ) : (
<DataTable <DataTable
columns={columns} data={filteredTeams}
data={teamsData?.items || []} keyExtractor={(team) => team.id}
keyExtractor={team => team.id} onRowClick={(team) => navigate(`/teams/${team.slug}`)}
onRowClick={team => navigate(`/teams/${team.slug}`)} columns={[
{
key: 'name',
header: 'Name',
render: (team) => (
<div className="team-name-cell">
<Link
to={`/teams/${team.slug}`}
className="cell-name"
onClick={(e) => e.stopPropagation()}
>
{team.name}
</Link>
<span className="team-slug">@{team.slug}</span>
</div>
),
},
{
key: 'description',
header: 'Description',
className: 'cell-description',
render: (team) => team.description || <span className="text-muted"></span>,
},
{
key: 'role',
header: 'Role',
render: (team) => team.user_role ? (
<Badge variant={roleConfig[team.user_role]?.variant || 'default'}>
{roleConfig[team.user_role]?.label || team.user_role}
</Badge>
) : null,
},
{
key: 'members',
header: 'Members',
render: (team) => <span className="text-muted">{team.member_count}</span>,
},
{
key: 'projects',
header: 'Projects',
render: (team) => <span className="text-muted">{team.project_count}</span>,
},
]}
/> />
)} )}
</div> </div>

View File

@@ -320,6 +320,8 @@ export interface UserUpdate {
} }
// Access Permission types // Access Permission types
export type AccessSource = 'explicit' | 'team';
export interface AccessPermission { export interface AccessPermission {
id: string; id: string;
project_id: string; project_id: string;
@@ -327,6 +329,9 @@ export interface AccessPermission {
level: AccessLevel; level: AccessLevel;
created_at: string; created_at: string;
expires_at: string | null; expires_at: string | null;
source?: AccessSource; // "explicit" or "team"
team_slug?: string; // Team slug if source is "team"
team_role?: string; // Team role if source is "team"
} }
export interface AccessPermissionCreate { export interface AccessPermissionCreate {