feat: complete user config management

- Add theme support with dark/light/system modes
- Add useTheme hook for applying user config theme
- Update router to use SettingsPage
- Update app-shell to apply theme on load
- Add CSS variables for dark theme
- Fix mypy errors in user_config.py
- Quality gates pass: ruff, mypy, typecheck, lint, build
This commit is contained in:
Fusion
2026-05-18 15:58:01 +02:00
parent 9f7a750898
commit 94254ee3fd
18 changed files with 463 additions and 171 deletions
@@ -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')
+96
View File
@@ -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)
+2
View File
@@ -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")
+25
View File
@@ -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<UserConfig> => {
const response = await apiClient.get<UserConfig>("/users/me/config");
return response.data;
};
export const updateUserConfig = async (data: UserConfigUpdate): Promise<UserConfig> => {
const response = await apiClient.patch<UserConfig>("/users/me/config", data);
return response.data;
};
+2
View File
@@ -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 (
+23
View File
@@ -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
}
})();
}, []);
}
+142
View File
@@ -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<SettingsStatus>("loading");
const [config, setConfig] = useState<UserConfig>({
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 <section className="stack"><p className="muted">Loading settings...</p></section>;
}
if (status === "error") {
return (
<section className="stack">
<p>Failed to load settings</p>
<button className="secondary-button" onClick={() => void loadConfig()} type="button">
Retry
</button>
</section>
);
}
return (
<section className="stack">
<div className="page-header">
<h1>Settings</h1>
</div>
<div className="card stack">
<h2>Appearance</h2>
<label className="form-field">
Theme
<select
value={config.theme}
onChange={(e) => handleChange("theme", e.target.value)}
>
{THEME_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
</div>
<div className="card stack">
<h2>Git Identity</h2>
<label className="form-field">
User Name
<input
type="text"
value={config.git_user_name ?? ""}
onChange={(e) => handleChange("git_user_name", e.target.value || null)}
placeholder="Your git commit name"
/>
</label>
<label className="form-field">
User Email
<input
type="email"
value={config.git_user_email ?? ""}
onChange={(e) => handleChange("git_user_email", e.target.value || null)}
placeholder="your.email@example.com"
/>
</label>
</div>
<div className="card stack">
<h2>Editor</h2>
<label className="form-field">
Default Editor
<input
type="text"
value={config.default_editor ?? ""}
onChange={(e) => handleChange("default_editor", e.target.value || null)}
placeholder="e.g., vscode, vim, cursor"
/>
</label>
</div>
<div className="settings-actions">
<button className="primary-button" onClick={() => void handleSave()} type="button">
{saveStatus === "saving" ? "Saving..." : "Save Settings"}
</button>
{saveStatus === "saved" && <span className="success-text">Settings saved!</span>}
{saveStatus === "error" && <span className="error-text">Failed to save</span>}
</div>
</section>
);
};
+3 -2
View File
@@ -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 = () => {
<Route path="projects/:projectId/repositories" element={<GitRepositoriesPage />} />
<Route path="ssh-keys" element={<SSHKeysPage />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="settings" element={<PlaceholderPage title="Settings" />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
<Route path="/404" element={<NotFoundPage />} />
<Route path="*" element={<Navigate to="/404" replace />} />
+16 -1
View File
@@ -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;
@@ -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?
@@ -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
@@ -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
@@ -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.
@@ -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
@@ -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
@@ -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
@@ -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