diff --git a/apps/api/src/api/ssh_keys.py b/apps/api/src/api/ssh_keys.py new file mode 100644 index 0000000..870efd5 --- /dev/null +++ b/apps/api/src/api/ssh_keys.py @@ -0,0 +1,130 @@ +import uuid +from datetime import datetime +from typing import Annotated + +from cryptography.fernet import Fernet +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from fastapi import APIRouter, Cookie, Depends, HTTPException, status +from pydantic import BaseModel, ConfigDict +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.auth.jwt_service import decode_access_token +from src.config import Settings +from src.database import SessionLocal +from src.models.ssh_key import SSHKey +from src.models.user import User + +router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"]) + + +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 + + +def _get_fernet() -> Fernet: + settings = Settings() + key = settings.jwt_secret[:32].ljust(32, "=") + return Fernet(key.encode()) + + +def generate_ssh_key_pair() -> tuple[str, str]: + private_key = Ed25519PrivateKey.generate() + public_key = private_key.public_key() + + private_bytes = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.OpenSSH, + encryption_algorithm=serialization.NoEncryption(), + ) + + public_bytes = public_key.public_bytes( + encoding=serialization.Encoding.OpenSSH, + format=serialization.PublicFormat.OpenSSH, + ) + + return private_bytes.decode("utf-8"), public_bytes.decode("utf-8") + + +class SSHKeyCreate(BaseModel): + name: str + + +class SSHKeyResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + name: str + public_key: str + created_at: datetime + + +@router.post("", response_model=SSHKeyResponse, status_code=status.HTTP_201_CREATED) +async def create_ssh_key( + data: SSHKeyCreate, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> SSHKey: + user = await _get_user(session, user_id) + private_key, public_key = generate_ssh_key_pair() + + fernet = _get_fernet() + encrypted_private = fernet.encrypt(private_key.encode()).decode() + + ssh_key = SSHKey( + name=data.name, + public_key=public_key, + private_key_encrypted=encrypted_private, + user_id=user.id, + ) + session.add(ssh_key) + await session.commit() + await session.refresh(ssh_key) + return ssh_key + + +@router.get("", response_model=list[SSHKeyResponse]) +async def list_ssh_keys( + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> list[SSHKey]: + user = await _get_user(session, user_id) + result = await session.execute(select(SSHKey).where(SSHKey.user_id == user.id)) + return list(result.scalars().all()) + + +@router.delete("/{key_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_ssh_key( + key_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> None: + user = await _get_user(session, user_id) + ssh_key = await session.get(SSHKey, key_id) + if ssh_key is None or ssh_key.user_id != user.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found") + + await session.delete(ssh_key) + await session.commit() diff --git a/apps/api/src/main.py b/apps/api/src/main.py index bcc176e..0123b8f 100644 --- a/apps/api/src/main.py +++ b/apps/api/src/main.py @@ -3,10 +3,12 @@ from fastapi.staticfiles import StaticFiles from src.api.auth import router as auth_router from src.api.projects import router as projects_router +from src.api.ssh_keys import router as ssh_keys_router from src.api.users import router as users_router app = FastAPI(title="Headquarter API") app.include_router(auth_router) app.include_router(projects_router) app.include_router(users_router) +app.include_router(ssh_keys_router) app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads") diff --git a/apps/api/tests/test_ssh_keys_api.py b/apps/api/tests/test_ssh_keys_api.py new file mode 100644 index 0000000..322b377 --- /dev/null +++ b/apps/api/tests/test_ssh_keys_api.py @@ -0,0 +1,22 @@ +import pytest +from httpx import AsyncClient + +from src.main import app + + +@pytest.fixture +async def async_client(): + async with AsyncClient(app=app, base_url="http://test") as client: + yield client + + +@pytest.mark.asyncio +async def test_create_ssh_key_requires_authentication(async_client: AsyncClient) -> None: + response = await async_client.post("/ssh-keys", json={"name": "test-key"}) + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_list_ssh_keys_requires_authentication(async_client: AsyncClient) -> None: + response = await async_client.get("/ssh-keys") + assert response.status_code == 401 diff --git a/apps/web/src/api/ssh_keys.ts b/apps/web/src/api/ssh_keys.ts new file mode 100644 index 0000000..84061d7 --- /dev/null +++ b/apps/web/src/api/ssh_keys.ts @@ -0,0 +1,26 @@ +import { apiClient } from "./client"; + +export interface SSHKey { + id: string; + name: string; + public_key: string; + created_at: string; +} + +export interface SSHKeyCreate { + name: string; +} + +export async function listSSHKeys(): Promise { + const response = await apiClient.get("/ssh-keys"); + return response.data; +} + +export async function createSSHKey(data: SSHKeyCreate): Promise { + const response = await apiClient.post("/ssh-keys", data); + return response.data; +} + +export async function deleteSSHKey(keyId: string): Promise { + await apiClient.delete(`/ssh-keys/${keyId}`); +} diff --git a/apps/web/src/pages/ssh-keys.tsx b/apps/web/src/pages/ssh-keys.tsx new file mode 100644 index 0000000..c28c0f4 --- /dev/null +++ b/apps/web/src/pages/ssh-keys.tsx @@ -0,0 +1,119 @@ +import { useEffect, useState } from "react"; +import { createSSHKey, deleteSSHKey, listSSHKeys, type SSHKey } from "../api/ssh_keys"; + +export const SSHKeysPage = () => { + const [keys, setKeys] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [newKeyName, setNewKeyName] = useState(""); + const [generating, setGenerating] = useState(false); + + useEffect(() => { + 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) { + e.preventDefault(); + if (!newKeyName.trim()) return; + + try { + setGenerating(true); + await createSSHKey({ name: newKeyName.trim() }); + setNewKeyName(""); + await loadKeys(); + } catch { + setError("Failed to generate SSH key"); + } finally { + setGenerating(false); + } + } + + async function handleDelete(keyId: string) { + if (!confirm("Are you sure you want to delete this SSH key?")) return; + + try { + await deleteSSHKey(keyId); + await loadKeys(); + } catch { + setError("Failed to delete SSH key"); + } + } + + function copyToClipboard(text: string) { + navigator.clipboard.writeText(text); + } + + if (loading) return
Loading...
; + + return ( +
+

SSH Keys

+ + {error &&
{error}
} + +
+
+ + setNewKeyName(e.target.value)} + placeholder="e.g., GitHub Work" + required + /> +
+ +
+ +
+ {keys.length === 0 ? ( +

No SSH keys yet. Generate one above.

+ ) : ( + keys.map((key) => ( +
+
+

{key.name}

+ +
+
+ + Created: {new Date(key.created_at).toLocaleDateString()} + +
+
+ {key.public_key.substring(0, 50)}... + +
+
+ )) + )} +
+
+ ); +}; diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 2ce10e7..c966ec1 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -6,6 +6,7 @@ import { DashboardPage } from "./pages/dashboard"; import { LoginRedirectPage, NotFoundPage, PlaceholderPage } from "./pages/placeholder"; import { ProfilePage } from "./pages/profile"; import { ProjectsPage } from "./pages/projects"; +import { SSHKeysPage } from "./pages/ssh-keys"; export const AppRouter = () => { return ( @@ -22,7 +23,7 @@ export const AppRouter = () => { } /> } /> } /> - } /> + } /> } /> } /> diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index b8d1589..7ddfbd2 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -270,6 +270,69 @@ a { gap: 0.6rem; } +.keys-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.key-card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 14px; + padding: 1rem; +} + +.key-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} + +.key-header h3 { + margin: 0; +} + +.key-meta { + margin-bottom: 0.75rem; +} + +.key-public { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem; + background: #f5f3ee; + border-radius: 8px; +} + +.key-public code { + font-size: 0.85rem; + word-break: break-all; + flex: 1; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.form-group input { + padding: 0.55rem 0.7rem; + border: 1px solid var(--border); + border-radius: 10px; + font: inherit; +} + +.error { + color: #b91c1c; + padding: 0.75rem; + background: #fef2f2; + border-radius: 10px; +} + @media (max-width: 860px) { .shell-body { grid-template-columns: 1fr; diff --git a/openspec/changes/oauth-traefik-integration/design.md b/openspec/changes/oauth-traefik-integration/design.md deleted file mode 100644 index afbeeab..0000000 --- a/openspec/changes/oauth-traefik-integration/design.md +++ /dev/null @@ -1,65 +0,0 @@ -## Context - -The current `docker-compose.yml` is a standalone development setup without reverse proxy support. The API has hardcoded Authentik URLs in `config.py` (`https://authentik.local/...`) which makes it impossible to deploy in real environments without code changes. The frontend also hardcodes `VITE_API_URL=http://localhost:8000`. - -For production deployment, the platform needs to work behind an existing Traefik reverse proxy (common in self-hosted stacks) and have all external service endpoints fully configurable. - -## Goals / Non-Goals - -**Goals:** -- Make all Authentik URLs configurable via environment variables (no hardcoded defaults). -- Make OAuth redirect/callback URLs configurable and domain-aware. -- Create `docker-compose.traefik.yml` for deployment behind an existing Traefik instance. -- Support configuring the public web domain, API domain, and Authentik domain via env vars. -- Ensure both development (`docker-compose.yml`) and traefik modes work correctly. - -**Non-Goals:** -- Setting up Traefik itself (assumes existing Traefik instance). -- Authentik installation/configuration (assumes existing Authentik instance). -- SSL certificate management (handled by Traefik). -- Changing the authentication flow or token logic. - -## Decisions - -1. **Remove all hardcoded URLs from `config.py` and require env vars** - - Rationale: Deployment environments have different domains. Hardcoded values are a deployment blocker. - - Alternative: Keep defaults and override in prod. Rejected because defaults mask configuration errors. - -2. **Use `API_DOMAIN` and `WEB_DOMAIN` env vars for constructing public URLs** - - Rationale: Centralizes domain configuration and makes it easy to switch between dev/prod. - - `API_PUBLIC_URL` will default to `http://${API_DOMAIN}` or can be overridden. - - `WEB_PUBLIC_URL` will default to `http://${WEB_DOMAIN}` or can be overridden. - -3. **Create a separate `docker-compose.traefik.yml` instead of modifying the existing one** - - Rationale: The existing `docker-compose.yml` is for standalone development. Traefik deployment is a different topology. - - Alternative: Use compose profiles or overrides. Rejected to keep each file simple and explicit. - -4. **Add `VITE_APP_URL` for the frontend so it knows its public URL** - - Rationale: OAuth redirect URI needs to be absolute and must match the public web URL. - - Frontend will use this for login redirect if needed. - -5. **Use Traefik labels for routing instead of ports** - - Rationale: Standard Traefik pattern - services are discovered via Docker labels. - - No port mappings exposed; Traefik handles all ingress. - -## Risks / Trade-offs - -- **[Missing env vars cause startup failures]** -> Document all required variables in `.env.example` and add validation in config.py. -- **[OAuth redirect URI mismatch]** -> Ensure the redirect URI configured in Authentik matches the env-configured callback URL exactly. -- **[Local development still works]** -> Keep `docker-compose.yml` unchanged for dev; traefik file is additive. -- **[Cookie secure flag]** -> Ensure `cookie_secure` property in config reads from env properly for HTTPS deployments. - -## Migration Plan - -1. Update `config.py` to read all Authentik URLs from environment with no defaults. -2. Update `auth.py` to construct redirect/callback URLs from env-configured domains. -3. Update `.env.example` with all new variables. -4. Create `docker-compose.traefik.yml` with Traefik labels. -5. Test that both `docker-compose.yml` (dev) and `docker-compose.traefik.yml` (prod) work. - -Rollback: -- Revert config.py and auth.py changes; remove `docker-compose.traefik.yml`. - -## Open Questions - -- Should we add a startup health check that validates all required env vars are set? diff --git a/openspec/changes/oauth-traefik-integration/proposal.md b/openspec/changes/oauth-traefik-integration/proposal.md deleted file mode 100644 index 26446ee..0000000 --- a/openspec/changes/oauth-traefik-integration/proposal.md +++ /dev/null @@ -1,28 +0,0 @@ -## Why - -The current setup hardcodes Authentik URLs in the API config and provides only a basic docker-compose.yml without reverse proxy support. For production deployment, the platform needs to integrate with an existing Traefik reverse proxy and have all external service URLs fully configurable via environment variables. - -## What Changes - -- **Externalize all Authentik and domain configuration** to environment variables (no more hardcoded URLs in config.py). -- **Add `docker-compose.traefik.yml`** for deployment behind an existing Traefik instance with all domain names as env vars. -- **Update `.env.example`** to document all new environment variables for both development and traefik modes. -- **Add proxy web name configuration** for the frontend to know its public URL. -- **Verify OAuth callback URLs work correctly** with configurable domains. - -## Capabilities - -### New Capabilities -- `traefik-deployment`: Docker Compose setup for deploying behind an existing Traefik reverse proxy with environment-based domain configuration. - -### Modified Capabilities -- `docker-infrastructure`: Add traefik deployment mode and externalize all domain/service URLs. -- `auth-oauth`: Make Authentik URLs and callback URLs fully environment-configurable instead of hardcoded. - -## Impact - -- `apps/api/src/config.py`: Remove hardcoded Authentik URLs, read from environment. -- `apps/api/src/api/auth.py`: Use configurable redirect/callback URLs. -- `.env.example`: Add all new environment variables. -- `docker-compose.traefik.yml`: New file for traefik deployment. -- Frontend may need `VITE_APP_URL` or similar for OAuth redirect. diff --git a/openspec/changes/oauth-traefik-integration/specs/auth-oauth/spec.md b/openspec/changes/oauth-traefik-integration/specs/auth-oauth/spec.md deleted file mode 100644 index 72e74e1..0000000 --- a/openspec/changes/oauth-traefik-integration/specs/auth-oauth/spec.md +++ /dev/null @@ -1,29 +0,0 @@ -## MODIFIED Requirements - -### Requirement: OAuth2/OIDC Flow - -The system SHALL support OAuth2/OIDC authentication via Authentik with fully configurable endpoints. - -#### Scenario: User login -- GIVEN a user clicks the login button -- WHEN the frontend redirects to Authentik authorization endpoint -- THEN the redirect URI SHALL be constructed from environment-configured domains -- AND the Authentik authorize URL SHALL be read from environment variables - -#### Scenario: Token exchange and validation -- GIVEN Authentik has redirected with authorization code -- WHEN the callback endpoint receives the code -- THEN it exchanges the code for provider tokens at the configured token URL -- AND verifies token signature using the configured JWKS URL -- AND validates the issuer and audience from environment configuration - -### Requirement: Session Security - -The system SHALL protect sessions using httpOnly cookies with environment-aware secure defaults. - -#### Scenario: Cookie attributes in production -- GIVEN successful authentication behind Traefik with HTTPS -- WHEN cookies are set -- THEN access_token cookie SHALL be httpOnly -- AND access_token cookie SHALL have Secure flag based on environment -- AND access_token cookie SHALL have SameSite based on environment diff --git a/openspec/changes/oauth-traefik-integration/specs/docker-infrastructure/spec.md b/openspec/changes/oauth-traefik-integration/specs/docker-infrastructure/spec.md deleted file mode 100644 index 55d489c..0000000 --- a/openspec/changes/oauth-traefik-integration/specs/docker-infrastructure/spec.md +++ /dev/null @@ -1,24 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Docker Compose Setup - -The system SHALL provide Docker Compose configurations for both development and traefik deployment. - -#### Scenario: Development compose file -- GIVEN the development environment -- THEN `docker-compose.yml` SHALL define all platform services for local development - -#### Scenario: Traefik compose file -- GIVEN the production deployment -- THEN `docker-compose.traefik.yml` SHALL define all platform services behind Traefik -- AND no ports SHALL be exposed directly (all traffic through Traefik) - -### Requirement: Environment Configuration - -The system SHALL document all required environment variables for both development and traefik deployment modes. - -#### Scenario: Environment setup -- GIVEN a new developer or operator -- WHEN they set up the project -- THEN `.env.example` SHALL document all variables for both modes -- AND variables SHALL include domain configuration for traefik mode diff --git a/openspec/changes/oauth-traefik-integration/specs/traefik-deployment/spec.md b/openspec/changes/oauth-traefik-integration/specs/traefik-deployment/spec.md deleted file mode 100644 index 31a00bd..0000000 --- a/openspec/changes/oauth-traefik-integration/specs/traefik-deployment/spec.md +++ /dev/null @@ -1,31 +0,0 @@ -## ADDED Requirements - -### Requirement: Traefik Docker Compose - -The system SHALL provide a `docker-compose.traefik.yml` for deployment behind an existing Traefik reverse proxy. - -#### Scenario: Service labels -- GIVEN the traefik deployment configuration -- WHEN services are started -- THEN `docker-compose.traefik.yml` SHALL define Traefik Docker labels for each service -- AND all routing rules SHALL use configurable domain names - -#### Scenario: Environment variables -- GIVEN the traefik deployment configuration -- WHEN configuring the deployment -- THEN all domain names SHALL be configurable via environment variables -- AND the proxy web name SHALL be configurable via environment variable - -### Requirement: Environment Configuration - -The system SHALL document all required environment variables for traefik deployment. - -#### Scenario: Required variables -- GIVEN a new deployment -- WHEN setting up environment variables -- THEN `.env.example` SHALL document: - - `API_DOMAIN` - domain for API service - - `WEB_DOMAIN` - domain for web frontend - - `AUTHENTIK_DOMAIN` - domain for Authentik instance - - `PROXY_WEB_NAME` - name for web proxy service - - All Authentik OIDC configuration variables diff --git a/openspec/changes/oauth-traefik-integration/tasks.md b/openspec/changes/oauth-traefik-integration/tasks.md deleted file mode 100644 index f5d26d3..0000000 --- a/openspec/changes/oauth-traefik-integration/tasks.md +++ /dev/null @@ -1,25 +0,0 @@ -## 1. Externalize Authentik and domain configuration - -- [x] 1.1 Update `apps/api/src/config.py` to read all Authentik URLs from environment variables with no hardcoded defaults. -- [x] 1.2 Add `API_PUBLIC_URL`, `WEB_PUBLIC_URL`, and related domain env vars to config.py. -- [x] 1.3 Update `apps/api/src/api/auth.py` to construct OAuth redirect/callback URLs from configured domains. -- [x] 1.4 Update `.env.example` with all new environment variables for Authentik and domain configuration. - -## 2. Create Traefik deployment compose file - -- [x] 2.1 Create `docker-compose.traefik.yml` with all services configured for Traefik reverse proxy. -- [x] 2.2 Add Traefik Docker labels to all services with configurable domain-based routing rules. -- [x] 2.3 Ensure no ports are exposed directly in traefik mode (all through Traefik). -- [x] 2.4 Add `PROXY_WEB_NAME` and other traefik-specific env vars to `.env.example`. - -## 3. Frontend configuration - -- [x] 3.1 Update frontend to support configurable public URL for OAuth redirect. -- [x] 3.2 Update `apps/web/.env.example` or relevant config with `VITE_APP_URL`. - -## 4. Verification and testing - -- [x] 4.1 Run backend quality gates (`pytest`, `ruff`, `mypy`). -- [x] 4.2 Run frontend quality gates (`npm test`, `typecheck`, `lint`, `build`). -- [x] 4.3 Validate `docker-compose config` works for both compose files. -- [x] 4.4 Update this tasks file with completed checkboxes. diff --git a/openspec/changes/oauth-traefik-integration/.openspec.yaml b/openspec/changes/ssh-key-management/.openspec.yaml similarity index 50% rename from openspec/changes/oauth-traefik-integration/.openspec.yaml rename to openspec/changes/ssh-key-management/.openspec.yaml index 66da1ae..231e3ab 100644 --- a/openspec/changes/oauth-traefik-integration/.openspec.yaml +++ b/openspec/changes/ssh-key-management/.openspec.yaml @@ -1,2 +1,2 @@ schema: spec-driven -created: 2026-05-17 +created: 2026-05-18 diff --git a/openspec/changes/ssh-key-management/design.md b/openspec/changes/ssh-key-management/design.md new file mode 100644 index 0000000..a3dbab5 --- /dev/null +++ b/openspec/changes/ssh-key-management/design.md @@ -0,0 +1,51 @@ +## Context + +The SSHKey model exists in the database but there's no API to create, list, or manage SSH keys. Users need SSH keys for git operations with external providers. + +## Goals / Non-Goals + +**Goals:** +- Generate Ed25519 SSH key pairs via API +- Encrypt private keys with Fernet before storage +- List user's SSH keys with public key display +- Support copying public keys to clipboard +- Allow deletion of SSH keys + +**Non-Goals:** +- RSA key generation (Ed25519 only) +- Private key display/decryption to users +- SSH key editing (name changes only via update) +- Integration with git operations (separate feature) + +## Decisions + +1. **Use cryptography library for key generation** + - Ed25519 keys via `cryptography.hazmat.primitives.asymmetric.ed25519` + - Fernet symmetric encryption for private keys + - Already in project dependencies + +2. **Store private keys encrypted** + - Never expose private keys through API + - Fernet key derived from application secret + - One-way encryption, no decryption endpoint + +3. **Frontend uses simple table/list view** + - Name, created date, public key preview + - Copy button for full public key + - Generate and delete actions + +## Risks / Trade-offs + +- **[Fernet key rotation loses access to old keys]** → Document that rotating JWT_SECRET effectively locks old SSH keys +- **[Private key storage is only as secure as Fernet key]** → Store Fernet key securely, use strong application secret + +## Migration Plan + +1. Create API endpoints +2. Add frontend page +3. Register router +4. Run tests + +## Open Questions + +- Should we allow SSH key naming during generation? diff --git a/openspec/changes/ssh-key-management/proposal.md b/openspec/changes/ssh-key-management/proposal.md new file mode 100644 index 0000000..bdf4ca7 --- /dev/null +++ b/openspec/changes/ssh-key-management/proposal.md @@ -0,0 +1,25 @@ +## Why + +SSH keys are required for git operations with external providers. Currently the SSHKey model exists but there's no API or UI to generate, manage, or use SSH keys. + +## What Changes + +- Add backend API endpoints for SSH key CRUD operations +- Implement Ed25519 key generation with Fernet encryption for private keys +- Add frontend page for SSH key management +- Display public keys with copy functionality + +## Capabilities + +### New Capabilities +- `ssh-key-management`: Generate and manage SSH keys for git operations + +### Modified Capabilities +- None + +## Impact + +- `apps/api/src/api/`: New ssh_keys.py router +- `apps/api/src/models/ssh_key.py`: May need updates +- `apps/web/src/`: New SSH keys page +- `apps/api/src/main.py`: Register new router diff --git a/openspec/changes/ssh-key-management/specs/ssh-key-management/spec.md b/openspec/changes/ssh-key-management/specs/ssh-key-management/spec.md new file mode 100644 index 0000000..488ee9b --- /dev/null +++ b/openspec/changes/ssh-key-management/specs/ssh-key-management/spec.md @@ -0,0 +1,38 @@ +## ADDED Requirements + +### Requirement: SSH Key Generation + +The system SHALL generate Ed25519 SSH key pairs and encrypt the private key with Fernet. + +#### Scenario: Generate key +- **WHEN** an authenticated user requests a new SSH key with a name +- **THEN** an Ed25519 key pair is generated +- **AND** the private key is encrypted with Fernet +- **AND** the public key is stored in OpenSSH format +- **AND** the key is associated with the user + +### Requirement: SSH Key Listing + +The system SHALL list all SSH keys for the authenticated user. + +#### Scenario: List keys +- **WHEN** an authenticated user views their SSH keys +- **THEN** all their keys are listed with name, public key preview, and created date + +### Requirement: SSH Key Display + +The system SHALL display public keys for copying. + +#### Scenario: Copy public key +- **WHEN** an authenticated user views an SSH key +- **THEN** the full public key is displayed in OpenSSH format +- **AND** a copy-to-clipboard button is available + +### Requirement: SSH Key Deletion + +The system SHALL support key removal. + +#### Scenario: Delete key +- **WHEN** an authenticated user deletes an SSH key +- **THEN** it's removed from the database +- **AND** the key files are deleted if stored on disk diff --git a/openspec/changes/ssh-key-management/tasks.md b/openspec/changes/ssh-key-management/tasks.md new file mode 100644 index 0000000..bb0a84b --- /dev/null +++ b/openspec/changes/ssh-key-management/tasks.md @@ -0,0 +1,21 @@ +## 1. Backend SSH Key API + +- [ ] 1.1 Create `apps/api/src/api/ssh_keys.py` with endpoints for list, generate, and delete SSH keys. +- [ ] 1.2 Implement Ed25519 key generation using cryptography library. +- [ ] 1.3 Implement Fernet encryption for private keys. +- [ ] 1.4 Add Pydantic schemas for SSHKeyCreate, SSHKeyResponse. +- [ ] 1.5 Register ssh_keys router in `apps/api/src/main.py`. +- [ ] 1.6 Add backend tests for SSH key CRUD operations. + +## 2. Frontend SSH Keys Page + +- [ ] 2.1 Create `apps/web/src/api/ssh_keys.ts` with API methods. +- [ ] 2.2 Create `apps/web/src/pages/ssh-keys.tsx` with key list, generate form, and delete action. +- [ ] 2.3 Add `/ssh-keys` route in `apps/web/src/router.tsx`. +- [ ] 2.4 Update app shell navigation to include SSH keys link. + +## 3. Verification + +- [ ] 3.1 Run backend checks (pytest, ruff, mypy). +- [ ] 3.2 Run frontend checks (npm test, typecheck, lint, build). +- [ ] 3.3 Update tasks file with completed checkboxes. diff --git a/openspec/changes/user-profile/.openspec.yaml b/openspec/changes/user-profile/.openspec.yaml deleted file mode 100644 index 66da1ae..0000000 --- a/openspec/changes/user-profile/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-05-17 diff --git a/openspec/changes/user-profile/README.md b/openspec/changes/user-profile/README.md deleted file mode 100644 index 4ef8ed7..0000000 --- a/openspec/changes/user-profile/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# user-profile - -Implement authenticated user profile read/update flow across API and frontend diff --git a/openspec/changes/user-profile/design.md b/openspec/changes/user-profile/design.md deleted file mode 100644 index 9a18694..0000000 --- a/openspec/changes/user-profile/design.md +++ /dev/null @@ -1,58 +0,0 @@ -## Context - -The user-profile spec requires authenticated users to view and update their profile (name, email, avatar). The User model already has `email`, `name`, and `avatar_url` fields. The auth system provides cookie-based JWT authentication. This change connects those pieces into a working profile management flow. - -## Goals / Non-Goals - -**Goals:** -- Provide `GET /users/me` to retrieve the current user's profile. -- Provide `PUT /users/me` to update name and email with validation. -- Provide `POST /users/me/avatar` to upload an avatar image (PNG/JPG, max 2MB). -- Store uploaded avatars locally under `apps/api/uploads/avatars/`. -- Add a frontend `/profile` page with edit form and avatar upload UI. -- Update the app shell to link to the profile page. - -**Non-Goals:** -- Social features or public profile pages. -- External avatar providers (Gravatar, etc.). -- Image resizing or cropping. - -## Decisions - -1. **Add a dedicated `/users` router instead of extending `/auth/me`** - - Rationale: cleaner separation of concerns; auth routes handle login/logout, user routes handle profile data. - - Alternative: extend `/auth/me` to support PUT. Rejected to keep auth router focused. - -2. **Use `UploadFile` from FastAPI for avatar uploads** - - Rationale: standard FastAPI pattern, handles multipart parsing and streaming. - - Alternative: raw bytes in JSON body. Rejected as it complicates client and server. - -3. **Store avatars as files locally, not in the database** - - Rationale: keeps the database lightweight; files are served statically. - - Alternative: bytea/blob column. Rejected for performance and simplicity. - -4. **Use a simple form-based profile page in the frontend** - - Rationale: consistent with existing project pages and forms. - - Alternative: modal or inline editing. Rejected to keep implementation straightforward. - -## Risks / Trade-offs - -- **[File storage path]** -> use an environment-configurable upload directory; default to `apps/api/uploads/avatars`. -- **[Filename collisions]** -> use UUID-based filenames to avoid collisions. -- **[Unauthorized access to avatars]** -> for now, serve via static mount; later can add auth if needed. -- **[Frontend state sync]** -> after profile update, refresh auth context so the app shell shows updated name. - -## Migration Plan - -1. Create backend users router with GET/PUT/avatar endpoints. -2. Register router in main.py. -3. Create frontend profile page, API methods, and routing. -4. Update app shell with profile link. -5. Run quality gates (pytest, mypy, ruff, typecheck, lint). - -Rollback: -- Remove users router and frontend page; no database changes needed. - -## Open Questions - -- Should old avatars be deleted on new upload? (Yes, to avoid disk bloat.) \ No newline at end of file diff --git a/openspec/changes/user-profile/proposal.md b/openspec/changes/user-profile/proposal.md deleted file mode 100644 index 2e315bd..0000000 --- a/openspec/changes/user-profile/proposal.md +++ /dev/null @@ -1,24 +0,0 @@ -## Why - -The platform has authentication but users cannot view or edit their own profile information. Implementing profile management is essential for personalization and account management. - -## What Changes - -- Add authenticated user profile API endpoints (read, update, avatar upload). -- Add a frontend profile page with editable form and avatar upload. -- Validate avatar file type and size on upload. -- Store avatars locally and update the user's avatar_url. - -## Capabilities - -### New Capabilities -- `user-profile-management`: Users can view and edit their profile (name, email) and upload an avatar. - -### Modified Capabilities -- `auth-oauth`: Extend /auth/me or add dedicated /users/me endpoint for richer profile data. - -## Impact - -- Backend changes in `apps/api/src/api/` (new users router) and storage for avatars. -- Frontend changes in `apps/web/src/` (new profile page, API methods, routing). -- No schema migrations required (avatar_url already exists on User model). \ No newline at end of file diff --git a/openspec/changes/user-profile/specs/user-profile/spec.md b/openspec/changes/user-profile/specs/user-profile/spec.md deleted file mode 100644 index 51787b3..0000000 --- a/openspec/changes/user-profile/specs/user-profile/spec.md +++ /dev/null @@ -1,50 +0,0 @@ -# User Profile Management Specification - -## Purpose - -Manage user profiles including personal information and avatar. - -## Requirements - -### Requirement: Profile Retrieval - -The system SHALL allow users to view their profile. - -#### Scenario: View profile -- GIVEN an authenticated user -- WHEN they access the profile page -- THEN their name, email, and avatar are displayed - -### Requirement: Profile Updates - -The system SHALL allow users to update their profile. - -#### Scenario: Update name and email -- GIVEN an authenticated user -- WHEN they submit profile changes -- THEN the system validates the input -- AND updates the user record - -### Requirement: Avatar Upload - -The system SHALL support local avatar storage. - -#### Scenario: Upload avatar -- GIVEN an authenticated user -- WHEN they upload an image file -- THEN the system validates the file type and size -- AND stores it locally -- AND updates the user's avatar URL - -## Dependencies - -- auth-oauth (authenticated users) -- Database models: User - -## Quality Gates - -- `pytest` must pass -- `mypy .` must pass -- `ruff check .` must pass -- `npm run typecheck` must pass -- `npm run lint` must pass diff --git a/openspec/changes/user-profile/tasks.md b/openspec/changes/user-profile/tasks.md deleted file mode 100644 index 05f7a6b..0000000 --- a/openspec/changes/user-profile/tasks.md +++ /dev/null @@ -1,28 +0,0 @@ -## 1. Backend profile API - -- [x] 1.1 Create `apps/api/src/api/users.py` with `GET /users/me`, `PUT /users/me`, and `POST /users/me/avatar` endpoints. -- [x] 1.2 Add Pydantic schemas for `UserProfileResponse` and `UserProfileUpdate`. -- [x] 1.3 Implement avatar upload: validate file type (image/png, image/jpeg), max 2MB, save to `uploads/avatars/` with UUID filename, update `avatar_url`. -- [x] 1.4 Register users router in `apps/api/src/main.py`. -- [x] 1.5 Add backend tests for profile read, update, and avatar upload. - -## 2. Frontend profile page - -- [x] 2.1 Create `apps/web/src/api/profile.ts` with API methods for getProfile, updateProfile, and uploadAvatar. -- [x] 2.2 Create `apps/web/src/pages/profile.tsx` with profile display, edit form (name, email), and avatar upload. -- [x] 2.3 Add `/profile` route in `apps/web/src/router.tsx`. -- [x] 2.4 Update `apps/web/src/components/app-shell.tsx` to link to `/profile` from the user chip. -- [x] 2.5 Update `apps/web/src/types.ts` to include `avatar_url` in `SessionUser` if needed. -- [ ] 2.6 Add frontend tests for profile page rendering and interactions. - -## 3. Verification and OpenSpec tracking - -- [x] 3.1 Run backend checks (`pytest`, `ruff check src tests`, `mypy src`) and fix findings. -- [x] 3.2 Run frontend checks (`npm test`, `npm run typecheck`, `npm run lint`, `npm run build`) and fix findings. -- [x] 3.3 Update this tasks file with completed checkboxes and document blockers/follow-ups. - -## Blockers / Follow-ups - -- No blocking issues remain for this change. -- Frontend tests for profile page (task 2.6) were skipped to keep the change focused; existing tests pass (12/12). Profile page tests can be added in a follow-up. -- Vite deprecation warnings from `vite:react-babel` plugin are non-blocking and pre-existing. \ No newline at end of file