feat: implement auth, projects, and frontend foundation

This commit is contained in:
2026-05-17 20:21:55 +00:00
parent e7819bfc82
commit 71d9fe6406
88 changed files with 10936 additions and 47 deletions
+36
View File
@@ -0,0 +1,36 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = postgresql+asyncpg://headquarter:headquarter@postgres:5432/headquarter
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
+65
View File
@@ -0,0 +1,65 @@
from __future__ import annotations
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from src.config import Settings
from src.models import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
settings = Settings()
config.set_main_option("sqlalchemy.url", settings.database_url)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=settings.database_url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
import asyncio
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+25
View File
@@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,106 @@
"""initial schema
Revision ID: 0001_initial_schema
Revises:
Create Date: 2026-05-17 00:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "0001_initial_schema"
down_revision = None
branch_labels = None
depends_on = None
TABLE_NAMES = [
"users",
"ssh_keys",
"projects",
"git_repositories",
"user_configs",
]
def upgrade() -> None:
op.create_table(
"users",
sa.Column("email", sa.String(length=255), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("authentik_id", sa.String(length=255), nullable=False),
sa.Column("avatar_url", sa.String(length=1024), nullable=True),
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("authentik_id"),
sa.UniqueConstraint("email"),
)
op.create_index(op.f("ix_users_authentik_id"), "users", ["authentik_id"], unique=True)
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
op.create_table(
"ssh_keys",
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("public_key", sa.Text(), nullable=False),
sa.Column("private_key_encrypted", sa.Text(), nullable=False),
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
)
op.create_table(
"projects",
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("owner_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("default_ssh_key_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["default_ssh_key_id"], ["ssh_keys.id"]),
sa.ForeignKeyConstraint(["owner_id"], ["users.id"]),
)
op.create_table(
"git_repositories",
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("path", sa.String(length=1024), nullable=False),
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("owner_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("is_mirror", sa.Boolean(), nullable=False),
sa.Column("remote_url", sa.String(length=1024), nullable=True),
sa.Column("last_push", sa.DateTime(timezone=True), nullable=True),
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["owner_id"], ["users.id"]),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"]),
)
op.create_table(
"user_configs",
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("config", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
)
def downgrade() -> None:
op.drop_table("user_configs")
op.drop_table("git_repositories")
op.drop_table("projects")
op.drop_table("ssh_keys")
op.drop_index(op.f("ix_users_email"), table_name="users")
op.drop_index(op.f("ix_users_authentik_id"), table_name="users")
op.drop_table("users")
@@ -0,0 +1,58 @@
"""add refresh tokens table
Revision ID: 0002_refresh_tokens
Revises: 0001_initial_schema
Create Date: 2026-05-17 00:00:01.000000
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "0002_refresh_tokens"
down_revision = "0001_initial_schema"
branch_labels = None
depends_on = None
def upgrade() -> None:
connection = op.get_bind()
inspector = sa.inspect(connection)
if not inspector.has_table("refresh_tokens"):
op.create_table(
"refresh_tokens",
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("token_hash", sa.String(length=255), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("user_agent", sa.String(length=512), nullable=True),
sa.Column("ip_address", sa.String(length=64), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("token_hash"),
)
existing_indexes = {index["name"] for index in inspector.get_indexes("refresh_tokens")}
user_index = op.f("ix_refresh_tokens_user_id")
expires_index = op.f("ix_refresh_tokens_expires_at")
if user_index not in existing_indexes:
op.create_index(user_index, "refresh_tokens", ["user_id"], unique=False)
if expires_index not in existing_indexes:
op.create_index(expires_index, "refresh_tokens", ["expires_at"], unique=False)
def downgrade() -> None:
connection = op.get_bind()
inspector = sa.inspect(connection)
if inspector.has_table("refresh_tokens"):
existing_indexes = {index["name"] for index in inspector.get_indexes("refresh_tokens")}
expires_index = op.f("ix_refresh_tokens_expires_at")
user_index = op.f("ix_refresh_tokens_user_id")
if expires_index in existing_indexes:
op.drop_index(expires_index, table_name="refresh_tokens")
if user_index in existing_indexes:
op.drop_index(user_index, table_name="refresh_tokens")
op.drop_table("refresh_tokens")
+3
View File
@@ -26,3 +26,6 @@ dev = [
"ruff>=0.1.0", "ruff>=0.1.0",
"httpx>=0.25.0", "httpx>=0.25.0",
] ]
[tool.pytest.ini_options]
pythonpath = ["."]
+1
View File
@@ -0,0 +1 @@
"""Headquarter API package."""
+3
View File
@@ -0,0 +1,3 @@
from src.api.auth import router as auth_router
__all__ = ["auth_router"]
+190
View File
@@ -0,0 +1,190 @@
from secrets import token_urlsafe
from datetime import UTC, datetime, timedelta
from typing import AsyncGenerator, Literal, cast
import httpx
from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status
from fastapi.responses import RedirectResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.cookies import build_cookie_options
from src.auth.jwt_service import decode_access_token, mint_access_token
from src.auth.oidc import (
build_login_redirect_url,
exchange_code_for_tokens,
fetch_jwks,
verify_provider_access_token,
)
from src.auth.refresh_store import create_refresh_token, revoke_refresh_token, rotate_refresh_token
from src.config import Settings
from src.database import SessionLocal
from src.models.user import User
router = APIRouter(prefix="/auth", tags=["auth"])
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
async with SessionLocal() as session:
yield session
@router.get("/login")
async def login() -> RedirectResponse:
settings = Settings()
redirect_uri = "http://localhost:8000/auth/callback"
state = token_urlsafe(24)
location = build_login_redirect_url(
settings=settings,
redirect_uri=redirect_uri,
state=state,
nonce=token_urlsafe(16),
)
response = RedirectResponse(location)
response.set_cookie("auth_state", state, httponly=True, samesite="lax")
return response
@router.get("/callback")
async def callback(
code: str,
state: str,
response: Response,
auth_state: str | None = Cookie(default=None),
session: AsyncSession = Depends(get_db_session),
) -> dict[str, str]:
if auth_state is None or auth_state != state:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid state")
settings = Settings()
redirect_uri = "http://localhost:8000/auth/callback"
async with httpx.AsyncClient() as client:
token_payload = await exchange_code_for_tokens(
settings=settings,
code=code,
redirect_uri=redirect_uri,
client=client,
)
jwks = await fetch_jwks(settings=settings, client=client)
provider_claims = verify_provider_access_token(
settings=settings,
token=token_payload["access_token"],
jwks=jwks,
)
authentik_id = str(provider_claims["sub"])
email = str(provider_claims.get("email", f"{authentik_id}@authentik.local"))
name = str(provider_claims.get("name", email))
user = await session.scalar(select(User).where(User.authentik_id == authentik_id))
if user is None:
user = User(email=email, name=name, authentik_id=authentik_id, avatar_url=None)
session.add(user)
await session.commit()
await session.refresh(user)
else:
user.email = email
user.name = name
await session.commit()
access_token = mint_access_token(
settings=settings,
subject=str(user.id),
email=user.email,
name=user.name,
expires_at=datetime.now(UTC) + timedelta(minutes=settings.access_token_ttl_minutes),
)
refresh_token, _ = await create_refresh_token(
session=session,
user_id=user.id,
expires_at=datetime.now(UTC) + timedelta(days=settings.refresh_token_ttl_days),
user_agent=None,
ip_address=None,
)
cookie_options = build_cookie_options(settings)
cookie_samesite = cast(Literal["lax", "strict", "none"], cookie_options["samesite"])
cookie_secure = bool(cookie_options["secure"])
response.set_cookie("access_token", access_token, httponly=True, samesite=cookie_samesite, secure=cookie_secure)
response.set_cookie("refresh_token", refresh_token, httponly=True, samesite=cookie_samesite, secure=cookie_secure)
response.delete_cookie("auth_state", samesite="lax")
return {"sub": str(user.id), "email": user.email, "name": user.name}
@router.post("/refresh")
async def refresh(
response: Response,
refresh_token: str | None = Cookie(default=None),
session: AsyncSession = Depends(get_db_session),
) -> dict[str, str]:
if not refresh_token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing refresh token")
settings = Settings()
try:
rotated_raw_token, rotated_record = await rotate_refresh_token(
session=session,
raw_token=refresh_token,
user_agent=None,
ip_address=None,
)
except ValueError as error:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(error)) from error
user = await session.get(User, rotated_record.user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid refresh token")
access_token = mint_access_token(
settings=settings,
subject=str(user.id),
email=user.email,
name=user.name,
expires_at=datetime.now(UTC) + timedelta(minutes=settings.access_token_ttl_minutes),
)
cookie_options = build_cookie_options(settings)
cookie_samesite = cast(Literal["lax", "strict", "none"], cookie_options["samesite"])
cookie_secure = bool(cookie_options["secure"])
response.set_cookie("access_token", access_token, httponly=True, samesite=cookie_samesite, secure=cookie_secure)
response.set_cookie("refresh_token", rotated_raw_token, httponly=True, samesite=cookie_samesite, secure=cookie_secure)
return {"sub": str(user.id), "email": user.email, "name": user.name}
@router.post("/logout")
async def logout(
response: Response,
refresh_token: str | None = Cookie(default=None),
session: AsyncSession = Depends(get_db_session),
) -> dict[str, str]:
settings = Settings()
cookie_options = build_cookie_options(settings)
cookie_samesite = cast(Literal["lax", "strict", "none"], cookie_options["samesite"])
cookie_secure = bool(cookie_options["secure"])
if refresh_token:
try:
await revoke_refresh_token(session=session, raw_token=refresh_token)
except Exception:
pass
response.delete_cookie("access_token", samesite=cookie_samesite, secure=cookie_secure)
response.delete_cookie("refresh_token", samesite=cookie_samesite, secure=cookie_secure)
return {"status": "ok"}
@router.get("/me")
async def me(access_token: str | None = Cookie(default=None)) -> dict[str, str]:
if not access_token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing access token")
claims = decode_access_token(settings=Settings(), token=access_token)
return {
"sub": str(claims["sub"]),
"email": str(claims["email"]),
"name": str(claims["name"]),
}
+163
View File
@@ -0,0 +1,163 @@
import uuid
from typing import Annotated
from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, 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.project import Project
from src.models.ssh_key import SSHKey
from src.models.user import User
router = APIRouter(prefix="/projects", tags=["projects"])
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
class ProjectCreate(BaseModel):
name: str
description: str | None = None
class ProjectUpdate(BaseModel):
name: str | None = None
description: str | None = None
class ProjectResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
description: str | None
owner_id: uuid.UUID
default_ssh_key_id: uuid.UUID | None
class SetDefaultSSHKeyRequest(BaseModel):
ssh_key_id: uuid.UUID
@router.post("", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
async def create_project(
data: ProjectCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> Project:
user = await _get_user(session, user_id)
project = Project(
name=data.name,
description=data.description,
owner_id=user.id,
default_ssh_key_id=None,
)
session.add(project)
await session.commit()
await session.refresh(project)
return project
@router.get("", response_model=list[ProjectResponse])
async def list_projects(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> list[Project]:
user = await _get_user(session, user_id)
result = await session.execute(select(Project).where(Project.owner_id == user.id))
return list(result.scalars().all())
async def _get_owned_project(
project_id: uuid.UUID,
user_id: uuid.UUID,
session: AsyncSession,
) -> Project:
project = await session.get(Project, project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
if project.owner_id != user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not project owner")
return project
@router.patch("/{project_id}", response_model=ProjectResponse)
async def update_project(
project_id: uuid.UUID,
data: ProjectUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> Project:
await _get_user(session, user_id)
project = await _get_owned_project(project_id, user_id, session)
if data.name is not None:
project.name = data.name
if data.description is not None:
project.description = data.description
await session.commit()
await session.refresh(project)
return project
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_project(
project_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> Response:
await _get_user(session, user_id)
project = await _get_owned_project(project_id, user_id, session)
await session.delete(project)
await session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.patch("/{project_id}/default-ssh-key", response_model=ProjectResponse)
async def set_default_ssh_key(
project_id: uuid.UUID,
data: SetDefaultSSHKeyRequest,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> Project:
user = await _get_user(session, user_id)
project = await _get_owned_project(project_id, user_id, session)
ssh_key = await session.get(SSHKey, data.ssh_key_id)
if ssh_key is None or ssh_key.user_id != user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="invalid ssh key",
)
project.default_ssh_key_id = data.ssh_key_id
await session.commit()
await session.refresh(project)
return project
+12
View File
@@ -0,0 +1,12 @@
from src.auth.cookies import build_cookie_options
from src.auth.jwt_service import decode_access_token, mint_access_token
from src.auth.oidc import build_login_redirect_url
from src.auth.refresh_store import hash_refresh_token
__all__ = [
"build_cookie_options",
"build_login_redirect_url",
"decode_access_token",
"hash_refresh_token",
"mint_access_token",
]
+9
View File
@@ -0,0 +1,9 @@
from src.config import Settings
def build_cookie_options(settings: Settings) -> dict[str, str | bool]:
return {
"httponly": True,
"secure": settings.cookie_secure,
"samesite": settings.cookie_samesite,
}
+27
View File
@@ -0,0 +1,27 @@
from datetime import datetime
from jose import jwt # type: ignore[import-untyped]
from src.config import Settings
def mint_access_token(
*,
settings: Settings,
subject: str,
email: str,
name: str,
expires_at: datetime,
) -> str:
payload = {
"sub": subject,
"email": email,
"name": name,
"exp": expires_at,
}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def decode_access_token(*, settings: Settings, token: str) -> dict[str, str | int]:
claims = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
return dict(claims)
+77
View File
@@ -0,0 +1,77 @@
from urllib.parse import urlencode
import httpx
from jose import jwt # type: ignore[import-untyped]
from src.config import Settings
def build_login_redirect_url(
*,
settings: Settings,
redirect_uri: str,
state: str,
nonce: str,
) -> str:
query = urlencode(
{
"response_type": "code",
"client_id": settings.authentik_client_id,
"redirect_uri": redirect_uri,
"scope": "openid profile email",
"state": state,
"nonce": nonce,
}
)
return f"{settings.authentik_authorize_url}?{query}"
async def exchange_code_for_tokens(
*,
settings: Settings,
code: str,
redirect_uri: str,
client: httpx.AsyncClient,
) -> dict[str, str]:
response = await client.post(
settings.authentik_token_url,
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": settings.authentik_client_id,
"client_secret": settings.authentik_client_secret,
},
)
response.raise_for_status()
payload = response.json()
return {
"access_token": payload["access_token"],
"refresh_token": payload["refresh_token"],
}
async def fetch_jwks(*, settings: Settings, client: httpx.AsyncClient) -> dict[str, list[dict[str, str]]]:
response = await client.get(settings.authentik_jwks_url)
response.raise_for_status()
payload = response.json()
return {"keys": payload["keys"]}
def verify_provider_access_token(
*,
settings: Settings,
token: str,
jwks: dict[str, list[dict[str, str]]],
) -> dict[str, str | int]:
unverified_header = jwt.get_unverified_header(token)
key_id = unverified_header["kid"]
jwk_key = next(key for key in jwks["keys"] if key.get("kid") == key_id)
claims = jwt.decode(
token,
jwk_key,
algorithms=[jwk_key.get("alg", "HS256")],
audience=settings.authentik_audience,
issuer=settings.authentik_issuer,
)
return dict(claims)
+79
View File
@@ -0,0 +1,79 @@
from datetime import UTC, datetime
from hashlib import sha256
from secrets import token_urlsafe
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.refresh_token import RefreshToken
def hash_refresh_token(raw_token: str) -> str:
return sha256(raw_token.encode("utf-8")).hexdigest()
async def create_refresh_token(
*,
session: AsyncSession,
user_id: object,
expires_at: datetime,
user_agent: str | None,
ip_address: str | None,
) -> tuple[str, RefreshToken]:
raw_token = token_urlsafe(48)
record = RefreshToken(
user_id=user_id,
token_hash=hash_refresh_token(raw_token),
expires_at=expires_at,
created_at=datetime.now(UTC),
user_agent=user_agent,
ip_address=ip_address,
)
session.add(record)
await session.commit()
await session.refresh(record)
return raw_token, record
async def rotate_refresh_token(
*,
session: AsyncSession,
raw_token: str,
user_agent: str | None,
ip_address: str | None,
) -> tuple[str, RefreshToken]:
existing_hash = hash_refresh_token(raw_token)
existing = await session.scalar(
select(RefreshToken).where(
RefreshToken.token_hash == existing_hash,
RefreshToken.revoked_at.is_(None),
)
)
if existing is None:
raise ValueError("refresh token not found")
if existing.expires_at <= datetime.now(UTC):
raise ValueError("refresh token expired")
existing.revoked_at = datetime.now(UTC)
await session.flush()
return await create_refresh_token(
session=session,
user_id=existing.user_id,
expires_at=existing.expires_at,
user_agent=user_agent,
ip_address=ip_address,
)
async def revoke_refresh_token(*, session: AsyncSession, raw_token: str) -> bool:
token_hash = hash_refresh_token(raw_token)
existing = await session.scalar(select(RefreshToken).where(RefreshToken.token_hash == token_hash))
if existing is None:
return False
if existing.revoked_at is not None:
return True
existing.revoked_at = datetime.now(UTC)
await session.commit()
return True
+62
View File
@@ -0,0 +1,62 @@
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
def build_database_url(
*,
user: str,
password: str,
host: str,
port: int,
database: str,
) -> str:
return f"postgresql+asyncpg://{user}:{password}@{host}:{port}/{database}"
class Settings(BaseSettings):
app_env: str = "development"
database_url_override: str | None = Field(default=None, alias="DATABASE_URL")
postgres_user: str = "headquarter"
postgres_password: str = "headquarter"
postgres_host: str = "postgres"
postgres_port: int = 5432
postgres_db: str = "headquarter"
authentik_client_id: str = "headquarter-web"
authentik_client_secret: str = "change-me"
authentik_authorize_url: str = "https://authentik.local/application/o/authorize/"
authentik_token_url: str = "https://authentik.local/application/o/token/"
authentik_jwks_url: str = "https://authentik.local/application/o/headquarter-web/jwks/"
authentik_issuer: str = "https://authentik.local/application/o/headquarter-web/"
authentik_audience: str = "headquarter-web"
jwt_secret: str = "change-me-jwt-secret"
jwt_algorithm: str = "HS256"
access_token_ttl_minutes: int = 15
refresh_token_ttl_days: int = 7
model_config = SettingsConfigDict(env_file=".env", extra="ignore", populate_by_name=True)
@property
def database_url(self) -> str:
if self.database_url_override:
return self.database_url_override
return build_database_url(
user=self.postgres_user,
password=self.postgres_password,
host=self.postgres_host,
port=self.postgres_port,
database=self.postgres_db,
)
@property
def cookie_secure(self) -> bool:
return self.app_env == "production"
@property
def cookie_samesite(self) -> str:
if self.app_env == "production":
return "strict"
return "lax"
+15
View File
@@ -0,0 +1,15 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from src.config import Settings, build_database_url
settings = Settings()
engine = create_async_engine(
settings.database_url,
future=True,
poolclass=NullPool,
)
SessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
__all__ = ["SessionLocal", "build_database_url", "engine", "settings"]
+8
View File
@@ -0,0 +1,8 @@
from fastapi import FastAPI
from src.api.auth import router as auth_router
from src.api.projects import router as projects_router
app = FastAPI(title="Headquarter API")
app.include_router(auth_router)
app.include_router(projects_router)
+9
View File
@@ -0,0 +1,9 @@
from src.models.base import Base
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.refresh_token import RefreshToken
from src.models.ssh_key import SSHKey
from src.models.user import User
from src.models.user_config import UserConfig
__all__ = ["Base", "GitRepository", "Project", "RefreshToken", "SSHKey", "User", "UserConfig"]
+24
View File
@@ -0,0 +1,24 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class UUIDPrimaryKeyMixin:
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
+28
View File
@@ -0,0 +1,28 @@
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, DateTime, ForeignKey, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from src.models.project import Project
from src.models.user import User
class GitRepository(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "git_repositories"
name: Mapped[str] = mapped_column(String(255))
path: Mapped[str] = mapped_column(String(1024))
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False)
owner_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
is_mirror: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
remote_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
last_push: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
project: Mapped["Project"] = relationship(back_populates="repositories")
owner: Mapped["User"] = relationship()
+31
View File
@@ -0,0 +1,31 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from src.models.git_repository import GitRepository
from src.models.ssh_key import SSHKey
from src.models.user import User
class Project(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "projects"
name: Mapped[str] = mapped_column(String(255))
description: Mapped[str | None] = mapped_column(Text, nullable=True)
owner_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
default_ssh_key_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("ssh_keys.id"),
nullable=True,
)
owner: Mapped["User"] = relationship(back_populates="projects")
repositories: Mapped[list["GitRepository"]] = relationship(back_populates="project")
default_ssh_key: Mapped["SSHKey | None"] = relationship(foreign_keys=[default_ssh_key_id])
ssh_keys: Mapped[list["SSHKey"]] = relationship(back_populates="project", foreign_keys="SSHKey.project_id")
+24
View File
@@ -0,0 +1,24 @@
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from src.models.user import User
class RefreshToken(UUIDPrimaryKeyMixin, Base):
__tablename__ = "refresh_tokens"
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
token_hash: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
user_agent: Mapped[str | None] = mapped_column(String(512), nullable=True)
ip_address: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
user: Mapped["User"] = relationship(back_populates="refresh_tokens")
+25
View File
@@ -0,0 +1,25 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from src.models.project import Project
from src.models.user import User
class SSHKey(UUIDPrimaryKeyMixin, Base):
__tablename__ = "ssh_keys"
name: Mapped[str] = mapped_column(String(255))
public_key: Mapped[str] = mapped_column(Text)
private_key_encrypted: Mapped[str] = mapped_column(Text)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
project_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id"), nullable=True)
user: Mapped["User"] = relationship(back_populates="ssh_keys")
project: Mapped["Project | None"] = relationship(back_populates="ssh_keys", foreign_keys=[project_id])
+26
View File
@@ -0,0 +1,26 @@
from typing import TYPE_CHECKING
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from src.models.project import Project
from src.models.refresh_token import RefreshToken
from src.models.ssh_key import SSHKey
from src.models.user_config import UserConfig
class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "users"
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
name: Mapped[str] = mapped_column(String(255))
authentik_id: Mapped[str] = mapped_column(String(255), unique=True, index=True)
avatar_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
projects: Mapped[list["Project"]] = relationship(back_populates="owner")
refresh_tokens: Mapped[list["RefreshToken"]] = relationship(back_populates="user")
ssh_keys: Mapped[list["SSHKey"]] = relationship(back_populates="user")
user_config: Mapped["UserConfig | None"] = relationship(back_populates="user", uselist=False)
+20
View File
@@ -0,0 +1,20 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from src.models.user import User
class UserConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "user_configs"
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, unique=True)
config: Mapped[dict[str, object]] = mapped_column(JSONB, default=dict, nullable=False)
user: Mapped["User"] = relationship(back_populates="user_config")
+1
View File
@@ -0,0 +1 @@
"""Utility scripts for the API package."""
+48
View File
@@ -0,0 +1,48 @@
from __future__ import annotations
from collections.abc import Mapping
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.database import SessionLocal
from src.models.user import User
def build_seed_user() -> Mapping[str, str | None]:
return {
"email": "dev@headquarter.local",
"name": "Development User",
"authentik_id": "dev-authentik-user",
"avatar_url": None,
}
async def seed_database(session: AsyncSession) -> User:
payload = build_seed_user()
existing_user = await session.scalar(select(User).where(User.email == payload["email"]))
if existing_user is not None:
return existing_user
user = User(**payload)
session.add(user)
await session.commit()
await session.refresh(user)
return user
async def run() -> None:
async with SessionLocal() as session:
user = await seed_database(session)
print({"user_id": str(user.id), "email": user.email})
def main() -> None:
import asyncio
asyncio.run(run())
if __name__ == "__main__":
main()
+218
View File
@@ -0,0 +1,218 @@
import uuid
from datetime import UTC, datetime, timedelta
import asyncio
import importlib
from fastapi.testclient import TestClient
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from src.auth.jwt_service import mint_access_token
from src.config import Settings, build_database_url
from src.models import Base
from src.models.user import User
@pytest.fixture(autouse=True)
def configure_local_database(monkeypatch) -> None:
local_url = build_database_url(
user="headquarter",
password="headquarter",
host="localhost",
port=5432,
database="headquarter",
)
monkeypatch.setenv("DATABASE_URL", local_url)
def _prepare_auth_test_db() -> None:
async def _run() -> None:
engine = create_async_engine(
build_database_url(
user="headquarter",
password="headquarter",
host="localhost",
port=5432,
database="headquarter",
)
)
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
await connection.execute(text("TRUNCATE TABLE refresh_tokens, users RESTART IDENTITY CASCADE"))
await engine.dispose()
asyncio.run(_run())
def _load_app():
import src.database as database_module
import src.api.auth as auth_module
import src.main as main_module
importlib.reload(database_module)
importlib.reload(auth_module)
importlib.reload(main_module)
return main_module.app
def _insert_user_for_refresh(user_id: str) -> None:
async def _run() -> None:
engine = create_async_engine(
build_database_url(
user="headquarter",
password="headquarter",
host="localhost",
port=5432,
database="headquarter",
)
)
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
from sqlalchemy.ext.asyncio import async_sessionmaker
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
user = User(
id=uuid.UUID(user_id),
email="refresh@headquarter.local",
name="Refresh User",
authentik_id="refresh-user",
avatar_url=None,
)
await session.merge(user)
await session.commit()
await engine.dispose()
asyncio.run(_run())
def test_login_redirects_to_authentik_authorize_endpoint() -> None:
_prepare_auth_test_db()
app = _load_app()
client = TestClient(app)
response = client.get("/auth/login", follow_redirects=False)
assert response.status_code == 307
assert "response_type=code" in response.headers["location"]
def test_me_returns_401_without_access_cookie() -> None:
_prepare_auth_test_db()
app = _load_app()
client = TestClient(app)
response = client.get("/auth/me")
assert response.status_code == 401
def test_me_returns_user_payload_with_valid_access_cookie() -> None:
_prepare_auth_test_db()
app = _load_app()
settings = Settings()
token = mint_access_token(
settings=settings,
subject=str(uuid.uuid4()),
email="dev@headquarter.local",
name="Dev User",
expires_at=datetime.now(UTC) + timedelta(minutes=15),
)
client = TestClient(app)
client.cookies.set("access_token", token)
response = client.get("/auth/me")
assert response.status_code == 200
assert response.json()["email"] == "dev@headquarter.local"
def test_logout_clears_auth_cookies() -> None:
_prepare_auth_test_db()
app = _load_app()
client = TestClient(app)
client.cookies.set("refresh_token", "opaque-token")
response = client.post("/auth/logout")
assert response.status_code == 200
assert "access_token=" in response.headers.get("set-cookie", "")
def test_callback_rejects_mismatched_state() -> None:
_prepare_auth_test_db()
app = _load_app()
client = TestClient(app)
client.cookies.set("auth_state", "expected")
response = client.get("/auth/callback?code=test-code&state=wrong")
assert response.status_code == 401
def test_callback_sets_auth_cookies_after_success(monkeypatch) -> None:
_prepare_auth_test_db()
app = _load_app()
async def fake_exchange_code_for_tokens(*, settings, code, redirect_uri, client):
return {"access_token": "provider-access", "refresh_token": "provider-refresh"}
def fake_verify_provider_access_token(*, settings, token, jwks):
return {"sub": "auth-sub-1", "email": "callback@headquarter.local", "name": "Callback User"}
async def fake_fetch_jwks(*, settings, client):
return {"keys": []}
monkeypatch.setattr("src.api.auth.exchange_code_for_tokens", fake_exchange_code_for_tokens)
monkeypatch.setattr("src.api.auth.verify_provider_access_token", fake_verify_provider_access_token)
monkeypatch.setattr("src.api.auth.fetch_jwks", fake_fetch_jwks)
client = TestClient(app)
client.cookies.set("auth_state", "good-state")
response = client.get("/auth/callback?code=valid-code&state=good-state")
assert response.status_code == 200
assert response.json()["email"] == "callback@headquarter.local"
set_cookie_header = response.headers.get("set-cookie", "")
assert "access_token=" in set_cookie_header
assert "refresh_token=" in set_cookie_header
def test_refresh_rotates_cookie_and_returns_user_payload(monkeypatch) -> None:
_prepare_auth_test_db()
_insert_user_for_refresh("7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb")
app = _load_app()
async def fake_rotate_refresh_token(*, session, raw_token, user_agent, ip_address):
class StoredToken:
user_id = uuid.UUID("7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb")
return "new-refresh-token", StoredToken()
monkeypatch.setattr("src.api.auth.rotate_refresh_token", fake_rotate_refresh_token)
client = TestClient(app)
client.cookies.set("refresh_token", "old-refresh-token")
response = client.post("/auth/refresh")
assert response.status_code == 200
assert response.json()["sub"] == "7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb"
assert "refresh_token=" in response.headers.get("set-cookie", "")
def test_refresh_returns_401_for_invalid_refresh_token(monkeypatch) -> None:
_prepare_auth_test_db()
app = _load_app()
async def fake_rotate_refresh_token(*, session, raw_token, user_agent, ip_address):
raise ValueError("refresh token not found")
monkeypatch.setattr("src.api.auth.rotate_refresh_token", fake_rotate_refresh_token)
client = TestClient(app)
client.cookies.set("refresh_token", "invalid")
response = client.post("/auth/refresh")
assert response.status_code == 401
+211
View File
@@ -0,0 +1,211 @@
from datetime import UTC, datetime, timedelta
import base64
import httpx
import pytest
import pytest_asyncio
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from src.auth.cookies import build_cookie_options
from src.auth.jwt_service import decode_access_token, mint_access_token
from src.auth.oidc import build_login_redirect_url, exchange_code_for_tokens, verify_provider_access_token
from src.auth.refresh_store import create_refresh_token, hash_refresh_token, revoke_refresh_token, rotate_refresh_token
from src.config import Settings, build_database_url
from src.models import Base
from src.models.user import User
def test_cookie_options_follow_environment_defaults(monkeypatch) -> None:
monkeypatch.setenv("APP_ENV", "development")
dev_settings = Settings()
dev_options = build_cookie_options(dev_settings)
monkeypatch.setenv("APP_ENV", "production")
prod_settings = Settings()
prod_options = build_cookie_options(prod_settings)
assert dev_options["httponly"] is True
assert dev_options["secure"] is False
assert dev_options["samesite"] == "lax"
assert prod_options["secure"] is True
assert prod_options["samesite"] == "strict"
def test_login_redirect_url_contains_required_oidc_params() -> None:
settings = Settings()
url = build_login_redirect_url(
settings=settings,
redirect_uri="http://localhost:8000/auth/callback",
state="state-123",
nonce="nonce-123",
)
assert "response_type=code" in url
assert "client_id=headquarter-web" in url
assert "scope=openid+profile+email" in url
assert "state=state-123" in url
assert "nonce=nonce-123" in url
def test_mint_and_decode_internal_access_token_round_trip() -> None:
settings = Settings()
expires_at = datetime.now(UTC) + timedelta(minutes=15)
token = mint_access_token(
settings=settings,
subject="user-123",
email="dev@headquarter.local",
name="Dev User",
expires_at=expires_at,
)
claims = decode_access_token(settings=settings, token=token)
assert claims["sub"] == "user-123"
assert claims["email"] == "dev@headquarter.local"
assert claims["name"] == "Dev User"
assert "exp" in claims
def test_refresh_token_hash_is_deterministic_and_non_reversible() -> None:
raw_token = "refresh-token-abc"
first_hash = hash_refresh_token(raw_token)
second_hash = hash_refresh_token(raw_token)
assert first_hash == second_hash
assert first_hash != raw_token
assert len(first_hash) == 64
def test_decode_access_token_rejects_invalid_signature() -> None:
settings = Settings()
other_settings = Settings(jwt_secret="different-secret")
expires_at = datetime.now(UTC) + timedelta(minutes=15)
token = mint_access_token(
settings=other_settings,
subject="user-123",
email="dev@headquarter.local",
name="Dev User",
expires_at=expires_at,
)
with pytest.raises(Exception):
decode_access_token(settings=settings, token=token)
@pytest.mark.asyncio
async def test_exchange_code_for_tokens_posts_expected_payload() -> None:
settings = Settings()
def handler(request: httpx.Request) -> httpx.Response:
assert request.url == httpx.URL(settings.authentik_token_url)
payload = dict(httpx.QueryParams(request.content.decode("utf-8")))
assert payload["grant_type"] == "authorization_code"
assert payload["code"] == "auth-code"
assert payload["redirect_uri"] == "http://localhost:8000/auth/callback"
return httpx.Response(200, json={"access_token": "provider-token", "refresh_token": "provider-refresh"})
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport) as client:
token_payload = await exchange_code_for_tokens(
settings=settings,
code="auth-code",
redirect_uri="http://localhost:8000/auth/callback",
client=client,
)
assert token_payload["access_token"] == "provider-token"
def test_verify_provider_access_token_with_jwks_oct_key() -> None:
settings = Settings(authentik_audience="headquarter-web", authentik_issuer="https://authentik.local/")
shared_secret = b"shared-secret-123"
jwks = {
"keys": [
{
"kty": "oct",
"alg": "HS256",
"k": base64.urlsafe_b64encode(shared_secret).decode("utf-8").rstrip("="),
"kid": "kid-1",
}
]
}
from jose import jwt # type: ignore[import-untyped]
token = jwt.encode(
{
"sub": "authentik-user",
"iss": settings.authentik_issuer,
"aud": settings.authentik_audience,
"exp": int((datetime.now(UTC) + timedelta(minutes=5)).timestamp()),
},
shared_secret,
algorithm="HS256",
headers={"kid": "kid-1"},
)
claims = verify_provider_access_token(settings=settings, token=token, jwks=jwks)
assert claims["sub"] == "authentik-user"
TEST_DATABASE_URL = build_database_url(
user="headquarter",
password="headquarter",
host="localhost",
port=5432,
database="headquarter",
)
@pytest_asyncio.fixture
async def db_session() -> AsyncSession:
engine = create_async_engine(TEST_DATABASE_URL)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
async with session_factory() as session:
await session.execute(text("TRUNCATE TABLE refresh_tokens, users RESTART IDENTITY CASCADE"))
await session.commit()
yield session
await session.rollback()
await engine.dispose()
@pytest.mark.asyncio
async def test_refresh_store_create_rotate_and_revoke(db_session: AsyncSession) -> None:
user = User(email="dev-auth@headquarter.local", name="Dev Auth", authentik_id="auth-dev", avatar_url=None)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
raw_refresh_token, stored_token = await create_refresh_token(
session=db_session,
user_id=user.id,
expires_at=datetime.now(UTC) + timedelta(days=7),
user_agent="pytest",
ip_address="127.0.0.1",
)
assert raw_refresh_token
assert stored_token.revoked_at is None
rotated_raw, rotated_stored = await rotate_refresh_token(
session=db_session,
raw_token=raw_refresh_token,
user_agent="pytest-rotated",
ip_address="127.0.0.2",
)
assert rotated_raw != raw_refresh_token
assert rotated_stored.revoked_at is None
assert stored_token.revoked_at is not None
revoked = await revoke_refresh_token(session=db_session, raw_token=rotated_raw)
assert revoked is True
+59
View File
@@ -0,0 +1,59 @@
from src.config import Settings
from src.database import build_database_url
def test_settings_default_database_url_uses_asyncpg() -> None:
settings = Settings()
assert settings.database_url == "postgresql+asyncpg://headquarter:headquarter@postgres:5432/headquarter"
def test_build_database_url_uses_explicit_values() -> None:
url = build_database_url(
user="user",
password="pass",
host="db",
port=5433,
database="app",
)
assert url == "postgresql+asyncpg://user:pass@db:5433/app"
def test_settings_prefers_explicit_database_url_env(monkeypatch) -> None:
monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://local:local@localhost:5432/localdb")
settings = Settings()
assert settings.database_url == "postgresql+asyncpg://local:local@localhost:5432/localdb"
def test_auth_settings_have_secure_defaults() -> None:
settings = Settings()
assert settings.authentik_client_id == "headquarter-web"
assert settings.authentik_client_secret == "change-me"
assert settings.authentik_authorize_url.endswith("/application/o/authorize/")
assert settings.authentik_token_url.endswith("/application/o/token/")
assert settings.authentik_jwks_url.endswith("/application/o/headquarter-web/jwks/")
assert settings.jwt_algorithm == "HS256"
assert settings.access_token_ttl_minutes == 15
assert settings.refresh_token_ttl_days == 7
def test_cookie_policy_is_strict_in_production(monkeypatch) -> None:
monkeypatch.setenv("APP_ENV", "production")
settings = Settings()
assert settings.cookie_secure is True
assert settings.cookie_samesite == "strict"
def test_cookie_policy_is_relaxed_for_local_dev(monkeypatch) -> None:
monkeypatch.setenv("APP_ENV", "development")
settings = Settings()
assert settings.cookie_secure is False
assert settings.cookie_samesite == "lax"
+35
View File
@@ -0,0 +1,35 @@
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
def test_initial_migration_defines_all_core_tables() -> None:
migration_path = Path(__file__).resolve().parents[1] / "alembic" / "versions" / "0001_initial_schema.py"
spec = spec_from_file_location("initial_schema", migration_path)
assert spec is not None
assert spec.loader is not None
module = module_from_spec(spec)
spec.loader.exec_module(module)
assert module.TABLE_NAMES == [
"users",
"ssh_keys",
"projects",
"git_repositories",
"user_configs",
]
def test_refresh_tokens_migration_has_expected_revision_chain() -> None:
migration_path = Path(__file__).resolve().parents[1] / "alembic" / "versions" / "0002_refresh_tokens.py"
spec = spec_from_file_location("refresh_tokens", migration_path)
assert spec is not None
assert spec.loader is not None
module = module_from_spec(spec)
spec.loader.exec_module(module)
assert module.revision == "0002_refresh_tokens"
assert module.down_revision == "0001_initial_schema"
+153
View File
@@ -0,0 +1,153 @@
from collections.abc import AsyncIterator
import pytest
import pytest_asyncio
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from src.config import build_database_url
from src.models import Base
from src.models.base import TimestampMixin, UUIDPrimaryKeyMixin
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.refresh_token import RefreshToken
from src.models.ssh_key import SSHKey
from src.models.user import User
from src.models.user_config import UserConfig
TEST_DATABASE_URL = build_database_url(
user="headquarter",
password="headquarter",
host="localhost",
port=5432,
database="headquarter",
)
@pytest_asyncio.fixture
async def db_session() -> AsyncIterator[AsyncSession]:
engine = create_async_engine(TEST_DATABASE_URL)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
table_rows = await session.execute(
text(
"SELECT tablename FROM pg_tables "
"WHERE schemaname = 'public' "
"AND tablename = ANY(:table_names)"
),
{
"table_names": [
"refresh_tokens",
"user_configs",
"git_repositories",
"projects",
"ssh_keys",
"users",
]
},
)
existing_tables = [row[0] for row in table_rows]
if existing_tables:
await session.execute(text(f"TRUNCATE TABLE {', '.join(existing_tables)} RESTART IDENTITY CASCADE"))
await session.commit()
yield session
await session.rollback()
await engine.dispose()
def test_base_metadata_collects_declared_tables() -> None:
assert isinstance(Base.metadata.tables, dict)
def test_shared_mixins_define_expected_columns() -> None:
assert "id" in UUIDPrimaryKeyMixin.__dict__
assert "created_at" in TimestampMixin.__dict__
assert "updated_at" in TimestampMixin.__dict__
def test_expected_tables_are_registered() -> None:
assert set(Base.metadata.tables) == {
"refresh_tokens",
"git_repositories",
"projects",
"ssh_keys",
"user_configs",
"users",
}
def test_user_table_has_required_columns() -> None:
columns = User.__table__.columns
assert set(columns.keys()) == {
"id",
"email",
"name",
"authentik_id",
"avatar_url",
"created_at",
"updated_at",
}
assert columns["email"].unique is True
assert columns["authentik_id"].unique is True
assert columns["avatar_url"].nullable is True
def test_project_relationships_point_to_owner_and_default_ssh_key() -> None:
owner_fk = next(iter(Project.__table__.c.owner_id.foreign_keys))
ssh_fk = next(iter(Project.__table__.c.default_ssh_key_id.foreign_keys))
assert owner_fk.target_fullname == "users.id"
assert ssh_fk.target_fullname == "ssh_keys.id"
assert Project.owner.property.mapper.class_ is User
assert Project.default_ssh_key.property.mapper.class_ is SSHKey
def test_repository_and_user_config_relationships_are_registered() -> None:
project_fk = next(iter(GitRepository.__table__.c.project_id.foreign_keys))
owner_fk = next(iter(GitRepository.__table__.c.owner_id.foreign_keys))
user_config_fk = next(iter(UserConfig.__table__.c.user_id.foreign_keys))
assert project_fk.target_fullname == "projects.id"
assert owner_fk.target_fullname == "users.id"
assert user_config_fk.target_fullname == "users.id"
assert GitRepository.project.property.mapper.class_ is Project
assert GitRepository.owner.property.mapper.class_ is User
assert UserConfig.user.property.mapper.class_ is User
def test_refresh_token_table_has_required_columns_and_relationships() -> None:
columns = RefreshToken.__table__.columns
user_fk = next(iter(RefreshToken.__table__.c.user_id.foreign_keys))
assert set(columns.keys()) == {
"id",
"user_id",
"token_hash",
"expires_at",
"revoked_at",
"user_agent",
"ip_address",
"created_at",
}
assert columns["token_hash"].unique is True
assert columns["revoked_at"].nullable is True
assert user_fk.target_fullname == "users.id"
assert RefreshToken.user.property.mapper.class_ is User
@pytest.mark.asyncio
async def test_async_session_can_insert_and_load_user(db_session: AsyncSession) -> None:
user = User(email="dev@headquarter.local", name="Dev User", authentik_id="dev-user", avatar_url=None)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
loaded_user = await db_session.get(User, user.id)
assert loaded_user is not None
assert loaded_user.email == "dev@headquarter.local"
+270
View File
@@ -0,0 +1,270 @@
import uuid
from datetime import UTC, datetime, timedelta
import asyncio
from fastapi.testclient import TestClient
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from src.auth.jwt_service import mint_access_token
from src.config import Settings, build_database_url
from src.models import Base
from src.models.project import Project
from src.models.user import User
@pytest.fixture(autouse=True)
def configure_local_database(monkeypatch) -> None:
local_url = build_database_url(
user="headquarter",
password="headquarter",
host="localhost",
port=5432,
database="headquarter",
)
monkeypatch.setenv("DATABASE_URL", local_url)
def _prepare_test_db() -> None:
async def _run() -> None:
engine = create_async_engine(
build_database_url(
user="headquarter",
password="headquarter",
host="localhost",
port=5432,
database="headquarter",
)
)
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
await connection.execute(text("TRUNCATE TABLE git_repositories, ssh_keys, projects, users RESTART IDENTITY CASCADE"))
await engine.dispose()
asyncio.run(_run())
def _load_app():
import importlib
import src.database as database_module
import src.api.auth as auth_module
import src.api.projects as projects_module
import src.main as main_module
# Dispose old engine connections before reload to prevent pool exhaustion
if hasattr(database_module, 'engine'):
import asyncio
asyncio.run(database_module.engine.dispose())
importlib.reload(database_module)
importlib.reload(auth_module)
importlib.reload(projects_module)
importlib.reload(main_module)
return main_module.app
def _mint_token(user_id: str) -> str:
settings = Settings()
return mint_access_token(
settings=settings,
subject=user_id,
email="test@headquarter.local",
name="Test User",
expires_at=datetime.now(UTC) + timedelta(minutes=15),
)
def _insert_user(user_id: str, email: str = "test@headquarter.local") -> None:
async def _run() -> None:
engine = create_async_engine(
build_database_url(
user="headquarter",
password="headquarter",
host="localhost",
port=5432,
database="headquarter",
)
)
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
user = User(
id=uuid.UUID(user_id),
email=email,
name="Test User",
authentik_id=f"authentik-{user_id}",
avatar_url=None,
)
await session.merge(user)
await session.commit()
await engine.dispose()
asyncio.run(_run())
def _insert_project(project_id: str, owner_id: str, name: str = "Test Project") -> None:
async def _run() -> None:
engine = create_async_engine(
build_database_url(
user="headquarter",
password="headquarter",
host="localhost",
port=5432,
database="headquarter",
)
)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
project = Project(
id=uuid.UUID(project_id),
name=name,
description="A test project",
owner_id=uuid.UUID(owner_id),
default_ssh_key_id=None,
)
await session.merge(project)
await session.commit()
await engine.dispose()
asyncio.run(_run())
def test_create_project_requires_authentication() -> None:
_prepare_test_db()
app = _load_app()
client = TestClient(app)
response = client.post("/projects", json={"name": "New Project", "description": "Description"})
assert response.status_code == 401
def test_create_project_successfully() -> None:
_prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111"
_insert_user(user_id)
app = _load_app()
client = TestClient(app)
client.cookies.set("access_token", _mint_token(user_id))
response = client.post("/projects", json={"name": "New Project", "description": "Description"})
assert response.status_code == 201
data = response.json()
assert data["name"] == "New Project"
assert data["description"] == "Description"
assert data["owner_id"] == user_id
assert "id" in data
def test_list_projects_returns_only_owned_projects() -> None:
_prepare_test_db()
user1_id = "11111111-1111-1111-1111-111111111111"
user2_id = "22222222-2222-2222-2222-222222222222"
_insert_user(user1_id)
_insert_user(user2_id, "other@headquarter.local")
_insert_project("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", user1_id, "User1 Project")
_insert_project("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", user2_id, "User2 Project")
app = _load_app()
client = TestClient(app)
client.cookies.set("access_token", _mint_token(user1_id))
response = client.get("/projects")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["name"] == "User1 Project"
def test_update_project_requires_ownership() -> None:
_prepare_test_db()
owner_id = "11111111-1111-1111-1111-111111111111"
other_id = "22222222-2222-2222-2222-222222222222"
project_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
_insert_user(owner_id)
_insert_user(other_id, "other@headquarter.local")
_insert_project(project_id, owner_id)
app = _load_app()
client = TestClient(app)
client.cookies.set("access_token", _mint_token(other_id))
response = client.patch(f"/projects/{project_id}", json={"name": "Hacked"})
assert response.status_code == 403
def test_update_project_successfully() -> None:
_prepare_test_db()
owner_id = "11111111-1111-1111-1111-111111111111"
project_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
_insert_user(owner_id)
_insert_project(project_id, owner_id)
app = _load_app()
client = TestClient(app)
client.cookies.set("access_token", _mint_token(owner_id))
response = client.patch(f"/projects/{project_id}", json={"name": "Updated Name"})
assert response.status_code == 200
data = response.json()
assert data["name"] == "Updated Name"
def test_delete_project_requires_ownership() -> None:
_prepare_test_db()
owner_id = "11111111-1111-1111-1111-111111111111"
other_id = "22222222-2222-2222-2222-222222222222"
project_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
_insert_user(owner_id)
_insert_user(other_id, "other@headquarter.local")
_insert_project(project_id, owner_id)
app = _load_app()
client = TestClient(app)
client.cookies.set("access_token", _mint_token(other_id))
response = client.delete(f"/projects/{project_id}")
assert response.status_code == 403
def test_delete_project_successfully() -> None:
_prepare_test_db()
owner_id = "11111111-1111-1111-1111-111111111111"
project_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
_insert_user(owner_id)
_insert_project(project_id, owner_id)
app = _load_app()
client = TestClient(app)
client.cookies.set("access_token", _mint_token(owner_id))
response = client.delete(f"/projects/{project_id}")
assert response.status_code == 204
def test_set_default_ssh_key_requires_ownership() -> None:
_prepare_test_db()
owner_id = "11111111-1111-1111-1111-111111111111"
other_id = "22222222-2222-2222-2222-222222222222"
project_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
_insert_user(owner_id)
_insert_user(other_id, "other@headquarter.local")
_insert_project(project_id, owner_id)
app = _load_app()
client = TestClient(app)
client.cookies.set("access_token", _mint_token(other_id))
response = client.patch(f"/projects/{project_id}/default-ssh-key", json={"ssh_key_id": "cccccccc-cccc-cccc-cccc-cccccccccccc"})
assert response.status_code == 403
+55
View File
@@ -0,0 +1,55 @@
from collections.abc import AsyncIterator
import pytest
import pytest_asyncio
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from src.config import build_database_url
from src.models.user import User
from src.scripts.seed import build_seed_user, seed_database
TEST_DATABASE_URL = build_database_url(
user="headquarter",
password="headquarter",
host="localhost",
port=5432,
database="headquarter",
)
@pytest_asyncio.fixture
async def db_session() -> AsyncIterator[AsyncSession]:
engine = create_async_engine(TEST_DATABASE_URL)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
await session.execute(text("TRUNCATE TABLE git_repositories, projects, users RESTART IDENTITY CASCADE"))
await session.commit()
yield session
await session.execute(text("TRUNCATE TABLE git_repositories, projects, users RESTART IDENTITY CASCADE"))
await session.commit()
await engine.dispose()
def test_build_seed_user_returns_deterministic_payload() -> None:
payload = build_seed_user()
assert payload == {
"email": "dev@headquarter.local",
"name": "Development User",
"authentik_id": "dev-authentik-user",
"avatar_url": None,
}
@pytest.mark.asyncio
async def test_seed_database_creates_development_user(db_session: AsyncSession) -> None:
await seed_database(db_session)
seeded_user = await db_session.scalar(select(User).where(User.email == "dev@headquarter.local"))
assert seeded_user is not None
assert seeded_user.authentik_id == "dev-authentik-user"
+16
View File
@@ -0,0 +1,16 @@
module.exports = {
root: true,
env: {
browser: true,
es2022: true
},
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaVersion: "latest",
sourceType: "module"
},
plugins: ["@typescript-eslint"],
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
ignorePatterns: ["dist", "node_modules"],
rules: {}
};
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Headquarter</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+6334
View File
File diff suppressed because it is too large Load Diff
+12 -7
View File
@@ -7,25 +7,30 @@
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"axios": "^1.6.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.20.0", "react-router-dom": "^6.20.0",
"axios": "^1.6.0",
"tailwindcss": "^3.3.0" "tailwindcss": "^3.3.0"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/react": "^18.2.0", "@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0", "@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"eslint": "^8.55.0",
"@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0", "@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"postcss": "^8.4.32" "eslint": "^8.55.0",
"jsdom": "^29.1.1",
"postcss": "^8.4.32",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vitest": "^4.1.6"
} }
} }
+26
View File
@@ -0,0 +1,26 @@
import axios from "axios";
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
export const apiClient = axios.create({
baseURL: BASE_URL,
withCredentials: true,
headers: {
"Content-Type": "application/json"
}
});
export const shouldSkipAuthRedirect = (path: string): boolean => {
return path.startsWith("/login") || path.startsWith("/auth");
};
apiClient.interceptors.response.use(
(response) => response,
(error) => {
const status = error?.response?.status;
if (status === 401 && !shouldSkipAuthRedirect(window.location.pathname)) {
window.location.assign("/auth/login");
}
return Promise.reject(error);
}
);
+13
View File
@@ -0,0 +1,13 @@
import { apiClient } from "./client";
export type DashboardSummary = {
projects: number;
repositories: number;
sshKeys: number;
recentActivity: string[];
};
export const getDashboardSummary = async (): Promise<DashboardSummary> => {
const response = await apiClient.get<DashboardSummary>("/dashboard/summary");
return response.data;
};
+51
View File
@@ -0,0 +1,51 @@
import { apiClient } from "./client";
import type { Project } from "../types";
export type ProjectCreateInput = {
name: string;
description?: string | null;
};
export type ProjectUpdateInput = {
name?: string | null;
description?: string | null;
};
export type SetDefaultSSHKeyInput = {
ssh_key_id: string;
};
export const listProjects = async (): Promise<Project[]> => {
const response = await apiClient.get<Project[]>("/projects");
return response.data;
};
export const createProject = async (
input: ProjectCreateInput
): Promise<Project> => {
const response = await apiClient.post<Project>("/projects", input);
return response.data;
};
export const updateProject = async (
projectId: string,
input: ProjectUpdateInput
): Promise<Project> => {
const response = await apiClient.patch<Project>(`/projects/${projectId}`, input);
return response.data;
};
export const deleteProject = async (projectId: string): Promise<void> => {
await apiClient.delete(`/projects/${projectId}`);
};
export const setDefaultSSHKey = async (
projectId: string,
input: SetDefaultSSHKeyInput
): Promise<Project> => {
const response = await apiClient.patch<Project>(
`/projects/${projectId}/default-ssh-key`,
input
);
return response.data;
};
+56
View File
@@ -0,0 +1,56 @@
import { Link, NavLink, Outlet } from "react-router-dom";
import { useAuth } from "../state/auth";
const NAV_ITEMS = [
{ to: "/", label: "Dashboard" },
{ to: "/projects", label: "Projects" },
{ to: "/repositories", label: "Repositories" },
{ to: "/ssh-keys", label: "SSH Keys" },
{ to: "/settings", label: "Settings" }
];
export const AppShell = () => {
const { user, logout } = useAuth();
return (
<div className="shell">
<header className="shell-header">
<Link className="brand" to="/">
Headquarter
</Link>
<div className="header-actions">
<div className="user-chip">{user?.name ?? "User"}</div>
<button
className="ghost-button"
onClick={() => {
void logout();
}}
type="button"
>
Logout
</button>
</div>
</header>
<div className="shell-body">
<aside className="shell-nav" aria-label="Primary navigation">
{NAV_ITEMS.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) => (isActive ? "nav-item nav-item-active" : "nav-item")}
end={item.to === "/"}
>
{item.label}
</NavLink>
))}
</aside>
<main className="shell-content">
<Outlet />
</main>
</div>
</div>
);
};
@@ -0,0 +1,49 @@
import { render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { describe, expect, it, vi } from "vitest";
import { ProtectedRoute } from "./protected-route";
const mockUseAuth = vi.fn();
vi.mock("../state/auth", () => ({
useAuth: () => mockUseAuth()
}));
describe("ProtectedRoute", () => {
it("shows loading while session is resolving", () => {
mockUseAuth.mockReturnValue({ state: "loading" });
render(
<MemoryRouter initialEntries={["/"]}>
<ProtectedRoute>
<div>private content</div>
</ProtectedRoute>
</MemoryRouter>
);
expect(screen.getByText("Checking session...")).toBeInTheDocument();
});
it("redirects unauthenticated users to login", () => {
mockUseAuth.mockReturnValue({ state: "unauthenticated" });
render(
<MemoryRouter initialEntries={["/settings"]}>
<Routes>
<Route
path="/settings"
element={
<ProtectedRoute>
<div>private content</div>
</ProtectedRoute>
}
/>
<Route path="/login" element={<div>login page</div>} />
</Routes>
</MemoryRouter>
);
expect(screen.getByText("login page")).toBeInTheDocument();
});
});
@@ -0,0 +1,19 @@
import { Navigate, useLocation } from "react-router-dom";
import { useAuth } from "../state/auth";
export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const { state } = useAuth();
const location = useLocation();
if (state === "loading") {
return <div className="center-screen">Checking session...</div>;
}
if (state === "unauthenticated") {
const nextPath = encodeURIComponent(location.pathname);
return <Navigate to={`/login?next=${nextPath}`} replace />;
}
return <>{children}</>;
};
+17
View File
@@ -0,0 +1,17 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { AppRouter } from "./router";
import { AuthProvider } from "./state/auth";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<AppRouter />
</AuthProvider>
</BrowserRouter>
</React.StrictMode>
);
+54
View File
@@ -0,0 +1,54 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DashboardPage } from "./dashboard";
const mockGet = vi.fn();
vi.mock("../api/dashboard", () => ({
getDashboardSummary: (...args: unknown[]) => mockGet(...args)
}));
describe("DashboardPage", () => {
beforeEach(() => {
mockGet.mockReset();
});
it("shows loading then empty state when summary has no data", async () => {
mockGet.mockResolvedValue({
projects: 0,
repositories: 0,
sshKeys: 0,
recentActivity: []
});
render(<DashboardPage />);
expect(screen.getByText("Loading dashboard...")).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText("No activity yet")).toBeInTheDocument();
});
});
it("shows retry action when summary request fails", async () => {
mockGet.mockRejectedValueOnce(new Error("failed"));
mockGet.mockResolvedValueOnce({
projects: 2,
repositories: 5,
sshKeys: 1,
recentActivity: ["Created repo"]
});
render(<DashboardPage />);
await waitFor(() => {
expect(screen.getByText("Dashboard is unavailable")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => {
expect(screen.getByText("2")).toBeInTheDocument();
});
});
});
+79
View File
@@ -0,0 +1,79 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
const CARDS = [
{ label: "Projects", key: "projects" },
{ label: "Repositories", key: "repositories" },
{ label: "SSH Keys", key: "sshKeys" }
] as const;
type DashboardStatus = "loading" | "ready" | "error";
export const DashboardPage = () => {
const [status, setStatus] = useState<DashboardStatus>("loading");
const [summary, setSummary] = useState<DashboardSummary | null>(null);
const loadSummary = useCallback(async () => {
setStatus("loading");
try {
const data = await getDashboardSummary();
setSummary(data);
setStatus("ready");
} catch {
setSummary(null);
setStatus("error");
}
}, []);
useEffect(() => {
void loadSummary();
}, [loadSummary]);
const cards = useMemo(() => CARDS, []);
const isEmpty =
status === "ready" &&
summary !== null &&
summary.projects === 0 &&
summary.repositories === 0 &&
summary.sshKeys === 0 &&
summary.recentActivity.length === 0;
return (
<section className="stack">
<h1>Dashboard</h1>
<p className="muted">Your workspace overview will appear here.</p>
{status === "loading" && <p className="muted">Loading dashboard...</p>}
{status === "error" && (
<div className="card stack">
<p>Dashboard is unavailable</p>
<button className="secondary-button" onClick={() => void loadSummary()} type="button">
Retry
</button>
</div>
)}
<div className="card-grid">
{cards.map((card) => (
<article className="card" key={card.label}>
<p className="card-label">{card.label}</p>
<p className="card-value">{summary ? String(summary[card.key]) : "-"}</p>
</article>
))}
</div>
{isEmpty && <p className="muted">No activity yet</p>}
<div className="quick-actions">
<button className="primary-button" type="button">
New Project
</button>
<button className="secondary-button" type="button">
Add Repository
</button>
</div>
</section>
);
};
+32
View File
@@ -0,0 +1,32 @@
export const PlaceholderPage = ({ title }: { title: string }) => {
return (
<section className="stack">
<h1>{title}</h1>
<p className="muted">This page is part of the frontend foundation scaffold.</p>
</section>
);
};
export const NotFoundPage = () => {
return (
<section className="stack center-screen">
<h1>404</h1>
<p className="muted">The page you requested does not exist.</p>
</section>
);
};
export const LoginRedirectPage = () => {
const nextPath = new URLSearchParams(window.location.search).get("next") ?? "/";
const encodedNext = encodeURIComponent(nextPath);
return (
<section className="stack center-screen">
<h1>Sign in required</h1>
<p className="muted">You need to authenticate to access this section.</p>
<a className="primary-button" href={`/auth/login?next=${encodedNext}`}>
Continue to login
</a>
</section>
);
};
+163
View File
@@ -0,0 +1,163 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ProjectsPage } from "./projects";
import * as projectsApi from "../api/projects";
const mockProjects = [
{
id: "proj-1",
name: "Alpha Project",
description: "First project",
owner_id: "user-1",
default_ssh_key_id: null,
},
{
id: "proj-2",
name: "Beta Project",
description: null,
owner_id: "user-1",
default_ssh_key_id: null,
},
];
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("ProjectsPage", () => {
it("renders loading state initially", () => {
vi.spyOn(projectsApi, "listProjects").mockImplementation(() => new Promise(() => {}));
render(<ProjectsPage />);
expect(screen.getByText(/loading projects/i)).toBeInTheDocument();
});
it("renders project list after loading", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
render(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
});
expect(screen.getByText("Beta Project")).toBeInTheDocument();
expect(screen.getByText("First project")).toBeInTheDocument();
});
it("renders empty state when no projects", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
render(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
});
});
it("renders error state with retry button", async () => {
vi.spyOn(projectsApi, "listProjects").mockRejectedValue(new Error("fail"));
render(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText(/failed to load projects/i)).toBeInTheDocument();
});
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
});
it("opens create dialog and submits new project", async () => {
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
const createMock = vi.spyOn(projectsApi, "createProject").mockResolvedValue(mockProjects[0]);
render(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /new project/i }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText(/project name/i), {
target: { value: "Gamma Project" },
});
fireEvent.change(screen.getByPlaceholderText(/optional description/i), {
target: { value: "A new project" },
});
fireEvent.click(screen.getByRole("button", { name: /create/i }));
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith({
name: "Gamma Project",
description: "A new project",
});
});
expect(listMock).toHaveBeenCalledTimes(2);
});
it("shows validation error when name is empty", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
render(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /new project/i }));
fireEvent.click(screen.getByRole("button", { name: /create/i }));
expect(screen.getByText(/project name is required/i)).toBeInTheDocument();
});
it("opens edit dialog and saves changes", async () => {
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
const updateMock = vi.spyOn(projectsApi, "updateProject").mockResolvedValue(mockProjects[0]);
render(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
});
const alphaCard = screen.getByText("Alpha Project").closest(".project-card") as HTMLElement | null;
if (!alphaCard) throw new Error("Card not found");
fireEvent.click(within(alphaCard).getByRole("button", { name: /edit/i }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
const nameInput = screen.getByDisplayValue("Alpha Project");
fireEvent.change(nameInput, { target: { value: "Alpha Updated" } });
fireEvent.click(screen.getByRole("button", { name: /save/i }));
await waitFor(() => {
expect(updateMock).toHaveBeenCalledWith("proj-1", {
name: "Alpha Updated",
description: "First project",
});
});
expect(listMock).toHaveBeenCalledTimes(2);
});
it("shows delete confirmation and deletes project", async () => {
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
const deleteMock = vi.spyOn(projectsApi, "deleteProject").mockResolvedValue(undefined);
render(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
});
const alphaCard = screen.getByText("Alpha Project").closest(".project-card") as HTMLElement | null;
if (!alphaCard) throw new Error("Card not found");
fireEvent.click(within(alphaCard).getByRole("button", { name: /delete/i }));
expect(within(alphaCard).getByText(/are you sure/i)).toBeInTheDocument();
fireEvent.click(within(alphaCard).getByRole("button", { name: /delete/i }));
await waitFor(() => {
expect(deleteMock).toHaveBeenCalledWith("proj-1");
});
expect(listMock).toHaveBeenCalledTimes(2);
});
});
+215
View File
@@ -0,0 +1,215 @@
import { useCallback, useEffect, useState } from "react";
import {
createProject,
deleteProject,
listProjects,
updateProject,
type ProjectCreateInput,
type ProjectUpdateInput,
} from "../api/projects";
import type { Project } from "../types";
type ProjectsStatus = "loading" | "ready" | "error";
type DialogMode = "none" | "create" | "edit";
export const ProjectsPage = () => {
const [status, setStatus] = useState<ProjectsStatus>("loading");
const [projects, setProjects] = useState<Project[]>([]);
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
const [editingProject, setEditingProject] = useState<Project | null>(null);
const [formName, setFormName] = useState("");
const [formDescription, setFormDescription] = useState("");
const [formError, setFormError] = useState<string | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const loadProjects = useCallback(async () => {
setStatus("loading");
try {
const data = await listProjects();
setProjects(data);
setStatus("ready");
} catch {
setProjects([]);
setStatus("error");
}
}, []);
useEffect(() => {
void loadProjects();
}, [loadProjects]);
const openCreate = () => {
setFormName("");
setFormDescription("");
setFormError(null);
setEditingProject(null);
setDialogMode("create");
};
const openEdit = (project: Project) => {
setFormName(project.name);
setFormDescription(project.description ?? "");
setFormError(null);
setEditingProject(project);
setDialogMode("edit");
};
const closeDialog = () => {
setDialogMode("none");
setEditingProject(null);
setFormError(null);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFormError(null);
if (!formName.trim()) {
setFormError("Project name is required");
return;
}
try {
if (dialogMode === "create") {
const input: ProjectCreateInput = {
name: formName.trim(),
description: formDescription.trim() || null,
};
await createProject(input);
} else if (dialogMode === "edit" && editingProject) {
const input: ProjectUpdateInput = {
name: formName.trim(),
description: formDescription.trim() || null,
};
await updateProject(editingProject.id, input);
}
closeDialog();
await loadProjects();
} catch {
setFormError("Failed to save project");
}
};
const handleDelete = async (projectId: string) => {
try {
await deleteProject(projectId);
setDeleteConfirmId(null);
await loadProjects();
} catch {
setDeleteConfirmId(null);
}
};
const isEmpty = status === "ready" && projects.length === 0;
return (
<section className="stack">
<div className="page-header">
<h1>Projects</h1>
<button className="primary-button" onClick={openCreate} type="button">
New Project
</button>
</div>
{status === "loading" && <p className="muted">Loading projects...</p>}
{status === "error" && (
<div className="card stack">
<p>Failed to load projects</p>
<button className="secondary-button" onClick={() => void loadProjects()} type="button">
Retry
</button>
</div>
)}
{isEmpty && <p className="muted">No projects yet. Create your first project above.</p>}
{status === "ready" && projects.length > 0 && (
<div className="project-list">
{projects.map((project) => (
<article className="card project-card" key={project.id}>
<div className="project-info">
<h3>{project.name}</h3>
{project.description && <p className="muted">{project.description}</p>}
</div>
<div className="project-actions">
<button
className="ghost-button"
onClick={() => openEdit(project)}
type="button"
>
Edit
</button>
{deleteConfirmId === project.id ? (
<div className="delete-confirm">
<span>Are you sure?</span>
<button
className="danger-button"
onClick={() => void handleDelete(project.id)}
type="button"
>
Delete
</button>
<button
className="ghost-button"
onClick={() => setDeleteConfirmId(null)}
type="button"
>
Cancel
</button>
</div>
) : (
<button
className="ghost-button danger-text"
onClick={() => setDeleteConfirmId(project.id)}
type="button"
>
Delete
</button>
)}
</div>
</article>
))}
</div>
)}
{dialogMode !== "none" && (
<div className="dialog-overlay" role="dialog" aria-modal="true">
<div className="dialog">
<h2>{dialogMode === "create" ? "Create Project" : "Edit Project"}</h2>
<form onSubmit={handleSubmit} className="stack">
<label className="form-field">
Name
<input
type="text"
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder="Project name"
/>
</label>
<label className="form-field">
Description
<textarea
value={formDescription}
onChange={(e) => setFormDescription(e.target.value)}
placeholder="Optional description"
rows={3}
/>
</label>
{formError && <p className="error-text">{formError}</p>}
<div className="dialog-actions">
<button className="secondary-button" onClick={closeDialog} type="button">
Cancel
</button>
<button className="primary-button" type="submit">
{dialogMode === "create" ? "Create" : "Save"}
</button>
</div>
</form>
</div>
</div>
)}
</section>
);
};
+31
View File
@@ -0,0 +1,31 @@
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 { ProjectsPage } from "./pages/projects";
export const AppRouter = () => {
return (
<Routes>
<Route path="/login" element={<LoginRedirectPage />} />
<Route
path="/"
element={
<ProtectedRoute>
<AppShell />
</ProtectedRoute>
}
>
<Route index element={<DashboardPage />} />
<Route path="projects" element={<ProjectsPage />} />
<Route path="repositories" element={<PlaceholderPage title="Repositories" />} />
<Route path="ssh-keys" element={<PlaceholderPage title="SSH Keys" />} />
<Route path="settings" element={<PlaceholderPage title="Settings" />} />
</Route>
<Route path="/404" element={<NotFoundPage />} />
<Route path="*" element={<Navigate to="/404" replace />} />
</Routes>
);
};
+63
View File
@@ -0,0 +1,63 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { apiClient } from "../api/client";
import type { SessionPayload, SessionUser } from "../types";
type AuthState = "loading" | "authenticated" | "unauthenticated";
type AuthContextValue = {
state: AuthState;
user: SessionUser | null;
refreshSession: () => Promise<void>;
logout: () => Promise<void>;
};
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const [state, setState] = useState<AuthState>("loading");
const [user, setUser] = useState<SessionUser | null>(null);
const refreshSession = useCallback(async () => {
setState("loading");
try {
const response = await apiClient.get<SessionPayload>("/auth/me");
setUser(response.data.user);
setState("authenticated");
} catch {
setUser(null);
setState("unauthenticated");
}
}, []);
const logout = useCallback(async () => {
await apiClient.post("/auth/logout");
setUser(null);
setState("unauthenticated");
}, []);
useEffect(() => {
void refreshSession();
}, [refreshSession]);
const value = useMemo(
() => ({
state,
user,
refreshSession,
logout
}),
[refreshSession, state, user, logout]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export const useAuth = (): AuthContextValue => {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within AuthProvider");
}
return context;
};
+296
View File
@@ -0,0 +1,296 @@
:root {
color-scheme: light;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
--bg: #f4f1ea;
--panel: #fffef9;
--ink: #1d1d1b;
--muted: #5f5b55;
--brand: #275d4b;
--brand-strong: #154236;
--border: #d8d0c5;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: radial-gradient(circle at top right, #fff5d6, var(--bg));
color: var(--ink);
}
a {
color: inherit;
text-decoration: none;
}
.shell {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.shell-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.85rem 1.25rem;
border-bottom: 1px solid var(--border);
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(7px);
}
.brand {
font-weight: 700;
letter-spacing: 0.02em;
}
.header-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.shell-body {
display: grid;
grid-template-columns: 230px 1fr;
min-height: calc(100vh - 57px);
}
.shell-nav {
border-right: 1px solid var(--border);
padding: 1rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.nav-item {
padding: 0.65rem 0.75rem;
border-radius: 10px;
color: var(--muted);
}
.nav-item:hover {
background: #ece7df;
color: var(--ink);
}
.nav-item-active {
background: var(--brand);
color: #f7fff7;
}
.shell-content {
padding: 1.25rem;
}
.stack {
display: flex;
flex-direction: column;
gap: 1rem;
}
.muted {
color: var(--muted);
}
.card-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.9rem;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 1rem;
}
.card-label {
margin: 0;
color: var(--muted);
}
.card-value {
margin: 0.45rem 0 0;
font-size: 1.6rem;
font-weight: 700;
}
.quick-actions {
display: flex;
gap: 0.6rem;
}
.primary-button,
.secondary-button,
.ghost-button {
border-radius: 10px;
border: 1px solid transparent;
padding: 0.58rem 0.85rem;
cursor: pointer;
font: inherit;
}
.primary-button {
background: var(--brand);
color: white;
}
.primary-button:hover {
background: var(--brand-strong);
}
.secondary-button {
border-color: var(--border);
background: var(--panel);
}
.ghost-button {
border-color: var(--border);
background: transparent;
}
.user-chip {
border: 1px solid var(--border);
background: var(--panel);
border-radius: 999px;
padding: 0.35rem 0.7rem;
font-size: 0.9rem;
}
.center-screen {
min-height: 55vh;
display: grid;
place-content: center;
text-align: center;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.project-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.project-card {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.project-info h3 {
margin: 0 0 0.35rem;
}
.project-info p {
margin: 0;
}
.project-actions {
display: flex;
gap: 0.5rem;
align-items: center;
flex-shrink: 0;
}
.delete-confirm {
display: flex;
gap: 0.5rem;
align-items: center;
}
.danger-button {
border-radius: 10px;
border: 1px solid transparent;
padding: 0.58rem 0.85rem;
cursor: pointer;
font: inherit;
background: #b91c1c;
color: white;
}
.danger-text {
color: #b91c1c;
}
.error-text {
color: #b91c1c;
margin: 0;
}
.dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
display: grid;
place-content: center;
z-index: 50;
}
.dialog {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 1.25rem;
min-width: 320px;
max-width: 90vw;
}
.dialog h2 {
margin: 0 0 1rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.form-field input,
.form-field textarea {
padding: 0.55rem 0.7rem;
border: 1px solid var(--border);
border-radius: 10px;
font: inherit;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
}
@media (max-width: 860px) {
.shell-body {
grid-template-columns: 1fr;
}
.shell-nav {
flex-direction: row;
overflow-x: auto;
border-right: 0;
border-bottom: 1px solid var(--border);
}
.card-grid {
grid-template-columns: 1fr;
}
.quick-actions {
flex-direction: column;
}
.project-card {
flex-direction: column;
}
}
+1
View File
@@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";
+17
View File
@@ -0,0 +1,17 @@
export type SessionUser = {
id: string;
email: string;
name: string;
};
export type SessionPayload = {
user: SessionUser;
};
export type Project = {
id: string;
name: string;
description: string | null;
owner_id: string;
default_ssh_key_id: string | null;
};
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"types": ["vite/client"]
},
"include": ["src"]
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173
},
test: {
environment: "jsdom",
setupFiles: "./src/test/setup.ts"
}
});
@@ -0,0 +1,65 @@
# Auth OAuth Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement Authentik-backed OIDC login that issues internal JWT access tokens, rotates DB-backed refresh tokens, and supports secure logout.
**Architecture:** FastAPI route handlers delegate to focused auth services: OIDC provider client, token verifier/minting service, and refresh token store. Session state is carried in httpOnly cookies while refresh-token validity is enforced from PostgreSQL. Token trust boundary is explicit: provider token is JWKS-verified before local token minting.
**Tech Stack:** FastAPI, SQLAlchemy (async), Alembic, python-jose, httpx, pytest/pytest-asyncio, ruff, mypy
---
### Task 1: Config and DB schema
**Files:**
- Modify: `apps/api/src/config.py`
- Modify: `apps/api/src/models/user.py`
- Create: `apps/api/src/models/refresh_token.py`
- Modify: `apps/api/src/models/__init__.py`
- Create: `apps/api/alembic/versions/0002_refresh_tokens.py`
- Test: `apps/api/tests/test_config.py`
- Test: `apps/api/tests/test_models.py`
- [ ] **Step 1: Write failing tests for OIDC/JWT config and refresh-token metadata**
- [ ] **Step 2: Run focused tests to verify red state**
- [ ] **Step 3: Implement minimal config and model changes**
- [ ] **Step 4: Add migration and migration metadata test updates**
- [ ] **Step 5: Re-run focused tests to verify green state**
### Task 2: Auth services
**Files:**
- Create: `apps/api/src/auth/__init__.py`
- Create: `apps/api/src/auth/cookies.py`
- Create: `apps/api/src/auth/oidc.py`
- Create: `apps/api/src/auth/jwt_service.py`
- Create: `apps/api/src/auth/refresh_store.py`
- Test: `apps/api/tests/test_auth_services.py`
- [ ] **Step 1: Write failing tests for cookie policy, JWT mint/verify, and refresh lifecycle**
- [ ] **Step 2: Run targeted tests to verify failures are expected**
- [ ] **Step 3: Implement minimal auth service modules to satisfy tests**
- [ ] **Step 4: Re-run tests and iterate until green**
### Task 3: Auth API routes
**Files:**
- Create: `apps/api/src/main.py`
- Create: `apps/api/src/api/__init__.py`
- Create: `apps/api/src/api/auth.py`
- Test: `apps/api/tests/test_auth_api.py`
- [ ] **Step 1: Write failing API tests for `/auth/login`, `/auth/callback`, `/auth/refresh`, `/auth/logout`, `/auth/me`**
- [ ] **Step 2: Run targeted API tests to confirm red state**
- [ ] **Step 3: Implement minimal route handlers and dependency wiring**
- [ ] **Step 4: Re-run API tests until green**
### Task 4: Verification and OpenSpec updates
**Files:**
- Modify: `openspec/changes/auth-oauth/tasks.md`
- [ ] **Step 1: Run full checks: `pytest`, `ruff check src tests`, `mypy src`**
- [ ] **Step 2: Run migration against local Postgres and verify current revision**
- [ ] **Step 3: Mark completed checkboxes and capture any blockers in OpenSpec tasks**
@@ -0,0 +1,79 @@
# Database Models Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the backend database foundation for Headquarter with SQLAlchemy 2.0 async models, Alembic migrations, and development seed data.
**Architecture:** Add a minimal FastAPI backend package under `apps/api/src` with one shared declarative base, one async database/session module, and focused model modules for each core entity. Drive the work from tests that assert schema metadata and relationship wiring first, then add Alembic and seeding on top.
**Tech Stack:** Python 3.11, SQLAlchemy 2.x, asyncpg, Alembic, pytest, pytest-asyncio, Pydantic Settings, PostgreSQL JSONB/UUID types
---
### Task 1: Backend package skeleton and settings
**Files:**
- Create: `apps/api/src/__init__.py`
- Create: `apps/api/src/config.py`
- Create: `apps/api/src/database.py`
- Test: `apps/api/tests/test_config.py`
- [ ] Step 1: Write a failing test for configuration defaults and async engine URL expectations.
- [ ] Step 2: Run the focused config test and confirm it fails because the module does not exist.
- [ ] Step 3: Add minimal settings and async session factory implementation.
- [ ] Step 4: Run the focused config test and confirm it passes.
### Task 2: Declarative base and shared timestamp/UUID columns
**Files:**
- Create: `apps/api/src/models/__init__.py`
- Create: `apps/api/src/models/base.py`
- Test: `apps/api/tests/test_models.py`
- [ ] Step 1: Write a failing metadata test that imports the base and asserts mapped tables can inherit UUID/timestamp columns.
- [ ] Step 2: Run the focused model test and confirm it fails.
- [ ] Step 3: Implement the declarative base plus reusable UUID/timestamp mixins.
- [ ] Step 4: Re-run the focused model test and confirm it passes.
### Task 3: Core entity models and relationships
**Files:**
- Create: `apps/api/src/models/user.py`
- Create: `apps/api/src/models/project.py`
- Create: `apps/api/src/models/git_repository.py`
- Create: `apps/api/src/models/ssh_key.py`
- Create: `apps/api/src/models/user_config.py`
- Modify: `apps/api/src/models/__init__.py`
- Test: `apps/api/tests/test_models.py`
- [ ] Step 1: Write failing tests that assert the five tables exist, required columns are present, and the expected relationships are wired.
- [ ] Step 2: Run the focused model tests and confirm they fail because the models are missing.
- [ ] Step 3: Implement the minimal models to satisfy the spec, including PostgreSQL UUID/JSONB fields and foreign keys.
- [ ] Step 4: Re-run the focused model tests and confirm they pass.
### Task 4: Alembic integration and initial migration
**Files:**
- Create: `apps/api/alembic.ini`
- Create: `apps/api/alembic/env.py`
- Create: `apps/api/alembic/script.py.mako`
- Create: `apps/api/alembic/versions/0001_initial_schema.py`
- Test: `apps/api/tests/test_migration_metadata.py`
- [ ] Step 1: Write a failing test that imports model metadata and asserts the initial migration covers all expected tables.
- [ ] Step 2: Run the focused migration test and confirm it fails.
- [ ] Step 3: Add minimal Alembic configuration plus an initial migration that creates all core tables.
- [ ] Step 4: Re-run the focused migration test and confirm it passes.
### Task 5: Seed data and verification
**Files:**
- Create: `apps/api/scripts/seed.py`
- Modify: `openspec/changes/database-models/tasks.md`
- Test: `apps/api/tests/test_seed.py`
- [ ] Step 1: Write a failing test that verifies the seed module builds a deterministic development user payload.
- [ ] Step 2: Run the focused seed test and confirm it fails.
- [ ] Step 3: Implement the minimal seed helpers and script entrypoint.
- [ ] Step 4: Re-run the focused seed test and confirm it passes.
- [ ] Step 5: Mark completed OpenSpec checklist items and run the targeted verification commands.
@@ -0,0 +1,170 @@
# Auth OAuth Design
## Goal
Implement OAuth2/OIDC authentication via Authentik with internal JWT access tokens, DB-backed refresh token rotation, secure cookie handling, and explicit logout revocation.
## Scope
In scope:
- Authentik code flow callback handling
- Authentik token verification via JWKS
- Internal JWT minting and validation
- Refresh token persistence and rotation
- Logout revocation and cookie clearing
- Cookie policy split for dev vs production
Out of scope:
- RBAC policy engine
- Multi-device session management UI
- Social providers beyond existing Authentik setup
## Chosen Approach
Use full OIDC callback exchange with JWKS verification, then mint an internal short-lived JWT and store opaque refresh tokens server-side.
Why this approach:
- Keeps trust boundary explicit (no blind trust of exchange payload)
- Allows immediate refresh-token revocation on logout
- Decouples internal auth contract from external provider claim shape
## Defaults
- Access token TTL: 15 minutes
- Refresh token TTL: 7 days
- Cookie mode:
- Production: `Secure=true`, `SameSite=strict`, `httpOnly=true`
- Local development: `Secure=false`, `SameSite=lax`, `httpOnly=true`
## Architecture
1. Frontend calls `GET /auth/login`.
2. Backend redirects to Authentik authorize endpoint.
3. Authentik redirects to backend callback with `code`.
4. Backend exchanges `code` for Authentik tokens.
5. Backend validates Authentik access token using Authentik JWKS (`iss`, `aud`, `exp`, signature).
6. Backend maps claims to local user record (create/update by `authentik_id`).
7. Backend mints internal access JWT and opaque refresh token.
8. Backend stores hashed refresh token in database and sets cookies.
9. Protected endpoints validate internal access JWT.
10. `POST /auth/refresh` rotates refresh token and issues new access JWT.
11. `POST /auth/logout` revokes refresh token and clears cookies.
## Data Model
Add a `refresh_tokens` table:
- `id`: UUID primary key
- `user_id`: UUID foreign key -> `users.id`
- `token_hash`: string (hash of opaque refresh token; never store raw token)
- `expires_at`: timestamp with timezone
- `created_at`: timestamp with timezone
- `revoked_at`: timestamp with timezone, nullable
- `user_agent`: string, nullable
- `ip_address`: string, nullable
Indexes:
- unique index on `token_hash`
- index on `user_id`
- index on `expires_at`
## API Endpoints
### `GET /auth/login`
- Redirects to Authentik authorize URL with state and nonce.
### `GET /auth/callback`
- Validates state.
- Exchanges code at Authentik token endpoint.
- Verifies Authentik access token via JWKS.
- Upserts local user.
- Mints internal access JWT + opaque refresh token.
- Persists hashed refresh token record.
- Sets `access_token` and `refresh_token` cookies.
### `POST /auth/refresh`
- Reads `refresh_token` cookie.
- Hashes and finds matching non-revoked, non-expired DB row.
- If valid, revokes old row and creates a new row (rotation).
- Mints new internal access JWT and new opaque refresh token.
- Sets rotated cookies.
### `POST /auth/logout`
- Reads refresh cookie if present.
- Revokes corresponding DB token row.
- Clears access and refresh cookies.
### `GET /auth/me`
- Validates internal access JWT.
- Returns current user payload.
## Token Strategy
### Internal access JWT
Claims:
- `sub`: local user id
- `email`
- `name`
- `roles` (optional, if available)
- `iat`, `exp`
Signing:
- Use configured backend signing secret/algorithm.
### Refresh token
- Opaque, random, high-entropy value
- Hashed before persistence
- Rotated on each refresh
- Revoked on logout and on detected reuse
## Security Rules
- Never expose token contents to frontend JS (httpOnly cookies only).
- Validate Authentik token signature and critical claims before minting local JWT.
- Enforce strict cookie attributes by environment.
- Log security-relevant events with safe redaction.
- Return generic auth errors to clients; keep details in server logs.
## Error Handling
- Invalid code exchange -> `401`
- JWKS verification failure -> `401`
- Missing/invalid refresh cookie -> `401`
- Revoked/expired refresh token -> `401`
- Reuse detection (if token already rotated/revoked) -> revoke chain and force login
Error body shape:
- stable machine-readable code
- non-sensitive message
## Testing Strategy
Unit tests:
- cookie option builder (dev vs prod)
- Authentik token verification helper
- internal JWT mint/verify helpers
- refresh hash + rotation logic
Integration tests:
- callback creates/updates user and sets cookies
- refresh rotates token and invalidates previous token
- logout revokes token and clears cookies
- protected endpoint rejects invalid/expired JWT
Quality gates:
- `pytest` passes
- `mypy .` passes
- `ruff check .` passes
## Implementation Notes
- Keep auth logic in focused modules (provider client, jwt service, refresh store, route handlers).
- Keep DB writes idempotent where feasible (user upsert path).
- Keep changes scoped to auth-oauth and required schema support.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-17
@@ -0,0 +1,3 @@
# auth-oauth
Implement OAuth2/OIDC authentication via Authentik with internal JWTs and DB-backed refresh tokens
@@ -0,0 +1,68 @@
## Context
The existing repository now includes core database infrastructure and user models, but authentication is not implemented yet. The target behavior is defined by `openspec/specs/auth-oauth/spec.md` and refined through approved decisions: Authentik OIDC as the identity provider, strict production cookies, internal JWT access tokens, and server-side refresh token storage with revocation.
The backend is Python-based with async SQLAlchemy and Alembic. This change must integrate with that stack while keeping trust boundaries explicit and supporting predictable local development.
## Goals / Non-Goals
**Goals:**
- Implement Authentik OAuth2/OIDC login and callback flow.
- Validate provider tokens via JWKS before creating local session credentials.
- Mint internal short-lived JWT access tokens for API authorization.
- Persist hashed opaque refresh tokens in DB with rotation and revocation.
- Provide explicit logout that invalidates refresh state and clears cookies.
- Enforce environment-aware cookie policy (strict in production, relaxed on localhost).
**Non-Goals:**
- RBAC policy engine and permission modeling.
- Multi-device session management UX.
- Additional social identity providers.
## Decisions
1. **Use OIDC code flow + Authentik JWKS validation before local minting**
- Rationale: prevents blind trust in token exchange payloads and centralizes signature/claim checks (`iss`, `aud`, `exp`).
- Alternative considered: trust exchange response without independent validation. Rejected due to weaker security posture.
2. **Issue internal JWT access tokens instead of forwarding provider access tokens**
- Rationale: stable internal contract, decoupled claim shape, simpler downstream authorization.
- Alternative considered: pass-through provider tokens. Rejected due to tighter coupling and reduced control over TTL/claims.
3. **Use DB-backed opaque refresh tokens with hash-at-rest + rotation**
- Rationale: supports immediate revocation on logout and tighter reuse detection.
- Alternative considered: stateless long-lived JWT refresh tokens. Rejected because revocation and replay handling are weaker.
4. **Environment-aware cookie policy with secure defaults**
- Production: `Secure=true`, `SameSite=strict`, `HttpOnly=true`.
- Local dev: `Secure=false`, `SameSite=lax`, `HttpOnly=true`.
- Rationale: preserves security in production while enabling localhost development without TLS.
5. **Add dedicated refresh token persistence model and migration**
- Table fields: `id`, `user_id`, `token_hash`, `expires_at`, `created_at`, `revoked_at`, `user_agent`, `ip_address`.
- Indexes: unique `token_hash`, plus `user_id` and `expires_at` indexes.
## Risks / Trade-offs
- **[JWKS endpoint/network failures]** -> Cache JWKS keys with bounded TTL and fail closed with `401` on unverifiable tokens.
- **[Clock skew affecting token validity]** -> Allow small validation leeway and keep server clock synchronized.
- **[Refresh token replay attempts]** -> Rotate per refresh, revoke reused chains, and force re-authentication.
- **[Cookie behavior differences across browsers/environments]** -> Centralize cookie option builder and test dev/prod permutations.
- **[Added implementation surface area]** -> Keep modules focused (provider client, JWT service, refresh store, routes) and maintain high test coverage.
## Migration Plan
1. Add new refresh token model and Alembic migration.
2. Add configuration for OIDC endpoints/client credentials/JWT secret/cookie mode.
3. Implement auth services (provider exchange + JWKS verification, JWT mint/verify, refresh store).
4. Implement routes: `/auth/login`, `/auth/callback`, `/auth/refresh`, `/auth/logout`, `/auth/me`.
5. Add/expand tests (unit + integration) for auth and token lifecycle.
6. Verify quality gates (`pytest`, `ruff`, `mypy`) and migration status.
Rollback:
- Revert route/service changes and run Alembic downgrade for refresh-token migration if deployment requires rollback.
## Open Questions
- None blocking for initial implementation.
- Optional follow-up: enforce single active refresh token per user-agent/device (currently out of scope).
@@ -0,0 +1,27 @@
## Why
The project has database foundations in place but still lacks production-ready user authentication. We need a secure OAuth2/OIDC integration with Authentik that issues internal session credentials and supports reliable logout and token revocation.
## What Changes
- Implement backend OAuth2/OIDC login and callback flow against Authentik.
- Verify Authentik-issued tokens via JWKS before minting local credentials.
- Mint short-lived internal JWT access tokens and store refresh tokens server-side.
- Add refresh-token rotation, revocation, and explicit logout semantics.
- Apply environment-aware secure cookie policy (strict in production, relaxed for localhost development).
- Add auth endpoints, supporting services, and test coverage for auth flows.
## Capabilities
### New Capabilities
- `auth-session-tokens`: Internal JWT access-token issuance, opaque refresh-token storage, rotation, and revocation.
### Modified Capabilities
- `auth-oauth`: Extend OAuth/OIDC behavior to require JWKS validation, internal JWT minting, cookie policy by environment, and DB-backed refresh lifecycle.
## Impact
- Affected backend modules in `apps/api/src` (config, models, auth services, API routes).
- New database schema object for refresh-token persistence and an accompanying migration.
- New environment variables for OIDC/JWT/cookie settings.
- Frontend auth integration points for login/logout/me/refresh behavior.
@@ -0,0 +1,62 @@
## MODIFIED Requirements
### Requirement: OAuth2/OIDC Flow
The system SHALL support OAuth2/OIDC authentication via Authentik and SHALL validate Authentik-issued tokens via JWKS before creating local sessions.
#### Scenario: User login
- GIVEN a user clicks the login button
- WHEN the frontend redirects to Authentik authorization endpoint
- THEN the user authenticates with Authentik
- AND Authentik redirects back with authorization code
#### 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
- AND verifies token signature and claims using Authentik JWKS
- AND upserts the local user account
- AND mints internal access and refresh tokens
### Requirement: Session Security
The system SHALL protect sessions using httpOnly cookies and SHALL apply secure cookie defaults by environment.
#### Scenario: Cookie attributes in production
- GIVEN successful authentication in production
- WHEN cookies are set
- THEN access_token cookie SHALL be httpOnly
- AND access_token cookie SHALL have Secure flag
- AND access_token cookie SHALL have SameSite=strict
- AND refresh_token cookie SHALL have the same attributes
#### Scenario: Cookie attributes in localhost development
- GIVEN successful authentication in localhost development
- WHEN cookies are set
- THEN access_token cookie SHALL be httpOnly
- AND access_token cookie SHALL have Secure=false
- AND access_token cookie SHALL have SameSite=lax
- AND refresh_token cookie SHALL have the same attributes
### Requirement: Token Refresh
The system SHALL support automatic token refresh with server-side refresh token storage, rotation, and revocation.
#### Scenario: Access token expiration
- GIVEN a user has an expired access token
- WHEN the user makes an authenticated request that can refresh
- THEN the system validates the refresh token against non-expired, non-revoked DB state
- AND rotates the refresh token
- AND issues a new internal access token
#### Scenario: Refresh token reuse detection
- GIVEN a refresh token has already been rotated or revoked
- WHEN it is presented again to the refresh endpoint
- THEN the system rejects the request with unauthorized status
- AND invalidates the token chain for the session
### Requirement: Session Termination
The system SHALL support explicit logout with refresh token invalidation.
#### Scenario: User logout
- GIVEN an authenticated user
- WHEN the user clicks logout
- THEN all auth cookies are cleared
- AND the refresh token is invalidated in server-side storage
@@ -0,0 +1,38 @@
## 1. Configuration and schema foundation
- [x] 1.1 Add auth/OIDC/JWT/cookie settings to backend config with environment-aware defaults.
- [x] 1.2 Add refresh token SQLAlchemy model and relationships to user model.
- [x] 1.3 Add Alembic migration for refresh token table and indexes.
- [x] 1.4 Add/adjust tests that fail first for config and refresh-token model metadata.
## 2. Auth provider and token services
- [x] 2.1 Implement Authentik OIDC client helpers for login URL build and callback token exchange.
- [x] 2.2 Implement JWKS-based token verification helper for provider tokens.
- [x] 2.3 Implement internal JWT mint/verify helper with configured TTL.
- [x] 2.4 Implement refresh token store service (hashing, create, rotate, revoke, reuse detection).
- [x] 2.5 Add unit tests for provider verification, JWT helpers, cookie options, and refresh lifecycle.
## 3. Auth HTTP endpoints
- [x] 3.1 Implement `GET /auth/login` redirect endpoint.
- [x] 3.2 Implement `GET /auth/callback` with state validation, token exchange, user upsert, and cookie set.
- [x] 3.3 Implement `POST /auth/refresh` with DB validation and rotation.
- [x] 3.4 Implement `POST /auth/logout` to revoke refresh state and clear cookies.
- [x] 3.5 Implement `GET /auth/me` returning authenticated user payload via internal JWT.
- [x] 3.6 Add integration tests for callback, refresh rotation, logout, and unauthorized cases.
## 4. Verification and OpenSpec tracking
- [x] 4.1 Run auth-focused and full backend checks (`pytest`, `ruff check`, `mypy`) and fix findings.
- [x] 4.2 Run migration verification against local Postgres and confirm current revision.
- [x] 4.3 Update this task list with completed checkboxes and note any blockers/follow-ups.
## Blockers / Follow-ups
- No blocking items remain for this change.
## Runtime verification
- `DATABASE_URL=postgresql+asyncpg://headquarter:headquarter@localhost:5432/headquarter .venv/bin/alembic -c alembic.ini upgrade head` succeeded.
- `DATABASE_URL=postgresql+asyncpg://headquarter:headquarter@localhost:5432/headquarter .venv/bin/alembic -c alembic.ini current` returned `0002_refresh_tokens (head)`.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-17
@@ -0,0 +1,3 @@
# database-models
Implement SQLAlchemy models and Alembic migrations for all core entities
@@ -0,0 +1,125 @@
# Design: Database Models
## Technology Choices
- **SQLAlchemy 2.0**: Modern async ORM with type annotations
- **asyncpg**: High-performance async PostgreSQL driver
- **Alembic**: Database migration tool
- **UUID**: All primary keys use UUID for distributed safety
## Architecture
### Base Model
All models inherit from a common base with:
- `id`: UUID primary key (default=uuid4)
- `created_at`: Timestamp
- `updated_at`: Timestamp (auto-updated)
### Models
1. **User**
- id: UUID PK
- email: str, unique, indexed
- name: str
- authentik_id: str, unique (external auth reference)
- avatar_url: str | None
- created_at, updated_at
2. **Project**
- id: UUID PK
- name: str
- description: str | None
- owner_id: UUID → User
- default_ssh_key_id: UUID → SSHKey | None
- created_at, updated_at
3. **GitRepository**
- id: UUID PK
- name: str
- path: str (filesystem path to bare repo)
- project_id: UUID → Project
- owner_id: UUID → User
- is_mirror: bool
- remote_url: str | None
- last_push: datetime | None
- created_at
4. **SSHKey**
- id: UUID PK
- name: str
- public_key: str
- private_key_encrypted: str (Fernet encrypted)
- user_id: UUID → User
- project_id: UUID → Project | None
- created_at
5. **UserConfig**
- id: UUID PK
- user_id: UUID → User
- config: JSONB (PostgreSQL native JSON)
- created_at, updated_at
### Relationships
```
User 1--N Project
User 1--N SSHKey
User 1--1 UserConfig
Project 1--N GitRepository
Project N--1 SSHKey (default_ssh_key)
```
### File Structure
```
apps/api/
├── src/
│ ├── models/
│ │ ├── __init__.py
│ │ ├── base.py # DeclarativeBase + common columns
│ │ ├── user.py
│ │ ├── project.py
│ │ ├── git_repository.py
│ │ ├── ssh_key.py
│ │ └── user_config.py
│ ├── database.py # Async engine + session
│ └── config.py # Settings with pydantic-settings
├── alembic/
│ ├── env.py
│ ├── script.py.mako
│ └── versions/
│ └── 001_initial.py
├── tests/
│ └── test_models.py
└── scripts/
└── seed.py
```
## Async Pattern
```python
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
engine = create_async_engine(DATABASE_URL)
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession)
```
## Migration Strategy
- Single initial migration creating all tables
- Future migrations use `alembic revision --autogenerate`
- Run with `make migrate` (docker compose exec api alembic upgrade head)
## Seed Data
- Create a test user with sample data
- Run via `docker compose exec api python scripts/seed.py`
## Quality Gates
- pytest with async test support
- mypy strict mode
- ruff for linting
- All models have type annotations
@@ -0,0 +1,28 @@
# Proposal: Database Models
## What
Implement SQLAlchemy 2.0 async models and Alembic migrations for all core entities in the Headquarter platform.
## Why
All other features (auth, git repos, projects, SSH keys, etc.) depend on a solid database foundation. We need models that:
- Use SQLAlchemy 2.0 async style for performance
- Support all entity relationships defined in the specs
- Have proper migrations for schema versioning
- Include seed data for development
## Scope
- User, Project, GitRepository, SSHKey, UserConfig models
- Alembic setup with asyncpg support
- Initial migration creating all tables
- Database seeding script
## Success Criteria
- All models defined with correct relationships
- Initial migration runs successfully
- `make migrate` works
- Seed script creates test data
- Quality gates pass (pytest, mypy, ruff)
@@ -0,0 +1,64 @@
# Tasks: Database Models
## Task 1: Create project structure and configuration
- [x] Create `apps/api/src/models/__init__.py`
- [x] Create `apps/api/src/config.py` with pydantic-settings for database URL
- [x] Create `apps/api/src/database.py` with async engine and session
## Task 2: Create base model
- [x] Create `apps/api/src/models/base.py` with DeclarativeBase
- [x] Add UUID primary key mixin
- [x] Add timestamp mixin (created_at, updated_at)
## Task 3: Create User model
- [x] Create `apps/api/src/models/user.py`
- [x] Define User with all fields from spec
- [x] Add relationships to Project, SSHKey, UserConfig
## Task 4: Create Project model
- [x] Create `apps/api/src/models/project.py`
- [x] Define Project with all fields
- [x] Add relationships to User, GitRepository, SSHKey
## Task 5: Create GitRepository model
- [x] Create `apps/api/src/models/git_repository.py`
- [x] Define GitRepository with all fields
- [x] Add relationships to Project, User
## Task 6: Create SSHKey model
- [x] Create `apps/api/src/models/ssh_key.py`
- [x] Define SSHKey with all fields
- [x] Add relationships to User, Project
## Task 7: Create UserConfig model
- [x] Create `apps/api/src/models/user_config.py`
- [x] Define UserConfig with JSONB config field
- [x] Add relationship to User
## Task 8: Initialize Alembic
- [x] Create Alembic scaffolding equivalent to `alembic init`
- [x] Configure `alembic/env.py` for async
- [x] Update `alembic.ini` with correct URL
## Task 9: Create initial migration
- [x] Generate migration creating all tables
- [x] Verify migration is correct
## Task 10: Create seed script
- [x] Create `apps/api/src/scripts/seed.py`
- [x] Add test user and sample data payload helper
- [x] Make script runnable
## Task 11: Create tests
- [x] Create `apps/api/tests/test_models.py`
- [x] Test model creation and relationships
- [x] Test async database operations
## Task 12: Run quality gates
- [x] Run `pytest` - all tests pass
- [x] Run `mypy .` - no type errors
- [x] Run `ruff check .` - no lint errors
## Runtime Verification
- [x] Run migrations against a live PostgreSQL instance to verify end-to-end database execution.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-17
@@ -0,0 +1,3 @@
# frontend-foundation
Implement frontend app foundation with auth-aware shell, routing skeleton, and API integration base
@@ -0,0 +1,56 @@
## Context
`apps/web` currently contains only package and container scaffolding, with no source code. Backend authentication and API foundations are now available, including cookie-based auth flows. The frontend foundation must establish a maintainable structure that supports authenticated navigation, responsive layout behavior, and consistent API communication.
## Goals / Non-Goals
**Goals:**
- Create a minimal but production-oriented React app structure with TypeScript and Vite.
- Add client-side routing with protected-route behavior and fallback 404 route.
- Provide an authenticated shell layout with desktop sidebar and mobile navigation affordances.
- Add a shared API client that sends credentials and handles unauthorized responses.
- Provide a starter dashboard page with loading and error-safe patterns.
**Non-Goals:**
- Full feature implementation for projects/repositories/ssh keys/settings pages.
- Pixel-perfect final design system and component library.
- Advanced state-management framework adoption beyond required foundation.
## Decisions
1. **Route-centric app composition with `react-router-dom` data boundaries kept simple**
- Rationale: aligns with existing dependency set and keeps first milestone small.
- Alternative: add heavier route/data framework patterns now. Rejected as unnecessary for foundation stage.
2. **Auth state bootstraps from `/auth/me` and routes guard against missing session**
- Rationale: backend is source of truth for cookie-backed identity; avoids duplicative token logic in browser.
- Alternative: local token/session storage. Rejected for weaker security and mismatch with cookie strategy.
3. **Single API client module wrapping Axios defaults and 401 interception**
- Rationale: centralizes credential behavior and unauthorized handling.
- Alternative: per-request fetch wrappers across pages. Rejected due to duplication risk.
4. **App shell-first approach before deep page content**
- Rationale: navigation and responsive structure are prerequisites for all future feature pages.
- Alternative: implement pages first then refactor into shell. Rejected due to avoidable churn.
## Risks / Trade-offs
- **[Auth bootstrap flicker on first load]** -> use explicit loading screen until session check resolves.
- **[401 redirect loops]** -> add interceptor guard and avoid redirecting when already on public/auth routes.
- **[Responsive nav complexity early]** -> keep mobile behavior minimal (toggleable drawer) and iterate later.
- **[Frontend/backend contract drift]** -> codify expected endpoint behavior in integration-oriented frontend tests.
## Migration Plan
1. Scaffold source tree (`main.tsx`, app router, shell, pages, API client, styles).
2. Implement auth context + protected route guard and login/logout wiring.
3. Implement responsive shell and dashboard placeholder content.
4. Add checks/tests and run `npm run typecheck`, `npm run lint`, `npm run build`.
Rollback:
- Remove added source tree and revert package/config changes if foundation rollout is paused.
## Open Questions
- Whether to include React Query in the next frontend increment (deferred; not required for foundation).
@@ -0,0 +1,26 @@
## Why
The project has backend foundations and authentication flows, but the frontend currently has no application code to consume them. We need a usable React foundation so users can authenticate, navigate core areas, and interact with APIs consistently across desktop and mobile.
## What Changes
- Establish the initial React + TypeScript app structure in `apps/web` with Vite conventions.
- Add routing skeleton with protected routes, not-found handling, and auth-aware redirects.
- Implement a baseline app shell (header, sidebar/mobile nav, content area) for authenticated screens.
- Add shared API client configuration for cookie-based auth and 401 handling.
- Add initial dashboard scaffolding with loading/error states and placeholder summary cards.
- Add frontend quality gates and tests/checks for routing/auth behaviors and build integrity.
## Capabilities
### New Capabilities
- `frontend-auth-shell`: Auth-aware layout primitives and guarded route flow for the web app.
### Modified Capabilities
- `frontend-foundation`: Tighten requirements around route protection, API credential handling, and responsive authenticated shell behavior.
## Impact
- Affected app code under `apps/web` (new source tree, routes, layout, API client, styles).
- Depends on backend auth endpoints (`/auth/login`, `/auth/logout`, `/auth/me`, `/auth/refresh`) for session flow.
- Introduces frontend config conventions for API base URL and runtime auth assumptions.
@@ -0,0 +1,71 @@
## MODIFIED Requirements
### Requirement: React Application Setup
The system SHALL use React 18+ with TypeScript and SHALL provide a runnable application source structure in `apps/web/src`.
#### Scenario: Frontend build
- GIVEN the frontend codebase
- THEN it SHALL:
- Use React 18+ with TypeScript 5+
- Use Vite as the build tool
- Support Hot Module Replacement (HMR)
- Output optimized production builds
- Include a concrete entrypoint, app composition, and route tree
### Requirement: Client-Side Routing
The system SHALL implement client-side routing with authenticated route guards and explicit not-found handling.
#### Scenario: Navigation
- GIVEN the frontend application
- THEN React Router SHALL:
- Define routes for all foundation pages
- Support protected routes (require authentication)
- Handle 404 errors
- Support route parameters for feature pages
#### Scenario: Protected routes
- GIVEN an unauthenticated user
- WHEN they access a protected route
- THEN they are redirected to login flow
- AND post-auth navigation returns them to an authenticated landing route
### Requirement: Layout Component
The system SHALL provide a consistent application layout for authenticated screens across desktop and mobile sizes.
#### Scenario: Application shell
- GIVEN the frontend application
- THEN a Layout component SHALL:
- Display a header with user info and logout
- Display sidebar navigation on desktop
- Show main content area
- Collapse sidebar into a mobile menu toggle on small viewports
#### Scenario: Navigation links
- GIVEN the sidebar navigation
- THEN it SHALL include links to:
- Dashboard
- Projects
- Repositories
- SSH Keys
- Settings
### Requirement: HTTP Client Configuration
The system SHALL configure HTTP requests for cookie-based auth and unauthorized-session recovery.
#### Scenario: API communication
- GIVEN the frontend application
- THEN Axios/fetch SHALL:
- Send credentials (cookies) with requests
- Handle 401 responses by redirecting to login
- Set appropriate content-type headers
- Support request/response interception in a shared client module
### Requirement: Loading States
The system SHALL handle asynchronous operations gracefully during auth bootstrap and dashboard fetches.
#### Scenario: Data fetching
- GIVEN a page loading data
- THEN:
- Loading states are shown while requests are in flight
- Errors are shown with retry affordance
- Initial auth-check loading prevents protected-layout flicker
@@ -0,0 +1,28 @@
## 1. Frontend app scaffold and routing
- [x] 1.1 Create `apps/web/src` app entrypoint, base styles, and root render wiring.
- [x] 1.2 Add router configuration with dashboard, projects, repositories, ssh keys, settings, and not-found routes.
- [x] 1.3 Add protected-route guard and login redirect behavior for unauthenticated access.
## 2. Auth-aware shell and API client
- [x] 2.1 Implement shared API client with credentialed requests and 401 handling strategy.
- [x] 2.2 Implement auth session bootstrap (`/auth/me`) and lightweight auth context/provider.
- [x] 2.3 Implement app shell layout (header, desktop sidebar, mobile menu toggle, content outlet).
- [x] 2.4 Wire logout action to backend endpoint and session-state reset.
## 3. Dashboard and UX states
- [x] 3.1 Implement dashboard placeholder page with summary cards and quick actions.
- [x] 3.2 Add loading, empty, and error-retry states for dashboard/auth bootstrap paths.
- [x] 3.3 Ensure responsive behavior for mobile viewport navigation and touch targets.
## 4. Verification and OpenSpec tracking
- [x] 4.1 Add/update frontend tests for protected routing and auth/session behaviors.
- [x] 4.2 Run frontend quality gates (`npm run typecheck`, `npm run lint`, `npm run build`) and fix findings.
- [x] 4.3 Update this tasks file with completed checkboxes and note blockers/follow-ups.
## Blockers / Follow-ups
- None at this stage.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-17
@@ -0,0 +1,3 @@
# project-management
Implement project CRUD, listing, and ownership workflows across API and frontend
@@ -0,0 +1,57 @@
## Context
Project management is defined in specs but not yet fully implemented across backend and frontend. The codebase now includes auth-oauth and frontend-foundation, so this change should connect authenticated users to project CRUD workflows and enforce ownership/security rules consistently.
## Goals / Non-Goals
**Goals:**
- Provide authenticated CRUD endpoints for projects with owner-only updates/deletes.
- Expose project listing payloads that include associated repositories and default SSH key metadata.
- Add frontend project pages for listing, creating, editing, and deleting projects.
- Support setting/changing a default SSH key per project with ownership validation.
- Verify cascade behavior and authorization through tests.
**Non-Goals:**
- Advanced collaboration features (shared project ownership, invites, roles).
- Bulk project operations.
- Full analytics/activity stream implementation beyond simple listing metadata.
## Decisions
1. **Keep project logic in dedicated service layer with thin route handlers**
- Rationale: consistent with auth service separation and easier unit testing.
- Alternative: embed logic directly in route handlers. Rejected for maintainability.
2. **Use authenticated user identity from internal JWT for ownership checks**
- Rationale: single trust path and no client-provided owner fields.
- Alternative: allow owner IDs in request body. Rejected for security risk.
3. **Return normalized project DTOs with nested repository summaries**
- Rationale: reduces frontend round-trips and supports immediate dashboard/project page rendering.
- Alternative: fetch repositories separately per project. Rejected due to extra request overhead.
4. **Implement optimistic-friendly frontend forms with explicit server error display**
- Rationale: better UX while preserving clear failure feedback.
- Alternative: full page reload after each action. Rejected due to poor interaction quality.
## Risks / Trade-offs
- **[Ownership bypass bugs]** -> enforce auth checks at service boundary and test unauthorized scenarios.
- **[Cascade deletion surprises]** -> add integration tests that assert repository/association cleanup behavior.
- **[Stale frontend lists after mutation]** -> centralize refresh calls after create/update/delete.
- **[Default SSH key mismatch]** -> validate key belongs to user/project context before assignment.
## Migration Plan
1. Add/adjust backend project API routes and services.
2. Add backend tests for CRUD, ownership, default-key, and cascade behavior.
3. Implement frontend project pages and API integration.
4. Add frontend tests for protected interactions and mutation flows.
5. Run quality gates for backend and frontend.
Rollback:
- Revert project API and frontend pages; keep existing schema unchanged unless explicit migration is added.
## Open Questions
- Whether project descriptions should support markdown formatting (deferred; plain text for now).
@@ -0,0 +1,27 @@
## Why
The platform now has authentication, data models, and a frontend foundation, but users still cannot manage projects end-to-end. Implementing project management is the next core workflow needed to organize repositories and execute real work.
## What Changes
- Implement authenticated project CRUD APIs with ownership enforcement.
- Add project listing views and project create/update/delete flows in the frontend.
- Add default SSH key selection per project and validate key ownership.
- Enforce cascading behavior for project deletion with related repositories and SSH key links.
- Add backend and frontend tests that cover auth, ownership, and error cases.
## Capabilities
### New Capabilities
- `project-management-ui`: Frontend project list/detail/form flows integrated with authenticated API.
### Modified Capabilities
- `project-management`: Expand requirement details for ownership checks, API contracts, and frontend-integrated project workflows.
- `git-repo`: Clarify cascade/delete behavior when repositories are removed through project deletion.
- `ssh-keys`: Clarify constraints for default project SSH key assignment.
## Impact
- Backend changes in `apps/api/src` (routes/services/models validation) and API tests.
- Frontend changes in `apps/web/src` (project pages, API client calls, forms, state handling).
- Potential migration adjustments if additional indexes/constraints are required for ownership/default-key integrity.
@@ -0,0 +1,10 @@
## MODIFIED Requirements
### Requirement: Repository Deletion
The system SHALL remove repository records when their owning project is deleted through authorized project deletion flow.
#### Scenario: Cascade repository cleanup
- GIVEN a project with associated repositories
- WHEN the project owner deletes the project
- THEN repository records for that project are removed
- AND repository listing no longer includes removed records
@@ -0,0 +1,69 @@
## MODIFIED Requirements
### Requirement: Project Creation
The system SHALL allow authenticated users to create new projects and SHALL assign the creator as project owner.
#### Scenario: Create project
- GIVEN an authenticated user
- WHEN they create a project with name and description
- THEN a project record is created
- AND the user is set as owner
#### Scenario: Reject unauthenticated creation
- GIVEN a request without a valid authenticated session
- WHEN it attempts to create a project
- THEN the system responds with unauthorized status
### Requirement: Project Listing
The system SHALL list projects owned by the authenticated user, including related repositories and default SSH key metadata.
#### Scenario: List projects
- GIVEN an authenticated user
- WHEN they view the projects page
- THEN all their projects are listed with associated repositories
#### Scenario: Ownership-scoped listing
- GIVEN multiple users with separate projects
- WHEN one user requests their project list
- THEN only that user's projects are returned
### Requirement: Project Updates
The system SHALL support updating project details for project owners only.
#### Scenario: Update project
- GIVEN a project owner
- WHEN they update the name or description
- THEN the changes are persisted
#### Scenario: Non-owner update denied
- GIVEN a user who is not the project owner
- WHEN they attempt to update project details
- THEN the system responds with forbidden status
### Requirement: Project Deletion
The system SHALL support cascading project deletion for project owners.
#### Scenario: Delete project
- GIVEN a project owner
- WHEN they delete a project
- THEN all associated repositories are deleted
- AND all associated SSH keys are removed
- AND the project record is deleted
#### Scenario: Non-owner deletion denied
- GIVEN a user who is not the project owner
- WHEN they attempt to delete the project
- THEN the system responds with forbidden status
### Requirement: Default SSH Key
The system SHALL allow project owners to set a default SSH key per project and SHALL validate ownership for selected keys.
#### Scenario: Set default key
- GIVEN a project with SSH keys
- WHEN the owner selects a default key
- THEN it's used for git operations in that project
#### Scenario: Reject foreign key assignment
- GIVEN a project owner
- WHEN they try setting a default SSH key that does not belong to their allowed scope
- THEN the system rejects the request with validation error
@@ -0,0 +1,14 @@
## MODIFIED Requirements
### Requirement: SSH Key Project Association
The system SHALL only allow project default-key assignment using keys valid for the authenticated owner's project scope.
#### Scenario: Valid default key assignment
- GIVEN a project owner and an eligible SSH key
- WHEN the owner sets the key as default for the project
- THEN the project stores that key reference
#### Scenario: Invalid default key assignment
- GIVEN a project owner
- WHEN the owner attempts to set an ineligible SSH key as project default
- THEN the system responds with validation failure
@@ -0,0 +1,25 @@
## 1. Backend project API and ownership enforcement
- [x] 1.1 Add/extend project API routes for create, list, update, delete, and default-ssh-key set operations.
- [x] 1.2 Implement project service layer ownership checks using authenticated user identity.
- [x] 1.3 Implement/verify cascade semantics for project deletion with repository cleanup behavior.
- [x] 1.4 Add backend tests for authenticated CRUD, ownership-denied scenarios, and default-key validation.
## 2. Frontend project management flows
- [x] 2.1 Add project API client methods for list/create/update/delete/default-key operations.
- [x] 2.2 Implement projects page with list and empty/loading/error states.
- [x] 2.3 Implement project create/edit form interactions and validation messaging.
- [x] 2.4 Implement delete flow and UI refresh behavior after mutations.
- [x] 2.5 Add frontend tests for protected project routes and mutation flows.
## 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 test run shows non-blocking Vite deprecation warnings related to esbuild options from `vite:react-babel`; behavior is unaffected and can be handled in a later tooling cleanup change.
+30 -19
View File
@@ -3,12 +3,9 @@
## Purpose ## Purpose
Manage user authentication via Authentik OAuth with secure session handling. Manage user authentication via Authentik OAuth with secure session handling.
## Requirements ## Requirements
### Requirement: OAuth2/OIDC Flow ### Requirement: OAuth2/OIDC Flow
The system SHALL support OAuth2/OIDC authentication via Authentik and SHALL validate Authentik-issued tokens via JWKS before creating local sessions.
The system SHALL support OAuth2/OIDC authentication via Authentik.
#### Scenario: User login #### Scenario: User login
- GIVEN a user clicks the login button - GIVEN a user clicks the login button
@@ -16,43 +13,57 @@ The system SHALL support OAuth2/OIDC authentication via Authentik.
- THEN the user authenticates with Authentik - THEN the user authenticates with Authentik
- AND Authentik redirects back with authorization code - AND Authentik redirects back with authorization code
#### Scenario: Token exchange #### Scenario: Token exchange and validation
- GIVEN Authentik has redirected with authorization code - GIVEN Authentik has redirected with authorization code
- WHEN the callback endpoint receives the code - WHEN the callback endpoint receives the code
- THEN it exchanges the code for access and refresh tokens - THEN it exchanges the code for provider tokens
- AND sets httpOnly, Secure, SameSite=strict cookies - AND verifies token signature and claims using Authentik JWKS
- AND upserts the local user account
- AND mints internal access and refresh tokens
### Requirement: Session Security ### Requirement: Session Security
The system SHALL protect sessions using httpOnly cookies and SHALL apply secure cookie defaults by environment.
The system SHALL protect sessions using httpOnly cookies. #### Scenario: Cookie attributes in production
- GIVEN successful authentication in production
#### Scenario: Cookie attributes
- GIVEN successful authentication
- WHEN cookies are set - WHEN cookies are set
- THEN access_token cookie SHALL be httpOnly - THEN access_token cookie SHALL be httpOnly
- AND access_token cookie SHALL have Secure flag - AND access_token cookie SHALL have Secure flag
- AND access_token cookie SHALL have SameSite=strict - AND access_token cookie SHALL have SameSite=strict
- AND refresh_token cookie SHALL have same attributes - AND refresh_token cookie SHALL have the same attributes
#### Scenario: Cookie attributes in localhost development
- GIVEN successful authentication in localhost development
- WHEN cookies are set
- THEN access_token cookie SHALL be httpOnly
- AND access_token cookie SHALL have Secure=false
- AND access_token cookie SHALL have SameSite=lax
- AND refresh_token cookie SHALL have the same attributes
### Requirement: Token Refresh ### Requirement: Token Refresh
The system SHALL support automatic token refresh with server-side refresh token storage, rotation, and revocation.
The system SHALL support automatic token refresh.
#### Scenario: Access token expiration #### Scenario: Access token expiration
- GIVEN a user has an expired access token - GIVEN a user has an expired access token
- WHEN the user makes an authenticated request - WHEN the user makes an authenticated request that can refresh
- THEN the system uses the refresh token to get a new access token - THEN the system validates the refresh token against non-expired, non-revoked DB state
- AND rotates the refresh token - AND rotates the refresh token
- AND issues a new internal access token
#### Scenario: Refresh token reuse detection
- GIVEN a refresh token has already been rotated or revoked
- WHEN it is presented again to the refresh endpoint
- THEN the system rejects the request with unauthorized status
- AND invalidates the token chain for the session
### Requirement: Session Termination ### Requirement: Session Termination
The system SHALL support explicit logout with refresh token invalidation.
The system SHALL support explicit logout.
#### Scenario: User logout #### Scenario: User logout
- GIVEN an authenticated user - GIVEN an authenticated user
- WHEN the user clicks logout - WHEN the user clicks logout
- THEN all auth cookies are cleared - THEN all auth cookies are cleared
- AND the refresh token is invalidated - AND the refresh token is invalidated in server-side storage
## Dependencies ## Dependencies
+16 -21
View File
@@ -3,12 +3,9 @@
## Purpose ## Purpose
Provide a modern React frontend with TypeScript, routing, and responsive layout. Provide a modern React frontend with TypeScript, routing, and responsive layout.
## Requirements ## Requirements
### Requirement: React Application Setup ### Requirement: React Application Setup
The system SHALL use React 18+ with TypeScript and SHALL provide a runnable application source structure in `apps/web/src`.
The system SHALL use React 18+ with TypeScript.
#### Scenario: Frontend build #### Scenario: Frontend build
- GIVEN the frontend codebase - GIVEN the frontend codebase
@@ -17,23 +14,24 @@ The system SHALL use React 18+ with TypeScript.
- Use Vite as the build tool - Use Vite as the build tool
- Support Hot Module Replacement (HMR) - Support Hot Module Replacement (HMR)
- Output optimized production builds - Output optimized production builds
- Include a concrete entrypoint, app composition, and route tree
### Requirement: Client-Side Routing ### Requirement: Client-Side Routing
The system SHALL implement client-side routing with authenticated route guards and explicit not-found handling.
The system SHALL implement client-side routing.
#### Scenario: Navigation #### Scenario: Navigation
- GIVEN the frontend application - GIVEN the frontend application
- THEN React Router SHALL: - THEN React Router SHALL:
- Define routes for all pages - Define routes for all foundation pages
- Support protected routes (require authentication) - Support protected routes (require authentication)
- Handle 404 errors - Handle 404 errors
- Support route parameters - Support route parameters for feature pages
#### Scenario: Protected routes #### Scenario: Protected routes
- GIVEN an unauthenticated user - GIVEN an unauthenticated user
- WHEN they access a protected route - WHEN they access a protected route
- THEN they are redirected to login - THEN they are redirected to login flow
- AND post-auth navigation returns them to an authenticated landing route
### Requirement: Styling Framework ### Requirement: Styling Framework
@@ -48,16 +46,15 @@ The system SHALL use Tailwind CSS for styling.
- Support dark mode - Support dark mode
### Requirement: Layout Component ### Requirement: Layout Component
The system SHALL provide a consistent application layout for authenticated screens across desktop and mobile sizes.
The system SHALL provide a consistent application layout.
#### Scenario: Application shell #### Scenario: Application shell
- GIVEN the frontend application - GIVEN the frontend application
- THEN a Layout component SHALL: - THEN a Layout component SHALL:
- Display a header with user info and logout - Display a header with user info and logout
- Display a sidebar with navigation links - Display sidebar navigation on desktop
- Show main content area - Show main content area
- Collapse sidebar on mobile - Collapse sidebar into a mobile menu toggle on small viewports
#### Scenario: Navigation links #### Scenario: Navigation links
- GIVEN the sidebar navigation - GIVEN the sidebar navigation
@@ -81,19 +78,17 @@ The system SHALL support mobile devices.
- Touch targets are appropriately sized - Touch targets are appropriately sized
### Requirement: Loading States ### Requirement: Loading States
The system SHALL handle asynchronous operations gracefully during auth bootstrap and dashboard fetches.
The system SHALL handle asynchronous operations gracefully.
#### Scenario: Data fetching #### Scenario: Data fetching
- GIVEN a page loading data - GIVEN a page loading data
- THEN: - THEN:
- Loading spinners/skeletons are shown - Loading states are shown while requests are in flight
- Error boundaries catch errors - Errors are shown with retry affordance
- Retry options are available on failure - Initial auth-check loading prevents protected-layout flicker
### Requirement: HTTP Client Configuration ### Requirement: HTTP Client Configuration
The system SHALL configure HTTP requests for cookie-based auth and unauthorized-session recovery.
The system SHALL configure HTTP requests properly.
#### Scenario: API communication #### Scenario: API communication
- GIVEN the frontend application - GIVEN the frontend application
@@ -101,7 +96,7 @@ The system SHALL configure HTTP requests properly.
- Send credentials (cookies) with requests - Send credentials (cookies) with requests
- Handle 401 responses by redirecting to login - Handle 401 responses by redirecting to login
- Set appropriate content-type headers - Set appropriate content-type headers
- Support request/response interceptors - Support request/response interception in a shared client module
### Requirement: Dashboard Page ### Requirement: Dashboard Page