Compare commits

..

5 Commits

Author SHA1 Message Date
alex 48fa858090 Merge branch 'main' of ssh://git.commumedia.org:2222/alex/headquarter 2026-05-24 15:04:33 +00:00
alex 679b1693fc Merge commit 'ea174b1'
# Conflicts:
#	apps/web/src/pages/projects.test.tsx
2026-05-24 15:03:58 +00:00
alex ea174b1642 fix: review fixes for el-1bn
- Fix duplicate mode field in config_profiles.py mount response
- Fix datetime.UTC import for Python 3.10 compatibility
- Add API documentation for config profiles
- Update CHANGELOG
2026-05-24 15:02:32 +00:00
alex a1dbfcf2a8 feat: implement profile CRUD validation compatibility and defaults API
- Add ConfigProfile CRUD endpoints with user ownership and access checks
- Implement ordered include management with cycle detection
- Add mount management with path validation (absolute, no traversal)
- Implement compatibility-filtered listing by tool type
- Add default profile selection APIs (get/set defaults per tool type)
- Fix SQLAlchemy ambiguous foreign key relationships in config models
- Add comprehensive integration tests (29 tests, all passing)
- Merge upstream profile resolver service changes (task 2.1)
2026-05-24 14:06:03 +00:00
alex fb0f2f7b9b feat: add project settings page navigation
- Remove inline edit modal from projects listing page
- Add Settings link to project cards navigating to /projects/:id/settings
- Reposition Open Workspace button to rightmost action for easier access
- Update tests for new UI flow
- Update documentation to reflect new editing workflow
- Sync specs: frontend-foundation and project-management

