feat: implement SSH key management
- Add backend API endpoints for SSH key CRUD (POST, GET, DELETE) - Implement Ed25519 key generation with Fernet-encrypted private keys - Add frontend SSH keys page with generate, list, and delete functionality - Include copy-to-clipboard for public keys - Add responsive CSS styles for key cards - Register ssh_keys router in main.py - Add basic auth tests for SSH key endpoints Quality gates: ruff ✓, mypy ✓, typecheck ✓, lint ✓
This commit is contained in:
@@ -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()
|
||||||
@@ -3,10 +3,12 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
|
|
||||||
from src.api.auth import router as auth_router
|
from src.api.auth import router as auth_router
|
||||||
from src.api.projects import router as projects_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
|
from src.api.users import router as users_router
|
||||||
|
|
||||||
app = FastAPI(title="Headquarter API")
|
app = FastAPI(title="Headquarter API")
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(projects_router)
|
app.include_router(projects_router)
|
||||||
app.include_router(users_router)
|
app.include_router(users_router)
|
||||||
|
app.include_router(ssh_keys_router)
|
||||||
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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<SSHKey[]> {
|
||||||
|
const response = await apiClient.get<SSHKey[]>("/ssh-keys");
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSSHKey(data: SSHKeyCreate): Promise<SSHKey> {
|
||||||
|
const response = await apiClient.post<SSHKey>("/ssh-keys", data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSSHKey(keyId: string): Promise<void> {
|
||||||
|
await apiClient.delete(`/ssh-keys/${keyId}`);
|
||||||
|
}
|
||||||
@@ -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<SSHKey[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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 <div>Loading...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="stack">
|
||||||
|
<h1>SSH Keys</h1>
|
||||||
|
|
||||||
|
{error && <div className="error">{error}</div>}
|
||||||
|
|
||||||
|
<form onSubmit={handleGenerate} className="stack">
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="key-name">Key Name</label>
|
||||||
|
<input
|
||||||
|
id="key-name"
|
||||||
|
type="text"
|
||||||
|
value={newKeyName}
|
||||||
|
onChange={(e) => setNewKeyName(e.target.value)}
|
||||||
|
placeholder="e.g., GitHub Work"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="primary-button" disabled={generating}>
|
||||||
|
{generating ? "Generating..." : "Generate SSH Key"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="keys-list">
|
||||||
|
{keys.length === 0 ? (
|
||||||
|
<p className="muted">No SSH keys yet. Generate one above.</p>
|
||||||
|
) : (
|
||||||
|
keys.map((key) => (
|
||||||
|
<div key={key.id} className="key-card">
|
||||||
|
<div className="key-header">
|
||||||
|
<h3>{key.name}</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(key.id)}
|
||||||
|
className="danger-button"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="key-meta">
|
||||||
|
<span className="muted">
|
||||||
|
Created: {new Date(key.created_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="key-public">
|
||||||
|
<code>{key.public_key.substring(0, 50)}...</code>
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(key.public_key)}
|
||||||
|
className="secondary-button"
|
||||||
|
>
|
||||||
|
Copy Full Key
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@ import { DashboardPage } from "./pages/dashboard";
|
|||||||
import { LoginRedirectPage, NotFoundPage, PlaceholderPage } from "./pages/placeholder";
|
import { LoginRedirectPage, NotFoundPage, PlaceholderPage } from "./pages/placeholder";
|
||||||
import { ProfilePage } from "./pages/profile";
|
import { ProfilePage } from "./pages/profile";
|
||||||
import { ProjectsPage } from "./pages/projects";
|
import { ProjectsPage } from "./pages/projects";
|
||||||
|
import { SSHKeysPage } from "./pages/ssh-keys";
|
||||||
|
|
||||||
export const AppRouter = () => {
|
export const AppRouter = () => {
|
||||||
return (
|
return (
|
||||||
@@ -22,7 +23,7 @@ export const AppRouter = () => {
|
|||||||
<Route index element={<DashboardPage />} />
|
<Route index element={<DashboardPage />} />
|
||||||
<Route path="projects" element={<ProjectsPage />} />
|
<Route path="projects" element={<ProjectsPage />} />
|
||||||
<Route path="repositories" element={<PlaceholderPage title="Repositories" />} />
|
<Route path="repositories" element={<PlaceholderPage title="Repositories" />} />
|
||||||
<Route path="ssh-keys" element={<PlaceholderPage title="SSH Keys" />} />
|
<Route path="ssh-keys" element={<SSHKeysPage />} />
|
||||||
<Route path="profile" element={<ProfilePage />} />
|
<Route path="profile" element={<ProfilePage />} />
|
||||||
<Route path="settings" element={<PlaceholderPage title="Settings" />} />
|
<Route path="settings" element={<PlaceholderPage title="Settings" />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -270,6 +270,69 @@ a {
|
|||||||
gap: 0.6rem;
|
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) {
|
@media (max-width: 860px) {
|
||||||
.shell-body {
|
.shell-body {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -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?
|
|
||||||
@@ -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.
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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.
|
|
||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
schema: spec-driven
|
schema: spec-driven
|
||||||
created: 2026-05-17
|
created: 2026-05-18
|
||||||
@@ -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?
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
schema: spec-driven
|
|
||||||
created: 2026-05-17
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# user-profile
|
|
||||||
|
|
||||||
Implement authenticated user profile read/update flow across API and frontend
|
|
||||||
@@ -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.)
|
|
||||||
@@ -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).
|
|
||||||
@@ -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
|
|
||||||
@@ -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.
|
|
||||||
Reference in New Issue
Block a user