diff --git a/apps/api/alembic/versions/0003_user_configs.py b/apps/api/alembic/versions/0003_user_configs.py new file mode 100644 index 0000000..6ba9d90 --- /dev/null +++ b/apps/api/alembic/versions/0003_user_configs.py @@ -0,0 +1,35 @@ +"""add user_configs table + +Revision ID: 0003 +Revises: 0002 +Create Date: 2025-05-18 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = '0003' +down_revision: Union[str, None] = '0002' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'user_configs', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('config', sa.JSON(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id') + ) + + +def downgrade() -> None: + op.drop_table('user_configs') diff --git a/apps/api/src/api/user_config.py b/apps/api/src/api/user_config.py new file mode 100644 index 0000000..4d6e22c --- /dev/null +++ b/apps/api/src/api/user_config.py @@ -0,0 +1,96 @@ +import uuid +from typing import Annotated + +from fastapi import APIRouter, Cookie, Depends, HTTPException, status +from pydantic import BaseModel, ConfigDict +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from typing import Any + +from src.auth.jwt_service import decode_access_token +from src.config import Settings +from src.database import SessionLocal +from src.models.user import User +from src.models.user_config import UserConfig + +router = APIRouter(prefix="/users/me", tags=["user-config"]) + + +async def get_db_session(): + async with SessionLocal() as session: + yield session + + +async def get_current_user_id( + access_token: Annotated[str | None, Cookie()] = None, +) -> uuid.UUID: + if not access_token: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing access token") + + try: + claims = decode_access_token(settings=Settings(), token=access_token) + return uuid.UUID(str(claims["sub"])) + except Exception: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid access token") + + +async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User: + 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: + result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id)) + config = result.scalar_one_or_none() + if config is None: + config = UserConfig(user_id=user_id, config={}) + session.add(config) + await session.commit() + await session.refresh(config) + return config + + +class UserConfigResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + default_editor: str | None = None + theme: str = "system" + git_user_name: str | None = None + git_user_email: str | None = None + + +class UserConfigUpdate(BaseModel): + default_editor: str | None = None + theme: str | None = None + git_user_name: str | None = None + git_user_email: str | None = None + + +@router.get("/config", response_model=UserConfigResponse) +async def get_user_config( + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> UserConfigResponse: + _user = await _get_user(session, user_id) + config = await _get_or_create_config(session, user_id) + return UserConfigResponse.model_validate(config.config) + + +@router.patch("/config", response_model=UserConfigResponse) +async def update_user_config( + data: UserConfigUpdate, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> UserConfigResponse: + _user = await _get_user(session, user_id) + config = await _get_or_create_config(session, user_id) + + # Merge updates + update_data = data.model_dump(exclude_unset=True, exclude_none=True) + config.config.update(update_data) + + await session.commit() + await session.refresh(config) + return UserConfigResponse.model_validate(config.config) diff --git a/apps/api/src/main.py b/apps/api/src/main.py index fc0d7cf..b3e68af 100644 --- a/apps/api/src/main.py +++ b/apps/api/src/main.py @@ -5,6 +5,7 @@ from src.api.auth import router as auth_router from src.api.git_repositories import router as git_repositories_router from src.api.projects import router as projects_router from src.api.ssh_keys import router as ssh_keys_router +from src.api.user_config import router as user_config_router from src.api.users import router as users_router app = FastAPI(title="Headquarter API") @@ -13,4 +14,5 @@ app.include_router(projects_router) app.include_router(users_router) app.include_router(ssh_keys_router) app.include_router(git_repositories_router) +app.include_router(user_config_router) app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads") diff --git a/apps/web/src/api/settings.ts b/apps/web/src/api/settings.ts new file mode 100644 index 0000000..bd32406 --- /dev/null +++ b/apps/web/src/api/settings.ts @@ -0,0 +1,25 @@ +import { apiClient } from "./client"; + +export interface UserConfig { + default_editor: string | null; + theme: string; + git_user_name: string | null; + git_user_email: string | null; +} + +export interface UserConfigUpdate { + default_editor?: string; + theme?: string; + git_user_name?: string; + git_user_email?: string; +} + +export const getUserConfig = async (): Promise => { + const response = await apiClient.get("/users/me/config"); + return response.data; +}; + +export const updateUserConfig = async (data: UserConfigUpdate): Promise => { + const response = await apiClient.patch("/users/me/config", data); + return response.data; +}; diff --git a/apps/web/src/components/app-shell.tsx b/apps/web/src/components/app-shell.tsx index 1499ce3..fd40b74 100644 --- a/apps/web/src/components/app-shell.tsx +++ b/apps/web/src/components/app-shell.tsx @@ -1,5 +1,6 @@ import { Link, NavLink, Outlet } from "react-router-dom"; +import { useTheme } from "../hooks/use-theme"; import { useAuth } from "../state/auth"; const NAV_ITEMS = [ @@ -10,6 +11,7 @@ const NAV_ITEMS = [ ]; export const AppShell = () => { + useTheme(); const { user, logout } = useAuth(); return ( diff --git a/apps/web/src/hooks/use-theme.ts b/apps/web/src/hooks/use-theme.ts new file mode 100644 index 0000000..418b010 --- /dev/null +++ b/apps/web/src/hooks/use-theme.ts @@ -0,0 +1,23 @@ +import { useEffect } from "react"; + +import { getUserConfig } from "../api/settings"; + +export function useTheme() { + useEffect(() => { + void (async () => { + try { + const config = await getUserConfig(); + const theme = config.theme ?? "system"; + + if (theme === "system") { + // Remove any explicit theme class and let system decide + document.documentElement.removeAttribute("data-theme"); + } else { + document.documentElement.setAttribute("data-theme", theme); + } + } catch { + // Silently fail - theme is not critical + } + })(); + }, []); +} diff --git a/apps/web/src/pages/settings.tsx b/apps/web/src/pages/settings.tsx new file mode 100644 index 0000000..63ece85 --- /dev/null +++ b/apps/web/src/pages/settings.tsx @@ -0,0 +1,142 @@ +import { useCallback, useEffect, useState } from "react"; + +import { getUserConfig, updateUserConfig, type UserConfig, type UserConfigUpdate } from "../api/settings"; + +type SettingsStatus = "loading" | "ready" | "error"; + +const THEME_OPTIONS = [ + { value: "system", label: "System" }, + { value: "light", label: "Light" }, + { value: "dark", label: "Dark" }, +]; + +export const SettingsPage = () => { + const [status, setStatus] = useState("loading"); + const [config, setConfig] = useState({ + theme: "system", + default_editor: null, + git_user_name: null, + git_user_email: null, + }); + const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle"); + + const loadConfig = useCallback(async () => { + try { + const data = await getUserConfig(); + setConfig(data); + setStatus("ready"); + } catch { + setStatus("error"); + } + }, []); + + useEffect(() => { + void loadConfig(); + }, [loadConfig]); + + const handleChange = (key: keyof UserConfigUpdate, value: string | null) => { + setConfig((prev) => ({ ...prev, [key]: value })); + setSaveStatus("idle"); + }; + + const handleSave = async () => { + setSaveStatus("saving"); + try { + const update: UserConfigUpdate = { + theme: config.theme, + default_editor: config.default_editor ?? undefined, + git_user_name: config.git_user_name ?? undefined, + git_user_email: config.git_user_email ?? undefined, + }; + const updated = await updateUserConfig(update); + setConfig(updated); + setSaveStatus("saved"); + setTimeout(() => setSaveStatus("idle"), 2000); + } catch { + setSaveStatus("error"); + } + }; + + if (status === "loading") { + return

Loading settings...

; + } + + if (status === "error") { + return ( +
+

Failed to load settings

+ +
+ ); + } + + return ( +
+
+

Settings

+
+ +
+

Appearance

+ +
+ +
+

Git Identity

+ + +
+ +
+

Editor

+ +
+ +
+ + {saveStatus === "saved" && Settings saved!} + {saveStatus === "error" && Failed to save} +
+
+ ); +}; diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 985a415..6a51f91 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -3,11 +3,12 @@ import { Navigate, Route, Routes } from "react-router-dom"; import { AppShell } from "./components/app-shell"; import { ProtectedRoute } from "./components/protected-route"; import { DashboardPage } from "./pages/dashboard"; -import { LoginRedirectPage, NotFoundPage, PlaceholderPage } from "./pages/placeholder"; +import { LoginRedirectPage, NotFoundPage } from "./pages/placeholder"; import { ProfilePage } from "./pages/profile"; import { ProjectsPage } from "./pages/projects"; import { GitRepositoriesPage } from "./pages/git-repositories"; import { SSHKeysPage } from "./pages/ssh-keys"; +import { SettingsPage } from "./pages/settings"; export const AppRouter = () => { return ( @@ -26,7 +27,7 @@ export const AppRouter = () => { } /> } /> } /> - } /> + } /> } /> } /> diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 7ddfbd2..ff6b227 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -10,16 +10,31 @@ --border: #d8d0c5; } +[data-theme="dark"] { + color-scheme: dark; + --bg: #1a1a18; + --panel: #252522; + --ink: #e8e6e1; + --muted: #a39e96; + --brand: #4a9e7f; + --brand-strong: #3d8a6e; + --border: #3d3d38; +} + * { box-sizing: border-box; } body { margin: 0; - background: radial-gradient(circle at top right, #fff5d6, var(--bg)); + background: var(--bg); color: var(--ink); } +[data-theme="dark"] body { + background: radial-gradient(circle at top right, #2a2520, var(--bg)); +} + a { color: inherit; text-decoration: none; diff --git a/openspec/changes/git-repo-management/design.md b/openspec/changes/git-repo-management/design.md deleted file mode 100644 index 55e9375..0000000 --- a/openspec/changes/git-repo-management/design.md +++ /dev/null @@ -1,53 +0,0 @@ -## Context - -The platform has projects and SSH keys but no git repository management. Users need to create bare repos for their projects and optionally clone external ones. - -## Goals / Non-Goals - -**Goals:** -- Create bare git repositories on disk within project structure -- List repositories per project -- Clone external repositories as bare mirrors -- Delete repositories (cascade with project deletion) -- Prevent duplicate repo names per project - -**Non-Goals:** -- Git hosting (push/pull via SSH/HTTP) -- Webhook handling -- CI/CD integration -- Repository browsing/file viewing - -## Decisions - -1. **Bare repositories only** - - Simplifies storage, no working tree needed - - Standard pattern for git servers - -2. **Storage path: `/data/repos/{user_id}/{project_id}/{name}.git`** - - Isolates repos by user and project - - Predictable structure - -3. **Use subprocess to run `git init --bare` and `git clone --mirror`** - - Standard git commands, no additional dependencies - - More reliable than git libraries for basic operations - -4. **Store only metadata in database** - - Name, path, remote_url, project_id - - Actual git data stays on disk - -## Risks / Trade-offs - -- **[Disk space]** -> Monitor usage, implement cleanup -- **[Git not in container]** -> Ensure git is installed in API Dockerfile -- **[Concurrent access]** -> File locking not implemented (future) - -## Migration Plan - -1. Create API endpoints -2. Add git to Dockerfile -3. Create frontend -4. Test with sample repos - -## Open Questions - -- Should we validate git URLs before cloning? diff --git a/openspec/changes/git-repo-management/proposal.md b/openspec/changes/git-repo-management/proposal.md deleted file mode 100644 index 07e1854..0000000 --- a/openspec/changes/git-repo-management/proposal.md +++ /dev/null @@ -1,25 +0,0 @@ -## Why - -Projects exist but users cannot create or manage git repositories. The platform needs to allow users to create bare repositories, clone external ones, and manage them within projects. - -## What Changes - -- Add backend API for git repository CRUD operations -- Implement bare repository initialization on disk -- Support cloning external repositories as mirrors -- Add frontend page for repository management -- Integrate with existing project structure - -## Capabilities - -### New Capabilities -- `git-repo-management`: Full git repository lifecycle within projects - -### Modified Capabilities -- `project-management`: Include repositories in project responses - -## Impact - -- New API endpoints under `/projects/{id}/repositories` -- Disk storage at `/data/repos/{user_id}/{project_id}/{repo_name}.git` -- Frontend repository list and creation UI diff --git a/openspec/changes/git-repo-management/specs/git-repo-management/spec.md b/openspec/changes/git-repo-management/specs/git-repo-management/spec.md deleted file mode 100644 index c613d72..0000000 --- a/openspec/changes/git-repo-management/specs/git-repo-management/spec.md +++ /dev/null @@ -1,62 +0,0 @@ -## ADDED Requirements - -### Requirement: Repository Creation - -The system SHALL allow creating new bare git repositories within projects. - -#### Scenario: Create repository -- GIVEN an authenticated user with a project -- WHEN they POST a repository name -- THEN a bare repo is initialized on disk at `/data/repos/{user_id}/{project_id}/{repo_name}.git` -- AND metadata is stored in the database -- AND the response includes the repository details - -#### Scenario: Duplicate name prevention -- GIVEN a project with a repo named "frontend" -- WHEN the user tries to create another "frontend" repo -- THEN the system responds with 400 Bad Request - -### Requirement: Repository Cloning - -The system SHALL support cloning external repositories as bare mirrors. - -#### Scenario: Clone repository -- GIVEN an authenticated user with a project -- WHEN they provide a valid remote URL -- THEN the system clones as a bare mirror -- AND stores it in the structured path -- AND records the remote URL in metadata - -#### Scenario: Invalid URL -- GIVEN an authenticated user -- WHEN they provide an invalid or unreachable URL -- THEN the system responds with 400 Bad Request - -### Requirement: Repository Listing - -The system SHALL list all repositories for a project. - -#### Scenario: List repositories -- GIVEN an authenticated user with a project -- WHEN they GET the repositories endpoint -- THEN all repos for that project are listed with name, path, and last push date - -### Requirement: Repository Deletion - -The system SHALL remove repository records and disk contents. - -#### Scenario: Delete repository -- GIVEN a project owner -- WHEN they delete a repository -- THEN the database record is removed -- AND the directory on disk is removed - -### Requirement: Project Cascade Delete - -The system SHALL clean up repositories when a project is deleted. - -#### Scenario: Cascade repository cleanup -- GIVEN a project with associated repositories -- WHEN the project owner deletes the project -- THEN all repository records for that project are removed -- AND all repository directories on disk are removed diff --git a/openspec/changes/git-repo-management/tasks.md b/openspec/changes/git-repo-management/tasks.md deleted file mode 100644 index e9431be..0000000 --- a/openspec/changes/git-repo-management/tasks.md +++ /dev/null @@ -1,28 +0,0 @@ -## 1. Backend Git Repository API - -- [ ] 1.1 Create `apps/api/src/api/git_repositories.py` with endpoints for list, create, clone, and delete repositories. -- [ ] 1.2 Add Pydantic schemas for GitRepositoryCreate, GitRepositoryResponse. -- [ ] 1.3 Implement bare repository initialization using `git init --bare`. -- [ ] 1.4 Implement mirror cloning using `git clone --mirror`. -- [ ] 1.5 Add ownership validation (only project owner can manage repos). -- [ ] 1.6 Add duplicate name validation per project. -- [ ] 1.7 Register router in `apps/api/src/main.py`. -- [ ] 1.8 Add backend tests for repository CRUD operations. - -## 2. Frontend Git Repositories Page - -- [ ] 2.1 Create `apps/web/src/api/git_repositories.ts` with API methods. -- [ ] 2.2 Create `apps/web/src/pages/git-repositories.tsx` with repo list, create form, and delete action. -- [ ] 2.3 Add route in `apps/web/src/router.tsx`. -- [ ] 2.4 Update project page to show associated repositories. - -## 3. Project Integration - -- [ ] 3.1 Update project deletion to cascade delete repositories. -- [ ] 3.2 Update project response to include repository count. - -## 4. Verification - -- [ ] 4.1 Run backend checks (pytest, ruff, mypy). -- [ ] 4.2 Run frontend checks (npm test, typecheck, lint, build). -- [ ] 4.3 Verify docker-compose config is valid. diff --git a/openspec/changes/git-repo-management/.openspec.yaml b/openspec/changes/user-config-management/.openspec.yaml similarity index 100% rename from openspec/changes/git-repo-management/.openspec.yaml rename to openspec/changes/user-config-management/.openspec.yaml diff --git a/openspec/changes/user-config-management/design.md b/openspec/changes/user-config-management/design.md new file mode 100644 index 0000000..17d5079 --- /dev/null +++ b/openspec/changes/user-config-management/design.md @@ -0,0 +1,32 @@ +## Context + +The platform needs a simple key-value configuration system for user preferences like theme, editor, and git identity. + +## Goals / Non-Goals + +**Goals:** +- Store user config as JSONB in PostgreSQL +- Support partial updates (PATCH semantics) +- Apply theme in frontend on load +- Provide settings UI + +**Non-Goals:** +- Complex nested config structures +- Config validation beyond type checking +- Per-project config (global only) + +## Decisions + +1. **JSONB column for flexibility** + - Rationale: Simple key-value, no schema migrations for new keys + +2. **Merge semantics for updates** + - Rationale: Frontend can update single key without sending entire config + +3. **Lazy creation** + - Rationale: Config row created on first write, not on user creation + +## Risks + +- **[JSONB query performance]** → Only querying by user_id, not by config keys +- **[No schema validation]** → Frontend validates known keys, backend accepts any JSON diff --git a/openspec/changes/user-config-management/proposal.md b/openspec/changes/user-config-management/proposal.md new file mode 100644 index 0000000..4f069fb --- /dev/null +++ b/openspec/changes/user-config-management/proposal.md @@ -0,0 +1,25 @@ +## Why + +Users need to store preferences and settings (theme, editor, git identity) that persist across sessions. + +## What Changes + +- Add UserConfig model for JSONB key-value storage +- Add API endpoints for get/update user config +- Add frontend settings page +- Apply theme preference in frontend + +## Capabilities + +### New Capabilities +- `user-config-management`: Store and manage user preferences + +### Modified Capabilities +- `user-profile`: Include config in profile responses + +## Impact + +- New database model and migration +- New API endpoints under /users/me/config +- New frontend settings page +- Theme application in AppShell diff --git a/openspec/changes/user-config-management/specs/user-config-management/spec.md b/openspec/changes/user-config-management/specs/user-config-management/spec.md new file mode 100644 index 0000000..f6ca739 --- /dev/null +++ b/openspec/changes/user-config-management/specs/user-config-management/spec.md @@ -0,0 +1,42 @@ +## ADDED Requirements + +### Requirement: User Config Storage + +The system SHALL store user configuration as JSONB key-value pairs linked to the user. + +#### Scenario: Create config on first write +- GIVEN an authenticated user with no config +- WHEN they update settings +- THEN a UserConfig row is created with their user_id + +### Requirement: Config Keys + +The system SHALL support these configuration keys: +- `default_editor`: string +- `theme`: "light" | "dark" | "system" +- `git_user_name`: string +- `git_user_email`: string + +#### Scenario: Update theme +- GIVEN an authenticated user +- WHEN they PATCH /users/me/config with {"theme": "dark"} +- THEN only the theme key is updated +- AND other keys remain unchanged + +### Requirement: Config Retrieval + +The system SHALL return user configuration on request. + +#### Scenario: Get config +- GIVEN an authenticated user with config +- WHEN they GET /users/me/config +- THEN their full configuration is returned + +### Requirement: Frontend Theme Application + +The system SHALL apply the user's theme preference on application load. + +#### Scenario: Load with dark theme +- GIVEN a user with theme="dark" +- WHEN the app loads +- THEN the dark CSS class is applied to the document diff --git a/openspec/changes/user-config-management/tasks.md b/openspec/changes/user-config-management/tasks.md new file mode 100644 index 0000000..952161b --- /dev/null +++ b/openspec/changes/user-config-management/tasks.md @@ -0,0 +1,20 @@ +## 1. Backend User Config API + +- [ ] 1.1 Create UserConfig SQLAlchemy model with JSONB config column +- [ ] 1.2 Create Alembic migration for UserConfig table +- [ ] 1.3 Add GET /users/me/config endpoint +- [ ] 1.4 Add PATCH /users/me/config endpoint with merge semantics +- [ ] 1.5 Add backend tests + +## 2. Frontend Settings + +- [ ] 2.1 Create settings API client (apps/web/src/api/settings.ts) +- [ ] 2.2 Create settings page with theme selector and git identity fields +- [ ] 2.3 Apply theme preference on app load in AppShell +- [ ] 2.4 Add /settings route to router + +## 3. Verification + +- [ ] 3.1 Run backend checks (ruff, mypy, pytest) +- [ ] 3.2 Run frontend checks (typecheck, lint, build) +- [ ] 3.3 Verify docker-compose config