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:
@@ -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')
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
}
|
||||
@@ -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,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
@@ -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
|
||||
Reference in New Issue
Block a user