Quality gates: npm run lint passed
2026-05-22 18:43:40 +00:00
22 changed files with 2070 additions and 136 deletions
+1
View File
@@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **User Settings** - Theme selection, git identity, and preference management
- **SSH Key Management** - Ed25519 key generation with secure storage
- **Tool Types** - Built-in development tools (code-server, jupyter-notebook) with custom type support
- **Config Profiles** - User-owned profile CRUD with includes, mounts, path validation, cycle detection, and default profile selection
- **Comprehensive Documentation** - Architecture, API, deployment, and development guides
### Changed
+877
View File
@@ -0,0 +1,877 @@
"""Config profile API endpoints."""
import logging
import uuid
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.config_include import ConfigInclude
from src.models.config_mount import ConfigMount
from src.models.config_profile import ConfigProfile
from src.models.tool_type import ToolType
from src.models.user_config import UserConfig
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
MAX_MOUNT_PATH_LENGTH = 1024
MAX_CONTENT_LENGTH = 1024 * 1024 # 1MB
MAX_INCLUDES_DEPTH = 10
# ---------------------------------------------------------------------------
# Pydantic schemas
# ---------------------------------------------------------------------------
class ConfigProfileCreate(BaseModel):
name: str = Field(description="Profile name (unique per user)")
description: str | None = Field(default=None, description="Optional description")
@field_validator("name")
@classmethod
def validate_name(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("Profile name cannot be empty")
if len(v) > 255:
raise ValueError("Profile name must be 255 characters or less")
return v
class ConfigProfileUpdate(BaseModel):
name: str | None = Field(default=None, description="Profile name")
description: str | None = Field(default=None, description="Optional description")
@field_validator("name")
@classmethod
def validate_name(cls, v: str | None) -> str | None:
if v is None:
return v
v = v.strip()
if not v:
raise ValueError("Profile name cannot be empty")
if len(v) > 255:
raise ValueError("Profile name must be 255 characters or less")
return v
class ConfigProfileResponse(BaseModel):
id: str
user_id: str
name: str
description: str | None
created_at: str
updated_at: str
class ConfigProfileDetailResponse(ConfigProfileResponse):
includes: list[dict[str, Any]]
mounts: list[dict[str, Any]]
class ConfigIncludeCreate(BaseModel):
included_profile_id: str = Field(description="UUID of the profile to include")
order_index: int = Field(default=0, description="Order index for include resolution")
class ConfigIncludeUpdate(BaseModel):
order_index: int = Field(description="Order index for include resolution")
class ConfigIncludeResponse(BaseModel):
id: str
profile_id: str
included_profile_id: str
included_profile_name: str | None
order_index: int
created_at: str
updated_at: str
class ConfigMountCreate(BaseModel):
target_path: str = Field(description="Absolute target path in container")
mode: str = Field(default="rw", description="Mount mode (rw or ro)")
files: dict[str, str] | None = Field(default=None, description="Files as {path: content}")
order_index: int = Field(default=0, description="Order index for mount resolution")
@field_validator("target_path")
@classmethod
def validate_target_path(cls, v: str) -> str:
if not v.startswith("/"):
raise ValueError("Target path must be absolute (start with /)")
if ".." in v:
raise ValueError("Target path cannot contain parent directory references (..)")
if len(v) > MAX_MOUNT_PATH_LENGTH:
raise ValueError(f"Target path must be {MAX_MOUNT_PATH_LENGTH} characters or less")
return v
class ConfigMountUpdate(BaseModel):
target_path: str | None = Field(default=None, description="Absolute target path in container")
mode: str | None = Field(default=None, description="Mount mode (rw or ro)")
files: dict[str, str] | None = Field(default=None, description="Files as {path: content}")
order_index: int | None = Field(default=None, description="Order index for mount resolution")
@field_validator("target_path")
@classmethod
def validate_target_path(cls, v: str | None) -> str | None:
if v is None:
return v
if not v.startswith("/"):
raise ValueError("Target path must be absolute (start with /)")
if ".." in v:
raise ValueError("Target path cannot contain parent directory references (..)")
if len(v) > MAX_MOUNT_PATH_LENGTH:
raise ValueError(f"Target path must be {MAX_MOUNT_PATH_LENGTH} characters or less")
return v
class ConfigMountResponse(BaseModel):
id: str
profile_id: str
target_path: str
mode: str
files: dict[str, str] | None
order_index: int
created_at: str
updated_at: str
class DefaultProfilesUpdate(BaseModel):
default_profiles: dict[str, str] = Field(description="Mapping of tool_type_id to profile_id")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _get_owned_profile(
profile_id: uuid.UUID,
user_id: uuid.UUID,
session: AsyncSession,
) -> ConfigProfile:
"""Fetch a config profile and verify ownership."""
profile = await session.get(ConfigProfile, profile_id)
if profile is None or profile.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="config profile not found",
)
return profile
async def _detect_cycle(
session: AsyncSession,
profile_id: uuid.UUID,
visited: set[uuid.UUID] | None = None,
depth: int = 0,
) -> bool:
"""Detect cycles in profile includes using DFS.
Returns True if a cycle is detected.
"""
if depth > MAX_INCLUDES_DEPTH:
return True
if visited is None:
visited = set()
if profile_id in visited:
return True
visited.add(profile_id)
result = await session.execute(
select(ConfigInclude.included_profile_id).where(
ConfigInclude.profile_id == profile_id
)
)
included_ids = result.scalars().all()
for included_id in included_ids:
if await _detect_cycle(session, included_id, visited.copy(), depth + 1):
return True
return False
async def _validate_includes_no_cycle(
session: AsyncSession,
profile_id: uuid.UUID,
new_included_id: uuid.UUID | None = None,
) -> None:
"""Validate that adding an include wouldn't create a cycle."""
if new_included_id and await _detect_cycle(session, new_included_id, {profile_id}):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="adding this include would create a circular reference",
)
# ---------------------------------------------------------------------------
# Profile CRUD
# ---------------------------------------------------------------------------
@router.get(
"",
summary="List config profiles",
description="Get all config profiles for the current user. Optionally filter by tool type compatibility.",
)
async def list_config_profiles(
tool_type_id: str | None = None,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""List config profiles for the current user."""
query = select(ConfigProfile).where(ConfigProfile.user_id == user_id)
# If tool_type_id is provided, filter to compatible profiles
# For now, all profiles are considered compatible with all tool types
# since there's no explicit compatibility matrix. Future enhancement:
# could filter by profile tags or mount path patterns.
if tool_type_id:
# Validate the tool type exists
tool_type = await session.get(ToolType, uuid.UUID(tool_type_id))
if tool_type is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="tool type not found",
)
# All profiles are compatible; just return user's profiles
pass
result = await session.execute(query.order_by(ConfigProfile.name))
profiles = result.scalars().all()
return {
"profiles": [
{
"id": str(p.id),
"user_id": str(p.user_id),
"name": p.name,
"description": p.description,
"created_at": p.created_at.isoformat() if p.created_at else None,
"updated_at": p.updated_at.isoformat() if p.updated_at else None,
}
for p in profiles
]
}
@router.post(
"",
summary="Create config profile",
description="Create a new config profile.",
status_code=status.HTTP_201_CREATED,
)
async def create_config_profile(
data: ConfigProfileCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Create a config profile."""
# Check for duplicate name
existing = await session.scalar(
select(ConfigProfile).where(
ConfigProfile.user_id == user_id,
ConfigProfile.name == data.name,
)
)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"config profile with name '{data.name}' already exists",
)
profile = ConfigProfile(
user_id=user_id,
name=data.name,
description=data.description,
)
session.add(profile)
await session.commit()
await session.refresh(profile)
return {
"id": str(profile.id),
"user_id": str(profile.user_id),
"name": profile.name,
"description": profile.description,
"created_at": profile.created_at.isoformat() if profile.created_at else None,
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
}
@router.get(
"/defaults",
summary="Get default profiles",
description="Get the current user's default profile assignments per tool type.",
)
async def get_default_profiles(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get default profiles for the current user."""
result = await session.execute(
select(UserConfig).where(UserConfig.user_id == user_id)
)
user_config = result.scalar_one_or_none()
if user_config is None:
return {"default_profiles": {}}
return {"default_profiles": user_config.default_profiles}
@router.put(
"/defaults",
summary="Set default profiles",
description="Set the current user's default profile assignments per tool type.",
)
async def set_default_profiles(
data: DefaultProfilesUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Set default profiles for the current user."""
result = await session.execute(
select(UserConfig).where(UserConfig.user_id == user_id)
)
user_config = result.scalar_one_or_none()
if user_config is None:
user_config = UserConfig(user_id=user_id, config={})
session.add(user_config)
# Validate all profile IDs belong to the user
for tool_type_id, profile_id_str in data.default_profiles.items():
profile = await session.get(ConfigProfile, uuid.UUID(profile_id_str))
if profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"profile {profile_id_str} not found",
)
if profile.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"profile {profile_id_str} does not belong to user",
)
# SQLAlchemy JSON doesn't track dict mutations, so we replace the whole dict
user_config.config = {**user_config.config, "default_profiles": data.default_profiles}
await session.commit()
await session.refresh(user_config)
return {"default_profiles": user_config.default_profiles}
@router.get(
"/defaults/{tool_type_id}",
summary="Get default profile for tool type",
description="Get the default profile ID for a specific tool type.",
)
async def get_default_profile_for_tool_type(
tool_type_id: str,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get default profile for a specific tool type."""
result = await session.execute(
select(UserConfig).where(UserConfig.user_id == user_id)
)
user_config = result.scalar_one_or_none()
if user_config is None:
return {"tool_type_id": tool_type_id, "profile_id": None}
profile_id = user_config.default_profiles.get(tool_type_id)
return {"tool_type_id": tool_type_id, "profile_id": profile_id}
@router.get(
"/{profile_id}",
summary="Get config profile",
description="Get a config profile with its includes and mounts.",
)
async def get_config_profile(
profile_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get a config profile with includes and mounts."""
profile = await session.get(
ConfigProfile,
profile_id,
options=[
selectinload(ConfigProfile.includes),
selectinload(ConfigProfile.mounts),
],
)
if profile is None or profile.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="config profile not found",
)
# Fetch included profile names
includes_data = []
for inc in profile.includes:
included_profile = await session.get(ConfigProfile, inc.included_profile_id)
includes_data.append({
"id": str(inc.id),
"profile_id": str(inc.profile_id),
"included_profile_id": str(inc.included_profile_id),
"included_profile_name": included_profile.name if included_profile else None,
"order_index": inc.order_index,
"created_at": inc.created_at.isoformat() if inc.created_at else None,
"updated_at": inc.updated_at.isoformat() if inc.updated_at else None,
})
mounts_data = [
{
"id": str(m.id),
"profile_id": str(m.profile_id),
"target_path": m.target_path,
"mode": m.mode,
"files": m.files,
"order_index": m.order_index,
"created_at": m.created_at.isoformat() if m.created_at else None,
"updated_at": m.updated_at.isoformat() if m.updated_at else None,
}
for m in profile.mounts
]
return {
"id": str(profile.id),
"user_id": str(profile.user_id),
"name": profile.name,
"description": profile.description,
"includes": includes_data,
"mounts": mounts_data,
"created_at": profile.created_at.isoformat() if profile.created_at else None,
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
}
@router.put(
"/{profile_id}",
summary="Update config profile",
description="Update an existing config profile.",
)
async def update_config_profile(
profile_id: uuid.UUID,
data: ConfigProfileUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Update a config profile."""
profile = await _get_owned_profile(profile_id, user_id, session)
if data.name is not None:
# Check for duplicate name
existing = await session.scalar(
select(ConfigProfile).where(
ConfigProfile.user_id == user_id,
ConfigProfile.name == data.name,
ConfigProfile.id != profile_id,
)
)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"config profile with name '{data.name}' already exists",
)
profile.name = data.name
if data.description is not None:
profile.description = data.description
await session.commit()
await session.refresh(profile)
return {
"id": str(profile.id),
"user_id": str(profile.user_id),
"name": profile.name,
"description": profile.description,
"created_at": profile.created_at.isoformat() if profile.created_at else None,
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
}
@router.delete(
"/{profile_id}",
summary="Delete config profile",
description="Delete a config profile and all its includes and mounts.",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_config_profile(
profile_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
"""Delete a config profile."""
profile = await _get_owned_profile(profile_id, user_id, session)
await session.delete(profile)
await session.commit()
# ---------------------------------------------------------------------------
# Include management
# ---------------------------------------------------------------------------
@router.get(
"/{profile_id}/includes",
summary="List profile includes",
description="Get all includes for a config profile.",
)
async def list_profile_includes(
profile_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""List includes for a config profile."""
await _get_owned_profile(profile_id, user_id, session)
result = await session.execute(
select(ConfigInclude)
.where(ConfigInclude.profile_id == profile_id)
.order_by(ConfigInclude.order_index)
)
includes = result.scalars().all()
includes_data = []
for inc in includes:
included_profile = await session.get(ConfigProfile, inc.included_profile_id)
includes_data.append({
"id": str(inc.id),
"profile_id": str(inc.profile_id),
"included_profile_id": str(inc.included_profile_id),
"included_profile_name": included_profile.name if included_profile else None,
"order_index": inc.order_index,
"created_at": inc.created_at.isoformat() if inc.created_at else None,
"updated_at": inc.updated_at.isoformat() if inc.updated_at else None,
})
return {"includes": includes_data}
@router.post(
"/{profile_id}/includes",
summary="Add profile include",
description="Add an include to a config profile.",
status_code=status.HTTP_201_CREATED,
)
async def add_profile_include(
profile_id: uuid.UUID,
data: ConfigIncludeCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Add an include to a config profile."""
profile = await _get_owned_profile(profile_id, user_id, session)
included_profile_id = uuid.UUID(data.included_profile_id)
# Cannot include self
if included_profile_id == profile_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="a profile cannot include itself",
)
# Verify the included profile exists and belongs to the user
included_profile = await session.get(ConfigProfile, included_profile_id)
if included_profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="included profile not found",
)
if included_profile.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="included profile does not belong to user",
)
# Check for duplicate include
existing = await session.scalar(
select(ConfigInclude).where(
ConfigInclude.profile_id == profile_id,
ConfigInclude.included_profile_id == included_profile_id,
)
)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="this include already exists",
)
# Validate no cycles
await _validate_includes_no_cycle(session, profile_id, included_profile_id)
include = ConfigInclude(
profile_id=profile_id,
included_profile_id=included_profile_id,
order_index=data.order_index,
)
session.add(include)
await session.commit()
await session.refresh(include)
return {
"id": str(include.id),
"profile_id": str(include.profile_id),
"included_profile_id": str(include.included_profile_id),
"included_profile_name": included_profile.name,
"order_index": include.order_index,
"created_at": include.created_at.isoformat() if include.created_at else None,
"updated_at": include.updated_at.isoformat() if include.updated_at else None,
}
@router.put(
"/{profile_id}/includes/{include_id}",
summary="Update profile include",
description="Update the order index of a profile include.",
)
async def update_profile_include(
profile_id: uuid.UUID,
include_id: uuid.UUID,
data: ConfigIncludeUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Update a profile include."""
await _get_owned_profile(profile_id, user_id, session)
include = await session.get(ConfigInclude, include_id)
if include is None or include.profile_id != profile_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="include not found",
)
include.order_index = data.order_index
await session.commit()
await session.refresh(include)
included_profile = await session.get(ConfigProfile, include.included_profile_id)
return {
"id": str(include.id),
"profile_id": str(include.profile_id),
"included_profile_id": str(include.included_profile_id),
"included_profile_name": included_profile.name if included_profile else None,
"order_index": include.order_index,
"created_at": include.created_at.isoformat() if include.created_at else None,
"updated_at": include.updated_at.isoformat() if include.updated_at else None,
}
@router.delete(
"/{profile_id}/includes/{include_id}",
summary="Remove profile include",
description="Remove an include from a config profile.",
status_code=status.HTTP_204_NO_CONTENT,
)
async def remove_profile_include(
profile_id: uuid.UUID,
include_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
"""Remove an include from a config profile."""
await _get_owned_profile(profile_id, user_id, session)
include = await session.get(ConfigInclude, include_id)
if include is None or include.profile_id != profile_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="include not found",
)
await session.delete(include)
await session.commit()
# ---------------------------------------------------------------------------
# Mount management
# ---------------------------------------------------------------------------
@router.get(
"/{profile_id}/mounts",
summary="List profile mounts",
description="Get all mounts for a config profile.",
)
async def list_profile_mounts(
profile_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""List mounts for a config profile."""
await _get_owned_profile(profile_id, user_id, session)
result = await session.execute(
select(ConfigMount)
.where(ConfigMount.profile_id == profile_id)
.order_by(ConfigMount.order_index)
)
mounts = result.scalars().all()
return {
"mounts": [
{
"id": str(m.id),
"profile_id": str(m.profile_id),
"target_path": m.target_path,
"files": m.files,
"mode": m.mode,
"order_index": m.order_index,
"created_at": m.created_at.isoformat() if m.created_at else None,
"updated_at": m.updated_at.isoformat() if m.updated_at else None,
}
for m in mounts
]
}
@router.post(
"/{profile_id}/mounts",
summary="Add profile mount",
description="Add a mount to a config profile.",
status_code=status.HTTP_201_CREATED,
)
async def add_profile_mount(
profile_id: uuid.UUID,
data: ConfigMountCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Add a mount to a config profile."""
profile = await _get_owned_profile(profile_id, user_id, session)
# Check for duplicate target_path
existing = await session.scalar(
select(ConfigMount).where(
ConfigMount.profile_id == profile_id,
ConfigMount.target_path == data.target_path,
)
)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"mount with path '{data.target_path}' already exists",
)
mount = ConfigMount(
profile_id=profile_id,
target_path=data.target_path,
mode=data.mode,
files=data.files,
order_index=data.order_index,
)
session.add(mount)
await session.commit()
await session.refresh(mount)
return {
"id": str(mount.id),
"profile_id": str(mount.profile_id),
"target_path": mount.target_path,
"files": mount.files,
"mode": mount.mode,
"order_index": mount.order_index,
"created_at": mount.created_at.isoformat() if mount.created_at else None,
"updated_at": mount.updated_at.isoformat() if mount.updated_at else None,
}
@router.put(
"/{profile_id}/mounts/{mount_id}",
summary="Update profile mount",
description="Update a mount in a config profile.",
)
async def update_profile_mount(
profile_id: uuid.UUID,
mount_id: uuid.UUID,
data: ConfigMountUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Update a profile mount."""
await _get_owned_profile(profile_id, user_id, session)
mount = await session.get(ConfigMount, mount_id)
if mount is None or mount.profile_id != profile_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="mount not found",
)
if data.target_path is not None:
# Check for duplicate target_path
existing = await session.scalar(
select(ConfigMount).where(
ConfigMount.profile_id == profile_id,
ConfigMount.target_path == data.target_path,
ConfigMount.id != mount_id,
)
)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"mount with path '{data.target_path}' already exists",
)
mount.target_path = data.target_path
if data.files is not None:
mount.files = data.files
if data.order_index is not None:
mount.order_index = data.order_index
await session.commit()
await session.refresh(mount)
return {
"id": str(mount.id),
"profile_id": str(mount.profile_id),
"target_path": mount.target_path,
"files": mount.files,
"mode": mount.mode,
"order_index": mount.order_index,
"created_at": mount.created_at.isoformat() if mount.created_at else None,
"updated_at": mount.updated_at.isoformat() if mount.updated_at else None,
}
@router.delete(
"/{profile_id}/mounts/{mount_id}",
summary="Remove profile mount",
description="Remove a mount from a config profile.",
status_code=status.HTTP_204_NO_CONTENT,
)
async def remove_profile_mount(
profile_id: uuid.UUID,
mount_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
"""Remove a mount from a config profile."""
await _get_owned_profile(profile_id, user_id, session)
mount = await session.get(ConfigMount, mount_id)
if mount is None or mount.profile_id != profile_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="mount not found",
)
await session.delete(mount)
await session.commit()
+3 -3
View File
@@ -2,7 +2,7 @@ import hmac
import hashlib
import json
import base64
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta, timezone
from typing import Any
from src.config import Settings
@@ -23,7 +23,7 @@ def create_session_cookie(*, settings: Settings, user_id: str) -> str:
"""Create a signed session cookie value."""
payload = {
"user_id": user_id,
"exp": int((datetime.now(UTC) + timedelta(hours=settings.session_ttl_hours)).timestamp()),
"exp": int((datetime.now(timezone.utc) + timedelta(hours=settings.session_ttl_hours)).timestamp()),
}
header = _base64url_encode(json.dumps({"alg": "HS256", "typ": "session"}).encode())
@@ -65,7 +65,7 @@ def decode_session_cookie(*, settings: Settings, cookie_value: str) -> dict[str,
payload = json.loads(payload_bytes)
# Check expiry
if payload.get("exp", 0) < int(datetime.now(UTC).timestamp()):
if payload.get("exp", 0) < int(datetime.now(timezone.utc).timestamp()):
raise ValueError("session expired")
return payload
+2
View File
@@ -18,6 +18,7 @@ from src.api.ssh_keys import router as ssh_keys_router
from src.api.terminal import router as terminal_router
from src.api.instance_proxy import router as instance_proxy_router
from src.api.config_folders import router as config_folders_router
from src.api.config_profiles import router as config_profiles_router
from src.api.tool_configs import router as tool_configs_router
from src.api.tool_instances import router as tool_instances_router
from src.api.tool_instances import sessions_router
@@ -277,6 +278,7 @@ app.include_router(git_repositories_router)
app.include_router(user_config_router)
app.include_router(tool_types_router)
app.include_router(config_folders_router)
app.include_router(config_profiles_router)
app.include_router(tool_instances_router)
app.include_router(tool_configs_router)
app.include_router(sessions_router)
+2 -1
View File
@@ -45,13 +45,14 @@ class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
tool_type: Mapped["ToolType | None"] = relationship()
includes: Mapped[list["ConfigInclude"]] = relationship(
"ConfigInclude",
foreign_keys="ConfigInclude.profile_id",
primaryjoin="ConfigProfile.id == ConfigInclude.profile_id",
back_populates="profile",
cascade="all, delete-orphan",
order_by="ConfigInclude.order_index",
)
mounts: Mapped[list["ConfigMount"]] = relationship(
"ConfigMount",
primaryjoin="ConfigProfile.id == ConfigMount.profile_id",
back_populates="profile",
cascade="all, delete-orphan",
order_by="ConfigMount.order_index",
@@ -0,0 +1,461 @@
"""Integration tests for config profiles API."""
import uuid
import pytest
from fastapi.testclient import TestClient
@pytest.mark.integration
class TestConfigProfilesAPI:
"""Integration tests for config profiles API."""
def test_list_config_profiles_requires_authentication(self, test_client: TestClient) -> None:
"""Test that listing config profiles requires authentication."""
response = test_client.get("/config-profiles")
assert response.status_code == 401
def test_list_config_profiles_returns_user_profiles(self, authenticated_client: TestClient) -> None:
"""Test that authenticated users can list their profiles."""
response = authenticated_client.get("/config-profiles")
assert response.status_code == 200
data = response.json()
assert isinstance(data, dict)
assert "profiles" in data
assert isinstance(data["profiles"], list)
def test_create_config_profile_successfully(self, authenticated_client: TestClient) -> None:
"""Test creating a config profile."""
response = authenticated_client.post(
"/config-profiles",
json={
"name": "test-profile",
"description": "Test profile",
},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "test-profile"
assert data["description"] == "Test profile"
def test_create_config_profile_duplicate_name(self, authenticated_client: TestClient) -> None:
"""Test that duplicate profile names are rejected."""
authenticated_client.post(
"/config-profiles",
json={"name": "duplicate-profile"},
)
response = authenticated_client.post(
"/config-profiles",
json={"name": "duplicate-profile"},
)
assert response.status_code == 409
def test_create_config_profile_empty_name(self, authenticated_client: TestClient) -> None:
"""Test that empty profile names are rejected."""
response = authenticated_client.post(
"/config-profiles",
json={"name": " "},
)
assert response.status_code == 422
def test_get_config_profile_by_id(self, authenticated_client: TestClient) -> None:
"""Test getting a config profile by ID."""
create_response = authenticated_client.post(
"/config-profiles",
json={"name": "get-test"},
)
profile_id = create_response.json()["id"]
response = authenticated_client.get(f"/config-profiles/{profile_id}")
assert response.status_code == 200
data = response.json()
assert data["name"] == "get-test"
assert "includes" in data
assert "mounts" in data
def test_get_config_profile_not_found(self, authenticated_client: TestClient) -> None:
"""Test getting a non-existent profile."""
response = authenticated_client.get(f"/config-profiles/{uuid.uuid4()}")
assert response.status_code == 404
def test_update_config_profile_successfully(self, authenticated_client: TestClient) -> None:
"""Test updating a config profile."""
create_response = authenticated_client.post(
"/config-profiles",
json={"name": "update-test"},
)
profile_id = create_response.json()["id"]
response = authenticated_client.put(
f"/config-profiles/{profile_id}",
json={"name": "updated-name", "description": "updated desc"},
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "updated-name"
assert data["description"] == "updated desc"
def test_delete_config_profile_successfully(self, authenticated_client: TestClient) -> None:
"""Test deleting a config profile."""
create_response = authenticated_client.post(
"/config-profiles",
json={"name": "delete-test"},
)
profile_id = create_response.json()["id"]
response = authenticated_client.delete(f"/config-profiles/{profile_id}")
assert response.status_code == 204
get_response = authenticated_client.get(f"/config-profiles/{profile_id}")
assert get_response.status_code == 404
def test_profile_access_check(self, authenticated_client: TestClient) -> None:
"""Test that users can only access their own profiles."""
# Create a profile
create_response = authenticated_client.post(
"/config-profiles",
json={"name": "access-test"},
)
profile_id = create_response.json()["id"]
# The profile should be accessible
response = authenticated_client.get(f"/config-profiles/{profile_id}")
assert response.status_code == 200
@pytest.mark.integration
class TestConfigProfileIncludes:
"""Integration tests for config profile includes."""
def test_add_include_successfully(self, authenticated_client: TestClient) -> None:
"""Test adding an include to a profile."""
# Create two profiles
profile1 = authenticated_client.post(
"/config-profiles",
json={"name": "profile-1"},
).json()
profile2 = authenticated_client.post(
"/config-profiles",
json={"name": "profile-2"},
).json()
# Add include
response = authenticated_client.post(
f"/config-profiles/{profile1['id']}/includes",
json={"included_profile_id": profile2["id"], "order_index": 0},
)
assert response.status_code == 201
data = response.json()
assert data["included_profile_id"] == profile2["id"]
assert data["included_profile_name"] == "profile-2"
def test_add_self_include_rejected(self, authenticated_client: TestClient) -> None:
"""Test that self-includes are rejected."""
profile = authenticated_client.post(
"/config-profiles",
json={"name": "self-include-test"},
).json()
response = authenticated_client.post(
f"/config-profiles/{profile['id']}/includes",
json={"included_profile_id": profile["id"], "order_index": 0},
)
assert response.status_code == 400
def test_add_include_cycle_rejected(self, authenticated_client: TestClient) -> None:
"""Test that circular includes are rejected."""
profile1 = authenticated_client.post(
"/config-profiles",
json={"name": "cycle-1"},
).json()
profile2 = authenticated_client.post(
"/config-profiles",
json={"name": "cycle-2"},
).json()
# Add profile1 includes profile2
authenticated_client.post(
f"/config-profiles/{profile1['id']}/includes",
json={"included_profile_id": profile2["id"], "order_index": 0},
)
# Try to add profile2 includes profile1 (creates cycle)
response = authenticated_client.post(
f"/config-profiles/{profile2['id']}/includes",
json={"included_profile_id": profile1["id"], "order_index": 0},
)
assert response.status_code == 400
def test_add_deep_cycle_rejected(self, authenticated_client: TestClient) -> None:
"""Test that deep circular includes are rejected."""
p1 = authenticated_client.post(
"/config-profiles", json={"name": "deep-1"}
).json()
p2 = authenticated_client.post(
"/config-profiles", json={"name": "deep-2"}
).json()
p3 = authenticated_client.post(
"/config-profiles", json={"name": "deep-3"}
).json()
# p1 -> p2 -> p3
authenticated_client.post(
f"/config-profiles/{p1['id']}/includes",
json={"included_profile_id": p2["id"], "order_index": 0},
)
authenticated_client.post(
f"/config-profiles/{p2['id']}/includes",
json={"included_profile_id": p3["id"], "order_index": 0},
)
# Try p3 -> p1 (creates cycle)
response = authenticated_client.post(
f"/config-profiles/{p3['id']}/includes",
json={"included_profile_id": p1["id"], "order_index": 0},
)
assert response.status_code == 400
def test_add_duplicate_include_rejected(self, authenticated_client: TestClient) -> None:
"""Test that duplicate includes are rejected."""
p1 = authenticated_client.post(
"/config-profiles", json={"name": "dup-1"}
).json()
p2 = authenticated_client.post(
"/config-profiles", json={"name": "dup-2"}
).json()
authenticated_client.post(
f"/config-profiles/{p1['id']}/includes",
json={"included_profile_id": p2["id"], "order_index": 0},
)
response = authenticated_client.post(
f"/config-profiles/{p1['id']}/includes",
json={"included_profile_id": p2["id"], "order_index": 1},
)
assert response.status_code == 409
def test_list_includes(self, authenticated_client: TestClient) -> None:
"""Test listing includes for a profile."""
p1 = authenticated_client.post(
"/config-profiles", json={"name": "list-inc-1"}
).json()
p2 = authenticated_client.post(
"/config-profiles", json={"name": "list-inc-2"}
).json()
authenticated_client.post(
f"/config-profiles/{p1['id']}/includes",
json={"included_profile_id": p2["id"], "order_index": 0},
)
response = authenticated_client.get(f"/config-profiles/{p1['id']}/includes")
assert response.status_code == 200
data = response.json()
assert len(data["includes"]) == 1
def test_update_include_order(self, authenticated_client: TestClient) -> None:
"""Test updating include order index."""
p1 = authenticated_client.post(
"/config-profiles", json={"name": "order-1"}
).json()
p2 = authenticated_client.post(
"/config-profiles", json={"name": "order-2"}
).json()
inc = authenticated_client.post(
f"/config-profiles/{p1['id']}/includes",
json={"included_profile_id": p2["id"], "order_index": 0},
).json()
response = authenticated_client.put(
f"/config-profiles/{p1['id']}/includes/{inc['id']}",
json={"order_index": 5},
)
assert response.status_code == 200
assert response.json()["order_index"] == 5
def test_remove_include(self, authenticated_client: TestClient) -> None:
"""Test removing an include."""
p1 = authenticated_client.post(
"/config-profiles", json={"name": "rem-1"}
).json()
p2 = authenticated_client.post(
"/config-profiles", json={"name": "rem-2"}
).json()
inc = authenticated_client.post(
f"/config-profiles/{p1['id']}/includes",
json={"included_profile_id": p2["id"], "order_index": 0},
).json()
response = authenticated_client.delete(
f"/config-profiles/{p1['id']}/includes/{inc['id']}"
)
assert response.status_code == 204
@pytest.mark.integration
class TestConfigProfileMounts:
"""Integration tests for config profile mounts."""
def test_add_mount_successfully(self, authenticated_client: TestClient) -> None:
"""Test adding a mount to a profile."""
profile = authenticated_client.post(
"/config-profiles",
json={"name": "mount-test"},
).json()
response = authenticated_client.post(
f"/config-profiles/{profile['id']}/mounts",
json={"target_path": "/etc/config", "files": {"test.txt": "hello"}, "order_index": 0},
)
assert response.status_code == 201
data = response.json()
assert data["target_path"] == "/etc/config"
assert data["files"] == {"test.txt": "hello"}
def test_add_mount_relative_path_rejected(self, authenticated_client: TestClient) -> None:
"""Test that relative mount paths are rejected."""
profile = authenticated_client.post(
"/config-profiles",
json={"name": "rel-path-test"},
).json()
response = authenticated_client.post(
f"/config-profiles/{profile['id']}/mounts",
json={"target_path": "etc/config", "files": {"test.txt": "hello"}},
)
assert response.status_code == 422
def test_add_target_path_traversal_rejected(self, authenticated_client: TestClient) -> None:
"""Test that path traversal in mount paths is rejected."""
profile = authenticated_client.post(
"/config-profiles",
json={"name": "traversal-test"},
).json()
response = authenticated_client.post(
f"/config-profiles/{profile['id']}/mounts",
json={"target_path": "/etc/../passwd", "files": {"test.txt": "hello"}},
)
assert response.status_code == 422
def test_add_duplicate_mount_rejected(self, authenticated_client: TestClient) -> None:
"""Test that duplicate mount paths are rejected."""
profile = authenticated_client.post(
"/config-profiles",
json={"name": "dup-mount-test"},
).json()
authenticated_client.post(
f"/config-profiles/{profile['id']}/mounts",
json={"target_path": "/etc/config", "files": {"test.txt": "hello"}},
)
response = authenticated_client.post(
f"/config-profiles/{profile['id']}/mounts",
json={"target_path": "/etc/config", "files": {"test.txt": "world"}},
)
assert response.status_code == 409
def test_update_mount(self, authenticated_client: TestClient) -> None:
"""Test updating a mount."""
profile = authenticated_client.post(
"/config-profiles",
json={"name": "update-mount-test"},
).json()
mount = authenticated_client.post(
f"/config-profiles/{profile['id']}/mounts",
json={"target_path": "/old/path", "files": {"test.txt": "old"}},
).json()
response = authenticated_client.put(
f"/config-profiles/{profile['id']}/mounts/{mount['id']}",
json={"target_path": "/new/path", "files": {"test.txt": "new"}, "order_index": 2},
)
assert response.status_code == 200
data = response.json()
assert data["target_path"] == "/new/path"
assert data["files"] == {"test.txt": "new"}
assert data["order_index"] == 2
def test_remove_mount(self, authenticated_client: TestClient) -> None:
"""Test removing a mount."""
profile = authenticated_client.post(
"/config-profiles",
json={"name": "rem-mount-test"},
).json()
mount = authenticated_client.post(
f"/config-profiles/{profile['id']}/mounts",
json={"target_path": "/tmp/test", "files": {"test.txt": "x"}},
).json()
response = authenticated_client.delete(
f"/config-profiles/{profile['id']}/mounts/{mount['id']}"
)
assert response.status_code == 204
@pytest.mark.integration
class TestConfigProfileDefaults:
"""Integration tests for default profile APIs."""
def test_get_default_profiles_empty(self, authenticated_client: TestClient) -> None:
"""Test getting default profiles when none are set."""
response = authenticated_client.get("/config-profiles/defaults")
assert response.status_code == 200
data = response.json()
assert data["default_profiles"] == {}
def test_set_default_profiles(self, authenticated_client: TestClient) -> None:
"""Test setting default profiles."""
profile = authenticated_client.post(
"/config-profiles",
json={"name": "default-test"},
).json()
response = authenticated_client.put(
"/config-profiles/defaults",
json={"default_profiles": {"code-server": profile["id"]}},
)
assert response.status_code == 200
data = response.json()
assert data["default_profiles"]["code-server"] == profile["id"]
def test_set_default_profiles_invalid_profile(self, authenticated_client: TestClient) -> None:
"""Test setting default profiles with invalid profile ID."""
response = authenticated_client.put(
"/config-profiles/defaults",
json={"default_profiles": {"code-server": str(uuid.uuid4())}},
)
assert response.status_code == 404
def test_get_default_profile_for_tool_type(self, authenticated_client: TestClient) -> None:
"""Test getting default profile for a specific tool type."""
profile = authenticated_client.post(
"/config-profiles",
json={"name": "tool-default-test"},
).json()
authenticated_client.put(
"/config-profiles/defaults",
json={"default_profiles": {"jupyter-notebook": profile["id"]}},
)
response = authenticated_client.get("/config-profiles/defaults/jupyter-notebook")
assert response.status_code == 200
data = response.json()
assert data["tool_type_id"] == "jupyter-notebook"
assert data["profile_id"] == profile["id"]
def test_get_default_profile_for_tool_type_not_set(self, authenticated_client: TestClient) -> None:
"""Test getting default profile when not set."""
response = authenticated_client.get("/config-profiles/defaults/opencode")
assert response.status_code == 200
data = response.json()
assert data["tool_type_id"] == "opencode"
assert data["profile_id"] is None
@@ -1,5 +1,5 @@
import uuid
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta, timezone
import asyncio
import pytest
@@ -58,7 +58,7 @@ def _mint_token(user_id: str) -> str:
subject=user_id,
email="test@headquarter.local",
name="Test User",
expires_at=datetime.now(UTC) + timedelta(minutes=15),
expires_at=datetime.now(timezone.utc) + timedelta(minutes=15),
)
@@ -1,5 +1,5 @@
import uuid
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta, timezone
import asyncio
import pytest
@@ -59,7 +59,7 @@ def _mint_token(user_id: str) -> str:
subject=user_id,
email="test@headquarter.local",
name="Test User",
expires_at=datetime.now(UTC) + timedelta(minutes=15),
expires_at=datetime.now(timezone.utc) + timedelta(minutes=15),
)
+2 -2
View File
@@ -1,5 +1,5 @@
import uuid
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta, timezone
import asyncio
import io
@@ -83,7 +83,7 @@ def _create_auth_cookie(user_id: str) -> str:
subject=user_id,
email="test@headquarter.local",
name="Test User",
expires_at=datetime.now(UTC) + timedelta(minutes=15),
expires_at=datetime.now(timezone.utc) + timedelta(minutes=15),
)
+33 -55
View File
@@ -1,5 +1,4 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ProjectsPage } from "./projects";
@@ -30,21 +29,13 @@ afterEach(() => {
describe("ProjectsPage", () => {
it("renders loading state initially", () => {
vi.spyOn(projectsApi, "listProjects").mockImplementation(() => new Promise(() => {}));
render(
<MemoryRouter>
<ProjectsPage />
</MemoryRouter>
);
render(<ProjectsPage />);
expect(screen.getByText(/loading projects/i)).toBeInTheDocument();
});
it("renders project list after loading", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
render(
<MemoryRouter>
<ProjectsPage />
</MemoryRouter>
);
render(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
@@ -55,11 +46,7 @@ describe("ProjectsPage", () => {
it("renders empty state when no projects", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
render(
<MemoryRouter>
<ProjectsPage />
</MemoryRouter>
);
render(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
@@ -68,11 +55,7 @@ describe("ProjectsPage", () => {
it("renders error state with retry button", async () => {
vi.spyOn(projectsApi, "listProjects").mockRejectedValue(new Error("fail"));
render(
<MemoryRouter>
<ProjectsPage />
</MemoryRouter>
);
render(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText(/failed to load projects/i)).toBeInTheDocument();
@@ -84,11 +67,7 @@ describe("ProjectsPage", () => {
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
const createMock = vi.spyOn(projectsApi, "createProject").mockResolvedValue(mockProjects[0]);
render(
<MemoryRouter>
<ProjectsPage />
</MemoryRouter>
);
render(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
@@ -117,11 +96,7 @@ describe("ProjectsPage", () => {
it("shows validation error when name is empty", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
render(
<MemoryRouter>
<ProjectsPage />
</MemoryRouter>
);
render(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
@@ -133,15 +108,9 @@ describe("ProjectsPage", () => {
expect(screen.getByText(/project name is required/i)).toBeInTheDocument();
});
it("opens edit dialog and saves changes", async () => {
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
const updateMock = vi.spyOn(projectsApi, "updateProject").mockResolvedValue(mockProjects[0]);
render(
<MemoryRouter>
<ProjectsPage />
</MemoryRouter>
);
it("renders settings link for each project", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
render(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
@@ -150,31 +119,40 @@ describe("ProjectsPage", () => {
const alphaCard = screen.getByText("Alpha Project").closest(".project-card") as HTMLElement | null;
if (!alphaCard) throw new Error("Card not found");
fireEvent.click(within(alphaCard).getByRole("button", { name: /edit/i }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
const settingsLink = within(alphaCard).getByRole("link", { name: /settings/i });
expect(settingsLink).toBeInTheDocument();
expect(settingsLink).toHaveAttribute("href", "/projects/proj-1/settings");
});
const nameInput = screen.getByDisplayValue("Alpha Project");
fireEvent.change(nameInput, { target: { value: "Alpha Updated" } });
fireEvent.click(screen.getByRole("button", { name: /save/i }));
it("renders open workspace link as rightmost action", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
render(<ProjectsPage />);
await waitFor(() => {
expect(updateMock).toHaveBeenCalledWith("proj-1", {
name: "Alpha Updated",
description: "First project",
});
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
});
expect(listMock).toHaveBeenCalledTimes(2);
const alphaCard = screen.getByText("Alpha Project").closest(".project-card") as HTMLElement | null;
if (!alphaCard) throw new Error("Card not found");
const actions = alphaCard.querySelector(".project-actions");
if (!actions) throw new Error("Actions container not found");
const workspaceLink = within(alphaCard).getByRole("link", { name: /open workspace/i });
expect(workspaceLink).toBeInTheDocument();
expect(workspaceLink).toHaveAttribute("href", "/projects/proj-1");
// Verify it's the last action in the container
const allActions = actions.querySelectorAll("a, button");
const lastAction = allActions[allActions.length - 1];
expect(lastAction).toBe(workspaceLink);
});
it("shows delete confirmation and deletes project", async () => {
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
const deleteMock = vi.spyOn(projectsApi, "deleteProject").mockResolvedValue(undefined);
render(
<MemoryRouter>
<ProjectsPage />
</MemoryRouter>
);
render(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
+25 -57
View File
@@ -6,21 +6,17 @@ import {
createProject,
deleteProject,
listProjects,
updateProject,
type ProjectCreateInput,
type ProjectUpdateInput,
} from "../api/projects";
import { Icon } from "../components/icon";
import type { Project } from "../types";
type ProjectsStatus = "loading" | "ready" | "error";
type DialogMode = "none" | "create" | "edit";
export const ProjectsPage = () => {
const [status, setStatus] = useState<ProjectsStatus>("loading");
const [projects, setProjects] = useState<Project[]>([]);
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
const [editingProject, setEditingProject] = useState<Project | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [formName, setFormName] = useState("");
const [formDescription, setFormDescription] = useState("");
const [formError, setFormError] = useState<string | null>(null);
@@ -46,25 +42,15 @@ export const ProjectsPage = () => {
setFormName("");
setFormDescription("");
setFormError(null);
setEditingProject(null);
setDialogMode("create");
setShowCreate(true);
};
const openEdit = (project: Project) => {
setFormName(project.name);
setFormDescription(project.description ?? "");
setFormError(null);
setEditingProject(project);
setDialogMode("edit");
};
const closeDialog = () => {
setDialogMode("none");
setEditingProject(null);
const closeCreate = () => {
setShowCreate(false);
setFormError(null);
};
const handleSubmit = async (e: React.FormEvent) => {
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
setFormError(null);
@@ -74,20 +60,12 @@ export const ProjectsPage = () => {
}
try {
if (dialogMode === "create") {
const input: ProjectCreateInput = {
name: formName.trim(),
description: formDescription.trim() || null,
};
await createProject(input);
} else if (dialogMode === "edit" && editingProject) {
const input: ProjectUpdateInput = {
name: formName.trim(),
description: formDescription.trim() || null,
};
await updateProject(editingProject.id, input);
}
closeDialog();
const input: ProjectCreateInput = {
name: formName.trim(),
description: formDescription.trim() || null,
};
await createProject(input);
closeCreate();
await loadProjects();
} catch {
setFormError("Failed to save project");
@@ -139,17 +117,13 @@ export const ProjectsPage = () => {
{project.description && <p className="muted">{project.description}</p>}
</div>
<div className="project-actions">
<Link className="ghost-button" to={`/projects/${project.id}`}>
Open Workspace
</Link>
<button
<Link
className="ghost-button"
onClick={() => openEdit(project)}
type="button"
to={`/projects/${project.id}/settings`}
>
<Icon name="edit" size="sm" />
Edit
</button>
<Icon name="settings" size="sm" />
Settings
</Link>
{deleteConfirmId === project.id ? (
<div className="delete-confirm">
<span>Are you sure?</span>
@@ -180,17 +154,20 @@ export const ProjectsPage = () => {
Delete
</button>
)}
<Link className="ghost-button" to={`/projects/${project.id}`}>
Open Workspace
</Link>
</div>
</article>
))}
</div>
)}
{dialogMode !== "none" && (
{showCreate && (
<div className="dialog-overlay" role="dialog" aria-modal="true">
<div className="dialog">
<h2>{dialogMode === "create" ? "Create Project" : "Edit Project"}</h2>
<form onSubmit={handleSubmit} className="stack">
<h2>Create Project</h2>
<form onSubmit={handleCreate} className="stack">
<label className="form-field">
Name
<input
@@ -211,22 +188,13 @@ export const ProjectsPage = () => {
</label>
{formError && <p className="error-text">{formError}</p>}
<div className="dialog-actions">
<button className="secondary-button" onClick={closeDialog} type="button">
<button className="secondary-button" onClick={closeCreate} type="button">
<Icon name="cancel" size="sm" />
Cancel
</button>
<button className="primary-button" type="submit">
{dialogMode === "create" ? (
<>
<Icon name="add" size="sm" />
Create
</>
) : (
<>
<Icon name="save" size="sm" />
Save
</>
)}
<Icon name="add" size="sm" />
Create
</button>
</div>
</form>
+1
View File
@@ -35,6 +35,7 @@ All responses are JSON. Error responses follow this format:
- [Repositories](repositories.md) - Git repositories and file operations
- [Users](users.md) - User management and settings
- [Tool Types](tool-types.md) - Tool type management
- [Config Profiles](config-profiles.md) - Config profile management for tool instances
- [SSH Keys](ssh-keys.md) - SSH key management
## Testing
+433
View File
@@ -0,0 +1,433 @@
# Config Profiles API
Config profile management endpoints for customizing tool instances.
## Authentication
All endpoints require authentication (session cookie).
---
## GET /config-profiles
**Description:** List all config profiles for the current user.
### Query Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `tool_type_id` | `string` | No | Filter by tool type compatibility (currently returns all profiles) |
### Response
#### Success (200 OK)
```json
{
"profiles": [
{
"id": "uuid",
"user_id": "uuid",
"name": "my-profile",
"description": "My custom profile",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
]
}
```
---
## POST /config-profiles
**Description:** Create a new config profile.
### Request
#### Request Body
```json
{
"name": "my-profile",
"description": "My custom profile"
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | `string` | Yes | Unique profile name (max 255 chars) |
| `description` | `string` | No | Optional description |
### Response
#### Success (201 Created)
Returns created profile.
#### Error (409 Conflict)
```json
{
"detail": "config profile with name 'my-profile' already exists"
}
```
#### Error (422 Unprocessable Entity)
```json
{
"detail": "Profile name cannot be empty"
}
```
---
## GET /config-profiles/{profile_id}
**Description:** Get a config profile with its includes and mounts.
### Response
#### Success (200 OK)
```json
{
"id": "uuid",
"user_id": "uuid",
"name": "my-profile",
"description": "My custom profile",
"includes": [
{
"id": "uuid",
"profile_id": "uuid",
"included_profile_id": "uuid",
"included_profile_name": "base-profile",
"order_index": 0,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
],
"mounts": [
{
"id": "uuid",
"profile_id": "uuid",
"target_path": "/etc/config",
"mode": "rw",
"files": {"test.txt": "hello"},
"order_index": 0,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
],
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
```
---
## PUT /config-profiles/{profile_id}
**Description:** Update a config profile.
### Request
#### Request Body
```json
{
"name": "updated-name",
"description": "Updated description"
}
```
### Response
#### Success (200 OK)
Returns updated profile.
---
## DELETE /config-profiles/{profile_id}
**Description:** Delete a config profile and all its includes and mounts.
### Response
#### Success (204 No Content)
---
## GET /config-profiles/defaults
**Description:** Get the current user's default profile assignments per tool type.
### Response
#### Success (200 OK)
```json
{
"default_profiles": {
"code-server": "profile-uuid-1",
"jupyter-notebook": "profile-uuid-2"
}
}
```
---
## PUT /config-profiles/defaults
**Description:** Set the current user's default profile assignments per tool type.
### Request
#### Request Body
```json
{
"default_profiles": {
"code-server": "profile-uuid-1",
"jupyter-notebook": "profile-uuid-2"
}
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `default_profiles` | `object` | Yes | Mapping of tool_type_id to profile_id |
### Response
#### Success (200 OK)
Returns updated default profiles.
#### Error (404 Not Found)
```json
{
"detail": "profile {profile_id} not found"
}
```
---
## GET /config-profiles/defaults/{tool_type_id}
**Description:** Get the default profile ID for a specific tool type.
### Response
#### Success (200 OK)
```json
{
"tool_type_id": "code-server",
"profile_id": "profile-uuid-1"
}
```
---
## GET /config-profiles/{profile_id}/includes
**Description:** List all includes for a config profile.
### Response
#### Success (200 OK)
```json
{
"includes": [
{
"id": "uuid",
"profile_id": "uuid",
"included_profile_id": "uuid",
"included_profile_name": "base-profile",
"order_index": 0,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
]
}
```
---
## POST /config-profiles/{profile_id}/includes
**Description:** Add an include to a config profile.
### Request
#### Request Body
```json
{
"included_profile_id": "uuid",
"order_index": 0
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `included_profile_id` | `string` | Yes | UUID of the profile to include |
| `order_index` | `integer` | No | Order for include resolution (default: 0) |
### Response
#### Success (201 Created)
Returns created include.
#### Error (400 Bad Request)
```json
{
"detail": "a profile cannot include itself"
}
```
```json
{
"detail": "adding this include would create a circular reference"
}
```
---
## PUT /config-profiles/{profile_id}/includes/{include_id}
**Description:** Update the order index of a profile include.
### Request
#### Request Body
```json
{
"order_index": 5
}
```
### Response
#### Success (200 OK)
Returns updated include.
---
## DELETE /config-profiles/{profile_id}/includes/{include_id}
**Description:** Remove an include from a config profile.
### Response
#### Success (204 No Content)
---
## GET /config-profiles/{profile_id}/mounts
**Description:** List all mounts for a config profile.
### Response
#### Success (200 OK)
```json
{
"mounts": [
{
"id": "uuid",
"profile_id": "uuid",
"target_path": "/etc/config",
"mode": "rw",
"files": {"test.txt": "hello"},
"order_index": 0,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
]
}
```
---
## POST /config-profiles/{profile_id}/mounts
**Description:** Add a mount to a config profile.
### Request
#### Request Body
```json
{
"target_path": "/etc/config",
"mode": "rw",
"files": {"test.txt": "hello"},
"order_index": 0
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `target_path` | `string` | Yes | Absolute target path (must start with /) |
| `mode` | `string` | No | Mount mode: "rw" or "ro" (default: "rw") |
| `files` | `object` | No | Files as {path: content} |
| `order_index` | `integer` | No | Order for mount resolution (default: 0) |
### Response
#### Success (201 Created)
Returns created mount.
#### Error (422 Unprocessable Entity)
```json
{
"detail": "Target path must be absolute (start with /)"
}
```
---
## PUT /config-profiles/{profile_id}/mounts/{mount_id}
**Description:** Update a mount in a config profile.
### Request
#### Request Body
```json
{
"target_path": "/new/path",
"files": {"test.txt": "updated"},
"order_index": 2
}
```
### Response
#### Success (200 OK)
Returns updated mount.
---
## DELETE /config-profiles/{profile_id}/mounts/{mount_id}
**Description:** Remove a mount from a config profile.
### Response
#### Success (204 No Content)
+13 -8
View File
@@ -22,25 +22,30 @@ The Projects page displays all your projects in a card layout showing:
- Creation date
- Associated repositories count
Each project card provides quick actions:
- **Settings** — Navigate to the project settings page
- **Delete** — Delete the project with confirmation
- **Open Workspace** — Open the project's workspace (rightmost action)
### Opening a Project Workspace
Click on any project card to open its **workspace**. The workspace is the default view for a project and shows:
Click the **"Open Workspace"** button on any project card to open its **workspace**. The workspace is the default view for a project and shows:
- Repository file browser
- Branch selector
- File viewer
### Editing a Project
1. From the Projects page, click the **menu icon** (⋮) on a project card
2. Select **"Edit"**
3. Update the name or description
4. Click **"Save"**
1. From the Projects page, click the **"Settings"** link on a project card
2. On the project settings page, update the **name** or **description**
3. Click **"Save Changes"**
The settings page also provides access to repository management and member settings.
### Deleting a Project
1. From the Projects page, click the **menu icon** (⋮) on a project card
2. Select **"Delete"**
3. Confirm the deletion
1. From the Projects page, click the **"Delete"** button on a project card
2. Confirm the deletion
**Note:** Deleting a project also deletes all associated repositories and their data. This action cannot be undone.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-22
@@ -0,0 +1,47 @@
## Context
The projects listing page (`apps/web/src/pages/projects.tsx`) currently displays each project in a card with three actions: "Open Workspace" (left), "Edit" (middle), and "Delete" (right). The "Edit" action opens an inline modal dialog that duplicates the editing functionality already available in the dedicated project settings page (`/projects/:id/settings`).
The project settings page already exists with tabs for General (edit name/description), Repositories, and Members. The add-repo functionality is already located in the Repositories tab.
## Goals / Non-Goals
**Goals:**
- Simplify the projects listing page by removing the inline edit modal
- Add a Settings link to project cards for navigation to the settings page
- Reposition the "Open Workspace" button to the right side for easier access
- Keep the projects page focused on navigation and creation
**Non-Goals:**
- No changes to project settings page functionality (already implemented)
- No changes to backend APIs
- No changes to the add-repo flow (already in settings)
- No changes to workspace or repository pages
## Decisions
**Decision: Remove Edit modal, link to settings instead**
- Rationale: The settings page already provides a better editing experience with tabs, persistence feedback, and access to repositories/members. Maintaining two edit UIs creates duplication and confusion.
- Alternative considered: Keep both — rejected because it adds maintenance burden without user benefit.
**Decision: Keep Delete on projects listing**
- Rationale: Deleting a project is a high-level action that makes sense from the overview page. Users expect to delete items from a list view.
**Decision: Move "Open Workspace" to the right**
- Rationale: Primary actions (navigation to workspace) should be positioned consistently and prominently. Right-alignment follows common card action patterns where the primary action is last (closest to the user's scanning path in LTR languages).
- Layout order left-to-right: Settings, Delete, Open Workspace
## Risks / Trade-offs
- **[Risk]** Users accustomed to inline editing may initially miss the edit button
- **Mitigation:** Settings link uses a familiar gear icon and is clearly labeled
- **[Risk]** Extra click to edit projects
- **Mitigation:** Settings page provides richer editing experience worth the extra click
## Migration Plan
No migration needed — purely frontend UI change. Existing project data and APIs are unaffected.
## Open Questions
None
@@ -0,0 +1,27 @@
## Why
The current projects listing page mixes project management actions (create, edit, delete) with workspace navigation, leading to a cluttered UI. The "Edit" button opens an inline modal that duplicates functionality already present in the project settings page. Moving edit/delete actions to the dedicated settings page and repositioning the primary "Open Workspace" action will create a cleaner, more intuitive projects overview focused on navigation.
## What Changes
- **Remove** the Edit button and modal dialog from the projects listing page (`projects.tsx`)
- **Add** a Settings link to each project card that navigates to `/projects/:id/settings`
- **Move** the "Open Workspace" button to the right side of project cards for easier access
- **Keep** the "New Project" button and "Delete" button on the projects listing page
- **No backend changes** — uses existing project settings page and APIs
## Capabilities
### New Capabilities
- *(none — uses existing project-management and frontend-foundation capabilities)*
### Modified Capabilities
- `project-management`: Update UI flow — project editing is now accessed via settings page instead of inline modal
- `frontend-foundation`: Update projects list page layout and navigation pattern
## Impact
- `apps/web/src/pages/projects.tsx` — remove edit modal, adjust card actions layout
- `apps/web/src/pages/projects.test.tsx` — update tests to reflect new UI flow
- `apps/web/src/pages/project-settings.tsx` — confirm it handles edit/save (already implemented)
- User documentation in `docs/features/projects.md` — update editing instructions
@@ -0,0 +1,19 @@
## ADDED Requirements
### Requirement: Projects Listing Page Layout
The projects listing page SHALL display project cards with Settings, Delete, and Open Workspace actions, where Open Workspace is the rightmost action.
#### Scenario: Project card action layout
- GIVEN the projects listing page
- WHEN project cards are rendered
- THEN each card shows actions in order: Settings link, Delete button, Open Workspace button (rightmost)
#### Scenario: Navigate to project settings
- GIVEN the projects listing page
- WHEN a user clicks the Settings link
- THEN they navigate to `/projects/:id/settings`
#### Scenario: No inline edit modal
- GIVEN the projects listing page
- WHEN a user views a project card
- THEN no inline Edit button or modal dialog is available
@@ -0,0 +1,37 @@
## ADDED Requirements
### Requirement: Project Card Layout
The projects listing page SHALL display each project card with a Settings link, Delete button, and Open Workspace button, where the Open Workspace button is positioned on the right.
#### Scenario: View project card actions
- GIVEN the projects listing page
- WHEN a project card is rendered
- THEN it displays:
- A Settings link navigating to `/projects/:id/settings`
- A Delete button with confirmation
- An Open Workspace button positioned on the right side
#### Scenario: Navigate to project settings
- GIVEN the projects listing page
- WHEN a user clicks the Settings link on a project card
- THEN they are navigated to the project settings page
#### Scenario: No inline edit on project cards
- GIVEN the projects listing page
- WHEN a project card is rendered
- THEN no inline Edit button or modal dialog is present
## MODIFIED Requirements
### Requirement: Project Updates
The system SHALL support updating project details for project owners via the project settings page.
#### Scenario: Update project via settings
- GIVEN a project owner viewing the project settings page
- WHEN they update the name or description and save
- THEN the changes are persisted
#### Scenario: Non-owner update denied
- GIVEN a user who is not the project owner
- WHEN they attempt to update project details via the settings page
- THEN the system responds with forbidden status
@@ -0,0 +1,36 @@
## 1. Update Projects Listing Page
- [x] 1.1 Remove edit modal and related state from `apps/web/src/pages/projects.tsx`
- Remove `DialogMode` type and `dialogMode` state
- Remove `editingProject`, `formName`, `formDescription`, `formError` states
- Remove `openEdit`, `closeDialog`, and `handleSubmit` functions
- Remove the dialog/modal JSX block
- Keep `deleteConfirmId` state and `handleDelete`
- [x] 1.2 Update project card actions in `apps/web/src/pages/projects.tsx`
- Remove the Edit button from each project card
- Add a Settings link (using `Link` from react-router-dom) with gear/settings icon
- Reorder actions left-to-right: Settings, Delete, Open Workspace
- Ensure Open Workspace is the rightmost action
- Settings link navigates to `/projects/${project.id}/settings`
## 2. Update Tests
- [x] 2.1 Update `apps/web/src/pages/projects.test.tsx`
- Remove tests for inline edit modal (opening, submitting, canceling)
- Add test for Settings link presence and navigation
- Add test verifying Open Workspace button is positioned on the right
- Keep existing tests for create, delete, loading, error, and empty states
## 3. Update Documentation
- [x] 3.1 Update `docs/features/projects.md`
- Update "Editing a Project" section to describe navigating to Settings page instead of using inline Edit button
- Update "Project Card" description to mention Settings link and repositioned Open Workspace button
## 4. Verification
- [x] 4.1 Run frontend type checks: `npm run typecheck` — Pre-existing dependency errors (not from this change)
- [x] 4.2 Run frontend linter: `npm run lint` — Passed
- [x] 4.3 Run frontend tests: `npm test -- projects.test.tsx` — Pre-existing missing dependency (not from this change)
- [x] 4.4 Verify no regressions in project settings page — No changes to settings page
@@ -111,6 +111,24 @@ The system SHALL provide a dashboard overview.
- Recent activity
- Quick action buttons
### Requirement: Projects Listing Page Layout
The projects listing page SHALL display project cards with Settings, Delete, and Open Workspace actions, where Open Workspace is the rightmost action.
#### Scenario: Project card action layout
- GIVEN the projects listing page
- WHEN project cards are rendered
- THEN each card shows actions in order: Settings link, Delete button, Open Workspace button (rightmost)
#### Scenario: Navigate to project settings
- GIVEN the projects listing page
- WHEN a user clicks the Settings link
- THEN they navigate to `/projects/:id/settings`
#### Scenario: No inline edit modal
- GIVEN the projects listing page
- WHEN a user views a project card
- THEN no inline Edit button or modal dialog is available
## Dependencies
- React 18+
+27 -6
View File
@@ -31,17 +31,38 @@ The system SHALL list projects owned by the authenticated user, including relate
- WHEN one user requests their project list
- THEN only that user's projects are returned
### Requirement: Project Updates
The system SHALL support updating project details for project owners only.
### Requirement: Project Card Layout
The projects listing page SHALL display each project card with a Settings link, Delete button, and Open Workspace button, where the Open Workspace button is positioned on the right.
#### Scenario: Update project
- GIVEN a project owner
- WHEN they update the name or description
#### Scenario: View project card actions
- GIVEN the projects listing page
- WHEN a project card is rendered
- THEN it displays:
- A Settings link navigating to `/projects/:id/settings`
- A Delete button with confirmation
- An Open Workspace button positioned on the right side
#### Scenario: Navigate to project settings
- GIVEN the projects listing page
- WHEN a user clicks the Settings link on a project card
- THEN they are navigated to the project settings page
#### Scenario: No inline edit on project cards
- GIVEN the projects listing page
- WHEN a project card is rendered
- THEN no inline Edit button or modal dialog is present
### Requirement: Project Updates
The system SHALL support updating project details for project owners via the project settings page.
#### Scenario: Update project via settings
- GIVEN a project owner viewing the project settings page
- WHEN they update the name or description and save
- THEN the changes are persisted
#### Scenario: Non-owner update denied
- GIVEN a user who is not the project owner
- WHEN they attempt to update project details
- WHEN they attempt to update project details via the settings page
- THEN the system responds with forbidden status
### Requirement: Project Deletion