feat: indie status page SaaS - initial release
This commit is contained in:
parent
ee2bc87ade
commit
b7a8142ca0
14 changed files with 2703 additions and 0 deletions
0
app/routes/__init__.py
Normal file
0
app/routes/__init__.py
Normal file
122
app/routes/auth.py
Normal file
122
app/routes/auth.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
"""Auth routes: register, login, and current user profile."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import create_access_token, get_current_user, hash_password, verify_password
|
||||
from app.dependencies import get_db
|
||||
from app.models.saas_models import Organization, OrganizationMember, StatusPage, User
|
||||
|
||||
router = APIRouter(tags=["auth"])
|
||||
|
||||
|
||||
# ── Request / Response schemas ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class AuthResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class UserProfile(BaseModel):
|
||||
id: str
|
||||
email: str
|
||||
display_name: str | None = None
|
||||
is_email_verified: bool
|
||||
created_at: str | None = None
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/auth/register", status_code=status.HTTP_201_CREATED, response_model=AuthResponse)
|
||||
async def register(body: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""Register a new user, create a default Organization + StatusPage, return JWT."""
|
||||
# Check for existing user
|
||||
result = await db.execute(select(User).where(User.email == body.email))
|
||||
if result.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="A user with this email already exists",
|
||||
)
|
||||
|
||||
# Create user
|
||||
user = User(
|
||||
email=body.email,
|
||||
password_hash=hash_password(body.password),
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush() # assign user.id
|
||||
|
||||
# Create default organization
|
||||
org_slug = body.email.split("@")[0].lower()
|
||||
org = Organization(
|
||||
name=org_slug,
|
||||
slug=org_slug,
|
||||
)
|
||||
db.add(org)
|
||||
await db.flush() # assign org.id
|
||||
|
||||
# Create organization membership (owner)
|
||||
membership = OrganizationMember(
|
||||
organization_id=org.id,
|
||||
user_id=user.id,
|
||||
role="owner",
|
||||
)
|
||||
db.add(membership)
|
||||
|
||||
# Create default status page
|
||||
status_page = StatusPage(
|
||||
organization_id=org.id,
|
||||
slug="main",
|
||||
title="Status Page",
|
||||
)
|
||||
db.add(status_page)
|
||||
|
||||
# Commit all together (the get_db dependency also commits, but flush ensures
|
||||
# relationships are consistent before we return)
|
||||
await db.flush()
|
||||
|
||||
# Generate JWT
|
||||
token = create_access_token(user.id)
|
||||
return AuthResponse(access_token=token)
|
||||
|
||||
|
||||
@router.post("/auth/login", response_model=AuthResponse)
|
||||
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""Authenticate user with email + password, return JWT."""
|
||||
result = await db.execute(select(User).where(User.email == body.email))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None or not verify_password(body.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid email or password",
|
||||
)
|
||||
|
||||
token = create_access_token(user.id)
|
||||
return AuthResponse(access_token=token)
|
||||
|
||||
|
||||
@router.get("/auth/me", response_model=UserProfile)
|
||||
async def me(current_user: User = Depends(get_current_user)):
|
||||
"""Return the current authenticated user's profile."""
|
||||
return UserProfile(
|
||||
id=current_user.id,
|
||||
email=current_user.email,
|
||||
display_name=current_user.display_name,
|
||||
is_email_verified=current_user.is_email_verified,
|
||||
created_at=current_user.created_at.isoformat() if current_user.created_at else None,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue