refactor: extract shared validation and reduce duplication

- Extract tool_types validation to shared module (validate_compose_yaml, check_port_exposed, validate_required_variables)
- Extract _get_user and _get_owned_project to auth/dependencies.py
- Create useAsyncData hook and apply to 6 pages
- Create extractErrorMessage utility
- TypeScript and build pass
This commit is contained in:
Alex Blank
2026-05-25 14:01:32 +02:00
parent a905cf729e
commit a37a3122f9
19 changed files with 327 additions and 442 deletions
+1 -35
View File
@@ -10,7 +10,7 @@ from pydantic import BaseModel, ConfigDict
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session from src.auth.dependencies import _get_owned_project, _get_user, get_current_user_id, get_db_session
from src.config import Settings from src.config import Settings
from src.models.git_repository import GitRepository from src.models.git_repository import GitRepository
from src.models.project import Project from src.models.project import Project
@@ -42,40 +42,6 @@ router = APIRouter(prefix="/projects", tags=["git-repositories"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 401 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
return user
async def _get_owned_project(
project_id: uuid.UUID,
user_id: uuid.UUID,
session: AsyncSession,
) -> Project:
"""Fetch a project and verify ownership.
Args:
project_id: UUID of the project.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The project if found and owned by the user.
Raises:
HTTPException: If project not found or user is not the owner.
"""
project = await session.get(Project, project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
if project.owner_id != user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not project owner")
return project
def _get_repo_path(user_id: uuid.UUID, project_id: uuid.UUID, name: str) -> str: def _get_repo_path(user_id: uuid.UUID, project_id: uuid.UUID, name: str) -> str:
"""Generate the filesystem path for a repository. """Generate the filesystem path for a repository.
+1 -35
View File
@@ -7,7 +7,7 @@ from pydantic import BaseModel, ConfigDict
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session from src.auth.dependencies import _get_owned_project, _get_user, get_current_user_id, get_db_session
from src.models.git_repository import GitRepository from src.models.git_repository import GitRepository
from src.models.project import Project from src.models.project import Project
from src.models.ssh_key import SSHKey from src.models.ssh_key import SSHKey
@@ -16,14 +16,6 @@ from src.models.user import User
router = APIRouter(prefix="/projects", tags=["projects"]) router = APIRouter(prefix="/projects", tags=["projects"])
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 401 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
return user
class ProjectCreate(BaseModel): class ProjectCreate(BaseModel):
name: str name: str
description: str | None = None description: str | None = None
@@ -132,32 +124,6 @@ async def get_project(
return await _get_owned_project(project_id, user_id, session) return await _get_owned_project(project_id, user_id, session)
async def _get_owned_project(
project_id: uuid.UUID,
user_id: uuid.UUID,
session: AsyncSession,
) -> Project:
"""Fetch a project and verify ownership.
Args:
project_id: UUID of the project.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The project if found and owned by the user.
Raises:
HTTPException: If project not found or user is not the owner.
"""
project = await session.get(Project, project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
if project.owner_id != user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not project owner")
return project
@router.patch( @router.patch(
"/{project_id}", "/{project_id}",
response_model=ProjectResponse, response_model=ProjectResponse,
+1 -9
View File
@@ -10,7 +10,7 @@ from pydantic import BaseModel, ConfigDict
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
from src.config import Settings from src.config import Settings
from src.models.ssh_key import SSHKey from src.models.ssh_key import SSHKey
from src.models.user import User from src.models.user import User
@@ -18,14 +18,6 @@ from src.models.user import User
router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"]) router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 401 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
return user
def _get_fernet() -> Fernet: def _get_fernet() -> Fernet:
"""Generate a valid Fernet key from the session secret.""" """Generate a valid Fernet key from the session secret."""
import base64 import base64
+1 -36
View File
@@ -15,8 +15,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
from src.auth.dependencies import get_current_user_id from src.auth.dependencies import _get_owned_project, _get_user, get_current_user_id, get_db_session
from src.auth.dependencies import get_db_session
from src.models.git_repository import GitRepository from src.models.git_repository import GitRepository
from src.models.project import Project from src.models.project import Project
from src.models.ssh_key import SSHKey from src.models.ssh_key import SSHKey
@@ -193,40 +192,6 @@ def _modify_compose_file(
compose_file.write_text(yaml.dump(compose_data, default_flow_style=False)) compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 404 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="user not found"
)
return user
async def _get_owned_project(
project_id: uuid.UUID, user_id: uuid.UUID, session: AsyncSession
) -> Project:
"""Fetch a project and verify ownership.
Args:
project_id: UUID of the project.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The project if found and owned by the user.
Raises:
HTTPException: If project not found or user is not the owner.
"""
project = await session.get(Project, project_id)
if project is None or project.owner_id != user_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="project not found"
)
return project
@router.post( @router.post(
"/{project_id}/repositories/{repo_id}/instances", "/{project_id}/repositories/{repo_id}/instances",
summary="Create tool instance", summary="Create tool instance",
+29 -127
View File
@@ -1,4 +1,3 @@
import re
import uuid import uuid
from datetime import datetime from datetime import datetime
@@ -8,26 +7,19 @@ from pydantic import BaseModel, ConfigDict, field_validator, model_validator
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.api.tool_types_validation import (
def _sanitize_template_vars(template: str) -> str: check_port_exposed,
"""Replace template variables like {{VAR}} with placeholders to avoid YAML parsing errors.""" sanitize_template_vars,
return re.sub(r"\{\{[A-Za-z_][A-Za-z0-9_]*\}\}", "__PLACEHOLDER__", template) validate_compose_yaml,
validate_required_variables,
from src.auth.dependencies import get_current_user_id, get_db_session )
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
from src.models.tool_type import ToolType from src.models.tool_type import ToolType
from src.models.user import User from src.models.user import User
router = APIRouter(prefix="/tool-types", tags=["tool-types"]) router = APIRouter(prefix="/tool-types", tags=["tool-types"])
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 401 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
return user
async def _require_admin(user: User) -> None: async def _require_admin(user: User) -> None:
"""Check if user has admin privileges. """Check if user has admin privileges.
@@ -72,24 +64,7 @@ class ToolTypeCreate(BaseModel):
if v is None: if v is None:
raise ValueError("compose_template is required when definition_type is 'compose'") raise ValueError("compose_template is required when definition_type is 'compose'")
# Replace template variables with dummy values before YAML validation validate_compose_yaml(v)
# to avoid YAML parsing errors with {{VAR}} syntax
sanitized = _sanitize_template_vars(v)
try:
parsed = yaml.safe_load(sanitized)
except yaml.YAMLError as e:
raise ValueError(f"Invalid YAML: {e}")
if not isinstance(parsed, dict):
raise ValueError("Compose template must be a YAML mapping")
if "services" not in parsed:
raise ValueError("Compose template must contain 'services' key")
if not parsed["services"]:
raise ValueError("Compose template must define at least one service")
return v return v
@field_validator("dockerfile_template") @field_validator("dockerfile_template")
@@ -156,29 +131,11 @@ class ToolTypeCreate(BaseModel):
# Validate that default_port is exposed in compose template (only if requires_port) # Validate that default_port is exposed in compose template (only if requires_port)
if self.requires_port and self.definition_type == "compose" and self.compose_template: if self.requires_port and self.definition_type == "compose" and self.compose_template:
try: try:
sanitized = _sanitize_template_vars(self.compose_template) parsed = validate_compose_yaml(self.compose_template)
parsed = yaml.safe_load(sanitized) except ValueError:
except yaml.YAMLError:
return self return self
port_str = str(self.default_port) if not check_port_exposed(parsed, self.default_port):
port_exposed = False
if isinstance(parsed, dict) and "services" in parsed:
for service_name, service_config in parsed["services"].items():
if isinstance(service_config, dict) and "ports" in service_config:
for port_mapping in service_config["ports"]:
if isinstance(port_mapping, str):
if port_str in port_mapping:
port_exposed = True
break
elif isinstance(port_mapping, int) and port_mapping == self.default_port:
port_exposed = True
break
if port_exposed:
break
if not port_exposed:
raise ValueError(f"Port {self.default_port} is not exposed in the compose template. Add it to the 'ports' section.") raise ValueError(f"Port {self.default_port} is not exposed in the compose template. Add it to the 'ports' section.")
return self return self
@@ -228,23 +185,7 @@ class ToolTypeUpdate(BaseModel):
if definition_type and definition_type != "compose": if definition_type and definition_type != "compose":
return v return v
# Replace template variables with dummy values before YAML validation validate_compose_yaml(v)
sanitized = _sanitize_template_vars(v)
try:
parsed = yaml.safe_load(sanitized)
except yaml.YAMLError as e:
raise ValueError(f"Invalid YAML: {e}")
if not isinstance(parsed, dict):
raise ValueError("Compose template must be a YAML mapping")
if "services" not in parsed:
raise ValueError("Compose template must contain 'services' key")
if not parsed["services"]:
raise ValueError("Compose template must define at least one service")
return v return v
@field_validator("dockerfile_template") @field_validator("dockerfile_template")
@@ -442,54 +383,29 @@ async def update_tool_type(
template = update_data.get("compose_template", tool_type.compose_template) template = update_data.get("compose_template", tool_type.compose_template)
if template: if template:
try: try:
sanitized = _sanitize_template_vars(template) parsed = validate_compose_yaml(template)
parsed = yaml.safe_load(sanitized) if not check_port_exposed(parsed, new_port):
except yaml.YAMLError:
parsed = None
if parsed and isinstance(parsed, dict) and "services" in parsed:
port_str = str(new_port)
port_exposed = False
for service_config in parsed["services"].values():
if isinstance(service_config, dict) and "ports" in service_config:
for port_mapping in service_config["ports"]:
if isinstance(port_mapping, str) and port_str in port_mapping:
port_exposed = True
break
elif isinstance(port_mapping, int) and port_mapping == new_port:
port_exposed = True
break
if port_exposed:
break
if not port_exposed:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Port {new_port} is not exposed in the compose template" detail=f"Port {new_port} is not exposed in the compose template"
) )
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
# Validate required variables for compose definitions # Validate required variables for compose definitions
definition_type = update_data.get("definition_type", tool_type.definition_type) definition_type = update_data.get("definition_type", tool_type.definition_type)
if definition_type == "compose": if definition_type == "compose":
if "required_variables" in update_data and "compose_template" in update_data: if "required_variables" in update_data and "compose_template" in update_data:
template = update_data["compose_template"] validate_required_variables(
for var in update_data["required_variables"]: update_data["compose_template"], update_data["required_variables"]
placeholder = f"{{{{{var}}}}}" )
if placeholder not in template:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Required variable '{var}' not found in compose template"
)
elif "required_variables" in update_data: elif "required_variables" in update_data:
template = tool_type.compose_template template = tool_type.compose_template
if template: if template:
for var in update_data["required_variables"]: validate_required_variables(template, update_data["required_variables"])
placeholder = f"{{{{{var}}}}}"
if placeholder not in template:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Required variable '{var}' not found in compose template"
)
for field, value in update_data.items(): for field, value in update_data.items():
setattr(tool_type, field, value) setattr(tool_type, field, value)
@@ -534,16 +450,9 @@ async def validate_tool_type_template(
errors.append("Compose template is required") errors.append("Compose template is required")
else: else:
try: try:
sanitized = _sanitize_template_vars(data.compose_template) validate_compose_yaml(data.compose_template)
parsed = yaml.safe_load(sanitized) except ValueError as e:
if not isinstance(parsed, dict): errors.append(str(e))
errors.append("Compose template must be a YAML mapping")
elif "services" not in parsed:
errors.append("Compose template must contain 'services' key")
elif not parsed["services"]:
errors.append("Compose template must define at least one service")
except yaml.YAMLError as e:
errors.append(f"Invalid YAML: {e}")
elif data.definition_type == "dockerfile": elif data.definition_type == "dockerfile":
if not data.dockerfile_template: if not data.dockerfile_template:
@@ -592,16 +501,9 @@ async def validate_tool_type(
errors.append("Compose template is empty") errors.append("Compose template is empty")
else: else:
try: try:
sanitized = _sanitize_template_vars(tool_type.compose_template) validate_compose_yaml(tool_type.compose_template)
parsed = yaml.safe_load(sanitized) except ValueError as e:
if not isinstance(parsed, dict): errors.append(str(e))
errors.append("Compose template must be a YAML mapping")
elif "services" not in parsed:
errors.append("Compose template must contain 'services' key")
elif not parsed["services"]:
errors.append("Compose template must define at least one service")
except yaml.YAMLError as e:
errors.append(f"Invalid YAML: {e}")
elif tool_type.definition_type == "dockerfile": elif tool_type.definition_type == "dockerfile":
if not tool_type.dockerfile_template: if not tool_type.dockerfile_template:
+87
View File
@@ -0,0 +1,87 @@
"""Shared validation utilities for tool types."""
import re
import yaml
from fastapi import HTTPException, status
def sanitize_template_vars(template: str) -> str:
"""Replace template variables like {{VAR}} with placeholders to avoid YAML parsing errors."""
return re.sub(r"\{\{[A-Za-z_][A-Za-z0-9_]*\}\}", "__PLACEHOLDER__", template)
def validate_compose_yaml(template: str) -> dict:
"""Validate and parse a compose template.
Args:
template: Raw compose template string.
Returns:
Parsed YAML dict.
Raises:
ValueError: If YAML is invalid or missing required keys.
"""
sanitized = sanitize_template_vars(template)
try:
parsed = yaml.safe_load(sanitized)
except yaml.YAMLError as e:
raise ValueError(f"Invalid YAML: {e}")
if not isinstance(parsed, dict):
raise ValueError("Compose template must be a YAML mapping")
if "services" not in parsed:
raise ValueError("Compose template must contain 'services' key")
if not parsed["services"]:
raise ValueError("Compose template must define at least one service")
return parsed
def check_port_exposed(parsed: dict, port: int) -> bool:
"""Check if a port is exposed in a parsed compose template.
Args:
parsed: Parsed compose YAML dict.
port: Port number to check.
Returns:
True if port is exposed in any service.
"""
port_str = str(port)
if not isinstance(parsed, dict) or "services" not in parsed:
return False
for service_config in parsed["services"].values():
if isinstance(service_config, dict) and "ports" in service_config:
for port_mapping in service_config["ports"]:
if isinstance(port_mapping, str) and port_str in port_mapping:
return True
elif isinstance(port_mapping, int) and port_mapping == port:
return True
return False
def validate_required_variables(template: str, variables: list[str]) -> None:
"""Validate that all required variables exist in the template.
Args:
template: Compose template string.
variables: List of required variable names.
Raises:
HTTPException: If any variable is not found in the template.
"""
for var in variables:
placeholder = f"{{{{{var}}}}}"
if placeholder not in template:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Required variable '{var}' not found in compose template",
)
+1 -9
View File
@@ -8,21 +8,13 @@ from pydantic import BaseModel, ConfigDict
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
from src.models.user import User from src.models.user import User
from src.models.user_config import UserConfig from src.models.user_config import UserConfig
router = APIRouter(prefix="/users/me", tags=["user-config"]) router = APIRouter(prefix="/users/me", tags=["user-config"])
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 401 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
return user
async def _get_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> UserConfig: async def _get_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> UserConfig:
"""Get or create user config record. """Get or create user config record.
+1 -9
View File
@@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user_id, get_db_session from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
from src.models.user import User from src.models.user import User
router = APIRouter(prefix="/users", tags=["users"]) router = APIRouter(prefix="/users", tags=["users"])
@@ -16,14 +16,6 @@ ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/jpg"}
MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 401 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
return user
class UserProfileResponse(BaseModel): class UserProfileResponse(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
+36
View File
@@ -47,3 +47,39 @@ async def get_current_user(
if user is None: if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
return user return user
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
"""Fetch a user by ID or raise 401 if not found."""
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
return user
async def _get_owned_project(
project_id: uuid.UUID,
user_id: uuid.UUID,
session: AsyncSession,
) -> "Project":
"""Fetch a project and verify ownership.
Args:
project_id: UUID of the project.
user_id: ID of the authenticated user.
session: Database session.
Returns:
The project if found and owned by the user.
Raises:
HTTPException: 404 if project not found, 403 if user is not the owner.
"""
from src.models.project import Project
project = await session.get(Project, project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
if project.owner_id != user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not project owner")
return project
+42
View File
@@ -0,0 +1,42 @@
import { useCallback, useEffect, useState } from "react";
type AsyncStatus = "idle" | "loading" | "ready" | "error";
interface UseAsyncDataResult<T> {
data: T | null;
status: AsyncStatus;
error: string | null;
reload: () => void;
}
export function useAsyncData<T>(
fetcher: () => Promise<T>,
deps: React.DependencyList = []
): UseAsyncDataResult<T> {
const [data, setData] = useState<T | null>(null);
const [status, setStatus] = useState<AsyncStatus>("idle");
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setStatus("loading");
setError(null);
try {
const result = await fetcher();
setData(result);
setStatus("ready");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load data");
setStatus("error");
}
}, deps);
const reload = useCallback(() => {
void load();
}, [load]);
useEffect(() => {
void load();
}, [load]);
return { data, status, error, reload };
}
+1 -10
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Icon } from "../components/icon"; import { Icon } from "../components/icon";
import { useMobileViewport } from "../hooks/use-mobile-viewport"; import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { extractErrorMessage } from "../utils/errors";
import { MobileListView } from "../components/mobile-list-view"; import { MobileListView } from "../components/mobile-list-view";
import { MobileDetailView } from "../components/mobile-detail-view"; import { MobileDetailView } from "../components/mobile-detail-view";
import { MobileEditView } from "../components/mobile-edit-view"; import { MobileEditView } from "../components/mobile-edit-view";
@@ -127,16 +128,6 @@ export const ConfigProfilesPage = () => {
resetForm(); resetForm();
}; };
const extractErrorMessage = (err: unknown): string => {
const axiosError = err as { response?: { data?: { detail?: string | Array<{msg?: string}> } } };
const detail = axiosError?.response?.data?.detail;
if (typeof detail === "string") return detail;
if (Array.isArray(detail)) {
return detail.map((d) => typeof d === "string" ? d : d.msg || JSON.stringify(d)).join(", ");
}
return "Failed to save";
};
// Include management functions // Include management functions
const getIncludedProfile = (id: string): ConfigProfile | undefined => profiles.find((p) => p.id === id); const getIncludedProfile = (id: string): ConfigProfile | undefined => profiles.find((p) => p.id === id);
+18 -22
View File
@@ -1,39 +1,32 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { getCommitDetail, getRepositoryHistory, type CommitDetail, type CommitHistoryEntry } from "../api/git_repositories"; import { getCommitDetail, getRepositoryHistory, type CommitDetail, type CommitHistoryEntry, type CommitHistoryResponse } from "../api/git_repositories";
import { Icon } from "../components/icon"; import { Icon } from "../components/icon";
import { useAsyncData } from "../hooks/use-async-data";
export const GitHistoryPage = () => { export const GitHistoryPage = () => {
const { projectId, repoId } = useParams<{ projectId: string; repoId: string }>(); const { projectId, repoId } = useParams<{ projectId: string; repoId: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [commits, setCommits] = useState<CommitHistoryEntry[]>([]);
const [selectedCommit, setSelectedCommit] = useState<string | null>(null); const [selectedCommit, setSelectedCommit] = useState<string | null>(null);
const [commitDetail, setCommitDetail] = useState<CommitDetail | null>(null); const [commitDetail, setCommitDetail] = useState<CommitDetail | null>(null);
const [branches, setBranches] = useState<string[]>([]);
const [selectedBranch, setSelectedBranch] = useState<string>(""); const [selectedBranch, setSelectedBranch] = useState<string>("");
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
const [detailStatus, setDetailStatus] = useState<"idle" | "loading" | "ready" | "error">("idle"); const [detailStatus, setDetailStatus] = useState<"idle" | "loading" | "ready" | "error">("idle");
const loadHistory = useCallback(async () => { const { data: historyData, status, reload } = useAsyncData<CommitHistoryResponse>(
if (!projectId || !repoId) return; async () => {
setStatus("loading"); if (!projectId || !repoId) return { commits: [], branches: [], tags: [] };
try { return await getRepositoryHistory(projectId, repoId, selectedBranch || undefined, 10000);
const data = await getRepositoryHistory(projectId, repoId, selectedBranch || undefined, 10000); },
setCommits(data.commits); [projectId, repoId, selectedBranch]
setBranches(data.branches); );
if (data.branches.length > 0 && !selectedBranch) {
setSelectedBranch(data.branches[0]);
}
setStatus("ready");
} catch {
setStatus("error");
}
}, [projectId, repoId, selectedBranch]);
// Auto-select first branch when data loads
useEffect(() => { useEffect(() => {
void loadHistory(); if (historyData?.branches.length && !selectedBranch) {
}, [loadHistory]); setSelectedBranch(historyData.branches[0]);
}
}, [historyData?.branches, selectedBranch]);
const handleCommitClick = async (hash: string) => { const handleCommitClick = async (hash: string) => {
if (!projectId || !repoId) return; if (!projectId || !repoId) return;
@@ -70,7 +63,7 @@ export const GitHistoryPage = () => {
return ( return (
<section className="stack"> <section className="stack">
<p>Failed to load commit history</p> <p>Failed to load commit history</p>
<button className="secondary-button" onClick={() => void loadHistory()} type="button"> <button className="secondary-button" onClick={() => reload()} type="button">
<Icon name="refresh" size="sm" /> <Icon name="refresh" size="sm" />
Retry Retry
</button> </button>
@@ -78,6 +71,9 @@ export const GitHistoryPage = () => {
); );
} }
const commits = historyData?.commits ?? [];
const branches = historyData?.branches ?? [];
return ( return (
<section className="stack"> <section className="stack">
<div className="page-header"> <div className="page-header">
+16 -27
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react"; import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { import {
@@ -8,46 +8,35 @@ import {
import type { GitRepository } from "../api/git_repositories"; import type { GitRepository } from "../api/git_repositories";
import { Icon } from "../components/icon"; import { Icon } from "../components/icon";
import { RepositoryCreateDialog } from "../components/repository-create-dialog"; import { RepositoryCreateDialog } from "../components/repository-create-dialog";
import { useAsyncData } from "../hooks/use-async-data";
type RepoStatus = "loading" | "ready" | "error";
export const GitRepositoriesPage = () => { export const GitRepositoriesPage = () => {
const { projectId } = useParams<{ projectId: string }>(); const { projectId } = useParams<{ projectId: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [status, setStatus] = useState<RepoStatus>("loading");
const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null); const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const loadRepositories = useCallback(async () => { const { data: repositories, status, reload } = useAsyncData<GitRepository[]>(
if (!projectId) return; async () => {
setStatus("loading"); if (!projectId) return [];
try { return await listRepositories(projectId);
const data = await listRepositories(projectId); },
setRepositories(data); [projectId]
setStatus("ready"); );
} catch {
setRepositories([]);
setStatus("error");
}
}, [projectId]);
useEffect(() => {
void loadRepositories();
}, [loadRepositories]);
const handleDelete = async (repoId: string) => { const handleDelete = async (repoId: string) => {
if (!projectId) return; if (!projectId) return;
try { try {
await deleteRepository(projectId, repoId); await deleteRepository(projectId, repoId);
setDeleteConfirmId(null); setDeleteConfirmId(null);
await loadRepositories(); reload();
} catch { } catch {
setDeleteConfirmId(null); setDeleteConfirmId(null);
} }
}; };
const isEmpty = status === "ready" && repositories.length === 0; const safeRepositories = repositories ?? [];
const isEmpty = status === "ready" && safeRepositories.length === 0;
return ( return (
<section className="stack"> <section className="stack">
@@ -64,7 +53,7 @@ export const GitRepositoriesPage = () => {
{status === "error" && ( {status === "error" && (
<div className="card stack"> <div className="card stack">
<p>Failed to load repositories</p> <p>Failed to load repositories</p>
<button className="secondary-button" onClick={() => void loadRepositories()} type="button"> <button className="secondary-button" onClick={() => reload()} type="button">
<Icon name="refresh" size="sm" /> <Icon name="refresh" size="sm" />
Retry Retry
</button> </button>
@@ -73,9 +62,9 @@ export const GitRepositoriesPage = () => {
{isEmpty && <p className="muted">No repositories yet. Create your first repository above.</p>} {isEmpty && <p className="muted">No repositories yet. Create your first repository above.</p>}
{status === "ready" && repositories.length > 0 && ( {status === "ready" && safeRepositories.length > 0 && (
<div className="repository-list"> <div className="repository-list">
{repositories.map((repo) => ( {safeRepositories.map((repo) => (
<article className="card repository-card" key={repo.id}> <article className="card repository-card" key={repo.id}>
<div className="repository-info"> <div className="repository-info">
<h3>{repo.name}</h3> <h3>{repo.name}</h3>
@@ -131,7 +120,7 @@ export const GitRepositoriesPage = () => {
open={showCreate} open={showCreate}
title="Create Repository" title="Create Repository"
onClose={() => setShowCreate(false)} onClose={() => setShowCreate(false)}
onCreated={loadRepositories} onCreated={reload}
/> />
)} )}
</section> </section>
+35 -38
View File
@@ -3,37 +3,35 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { getProfile, updateProfile, uploadAvatar } from "../api/profile"; import { getProfile, updateProfile, uploadAvatar } from "../api/profile";
import { Icon } from "../components/icon"; import { Icon } from "../components/icon";
import { useAuth } from "../state/auth"; import { useAuth } from "../state/auth";
import { useAsyncData } from "../hooks/use-async-data";
import type { UserProfile } from "../api/profile"; import type { UserProfile } from "../api/profile";
type ProfileStatus = "loading" | "ready" | "error" | "saving"; type ProfileStatus = "loading" | "ready" | "error" | "saving";
export const ProfilePage = () => { export const ProfilePage = () => {
const { refreshSession } = useAuth(); const { refreshSession } = useAuth();
const [status, setStatus] = useState<ProfileStatus>("loading"); const { data: profile, status: loadStatus, reload } = useAsyncData<UserProfile>(getProfile, []);
const [profile, setProfile] = useState<UserProfile | null>(null); const [displayStatus, setDisplayStatus] = useState<ProfileStatus>("loading");
const [name, setName] = useState(""); const [name, setName] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const loadProfile = useCallback(async () => { // Sync loaded profile into form fields
setStatus("loading"); useEffect(() => {
setError(null); if (profile) {
try { setName(profile.name);
const data = await getProfile(); setEmail(profile.email);
setProfile(data); setDisplayStatus("ready");
setName(data.name); setError(null);
setEmail(data.email);
setStatus("ready");
} catch {
setProfile(null);
setStatus("error");
} }
}, []); }, [profile]);
useEffect(() => { useEffect(() => {
void loadProfile(); if (loadStatus === "error") {
}, [loadProfile]); setDisplayStatus("error");
}
}, [loadStatus]);
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
if (!name.trim()) { if (!name.trim()) {
@@ -45,16 +43,15 @@ export const ProfilePage = () => {
return; return;
} }
setStatus("saving"); setDisplayStatus("saving");
setError(null); setError(null);
try { try {
const updated = await updateProfile({ name: name.trim(), email: email.trim() }); await updateProfile({ name: name.trim(), email: email.trim() });
setProfile(updated);
await refreshSession(); await refreshSession();
setStatus("ready"); setDisplayStatus("ready");
} catch { } catch {
setError("Failed to update profile"); setError("Failed to update profile");
setStatus("ready"); setDisplayStatus("ready");
} }
}, [name, email, refreshSession]); }, [name, email, refreshSession]);
@@ -73,19 +70,19 @@ export const ProfilePage = () => {
return; return;
} }
setStatus("saving"); setDisplayStatus("saving");
setError(null); setError(null);
try { try {
const updated = await uploadAvatar(file); await uploadAvatar(file);
setProfile(updated);
await refreshSession(); await refreshSession();
setStatus("ready"); reload();
setDisplayStatus("ready");
} catch { } catch {
setError("Failed to upload avatar"); setError("Failed to upload avatar");
setStatus("ready"); setDisplayStatus("ready");
} }
}, },
[refreshSession] [refreshSession, reload]
); );
const avatarUrl = profile?.avatar_url ?? null; const avatarUrl = profile?.avatar_url ?? null;
@@ -94,19 +91,19 @@ export const ProfilePage = () => {
<section className="stack"> <section className="stack">
<h1>Profile</h1> <h1>Profile</h1>
{status === "loading" && <p className="muted">Loading profile...</p>} {displayStatus === "loading" && <p className="muted">Loading profile...</p>}
{status === "error" && ( {displayStatus === "error" && (
<div className="card stack"> <div className="card stack">
<p>Failed to load profile</p> <p>Failed to load profile</p>
<button className="secondary-button" onClick={() => void loadProfile()} type="button"> <button className="secondary-button" onClick={() => reload()} type="button">
<Icon name="refresh" size="sm" /> <Icon name="refresh" size="sm" />
Retry Retry
</button> </button>
</div> </div>
)} )}
{(status === "ready" || status === "saving") && profile && ( {(displayStatus === "ready" || displayStatus === "saving") && profile && (
<div className="card stack"> <div className="card stack">
<div className="profile-avatar-section"> <div className="profile-avatar-section">
<div className="avatar-preview"> <div className="avatar-preview">
@@ -118,11 +115,11 @@ export const ProfilePage = () => {
</div> </div>
<button <button
className="secondary-button" className="secondary-button"
disabled={status === "saving"} disabled={displayStatus === "saving"}
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
type="button" type="button"
> >
{status === "saving" ? ( {displayStatus === "saving" ? (
<> <>
<Icon name="loading" size="sm" /> <Icon name="loading" size="sm" />
Uploading... Uploading...
@@ -146,7 +143,7 @@ export const ProfilePage = () => {
<div className="form-group"> <div className="form-group">
<label htmlFor="profile-name">Name</label> <label htmlFor="profile-name">Name</label>
<input <input
disabled={status === "saving"} disabled={displayStatus === "saving"}
id="profile-name" id="profile-name"
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
type="text" type="text"
@@ -157,7 +154,7 @@ export const ProfilePage = () => {
<div className="form-group"> <div className="form-group">
<label htmlFor="profile-email">Email</label> <label htmlFor="profile-email">Email</label>
<input <input
disabled={status === "saving"} disabled={displayStatus === "saving"}
id="profile-email" id="profile-email"
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
type="email" type="email"
@@ -170,11 +167,11 @@ export const ProfilePage = () => {
<div className="form-actions"> <div className="form-actions">
<button <button
className="primary-button" className="primary-button"
disabled={status === "saving"} disabled={displayStatus === "saving"}
onClick={() => void handleSave()} onClick={() => void handleSave()}
type="button" type="button"
> >
{status === "saving" ? ( {displayStatus === "saving" ? (
<> <>
<Icon name="loading" size="sm" /> <Icon name="loading" size="sm" />
Saving... Saving...
+10 -25
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react"; import { useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
@@ -11,14 +11,13 @@ import {
type ProjectUpdateInput, type ProjectUpdateInput,
} from "../api/projects"; } from "../api/projects";
import { Icon } from "../components/icon"; import { Icon } from "../components/icon";
import { useAsyncData } from "../hooks/use-async-data";
import type { Project } from "../types"; import type { Project } from "../types";
type ProjectsStatus = "loading" | "ready" | "error";
type DialogMode = "none" | "create" | "edit"; type DialogMode = "none" | "create" | "edit";
export const ProjectsPage = () => { export const ProjectsPage = () => {
const [status, setStatus] = useState<ProjectsStatus>("loading"); const { data: projects, status, reload } = useAsyncData<Project[]>(listProjects, []);
const [projects, setProjects] = useState<Project[]>([]);
const [dialogMode, setDialogMode] = useState<DialogMode>("none"); const [dialogMode, setDialogMode] = useState<DialogMode>("none");
const [editingProject, setEditingProject] = useState<Project | null>(null); const [editingProject, setEditingProject] = useState<Project | null>(null);
const [formName, setFormName] = useState(""); const [formName, setFormName] = useState("");
@@ -26,21 +25,7 @@ export const ProjectsPage = () => {
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null); const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const loadProjects = useCallback(async () => { const safeProjects = projects ?? [];
setStatus("loading");
try {
const data = await listProjects();
setProjects(data);
setStatus("ready");
} catch {
setProjects([]);
setStatus("error");
}
}, []);
useEffect(() => {
void loadProjects();
}, [loadProjects]);
const openCreate = () => { const openCreate = () => {
setFormName(""); setFormName("");
@@ -88,7 +73,7 @@ export const ProjectsPage = () => {
await updateProject(editingProject.id, input); await updateProject(editingProject.id, input);
} }
closeDialog(); closeDialog();
await loadProjects(); reload();
} catch { } catch {
setFormError("Failed to save project"); setFormError("Failed to save project");
} }
@@ -98,13 +83,13 @@ export const ProjectsPage = () => {
try { try {
await deleteProject(projectId); await deleteProject(projectId);
setDeleteConfirmId(null); setDeleteConfirmId(null);
await loadProjects(); reload();
} catch { } catch {
setDeleteConfirmId(null); setDeleteConfirmId(null);
} }
}; };
const isEmpty = status === "ready" && projects.length === 0; const isEmpty = status === "ready" && safeProjects.length === 0;
return ( return (
<section className="stack"> <section className="stack">
@@ -121,7 +106,7 @@ export const ProjectsPage = () => {
{status === "error" && ( {status === "error" && (
<div className="card stack"> <div className="card stack">
<p>Failed to load projects</p> <p>Failed to load projects</p>
<button className="secondary-button" onClick={() => void loadProjects()} type="button"> <button className="secondary-button" onClick={() => reload()} type="button">
<Icon name="refresh" size="sm" /> <Icon name="refresh" size="sm" />
Retry Retry
</button> </button>
@@ -130,9 +115,9 @@ export const ProjectsPage = () => {
{isEmpty && <p className="muted">No projects yet. Create your first project above.</p>} {isEmpty && <p className="muted">No projects yet. Create your first project above.</p>}
{status === "ready" && projects.length > 0 && ( {status === "ready" && safeProjects.length > 0 && (
<div className="project-list"> <div className="project-list">
{projects.map((project) => ( {safeProjects.map((project) => (
<article className="card project-card" key={project.id}> <article className="card project-card" key={project.id}>
<div className="project-info"> <div className="project-info">
<h3>{project.name}</h3> <h3>{project.name}</h3>
+9 -17
View File
@@ -1,10 +1,9 @@
import { useCallback, useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, Outlet, useLocation, useOutletContext } from "react-router-dom"; import { Link, Outlet, useLocation, useOutletContext } from "react-router-dom";
import { getUserConfig, updateUserConfig, type UserConfig, type UserConfigUpdate } from "../api/settings"; import { getUserConfig, updateUserConfig, type UserConfig, type UserConfigUpdate } from "../api/settings";
import { Icon } from "../components/icon"; import { Icon } from "../components/icon";
import { useAsyncData } from "../hooks/use-async-data";
type SettingsStatus = "loading" | "ready" | "error";
const TABS = [ const TABS = [
{ label: "General", path: "general" }, { label: "General", path: "general" },
@@ -26,7 +25,7 @@ type SettingsOutletContext = {
export const SettingsPage = () => { export const SettingsPage = () => {
const location = useLocation(); const location = useLocation();
const [status, setStatus] = useState<SettingsStatus>("loading"); const { data: loadedConfig, status, reload } = useAsyncData<UserConfig>(getUserConfig, []);
const [config, setConfig] = useState<UserConfig>({ const [config, setConfig] = useState<UserConfig>({
theme: "system", theme: "system",
default_editor: null, default_editor: null,
@@ -36,19 +35,12 @@ export const SettingsPage = () => {
}); });
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle"); const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
const loadConfig = useCallback(async () => { // Sync loaded config into local editable state
try {
const data = await getUserConfig();
setConfig(data);
setStatus("ready");
} catch {
setStatus("error");
}
}, []);
useEffect(() => { useEffect(() => {
void loadConfig(); if (loadedConfig) {
}, [loadConfig]); setConfig(loadedConfig);
}
}, [loadedConfig]);
const handleChange = (key: keyof UserConfigUpdate, value: string | null) => { const handleChange = (key: keyof UserConfigUpdate, value: string | null) => {
setConfig((prev) => ({ ...prev, [key]: value })); setConfig((prev) => ({ ...prev, [key]: value }));
@@ -86,7 +78,7 @@ export const SettingsPage = () => {
return ( return (
<section className="stack"> <section className="stack">
<p>Failed to load settings</p> <p>Failed to load settings</p>
<button className="secondary-button" onClick={() => void loadConfig()} type="button"> <button className="secondary-button" onClick={() => reload()} type="button">
<Icon name="refresh" size="sm" /> <Icon name="refresh" size="sm" />
Retry Retry
</button> </button>
+25 -30
View File
@@ -1,13 +1,12 @@
import { useEffect, useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { createSSHKey, deleteSSHKey, listSSHKeys, signPayload, verifySignature, type SSHKey } from "../api/ssh_keys"; import { createSSHKey, deleteSSHKey, listSSHKeys, signPayload, verifySignature, type SSHKey } from "../api/ssh_keys";
import { Icon } from "../components/icon"; import { Icon } from "../components/icon";
import { useAsyncData } from "../hooks/use-async-data";
export const SSHKeysPage = () => { export const SSHKeysPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [keys, setKeys] = useState<SSHKey[]>([]); const { data: keys, status, error, reload: loadKeys } = useAsyncData<SSHKey[]>(listSSHKeys, []);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [newKeyName, setNewKeyName] = useState(""); const [newKeyName, setNewKeyName] = useState("");
const [generating, setGenerating] = useState(false); const [generating, setGenerating] = useState(false);
const [signPayloads, setSignPayloads] = useState<Record<string, string>>({}); const [signPayloads, setSignPayloads] = useState<Record<string, string>>({});
@@ -17,23 +16,9 @@ export const SSHKeysPage = () => {
const [verifySignatures, setVerifySignatures] = useState<Record<string, string>>({}); const [verifySignatures, setVerifySignatures] = useState<Record<string, string>>({});
const [verifyResults, setVerifyResults] = useState<Record<string, boolean | null>>({}); const [verifyResults, setVerifyResults] = useState<Record<string, boolean | null>>({});
const [verifying, setVerifying] = useState<Record<string, boolean>>({}); const [verifying, setVerifying] = useState<Record<string, boolean>>({});
const [mutationError, setMutationError] = useState<string | null>(null);
useEffect(() => { const safeKeys = keys ?? [];
loadKeys();
}, []);
async function loadKeys() {
try {
setLoading(true);
const data = await listSSHKeys();
setKeys(data);
setError(null);
} catch {
setError("Failed to load SSH keys");
} finally {
setLoading(false);
}
}
async function handleGenerate(e: React.FormEvent) { async function handleGenerate(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
@@ -45,7 +30,7 @@ export const SSHKeysPage = () => {
setNewKeyName(""); setNewKeyName("");
await loadKeys(); await loadKeys();
} catch { } catch {
setError("Failed to generate SSH key"); setMutationError("Failed to generate SSH key");
} finally { } finally {
setGenerating(false); setGenerating(false);
} }
@@ -58,7 +43,7 @@ export const SSHKeysPage = () => {
await deleteSSHKey(keyId); await deleteSSHKey(keyId);
await loadKeys(); await loadKeys();
} catch { } catch {
setError("Failed to delete SSH key"); setMutationError("Failed to delete SSH key");
} }
} }
@@ -74,9 +59,9 @@ export const SSHKeysPage = () => {
setSigning((prev) => ({ ...prev, [keyId]: true })); setSigning((prev) => ({ ...prev, [keyId]: true }));
const result = await signPayload(keyId, { payload: payload.trim() }); const result = await signPayload(keyId, { payload: payload.trim() });
setSignatures((prev) => ({ ...prev, [keyId]: result.signature })); setSignatures((prev) => ({ ...prev, [keyId]: result.signature }));
setError(null); setMutationError(null);
} catch { } catch {
setError("Failed to sign payload"); setMutationError("Failed to sign payload");
} finally { } finally {
setSigning((prev) => ({ ...prev, [keyId]: false })); setSigning((prev) => ({ ...prev, [keyId]: false }));
} }
@@ -94,15 +79,15 @@ export const SSHKeysPage = () => {
signature: signature.trim(), signature: signature.trim(),
}); });
setVerifyResults((prev) => ({ ...prev, [keyId]: result.valid })); setVerifyResults((prev) => ({ ...prev, [keyId]: result.valid }));
setError(null); setMutationError(null);
} catch { } catch {
setError("Failed to verify signature"); setMutationError("Failed to verify signature");
} finally { } finally {
setVerifying((prev) => ({ ...prev, [keyId]: false })); setVerifying((prev) => ({ ...prev, [keyId]: false }));
} }
} }
if (loading) return <div>Loading...</div>; if (status === "loading") return <div>Loading...</div>;
return ( return (
<section className="stack"> <section className="stack">
@@ -116,7 +101,7 @@ export const SSHKeysPage = () => {
</button> </button>
</div> </div>
{error && <div className="error">{error}</div>} {mutationError && <div className="error">{mutationError}</div>}
<form onSubmit={handleGenerate} className="stack"> <form onSubmit={handleGenerate} className="stack">
<div className="form-group"> <div className="form-group">
@@ -145,11 +130,21 @@ export const SSHKeysPage = () => {
</button> </button>
</form> </form>
{status === "error" && (
<div className="card stack">
<p>Failed to load SSH keys</p>
<button className="secondary-button" onClick={() => loadKeys()} type="button">
<Icon name="refresh" size="sm" />
Retry
</button>
</div>
)}
<div className="keys-list"> <div className="keys-list">
{keys.length === 0 ? ( {safeKeys.length === 0 ? (
<p className="muted">No SSH keys yet. Generate one above.</p> <p className="muted">No SSH keys yet. Generate one above.</p>
) : ( ) : (
keys.map((key) => ( safeKeys.map((key) => (
<div key={key.id} className="key-card"> <div key={key.id} className="key-card">
<div className="key-header"> <div className="key-header">
<h3>{key.name}</h3> <h3>{key.name}</h3>
+1 -10
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Icon } from "../components/icon"; import { Icon } from "../components/icon";
import { useMobileViewport } from "../hooks/use-mobile-viewport"; import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { extractErrorMessage } from "../utils/errors";
import { MobileListView } from "../components/mobile-list-view"; import { MobileListView } from "../components/mobile-list-view";
import { MobileDetailView } from "../components/mobile-detail-view"; import { MobileDetailView } from "../components/mobile-detail-view";
import { MobileEditView } from "../components/mobile-edit-view"; import { MobileEditView } from "../components/mobile-edit-view";
@@ -201,16 +202,6 @@ export const ToolWorkshopPage = () => {
setShowFolderForm(false); setShowFolderForm(false);
}; };
const extractErrorMessage = (err: unknown): string => {
const axiosError = err as { response?: { data?: { detail?: string | Array<{msg?: string}> } } };
const detail = axiosError?.response?.data?.detail;
if (typeof detail === 'string') return detail;
if (Array.isArray(detail)) {
return detail.map(d => typeof d === 'string' ? d : d.msg || JSON.stringify(d)).join(', ');
}
return "Failed to save";
};
const handleToolTypeSubmit = async (e: React.FormEvent) => { const handleToolTypeSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setToolTypeError(null); setToolTypeError(null);
+9
View File
@@ -0,0 +1,9 @@
export const extractErrorMessage = (err: unknown): string => {
const axiosError = err as { response?: { data?: { detail?: string | Array<{ msg?: string }> } } };
const detail = axiosError?.response?.data?.detail;
if (typeof detail === "string") return detail;
if (Array.isArray(detail)) {
return detail.map((d) => typeof d === "string" ? d : d.msg || JSON.stringify(d)).join(", ");
}
return "Failed to save";
};