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 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:
+11 -2
View File
@@ -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:
+11 -4
View File
@@ -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:
+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"] 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
View File
@@ -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"])
+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 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
+8 -1
View File
@@ -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
View File
@@ -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()">&times;</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()">&times;</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()">&times;</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;
}
}
+160
View File
@@ -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>
+177
View File
@@ -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;
}
}
+166
View File
@@ -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();
}
}
+3 -2
View File
@@ -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])),
], ],
}; };
+13
View File
@@ -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: '' },
]; ];
+21
View File
@@ -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;
}
}
+16
View File
@@ -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);
};
+13
View File
@@ -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;
}
+45
View File
@@ -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>
+93
View File
@@ -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;
}
}
+47
View File
@@ -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;
}
}
}
+95
View File
@@ -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
View File
@@ -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';