Admin mode. Data driven content sections. Bugfixes.

This commit is contained in:
2026-06-19 17:36:35 +00:00
parent bd65455945
commit 266c2451f1
30 changed files with 1733 additions and 13 deletions
+112
View File
@@ -0,0 +1,112 @@
"""Auth router: login, google OAuth, and current user info."""
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import (
create_access_token,
get_current_user,
verify_google_id_token,
)
from app.config import settings
from app.database import get_session
from app.user_models import User
router = APIRouter()
# ── Request/Response schemas ─────────────────────────────────
class LoginRequest(BaseModel):
username: str
password: str
class GoogleLoginRequest(BaseModel):
id_token: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class UserInfoResponse(BaseModel):
id: int
email: str
display_name: str
avatar_url: str | None
auth_provider: str
is_admin: bool
model_config = {"from_attributes": True}
# ── Routes ───────────────────────────────────────────────────
@router.post("/login", response_model=TokenResponse)
async def login(payload: LoginRequest):
"""Bootstrap admin login (plaintext credentials from env vars)."""
if not settings.ADMIN_USERNAME or not settings.ADMIN_PASSWORD:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Bootstrap admin not configured")
if payload.username != settings.ADMIN_USERNAME or payload.password != settings.ADMIN_PASSWORD:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
# This is handled in the auth router to keep it self-contained
from app.database import async_session
async with async_session() as session:
# Find or create the bootstrap admin user
result = await session.execute(
select(User).where(
User.email == f"{settings.ADMIN_USERNAME}@local",
User.auth_provider == "local",
)
)
user = result.scalar_one_or_none()
if user is None:
user = User(
email=f"{settings.ADMIN_USERNAME}@local",
display_name=settings.ADMIN_USERNAME,
auth_provider="local",
is_admin=True,
)
session.add(user)
await session.commit()
await session.refresh(user)
token = create_access_token(user.id, user.is_admin)
return TokenResponse(access_token=token)
@router.post("/google", response_model=TokenResponse)
async def login_with_google(payload: GoogleLoginRequest, session: AsyncSession = Depends(get_session)):
"""Google OAuth sign-in. Accepts an ID token, verifies it, upserts user, returns JWT."""
info = await verify_google_id_token(payload.id_token)
result = await session.execute(select(User).where(User.email == info["email"]))
user = result.scalar_one_or_none()
if user is None:
user = User(
email=info["email"],
display_name=info["name"],
avatar_url=info["picture"] or None,
auth_provider="google",
is_admin=False,
)
session.add(user)
await session.commit()
await session.refresh(user)
token = create_access_token(user.id, user.is_admin)
return TokenResponse(access_token=token)
@router.get("/me", response_model=UserInfoResponse)
async def me(current_user: User = Depends(get_current_user)):
"""Return current user info."""
return current_user
+11 -2
View File
@@ -4,9 +4,11 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import get_current_admin_user
from app.database import get_session
from app.models import Event
from app.schemas import EventCreate, EventUpdate, EventResponse
from app.user_models import User
router = APIRouter()
@@ -34,7 +36,9 @@ async def get_event(event_id: int, session: AsyncSession = Depends(get_session))
@router.post("/", response_model=EventResponse, status_code=201)
async def create_event(
payload: EventCreate, session: AsyncSession = Depends(get_session)
payload: EventCreate,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
event = Event(**payload.model_dump(), active=True)
session.add(event)
@@ -48,6 +52,7 @@ async def update_event(
event_id: int,
payload: EventUpdate,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
result = await session.execute(select(Event).where(Event.id == event_id))
event = result.scalar_one_or_none()
@@ -61,7 +66,11 @@ async def update_event(
@router.delete("/{event_id}", status_code=204)
async def delete_event(event_id: int, session: AsyncSession = Depends(get_session)):
async def delete_event(
event_id: int,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
result = await session.execute(select(Event).where(Event.id == event_id))
event = result.scalar_one_or_none()
if not event:
+11 -2
View File
@@ -4,9 +4,11 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import get_current_admin_user
from app.database import get_session
from app.models import Program
from app.schemas import ProgramCreate, ProgramUpdate, ProgramResponse
from app.user_models import User
router = APIRouter()
@@ -39,7 +41,9 @@ async def get_program(program_id: int, session: AsyncSession = Depends(get_sessi
@router.post("/", response_model=ProgramResponse, status_code=201)
async def create_program(
payload: ProgramCreate, session: AsyncSession = Depends(get_session)
payload: ProgramCreate,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
program = Program(**payload.model_dump())
session.add(program)
@@ -53,6 +57,7 @@ async def update_program(
program_id: int,
payload: ProgramUpdate,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
result = await session.execute(select(Program).where(Program.id == program_id))
program = result.scalar_one_or_none()
@@ -66,7 +71,11 @@ async def update_program(
@router.delete("/{program_id}", status_code=204)
async def delete_program(program_id: int, session: AsyncSession = Depends(get_session)):
async def delete_program(
program_id: int,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
result = await session.execute(select(Program).where(Program.id == program_id))
program = result.scalar_one_or_none()
if not program:
+11 -4
View File
@@ -1,12 +1,12 @@
from typing import Optional
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import get_current_admin_user
from app.database import get_session
from app.models import DonationTier
from app.schemas import TierCreate, TierUpdate, TierResponse
from app.user_models import User
router = APIRouter()
@@ -30,7 +30,9 @@ async def get_tier(tier_id: int, session: AsyncSession = Depends(get_session)):
@router.post("/", response_model=TierResponse, status_code=201)
async def create_tier(
payload: TierCreate, session: AsyncSession = Depends(get_session)
payload: TierCreate,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
tier = DonationTier(**payload.model_dump())
session.add(tier)
@@ -44,6 +46,7 @@ async def update_tier(
tier_id: int,
payload: TierUpdate,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
result = await session.execute(select(DonationTier).where(DonationTier.id == tier_id))
tier = result.scalar_one_or_none()
@@ -57,7 +60,11 @@ async def update_tier(
@router.delete("/{tier_id}", status_code=204)
async def delete_tier(tier_id: int, session: AsyncSession = Depends(get_session)):
async def delete_tier(
tier_id: int,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
result = await session.execute(select(DonationTier).where(DonationTier.id == tier_id))
tier = result.scalar_one_or_none()
if not tier:
+109
View File
@@ -0,0 +1,109 @@
"""Auth helpers, JWT token creation/verification, and FastAPI dependencies."""
from datetime import datetime, timedelta, timezone
from typing import Optional
import httpx
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_session
from app.user_models import User
class AuthBearer(HTTPBearer):
"""HTTP Bearer scheme that doesn't auto-fail on missing token."""
def __init__(self, auto_error: bool = False):
super().__init__(auto_error=auto_error)
bearer = AuthBearer(auto_error=False)
# ── Token helpers ──────────────────────────────────────────────
def create_access_token(user_id: int, is_admin: bool) -> str:
"""Create a JWT access token for the given user."""
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.JWT_EXPIRE_MINUTES)
payload = {
"sub": str(user_id),
"is_admin": is_admin,
"exp": expire,
}
return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm="HS256")
def decode_token(token: str) -> dict:
"""Decode and verify a JWT token. Raises HTTPException 401 on failure."""
try:
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=["HS256"])
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
return payload
except JWTError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token")
# ── FastAPI dependencies ──────────────────────────────────────
async def get_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer),
session: AsyncSession = Depends(get_session),
) -> User:
"""Validate JWT and return the current User."""
if credentials is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
payload = decode_token(credentials.credentials)
user_id = int(payload["sub"])
result = await session.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return user
async def get_current_admin_user(
current_user: User = Depends(get_current_user),
) -> User:
"""Raise 403 if the current user is not an admin."""
if not current_user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return current_user
# ── Google OIDC verification ─────────────────────────────────
_GOOGLE_CERTS_URL = "https://www.googleapis.com/oauth2/v3/certs"
_GOOGLE_TOKEN_INFO_URL = "https://oauth2.googleapis.com/tokeninfo"
async def verify_google_id_token(id_token: str) -> dict:
"""Verify a Google ID token and return (email, name, picture)."""
async with httpx.AsyncClient() as client:
# Use tokeninfo endpoint for simplicity (no signature parsing needed)
resp = await client.get(_GOOGLE_TOKEN_INFO_URL, params={"idtoken": id_token})
if resp.status_code != 200:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid Google ID token",
)
info = resp.json()
email = info.get("email")
if not email:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="No email in Google token")
return {
"email": email,
"name": info.get("name", email.split("@")[0]),
"picture": info.get("picture", ""),
}
+8
View File
@@ -6,6 +6,14 @@ class Settings(BaseSettings):
CORS_ORIGINS: list[str] = ["http://localhost:4200", "http://localhost:3000"]
ENV: str = "development"
# Auth
ADMIN_USERNAME: str = ""
ADMIN_PASSWORD: str = ""
GOOGLE_CLIENT_ID: str = ""
GOOGLE_REDIRECT_URI: str = "http://localhost:4200"
JWT_SECRET_KEY: str = "change-me-in-production"
JWT_EXPIRE_MINUTES: int = 1440 # 24 hours
model_config = {"env_prefix": "KMTN_"}
+23 -1
View File
@@ -7,7 +7,8 @@ from sqlalchemy import func, select
from app.config import settings
from app.database import async_session, engine
from app.models import Base, Program
from app.api import programs, events, tiers
from app.user_models import User
from app.api import programs, events, tiers, auth
@asynccontextmanager
@@ -25,6 +26,26 @@ async def lifespan(app: FastAPI):
from seed import seed as run_seed
await run_seed()
# Bootstrap admin user if configured
if settings.ADMIN_USERNAME and settings.ADMIN_PASSWORD:
async with async_session() as session:
result = await session.execute(
select(User).where(
User.email == f"{settings.ADMIN_USERNAME}@local",
User.auth_provider == "local",
)
)
if result.scalar_one_or_none() is None:
admin_user = User(
email=f"{settings.ADMIN_USERNAME}@local",
display_name=settings.ADMIN_USERNAME,
auth_provider="local",
is_admin=True,
)
session.add(admin_user)
await session.commit()
print(f" → Bootstrap admin created: {settings.ADMIN_USERNAME}")
yield
@@ -44,6 +65,7 @@ app.add_middleware(
)
# Register routers
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(programs.router, prefix="/api/programs", tags=["programs"])
app.include_router(events.router, prefix="/api/events", tags=["events"])
app.include_router(tiers.router, prefix="/api/tiers", tags=["tiers"])
+18
View File
@@ -0,0 +1,18 @@
from datetime import datetime
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from sqlalchemy.orm import DeclarativeBase
from app.models import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, autoincrement=True)
email = Column(String(255), unique=True, nullable=False)
display_name = Column(String(100), nullable=False)
avatar_url = Column(String(500), nullable=True)
auth_provider = Column(String(20), nullable=False, default="google")
is_admin = Column(Boolean, default=False)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
+2
View File
@@ -8,3 +8,5 @@ alembic==1.14.1
python-multipart==0.0.20
pydantic==2.10.4
pydantic-settings==2.7.1
python-jose[cryptography]==3.3.0
httpx==0.27.2