feat: implement auth, projects, and frontend foundation
This commit is contained in:
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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")
|
||||||
@@ -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 = ["."]
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Headquarter API package."""
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from src.api.auth import router as auth_router
|
||||||
|
|
||||||
|
__all__ = ["auth_router"]
|
||||||
@@ -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"]),
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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"]
|
||||||
@@ -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)
|
||||||
@@ -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"]
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -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])
|
||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Utility scripts for the API package."""
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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: {}
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
Generated
+6334
File diff suppressed because it is too large
Load Diff
+12
-7
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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}</>;
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
+71
@@ -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.
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user