Admin mode. Data driven content sections. Bugfixes.
This commit is contained in:
@@ -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
|
||||||
@@ -4,9 +4,11 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import get_current_admin_user
|
||||||
from app.database import get_session
|
from app.database import get_session
|
||||||
from app.models import Event
|
from app.models import Event
|
||||||
from app.schemas import EventCreate, EventUpdate, EventResponse
|
from app.schemas import EventCreate, EventUpdate, EventResponse
|
||||||
|
from app.user_models import User
|
||||||
|
|
||||||
router = APIRouter()
|
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)
|
@router.post("/", response_model=EventResponse, status_code=201)
|
||||||
async def create_event(
|
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)
|
event = Event(**payload.model_dump(), active=True)
|
||||||
session.add(event)
|
session.add(event)
|
||||||
@@ -48,6 +52,7 @@ async def update_event(
|
|||||||
event_id: int,
|
event_id: int,
|
||||||
payload: EventUpdate,
|
payload: EventUpdate,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
_: User = Depends(get_current_admin_user),
|
||||||
):
|
):
|
||||||
result = await session.execute(select(Event).where(Event.id == event_id))
|
result = await session.execute(select(Event).where(Event.id == event_id))
|
||||||
event = result.scalar_one_or_none()
|
event = result.scalar_one_or_none()
|
||||||
@@ -61,7 +66,11 @@ async def update_event(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{event_id}", status_code=204)
|
@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))
|
result = await session.execute(select(Event).where(Event.id == event_id))
|
||||||
event = result.scalar_one_or_none()
|
event = result.scalar_one_or_none()
|
||||||
if not event:
|
if not event:
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import get_current_admin_user
|
||||||
from app.database import get_session
|
from app.database import get_session
|
||||||
from app.models import Program
|
from app.models import Program
|
||||||
from app.schemas import ProgramCreate, ProgramUpdate, ProgramResponse
|
from app.schemas import ProgramCreate, ProgramUpdate, ProgramResponse
|
||||||
|
from app.user_models import User
|
||||||
|
|
||||||
router = APIRouter()
|
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)
|
@router.post("/", response_model=ProgramResponse, status_code=201)
|
||||||
async def create_program(
|
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())
|
program = Program(**payload.model_dump())
|
||||||
session.add(program)
|
session.add(program)
|
||||||
@@ -53,6 +57,7 @@ async def update_program(
|
|||||||
program_id: int,
|
program_id: int,
|
||||||
payload: ProgramUpdate,
|
payload: ProgramUpdate,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
_: User = Depends(get_current_admin_user),
|
||||||
):
|
):
|
||||||
result = await session.execute(select(Program).where(Program.id == program_id))
|
result = await session.execute(select(Program).where(Program.id == program_id))
|
||||||
program = result.scalar_one_or_none()
|
program = result.scalar_one_or_none()
|
||||||
@@ -66,7 +71,11 @@ async def update_program(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{program_id}", status_code=204)
|
@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))
|
result = await session.execute(select(Program).where(Program.id == program_id))
|
||||||
program = result.scalar_one_or_none()
|
program = result.scalar_one_or_none()
|
||||||
if not program:
|
if not program:
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import get_current_admin_user
|
||||||
from app.database import get_session
|
from app.database import get_session
|
||||||
from app.models import DonationTier
|
from app.models import DonationTier
|
||||||
from app.schemas import TierCreate, TierUpdate, TierResponse
|
from app.schemas import TierCreate, TierUpdate, TierResponse
|
||||||
|
from app.user_models import User
|
||||||
|
|
||||||
router = APIRouter()
|
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)
|
@router.post("/", response_model=TierResponse, status_code=201)
|
||||||
async def create_tier(
|
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())
|
tier = DonationTier(**payload.model_dump())
|
||||||
session.add(tier)
|
session.add(tier)
|
||||||
@@ -44,6 +46,7 @@ async def update_tier(
|
|||||||
tier_id: int,
|
tier_id: int,
|
||||||
payload: TierUpdate,
|
payload: TierUpdate,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
_: User = Depends(get_current_admin_user),
|
||||||
):
|
):
|
||||||
result = await session.execute(select(DonationTier).where(DonationTier.id == tier_id))
|
result = await session.execute(select(DonationTier).where(DonationTier.id == tier_id))
|
||||||
tier = result.scalar_one_or_none()
|
tier = result.scalar_one_or_none()
|
||||||
@@ -57,7 +60,11 @@ async def update_tier(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{tier_id}", status_code=204)
|
@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))
|
result = await session.execute(select(DonationTier).where(DonationTier.id == tier_id))
|
||||||
tier = result.scalar_one_or_none()
|
tier = result.scalar_one_or_none()
|
||||||
if not tier:
|
if not tier:
|
||||||
|
|||||||
@@ -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", ""),
|
||||||
|
}
|
||||||
@@ -6,6 +6,14 @@ class Settings(BaseSettings):
|
|||||||
CORS_ORIGINS: list[str] = ["http://localhost:4200", "http://localhost:3000"]
|
CORS_ORIGINS: list[str] = ["http://localhost:4200", "http://localhost:3000"]
|
||||||
ENV: str = "development"
|
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_"}
|
model_config = {"env_prefix": "KMTN_"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+23
-1
@@ -7,7 +7,8 @@ from sqlalchemy import func, select
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import async_session, engine
|
from app.database import async_session, engine
|
||||||
from app.models import Base, Program
|
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
|
@asynccontextmanager
|
||||||
@@ -25,6 +26,26 @@ async def lifespan(app: FastAPI):
|
|||||||
from seed import seed as run_seed
|
from seed import seed as run_seed
|
||||||
await 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
|
yield
|
||||||
|
|
||||||
|
|
||||||
@@ -44,6 +65,7 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Register routers
|
# 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(programs.router, prefix="/api/programs", tags=["programs"])
|
||||||
app.include_router(events.router, prefix="/api/events", tags=["events"])
|
app.include_router(events.router, prefix="/api/events", tags=["events"])
|
||||||
app.include_router(tiers.router, prefix="/api/tiers", tags=["tiers"])
|
app.include_router(tiers.router, prefix="/api/tiers", tags=["tiers"])
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -8,3 +8,5 @@ alembic==1.14.1
|
|||||||
python-multipart==0.0.20
|
python-multipart==0.0.20
|
||||||
pydantic==2.10.4
|
pydantic==2.10.4
|
||||||
pydantic-settings==2.7.1
|
pydantic-settings==2.7.1
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
httpx==0.27.2
|
||||||
|
|||||||
Generated
+8
-1
@@ -15,7 +15,8 @@
|
|||||||
"@angular/platform-browser": "^21.2.0",
|
"@angular/platform-browser": "^21.2.0",
|
||||||
"@angular/router": "^21.2.0",
|
"@angular/router": "^21.2.0",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0",
|
||||||
|
"zone.js": "^0.16.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular/build": "^21.2.14",
|
"@angular/build": "^21.2.14",
|
||||||
@@ -7721,6 +7722,12 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "^3.25.28 || ^4"
|
"zod": "^3.25.28 || ^4"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zone.js": {
|
||||||
|
"version": "0.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.16.2.tgz",
|
||||||
|
"integrity": "sha512-Eky7p2Z1Ig3NnbfodSPoARCjKBSTFMnE/ACsP1L/XJEfY4SdOFce19BsUCWVwL6K5ABZFy5J3bjcMWffX+YM3Q==",
|
||||||
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -18,7 +18,8 @@
|
|||||||
"@angular/platform-browser": "^21.2.0",
|
"@angular/platform-browser": "^21.2.0",
|
||||||
"@angular/router": "^21.2.0",
|
"@angular/router": "^21.2.0",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0",
|
||||||
|
"zone.js": "^0.16.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular/build": "^21.2.14",
|
"@angular/build": "^21.2.14",
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<div class="form-overlay" (click)="onClose()">
|
||||||
|
<div class="form-dialog" (click)="$event.stopPropagation()">
|
||||||
|
<div class="form-header">
|
||||||
|
<h2>{{ id ? 'Edit Event' : 'Add Event' }}</h2>
|
||||||
|
<button type="button" class="btn-close" (click)="onClose()">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (error) {
|
||||||
|
<div class="form-error" role="alert">{{ error }}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<form (ngSubmit)="onSubmit()" class="admin-form">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="date">Date</label>
|
||||||
|
<input id="date" type="date" [(ngModel)]="date" name="date" required [class.is-invalid]="submitted && !date">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="icon">Icon</label>
|
||||||
|
<input id="icon" type="text" [(ngModel)]="icon" name="icon" placeholder="🎵" maxlength="10">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="title">Title</label>
|
||||||
|
<input id="title" type="text" [(ngModel)]="title" name="title" placeholder="Event title" required [class.is-invalid]="submitted && !title">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="description">Description</label>
|
||||||
|
<textarea id="description" [(ngModel)]="description" name="description" rows="3" placeholder="Event description" required [class.is-invalid]="submitted && !description"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="location">Location</label>
|
||||||
|
<input id="location" type="text" [(ngModel)]="location" name="location" placeholder="Location" required [class.is-invalid]="submitted && !location">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="rsvp_url">RSVP URL (optional)</label>
|
||||||
|
<input id="rsvp_url" type="url" [(ngModel)]="rsvp_url" name="rsvp_url" placeholder="https://...">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group checkbox-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" [(ngModel)]="active" name="active">
|
||||||
|
Active
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn btn-cancel" (click)="onClose()">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-save" [disabled]="loading">
|
||||||
|
{{ loading ? 'Saving…' : (id ? 'Update' : 'Create') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
@use '../../styles/variables' as *;
|
||||||
|
@use '../../styles/mixins' as *;
|
||||||
|
|
||||||
|
.form-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
@include flex-center;
|
||||||
|
z-index: $z-modal;
|
||||||
|
@include fade-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-dialog {
|
||||||
|
background: $neutral-white;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
padding: $spacing-xl;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 560px;
|
||||||
|
box-shadow: $shadow-xl;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-header {
|
||||||
|
@include flex-between;
|
||||||
|
margin-bottom: $spacing-lg;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-family: $font-heading;
|
||||||
|
color: $primary-blue;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: $neutral-medium;
|
||||||
|
line-height: 1;
|
||||||
|
padding: $spacing-xs;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $neutral-dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
background: rgba($danger-red, 0.1);
|
||||||
|
color: $danger-red;
|
||||||
|
padding: $spacing-sm $spacing-md;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-form {
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $neutral-dark;
|
||||||
|
margin-bottom: $spacing-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select, textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: $spacing-sm $spacing-md;
|
||||||
|
border: 1px solid $neutral-light;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
transition: border-color $transition-fast;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: $primary-blue;
|
||||||
|
box-shadow: 0 0 0 3px rgba($primary-blue, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-invalid {
|
||||||
|
border-color: $danger-red;
|
||||||
|
box-shadow: 0 0 0 3px rgba($danger-red, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group {
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
width: auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
margin-top: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save {
|
||||||
|
@include button-style($primary-blue, $neutral-white, $primary-blue-light);
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
background: $neutral-light;
|
||||||
|
color: $neutral-dark;
|
||||||
|
border: none;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
padding: $spacing-sm $spacing-lg;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background $transition-fast;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $neutral-medium;
|
||||||
|
color: $neutral-white;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<div class="form-overlay" (click)="onClose()">
|
||||||
|
<div class="form-dialog" (click)="$event.stopPropagation()">
|
||||||
|
<div class="form-header">
|
||||||
|
<h2>{{ id ? 'Edit Program' : 'Add Program' }}</h2>
|
||||||
|
<button type="button" class="btn-close" (click)="onClose()">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (error) {
|
||||||
|
<div class="form-error" role="alert">{{ error }}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<form (ngSubmit)="onSubmit()" class="admin-form" #form="ngForm">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="day">Day</label>
|
||||||
|
<select id="day" [(ngModel)]="day_of_week" name="day" (change)="onDayChange()" required>
|
||||||
|
@for (day of days; track day.value) {
|
||||||
|
<option [value]="day.value">{{ day.label }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="time">Time</label>
|
||||||
|
<input id="time" type="text" [(ngModel)]="time" name="time" placeholder="6:00 AM" required [class.is-invalid]="submitted && !time">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="title">Title</label>
|
||||||
|
<input id="title" type="text" [(ngModel)]="title" name="title" placeholder="Program title" required [class.is-invalid]="submitted && !title">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="host">Host</label>
|
||||||
|
<input id="host" type="text" [(ngModel)]="host" name="host" placeholder="Host name" required [class.is-invalid]="submitted && !host">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="genre">Genre</label>
|
||||||
|
<input id="genre" type="text" [(ngModel)]="genre" name="genre" placeholder="Genre" required [class.is-invalid]="submitted && !genre">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn btn-cancel" (click)="onClose()">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-save" [disabled]="loading">
|
||||||
|
{{ loading ? 'Saving…' : (id ? 'Update' : 'Create') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
@use '../../styles/variables' as *;
|
||||||
|
@use '../../styles/mixins' as *;
|
||||||
|
|
||||||
|
.form-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
@include flex-center;
|
||||||
|
z-index: $z-modal;
|
||||||
|
@include fade-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-dialog {
|
||||||
|
background: $neutral-white;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
padding: $spacing-xl;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 560px;
|
||||||
|
box-shadow: $shadow-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-header {
|
||||||
|
@include flex-between;
|
||||||
|
margin-bottom: $spacing-lg;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-family: $font-heading;
|
||||||
|
color: $primary-blue;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: $neutral-medium;
|
||||||
|
line-height: 1;
|
||||||
|
padding: $spacing-xs;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $neutral-dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
background: rgba($danger-red, 0.1);
|
||||||
|
color: $danger-red;
|
||||||
|
padding: $spacing-sm $spacing-md;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-form {
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $neutral-dark;
|
||||||
|
margin-bottom: $spacing-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select, textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: $spacing-sm $spacing-md;
|
||||||
|
border: 1px solid $neutral-light;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
transition: border-color $transition-fast;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: $primary-blue;
|
||||||
|
box-shadow: 0 0 0 3px rgba($primary-blue, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-invalid {
|
||||||
|
border-color: $danger-red;
|
||||||
|
box-shadow: 0 0 0 3px rgba($danger-red, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
margin-top: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save {
|
||||||
|
@include button-style($primary-blue, $neutral-white, $primary-blue-light);
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
background: $neutral-light;
|
||||||
|
color: $neutral-dark;
|
||||||
|
border: none;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
padding: $spacing-sm $spacing-lg;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background $transition-fast;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $neutral-medium;
|
||||||
|
color: $neutral-white;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<div class="form-overlay" (click)="onClose()">
|
||||||
|
<div class="form-dialog" (click)="$event.stopPropagation()">
|
||||||
|
<div class="form-header">
|
||||||
|
<h2>{{ id ? 'Edit Tier' : 'Add Tier' }}</h2>
|
||||||
|
<button type="button" class="btn-close" (click)="onClose()">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (error) {
|
||||||
|
<div class="form-error" role="alert">{{ error }}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<form (ngSubmit)="onSubmit()" class="admin-form">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input id="name" type="text" [(ngModel)]="name" name="name" placeholder="Tier name" required [class.is-invalid]="submitted && !name">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="amount">Amount ($)</label>
|
||||||
|
<input id="amount" type="number" step="0.01" min="0" [(ngModel)]="amount" name="amount" required [class.is-invalid]="submitted && amount <= 0">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="icon">Icon</label>
|
||||||
|
<input id="icon" type="text" [(ngModel)]="icon" name="icon" placeholder="⭐" maxlength="10">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="display_order">Display Order</label>
|
||||||
|
<input id="display_order" type="number" min="1" [(ngModel)]="display_order" name="display_order" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="benefits">Benefits (comma-separated)</label>
|
||||||
|
<textarea id="benefits" [(ngModel)]="benefits_text" name="benefits" rows="3" placeholder="Benefit 1, Benefit 2, Benefit 3"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn btn-cancel" (click)="onClose()">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-save" [disabled]="loading">
|
||||||
|
{{ loading ? 'Saving…' : (id ? 'Update' : 'Create') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
@use '../../styles/variables' as *;
|
||||||
|
@use '../../styles/mixins' as *;
|
||||||
|
|
||||||
|
.form-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
@include flex-center;
|
||||||
|
z-index: $z-modal;
|
||||||
|
@include fade-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-dialog {
|
||||||
|
background: $neutral-white;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
padding: $spacing-xl;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 560px;
|
||||||
|
box-shadow: $shadow-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-header {
|
||||||
|
@include flex-between;
|
||||||
|
margin-bottom: $spacing-lg;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-family: $font-heading;
|
||||||
|
color: $primary-blue;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: $neutral-medium;
|
||||||
|
line-height: 1;
|
||||||
|
padding: $spacing-xs;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $neutral-dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
background: rgba($danger-red, 0.1);
|
||||||
|
color: $danger-red;
|
||||||
|
padding: $spacing-sm $spacing-md;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-form {
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $neutral-dark;
|
||||||
|
margin-bottom: $spacing-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select, textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: $spacing-sm $spacing-md;
|
||||||
|
border: 1px solid $neutral-light;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
transition: border-color $transition-fast;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: $primary-blue;
|
||||||
|
box-shadow: 0 0 0 3px rgba($primary-blue, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-invalid {
|
||||||
|
border-color: $danger-red;
|
||||||
|
box-shadow: 0 0 0 3px rgba($danger-red, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
margin-top: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save {
|
||||||
|
@include button-style($primary-blue, $neutral-white, $primary-blue-light);
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
background: $neutral-light;
|
||||||
|
color: $neutral-dark;
|
||||||
|
border: none;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
padding: $spacing-sm $spacing-lg;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background $transition-fast;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $neutral-medium;
|
||||||
|
color: $neutral-white;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<section class="admin-page">
|
||||||
|
<div class="container">
|
||||||
|
<div class="admin-header">
|
||||||
|
<h1>Admin Dashboard</h1>
|
||||||
|
<p>Manage programs, events, and donation tiers</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="admin-tabs">
|
||||||
|
<button
|
||||||
|
class="tab-btn"
|
||||||
|
[class.active]="activeTab() === 'programs'"
|
||||||
|
(click)="activeTab.set('programs')"
|
||||||
|
>Programs</button>
|
||||||
|
<button
|
||||||
|
class="tab-btn"
|
||||||
|
[class.active]="activeTab() === 'events'"
|
||||||
|
(click)="activeTab.set('events')"
|
||||||
|
>Events</button>
|
||||||
|
<button
|
||||||
|
class="tab-btn"
|
||||||
|
[class.active]="activeTab() === 'tiers'"
|
||||||
|
(click)="activeTab.set('tiers')"
|
||||||
|
>Tiers</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Programs Tab -->
|
||||||
|
@if (activeTab() === 'programs') {
|
||||||
|
@if (programs$ | async; as programs) {
|
||||||
|
<div class="tab-content">
|
||||||
|
<div class="tab-toolbar">
|
||||||
|
<h2>Programs ({{ programs.length }})</h2>
|
||||||
|
<button class="btn btn-add" (click)="openProgramForm()">+ Add Program</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Day</th>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Host</th>
|
||||||
|
<th>Genre</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (program of programs; track program.id) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ program.day_label }}</td>
|
||||||
|
<td>{{ program.time }}</td>
|
||||||
|
<td>{{ program.title }}</td>
|
||||||
|
<td>{{ program.host }}</td>
|
||||||
|
<td>{{ program.genre }}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<button class="btn-icon btn-edit" (click)="editProgram(program)">Edit</button>
|
||||||
|
<button class="btn-icon btn-delete" (click)="deleteProgram(program.id)">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr><td colspan="6" class="empty-row">No programs yet. Click "Add Program" to create one.</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Events Tab -->
|
||||||
|
@if (activeTab() === 'events') {
|
||||||
|
@if (events$ | async; as events) {
|
||||||
|
<div class="tab-content">
|
||||||
|
<div class="tab-toolbar">
|
||||||
|
<h2>Events ({{ events.length }})</h2>
|
||||||
|
<button class="btn btn-add" (click)="openEventForm()">+ Add Event</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Location</th>
|
||||||
|
<th>Active</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (event of events; track event.id) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ event.date }}</td>
|
||||||
|
<td>{{ event.title }}</td>
|
||||||
|
<td>{{ event.location }}</td>
|
||||||
|
<td>{{ event.active ? 'Yes' : 'No' }}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<button class="btn-icon btn-edit" (click)="editEvent(event)">Edit</button>
|
||||||
|
<button class="btn-icon btn-delete" (click)="deleteEvent(event.id)">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr><td colspan="5" class="empty-row">No events yet. Click "Add Event" to create one.</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Tiers Tab -->
|
||||||
|
@if (activeTab() === 'tiers') {
|
||||||
|
@if (tiers$ | async; as tiers) {
|
||||||
|
<div class="tab-content">
|
||||||
|
<div class="tab-toolbar">
|
||||||
|
<h2>Donation Tiers ({{ tiers.length }})</h2>
|
||||||
|
<button class="btn btn-add" (click)="openTierForm()">+ Add Tier</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Order</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Amount</th>
|
||||||
|
<th>Benefits</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (tier of tiers; track tier.id) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ tier.display_order }}</td>
|
||||||
|
<td>{{ tier.icon }} {{ tier.name }}</td>
|
||||||
|
<td>${{ tier.amount.toFixed(2) }}</td>
|
||||||
|
<td class="benefits-cell">{{ tier.benefits.join(', ') }}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<button class="btn-icon btn-edit" (click)="editTier(tier)">Edit</button>
|
||||||
|
<button class="btn-icon btn-delete" (click)="deleteTier(tier.id)">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr><td colspan="5" class="empty-row">No tiers yet. Click "Add Tier" to create one.</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form modals -->
|
||||||
|
@if (showProgramForm) {
|
||||||
|
<app-admin-program-form [editItem]="editingProgram" (saved)="onProgramSaved()" (closed)="onProgramClosed()" />
|
||||||
|
}
|
||||||
|
@if (showEventForm) {
|
||||||
|
<app-admin-event-form [editItem]="editingEvent" (saved)="onEventSaved()" (closed)="onEventClosed()" />
|
||||||
|
}
|
||||||
|
@if (showTierForm) {
|
||||||
|
<app-admin-tier-form [editItem]="editingTier" (saved)="onTierSaved()" (closed)="onTierClosed()" />
|
||||||
|
}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
@use '../../styles/variables' as *;
|
||||||
|
@use '../../styles/mixins' as *;
|
||||||
|
|
||||||
|
.admin-page {
|
||||||
|
min-height: calc(100vh - 80px);
|
||||||
|
background: $neutral-cream;
|
||||||
|
padding: $spacing-xl 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: $spacing-xl;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-family: $font-heading;
|
||||||
|
color: $primary-blue;
|
||||||
|
font-size: 2rem;
|
||||||
|
margin: 0 0 $spacing-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: $neutral-medium;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tabs ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
.admin-tabs {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
margin-bottom: $spacing-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
background: $neutral-white;
|
||||||
|
border: 1px solid $neutral-light;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
padding: $spacing-sm $spacing-xl;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $neutral-dark;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all $transition-fast;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $primary-blue-muted;
|
||||||
|
color: $primary-blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: $primary-blue;
|
||||||
|
border-color: $primary-blue;
|
||||||
|
color: $neutral-white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tab content ──────────────────────────────────────────
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
@include card-style;
|
||||||
|
padding: $spacing-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-toolbar {
|
||||||
|
@include flex-between;
|
||||||
|
margin-bottom: $spacing-lg;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-family: $font-heading;
|
||||||
|
color: $primary-blue;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add {
|
||||||
|
@include button-style($success-green, $neutral-white, darken($success-green, 8%));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Table ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
.admin-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
|
||||||
|
thead th {
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: $neutral-medium;
|
||||||
|
padding: $spacing-sm $spacing-md;
|
||||||
|
border-bottom: 2px solid $neutral-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody td {
|
||||||
|
padding: $spacing-sm $spacing-md;
|
||||||
|
border-bottom: 1px solid $neutral-light;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: $neutral-dark;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.benefits-cell {
|
||||||
|
max-width: 300px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-row {
|
||||||
|
text-align: center;
|
||||||
|
color: $neutral-medium;
|
||||||
|
padding: $spacing-xl !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
border: none;
|
||||||
|
padding: $spacing-xs $spacing-sm;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background $transition-fast;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-edit {
|
||||||
|
background: rgba($info-blue, 0.1);
|
||||||
|
color: $info-blue;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba($info-blue, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete {
|
||||||
|
background: rgba($danger-red, 0.1);
|
||||||
|
color: $danger-red;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba($danger-red, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-message {
|
||||||
|
text-align: center;
|
||||||
|
padding: $spacing-3xl;
|
||||||
|
color: $neutral-medium;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Responsive ───────────────────────────────────────────
|
||||||
|
|
||||||
|
@include responsive(md) {
|
||||||
|
.admin-table {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
|
||||||
|
thead th,
|
||||||
|
tbody td {
|
||||||
|
padding: $spacing-xs $spacing-sm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-toolbar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule, AsyncPipe } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { BehaviorSubject, firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
import { Program } from '../interfaces/program';
|
||||||
|
import { Event } from '../interfaces/event';
|
||||||
|
import { Tier } from '../interfaces/tier';
|
||||||
|
import { ProgramService } from '../services/program.service';
|
||||||
|
import { EventService } from '../services/event.service';
|
||||||
|
import { TierService } from '../services/tier.service';
|
||||||
|
import { AdminProgramFormComponent } from './admin-program-form.component';
|
||||||
|
import { AdminEventFormComponent } from './admin-event-form.component';
|
||||||
|
import { AdminTierFormComponent } from './admin-tier-form.component';
|
||||||
|
|
||||||
|
type TabKey = 'programs' | 'events' | 'tiers';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-admin',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
AsyncPipe,
|
||||||
|
FormsModule,
|
||||||
|
AdminProgramFormComponent,
|
||||||
|
AdminEventFormComponent,
|
||||||
|
AdminTierFormComponent,
|
||||||
|
],
|
||||||
|
templateUrl: './admin.component.html',
|
||||||
|
styleUrl: './admin.component.scss',
|
||||||
|
})
|
||||||
|
export class AdminComponent implements OnInit {
|
||||||
|
private programService = inject(ProgramService);
|
||||||
|
private eventService = inject(EventService);
|
||||||
|
private tierService = inject(TierService);
|
||||||
|
|
||||||
|
activeTab = signal<TabKey>('programs');
|
||||||
|
|
||||||
|
/** Item currently being edited (set by editXxx, cleared on save/close). */
|
||||||
|
editingProgram: Program | null = null;
|
||||||
|
editingEvent: Event | null = null;
|
||||||
|
editingTier: Tier | null = null;
|
||||||
|
|
||||||
|
/** BehaviorSubjects guarantee change detection via the async pipe — start empty, populated on load. */
|
||||||
|
readonly programs$ = new BehaviorSubject<Program[]>([]);
|
||||||
|
readonly events$ = new BehaviorSubject<Event[]>([]);
|
||||||
|
readonly tiers$ = new BehaviorSubject<Tier[]>([]);
|
||||||
|
|
||||||
|
showProgramForm = false;
|
||||||
|
showEventForm = false;
|
||||||
|
showTierForm = false;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
async reloadPrograms(): Promise<void> {
|
||||||
|
const data = await firstValueFrom(this.programService.getPrograms());
|
||||||
|
this.programs$.next(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async reloadEvents(): Promise<void> {
|
||||||
|
const data = await firstValueFrom(this.eventService.getEvents());
|
||||||
|
this.events$.next(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async reloadTiers(): Promise<void> {
|
||||||
|
const data = await firstValueFrom(this.tierService.getTiers());
|
||||||
|
this.tiers$.next(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadAll(): Promise<void> {
|
||||||
|
await Promise.all([
|
||||||
|
this.reloadPrograms(),
|
||||||
|
this.reloadEvents(),
|
||||||
|
this.reloadTiers(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Program CRUD ────────────────────────────────────────
|
||||||
|
|
||||||
|
openProgramForm(): void {
|
||||||
|
this.editingProgram = null;
|
||||||
|
this.showProgramForm = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
editProgram(program: Program): void {
|
||||||
|
this.editingProgram = program;
|
||||||
|
this.showProgramForm = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgramSaved(): void {
|
||||||
|
this.reloadPrograms();
|
||||||
|
this.showProgramForm = false;
|
||||||
|
this.editingProgram = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgramClosed(): void {
|
||||||
|
this.showProgramForm = false;
|
||||||
|
this.editingProgram = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteProgram(id: number): Promise<void> {
|
||||||
|
if (!confirm('Delete this program?')) return;
|
||||||
|
await firstValueFrom(this.programService.deleteProgram(id));
|
||||||
|
await this.reloadPrograms();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Event CRUD ──────────────────────────────────────────
|
||||||
|
|
||||||
|
openEventForm(): void {
|
||||||
|
this.editingEvent = null;
|
||||||
|
this.showEventForm = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
editEvent(event: Event): void {
|
||||||
|
this.editingEvent = event;
|
||||||
|
this.showEventForm = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
onEventSaved(): void {
|
||||||
|
this.reloadEvents();
|
||||||
|
this.showEventForm = false;
|
||||||
|
this.editingEvent = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onEventClosed(): void {
|
||||||
|
this.showEventForm = false;
|
||||||
|
this.editingEvent = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteEvent(id: number): Promise<void> {
|
||||||
|
if (!confirm('Delete this event?')) return;
|
||||||
|
await firstValueFrom(this.eventService.deleteEvent(id));
|
||||||
|
await this.reloadEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tier CRUD ───────────────────────────────────────────
|
||||||
|
|
||||||
|
openTierForm(): void {
|
||||||
|
this.editingTier = null;
|
||||||
|
this.showTierForm = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
editTier(tier: Tier): void {
|
||||||
|
this.editingTier = tier;
|
||||||
|
this.showTierForm = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
onTierSaved(): void {
|
||||||
|
this.reloadTiers();
|
||||||
|
this.showTierForm = false;
|
||||||
|
this.editingTier = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onTierClosed(): void {
|
||||||
|
this.showTierForm = false;
|
||||||
|
this.editingTier = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteTier(id: number): Promise<void> {
|
||||||
|
if (!confirm('Delete this tier?')) return;
|
||||||
|
await firstValueFrom(this.tierService.deleteTier(id));
|
||||||
|
await this.reloadTiers();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||||
import { provideHttpClient } from '@angular/common/http';
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
|
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
|
import { authInterceptor } from './interceptors/auth.interceptor';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
provideBrowserGlobalErrorListeners(),
|
provideBrowserGlobalErrorListeners(),
|
||||||
provideRouter(routes),
|
provideRouter(routes),
|
||||||
provideHttpClient(),
|
provideHttpClient(withInterceptors([authInterceptor])),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
|
|
||||||
|
import { AdminGuard } from './guards/admin.guard';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
@@ -31,5 +33,16 @@ export const routes: Routes = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./contact/contact.component').then((m) => m.ContactComponent),
|
import('./contact/contact.component').then((m) => m.ContactComponent),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'login',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./login/login.component').then((m) => m.LoginComponent),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'admin',
|
||||||
|
canActivate: [AdminGuard],
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./admin/admin.component').then((m) => m.AdminComponent),
|
||||||
|
},
|
||||||
{ path: '**', redirectTo: '' },
|
{ path: '**', redirectTo: '' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { CanActivate, Router } from '@angular/router';
|
||||||
|
|
||||||
|
import { AuthService } from '../services/auth.service';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AdminGuard implements CanActivate {
|
||||||
|
private auth = inject(AuthService);
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
async canActivate(): Promise<boolean> {
|
||||||
|
// Ensure user info is loaded (handles page-reload race where token
|
||||||
|
// exists but /me hasn't been called yet).
|
||||||
|
const user = await this.auth.getCurrentUser();
|
||||||
|
if (user?.is_admin) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
this.router.navigate(['/login']);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { HttpInterceptorFn, HttpRequest, HttpHandlerFn } from '@angular/common/http';
|
||||||
|
|
||||||
|
export const authInterceptor: HttpInterceptorFn = (req: HttpRequest<unknown>, next: HttpHandlerFn) => {
|
||||||
|
const token = localStorage.getItem('kmtn_access_token');
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
const cloned = req.clone({
|
||||||
|
setHeaders: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return next(cloned);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next(req);
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export interface AppUser {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
display_name: string;
|
||||||
|
avatar_url: string | null;
|
||||||
|
auth_provider: string;
|
||||||
|
is_admin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthTokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
token_type: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<section class="login-section">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-header">
|
||||||
|
<img src="svg/small-flower.svg" alt="" class="login-flower" aria-hidden="true">
|
||||||
|
<h1>Sign In</h1>
|
||||||
|
<p>Admin access for KMountain Flower Radio</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form (ngSubmit)="onSubmit()" class="login-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="username"
|
||||||
|
name="username"
|
||||||
|
autocomplete="username"
|
||||||
|
placeholder="Enter username"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
[(ngModel)]="password"
|
||||||
|
name="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
placeholder="Enter password"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (error) {
|
||||||
|
<div class="login-error" role="alert">{{ error }}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-login" [disabled]="loading">
|
||||||
|
{{ loading ? 'Signing in…' : 'Sign In' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
@use '../styles/variables' as *;
|
||||||
|
@use '../styles/mixins' as *;
|
||||||
|
|
||||||
|
.login-section {
|
||||||
|
@include flex-center;
|
||||||
|
min-height: calc(100vh - 80px);
|
||||||
|
background: $sky-gradient;
|
||||||
|
padding: $spacing-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
@include card-style;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: $spacing-2xl;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header {
|
||||||
|
margin-bottom: $spacing-lg;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-family: $font-heading;
|
||||||
|
color: $primary-blue;
|
||||||
|
margin: $spacing-sm 0 $spacing-xs;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: $neutral-medium;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-flower {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin-bottom: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $neutral-dark;
|
||||||
|
margin-bottom: $spacing-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: $spacing-sm $spacing-md;
|
||||||
|
border: 1px solid $neutral-light;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color $transition-fast;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: $primary-blue;
|
||||||
|
box-shadow: 0 0 0 3px rgba($primary-blue, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error {
|
||||||
|
background: rgba($danger-red, 0.1);
|
||||||
|
color: $danger-red;
|
||||||
|
padding: $spacing-sm $spacing-md;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login {
|
||||||
|
@include button-style($primary-blue, $neutral-white, $primary-blue-light);
|
||||||
|
width: 100%;
|
||||||
|
padding: $spacing-sm $spacing-lg;
|
||||||
|
margin-top: $spacing-sm;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
import { AuthService } from '../services/auth.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-login',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule],
|
||||||
|
templateUrl: './login.component.html',
|
||||||
|
styleUrl: './login.component.scss',
|
||||||
|
})
|
||||||
|
export class LoginComponent {
|
||||||
|
private auth = inject(AuthService);
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
username = '';
|
||||||
|
password = '';
|
||||||
|
error = '';
|
||||||
|
loading = false;
|
||||||
|
|
||||||
|
async onSubmit(): Promise<void> {
|
||||||
|
if (!this.username || !this.password) return;
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await firstValueFrom(this.auth.login(this.username, this.password));
|
||||||
|
// Token stored — fetch user info
|
||||||
|
const user = await this.auth.getCurrentUser();
|
||||||
|
if (user?.is_admin) {
|
||||||
|
this.router.navigate(['/admin']);
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['/']);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
const body = err.error?.detail;
|
||||||
|
this.error = typeof body === 'string' ? body : 'Login failed. Please try again.';
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { BehaviorSubject, firstValueFrom, map, Observable, tap } from 'rxjs';
|
||||||
|
|
||||||
|
import { environment } from '../../environments/environment';
|
||||||
|
import { AppUser, AuthTokenResponse } from '../interfaces/auth';
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'kmtn_access_token';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class AuthService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
private baseUrl = `${environment.apiBaseUrl}/api/auth`;
|
||||||
|
|
||||||
|
private authStateSubject = new BehaviorSubject<AppUser | null>(null);
|
||||||
|
|
||||||
|
/** Observable that emits the current auth state. */
|
||||||
|
readonly authState$: Observable<AppUser | null> = this.authStateSubject.asObservable();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Check for existing token on init
|
||||||
|
this._initAuth();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Login with username/password (bootstrap admin). */
|
||||||
|
login(username: string, password: string): Observable<AuthTokenResponse> {
|
||||||
|
return this.http.post<AuthTokenResponse>(`${this.baseUrl}/login`, {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
}).pipe(
|
||||||
|
tap((resp) => localStorage.setItem(TOKEN_KEY, resp.access_token)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Login with Google ID token. */
|
||||||
|
loginWithGoogle(idToken: string): Observable<AuthTokenResponse> {
|
||||||
|
return this.http.post<AuthTokenResponse>(`${this.baseUrl}/google`, {
|
||||||
|
id_token: idToken,
|
||||||
|
}).pipe(
|
||||||
|
tap((resp) => localStorage.setItem(TOKEN_KEY, resp.access_token)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear stored token and reset auth state. */
|
||||||
|
logout(): void {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
this.authStateSubject.next(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch current user info from the API. */
|
||||||
|
getCurrentUser(): Promise<AppUser | null> {
|
||||||
|
const token = this._getToken();
|
||||||
|
if (!token) return Promise.resolve(null);
|
||||||
|
|
||||||
|
return firstValueFrom(
|
||||||
|
this.http.get<AppUser>(`${this.baseUrl}/me`).pipe(
|
||||||
|
tap((user) => this.authStateSubject.next(user)),
|
||||||
|
map((user) => user),
|
||||||
|
)
|
||||||
|
).catch(() => {
|
||||||
|
this.logout();
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if there is a stored token. */
|
||||||
|
isLoggedIn(): boolean {
|
||||||
|
return !!this._getToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if the current user is an admin (synchronous, based on cached state). */
|
||||||
|
isAdmin(): boolean {
|
||||||
|
return this.authStateSubject.value?.is_admin ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the current user synchronously (from cached state). */
|
||||||
|
getUser(): AppUser | null {
|
||||||
|
return this.authStateSubject.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
private _getToken(): string | null {
|
||||||
|
return localStorage.getItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _initAuth(): Promise<void> {
|
||||||
|
const token = this._getToken();
|
||||||
|
if (token) {
|
||||||
|
await this.getCurrentUser();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'zone.js';
|
||||||
import { bootstrapApplication } from '@angular/platform-browser';
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
import { appConfig } from './app/app.config';
|
import { appConfig } from './app/app.config';
|
||||||
import { App } from './app/app';
|
import { App } from './app/app';
|
||||||
|
|||||||
Reference in New Issue
Block a user