From 266c2451f1afda58ef70b306ff62c8b146a1519e Mon Sep 17 00:00:00 2001 From: kfj001 Date: Fri, 19 Jun 2026 17:36:35 +0000 Subject: [PATCH] Admin mode. Data driven content sections. Bugfixes. --- backend/app/api/auth.py | 112 +++++++++++ backend/app/api/events.py | 13 +- backend/app/api/programs.py | 13 +- backend/app/api/tiers.py | 15 +- backend/app/auth.py | 109 +++++++++++ backend/app/config.py | 8 + backend/app/main.py | 24 ++- backend/app/user_models.py | 18 ++ backend/requirements.txt | 2 + package-lock.json | 9 +- package.json | 3 +- src/app/admin/admin-event-form.component.html | 60 ++++++ src/app/admin/admin-event-form.component.scss | 146 +++++++++++++++ .../admin/admin-program-form.component.html | 54 ++++++ .../admin/admin-program-form.component.scss | 129 +++++++++++++ src/app/admin/admin-tier-form.component.html | 50 +++++ src/app/admin/admin-tier-form.component.scss | 129 +++++++++++++ src/app/admin/admin.component.html | 160 ++++++++++++++++ src/app/admin/admin.component.scss | 177 ++++++++++++++++++ src/app/admin/admin.component.ts | 166 ++++++++++++++++ src/app/app.config.ts | 5 +- src/app/app.routes.ts | 13 ++ src/app/guards/admin.guard.ts | 21 +++ src/app/interceptors/auth.interceptor.ts | 16 ++ src/app/interfaces/auth.ts | 13 ++ src/app/login/login.component.html | 45 +++++ src/app/login/login.component.scss | 93 +++++++++ src/app/login/login.component.ts | 47 +++++ src/app/services/auth.service.ts | 95 ++++++++++ src/main.ts | 1 + 30 files changed, 1733 insertions(+), 13 deletions(-) create mode 100644 backend/app/api/auth.py create mode 100644 backend/app/auth.py create mode 100644 backend/app/user_models.py create mode 100644 src/app/admin/admin-event-form.component.html create mode 100644 src/app/admin/admin-event-form.component.scss create mode 100644 src/app/admin/admin-program-form.component.html create mode 100644 src/app/admin/admin-program-form.component.scss create mode 100644 src/app/admin/admin-tier-form.component.html create mode 100644 src/app/admin/admin-tier-form.component.scss create mode 100644 src/app/admin/admin.component.html create mode 100644 src/app/admin/admin.component.scss create mode 100644 src/app/admin/admin.component.ts create mode 100644 src/app/guards/admin.guard.ts create mode 100644 src/app/interceptors/auth.interceptor.ts create mode 100644 src/app/interfaces/auth.ts create mode 100644 src/app/login/login.component.html create mode 100644 src/app/login/login.component.scss create mode 100644 src/app/login/login.component.ts create mode 100644 src/app/services/auth.service.ts diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000..ea1c97b --- /dev/null +++ b/backend/app/api/auth.py @@ -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 diff --git a/backend/app/api/events.py b/backend/app/api/events.py index 8e6ce0f..9011517 100644 --- a/backend/app/api/events.py +++ b/backend/app/api/events.py @@ -4,9 +4,11 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import get_current_admin_user from app.database import get_session from app.models import Event from app.schemas import EventCreate, EventUpdate, EventResponse +from app.user_models import User router = APIRouter() @@ -34,7 +36,9 @@ async def get_event(event_id: int, session: AsyncSession = Depends(get_session)) @router.post("/", response_model=EventResponse, status_code=201) async def create_event( - payload: EventCreate, session: AsyncSession = Depends(get_session) + payload: EventCreate, + session: AsyncSession = Depends(get_session), + _: User = Depends(get_current_admin_user), ): event = Event(**payload.model_dump(), active=True) session.add(event) @@ -48,6 +52,7 @@ async def update_event( event_id: int, payload: EventUpdate, session: AsyncSession = Depends(get_session), + _: User = Depends(get_current_admin_user), ): result = await session.execute(select(Event).where(Event.id == event_id)) event = result.scalar_one_or_none() @@ -61,7 +66,11 @@ async def update_event( @router.delete("/{event_id}", status_code=204) -async def delete_event(event_id: int, session: AsyncSession = Depends(get_session)): +async def delete_event( + event_id: int, + session: AsyncSession = Depends(get_session), + _: User = Depends(get_current_admin_user), +): result = await session.execute(select(Event).where(Event.id == event_id)) event = result.scalar_one_or_none() if not event: diff --git a/backend/app/api/programs.py b/backend/app/api/programs.py index 20908d5..ab66c2b 100644 --- a/backend/app/api/programs.py +++ b/backend/app/api/programs.py @@ -4,9 +4,11 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import get_current_admin_user from app.database import get_session from app.models import Program from app.schemas import ProgramCreate, ProgramUpdate, ProgramResponse +from app.user_models import User router = APIRouter() @@ -39,7 +41,9 @@ async def get_program(program_id: int, session: AsyncSession = Depends(get_sessi @router.post("/", response_model=ProgramResponse, status_code=201) async def create_program( - payload: ProgramCreate, session: AsyncSession = Depends(get_session) + payload: ProgramCreate, + session: AsyncSession = Depends(get_session), + _: User = Depends(get_current_admin_user), ): program = Program(**payload.model_dump()) session.add(program) @@ -53,6 +57,7 @@ async def update_program( program_id: int, payload: ProgramUpdate, session: AsyncSession = Depends(get_session), + _: User = Depends(get_current_admin_user), ): result = await session.execute(select(Program).where(Program.id == program_id)) program = result.scalar_one_or_none() @@ -66,7 +71,11 @@ async def update_program( @router.delete("/{program_id}", status_code=204) -async def delete_program(program_id: int, session: AsyncSession = Depends(get_session)): +async def delete_program( + program_id: int, + session: AsyncSession = Depends(get_session), + _: User = Depends(get_current_admin_user), +): result = await session.execute(select(Program).where(Program.id == program_id)) program = result.scalar_one_or_none() if not program: diff --git a/backend/app/api/tiers.py b/backend/app/api/tiers.py index a7702c7..15f2cdd 100644 --- a/backend/app/api/tiers.py +++ b/backend/app/api/tiers.py @@ -1,12 +1,12 @@ -from typing import Optional - from fastapi import APIRouter, Depends from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import get_current_admin_user from app.database import get_session from app.models import DonationTier from app.schemas import TierCreate, TierUpdate, TierResponse +from app.user_models import User router = APIRouter() @@ -30,7 +30,9 @@ async def get_tier(tier_id: int, session: AsyncSession = Depends(get_session)): @router.post("/", response_model=TierResponse, status_code=201) async def create_tier( - payload: TierCreate, session: AsyncSession = Depends(get_session) + payload: TierCreate, + session: AsyncSession = Depends(get_session), + _: User = Depends(get_current_admin_user), ): tier = DonationTier(**payload.model_dump()) session.add(tier) @@ -44,6 +46,7 @@ async def update_tier( tier_id: int, payload: TierUpdate, session: AsyncSession = Depends(get_session), + _: User = Depends(get_current_admin_user), ): result = await session.execute(select(DonationTier).where(DonationTier.id == tier_id)) tier = result.scalar_one_or_none() @@ -57,7 +60,11 @@ async def update_tier( @router.delete("/{tier_id}", status_code=204) -async def delete_tier(tier_id: int, session: AsyncSession = Depends(get_session)): +async def delete_tier( + tier_id: int, + session: AsyncSession = Depends(get_session), + _: User = Depends(get_current_admin_user), +): result = await session.execute(select(DonationTier).where(DonationTier.id == tier_id)) tier = result.scalar_one_or_none() if not tier: diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000..9302091 --- /dev/null +++ b/backend/app/auth.py @@ -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", ""), + } diff --git a/backend/app/config.py b/backend/app/config.py index 85c2f98..8b04a71 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -6,6 +6,14 @@ class Settings(BaseSettings): CORS_ORIGINS: list[str] = ["http://localhost:4200", "http://localhost:3000"] ENV: str = "development" + # Auth + ADMIN_USERNAME: str = "" + ADMIN_PASSWORD: str = "" + GOOGLE_CLIENT_ID: str = "" + GOOGLE_REDIRECT_URI: str = "http://localhost:4200" + JWT_SECRET_KEY: str = "change-me-in-production" + JWT_EXPIRE_MINUTES: int = 1440 # 24 hours + model_config = {"env_prefix": "KMTN_"} diff --git a/backend/app/main.py b/backend/app/main.py index b227cf4..aeb1eee 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,7 +7,8 @@ from sqlalchemy import func, select from app.config import settings from app.database import async_session, engine from app.models import Base, Program -from app.api import programs, events, tiers +from app.user_models import User +from app.api import programs, events, tiers, auth @asynccontextmanager @@ -25,6 +26,26 @@ async def lifespan(app: FastAPI): from seed import seed as run_seed await run_seed() + # Bootstrap admin user if configured + if settings.ADMIN_USERNAME and settings.ADMIN_PASSWORD: + async with async_session() as session: + result = await session.execute( + select(User).where( + User.email == f"{settings.ADMIN_USERNAME}@local", + User.auth_provider == "local", + ) + ) + if result.scalar_one_or_none() is None: + admin_user = User( + email=f"{settings.ADMIN_USERNAME}@local", + display_name=settings.ADMIN_USERNAME, + auth_provider="local", + is_admin=True, + ) + session.add(admin_user) + await session.commit() + print(f" → Bootstrap admin created: {settings.ADMIN_USERNAME}") + yield @@ -44,6 +65,7 @@ app.add_middleware( ) # Register routers +app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(programs.router, prefix="/api/programs", tags=["programs"]) app.include_router(events.router, prefix="/api/events", tags=["events"]) app.include_router(tiers.router, prefix="/api/tiers", tags=["tiers"]) diff --git a/backend/app/user_models.py b/backend/app/user_models.py new file mode 100644 index 0000000..f17a9c8 --- /dev/null +++ b/backend/app/user_models.py @@ -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) diff --git a/backend/requirements.txt b/backend/requirements.txt index 8cd3a41..59a700c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -8,3 +8,5 @@ alembic==1.14.1 python-multipart==0.0.20 pydantic==2.10.4 pydantic-settings==2.7.1 +python-jose[cryptography]==3.3.0 +httpx==0.27.2 diff --git a/package-lock.json b/package-lock.json index f959b50..118a06a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,8 @@ "@angular/platform-browser": "^21.2.0", "@angular/router": "^21.2.0", "rxjs": "~7.8.0", - "tslib": "^2.3.0" + "tslib": "^2.3.0", + "zone.js": "^0.16.2" }, "devDependencies": { "@angular/build": "^21.2.14", @@ -7721,6 +7722,12 @@ "peerDependencies": { "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" } } } diff --git a/package.json b/package.json index 7f6fb14..78d8389 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "@angular/platform-browser": "^21.2.0", "@angular/router": "^21.2.0", "rxjs": "~7.8.0", - "tslib": "^2.3.0" + "tslib": "^2.3.0", + "zone.js": "^0.16.2" }, "devDependencies": { "@angular/build": "^21.2.14", diff --git a/src/app/admin/admin-event-form.component.html b/src/app/admin/admin-event-form.component.html new file mode 100644 index 0000000..29c847c --- /dev/null +++ b/src/app/admin/admin-event-form.component.html @@ -0,0 +1,60 @@ +
+
+
+

{{ id ? 'Edit Event' : 'Add Event' }}

+ +
+ + @if (error) { + + } + +
+
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ + +
+
+
+
diff --git a/src/app/admin/admin-event-form.component.scss b/src/app/admin/admin-event-form.component.scss new file mode 100644 index 0000000..6d7afb2 --- /dev/null +++ b/src/app/admin/admin-event-form.component.scss @@ -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; + } +} diff --git a/src/app/admin/admin-program-form.component.html b/src/app/admin/admin-program-form.component.html new file mode 100644 index 0000000..bdc0ae4 --- /dev/null +++ b/src/app/admin/admin-program-form.component.html @@ -0,0 +1,54 @@ +
+
+
+

{{ id ? 'Edit Program' : 'Add Program' }}

+ +
+ + @if (error) { + + } + +
+
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+
+
+
diff --git a/src/app/admin/admin-program-form.component.scss b/src/app/admin/admin-program-form.component.scss new file mode 100644 index 0000000..854d9bc --- /dev/null +++ b/src/app/admin/admin-program-form.component.scss @@ -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; + } +} diff --git a/src/app/admin/admin-tier-form.component.html b/src/app/admin/admin-tier-form.component.html new file mode 100644 index 0000000..37cf565 --- /dev/null +++ b/src/app/admin/admin-tier-form.component.html @@ -0,0 +1,50 @@ +
+
+
+

{{ id ? 'Edit Tier' : 'Add Tier' }}

+ +
+ + @if (error) { + + } + +
+
+
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+
+
+
diff --git a/src/app/admin/admin-tier-form.component.scss b/src/app/admin/admin-tier-form.component.scss new file mode 100644 index 0000000..854d9bc --- /dev/null +++ b/src/app/admin/admin-tier-form.component.scss @@ -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; + } +} diff --git a/src/app/admin/admin.component.html b/src/app/admin/admin.component.html new file mode 100644 index 0000000..464d63f --- /dev/null +++ b/src/app/admin/admin.component.html @@ -0,0 +1,160 @@ +
+
+
+

Admin Dashboard

+

Manage programs, events, and donation tiers

+
+ + +
+ + + +
+ + + @if (activeTab() === 'programs') { + @if (programs$ | async; as programs) { +
+
+

Programs ({{ programs.length }})

+ +
+ + + + + + + + + + + + + + @for (program of programs; track program.id) { + + + + + + + + + } @empty { + + } + +
DayTimeTitleHostGenreActions
{{ program.day_label }}{{ program.time }}{{ program.title }}{{ program.host }}{{ program.genre }} + + +
No programs yet. Click "Add Program" to create one.
+
+ } + } + + + @if (activeTab() === 'events') { + @if (events$ | async; as events) { +
+
+

Events ({{ events.length }})

+ +
+ + + + + + + + + + + + + @for (event of events; track event.id) { + + + + + + + + } @empty { + + } + +
DateTitleLocationActiveActions
{{ event.date }}{{ event.title }}{{ event.location }}{{ event.active ? 'Yes' : 'No' }} + + +
No events yet. Click "Add Event" to create one.
+
+ } + } + + + @if (activeTab() === 'tiers') { + @if (tiers$ | async; as tiers) { +
+
+

Donation Tiers ({{ tiers.length }})

+ +
+ + + + + + + + + + + + + @for (tier of tiers; track tier.id) { + + + + + + + + } @empty { + + } + +
OrderNameAmountBenefitsActions
{{ tier.display_order }}{{ tier.icon }} {{ tier.name }}${{ tier.amount.toFixed(2) }}{{ tier.benefits.join(', ') }} + + +
No tiers yet. Click "Add Tier" to create one.
+
+ } + } +
+ + + @if (showProgramForm) { + + } + @if (showEventForm) { + + } + @if (showTierForm) { + + } +
diff --git a/src/app/admin/admin.component.scss b/src/app/admin/admin.component.scss new file mode 100644 index 0000000..229c900 --- /dev/null +++ b/src/app/admin/admin.component.scss @@ -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; + } +} diff --git a/src/app/admin/admin.component.ts b/src/app/admin/admin.component.ts new file mode 100644 index 0000000..1225f8c --- /dev/null +++ b/src/app/admin/admin.component.ts @@ -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('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([]); + readonly events$ = new BehaviorSubject([]); + readonly tiers$ = new BehaviorSubject([]); + + showProgramForm = false; + showEventForm = false; + showTierForm = false; + + ngOnInit(): void { + this.loadAll(); + } + + async reloadPrograms(): Promise { + const data = await firstValueFrom(this.programService.getPrograms()); + this.programs$.next(data); + } + + async reloadEvents(): Promise { + const data = await firstValueFrom(this.eventService.getEvents()); + this.events$.next(data); + } + + async reloadTiers(): Promise { + const data = await firstValueFrom(this.tierService.getTiers()); + this.tiers$.next(data); + } + + async loadAll(): Promise { + 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 { + 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 { + 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 { + if (!confirm('Delete this tier?')) return; + await firstValueFrom(this.tierService.deleteTier(id)); + await this.reloadTiers(); + } +} diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 3660a5e..88f055d 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,13 +1,14 @@ 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 { routes } from './app.routes'; +import { authInterceptor } from './interceptors/auth.interceptor'; export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideRouter(routes), - provideHttpClient(), + provideHttpClient(withInterceptors([authInterceptor])), ], }; diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index f673630..97a311e 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -1,5 +1,7 @@ import { Routes } from '@angular/router'; +import { AdminGuard } from './guards/admin.guard'; + export const routes: Routes = [ { path: '', @@ -31,5 +33,16 @@ export const routes: Routes = [ loadComponent: () => 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: '' }, ]; diff --git a/src/app/guards/admin.guard.ts b/src/app/guards/admin.guard.ts new file mode 100644 index 0000000..c0201dc --- /dev/null +++ b/src/app/guards/admin.guard.ts @@ -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 { + // 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; + } +} diff --git a/src/app/interceptors/auth.interceptor.ts b/src/app/interceptors/auth.interceptor.ts new file mode 100644 index 0000000..4a68207 --- /dev/null +++ b/src/app/interceptors/auth.interceptor.ts @@ -0,0 +1,16 @@ +import { HttpInterceptorFn, HttpRequest, HttpHandlerFn } from '@angular/common/http'; + +export const authInterceptor: HttpInterceptorFn = (req: HttpRequest, 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); +}; diff --git a/src/app/interfaces/auth.ts b/src/app/interfaces/auth.ts new file mode 100644 index 0000000..59910aa --- /dev/null +++ b/src/app/interfaces/auth.ts @@ -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; +} diff --git a/src/app/login/login.component.html b/src/app/login/login.component.html new file mode 100644 index 0000000..ec17405 --- /dev/null +++ b/src/app/login/login.component.html @@ -0,0 +1,45 @@ + diff --git a/src/app/login/login.component.scss b/src/app/login/login.component.scss new file mode 100644 index 0000000..b74bb98 --- /dev/null +++ b/src/app/login/login.component.scss @@ -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; + } +} diff --git a/src/app/login/login.component.ts b/src/app/login/login.component.ts new file mode 100644 index 0000000..1b7ef4d --- /dev/null +++ b/src/app/login/login.component.ts @@ -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 { + 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; + } + } +} diff --git a/src/app/services/auth.service.ts b/src/app/services/auth.service.ts new file mode 100644 index 0000000..c34e393 --- /dev/null +++ b/src/app/services/auth.service.ts @@ -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(null); + + /** Observable that emits the current auth state. */ + readonly authState$: Observable = this.authStateSubject.asObservable(); + + constructor() { + // Check for existing token on init + this._initAuth(); + } + + /** Login with username/password (bootstrap admin). */ + login(username: string, password: string): Observable { + return this.http.post(`${this.baseUrl}/login`, { + username, + password, + }).pipe( + tap((resp) => localStorage.setItem(TOKEN_KEY, resp.access_token)), + ); + } + + /** Login with Google ID token. */ + loginWithGoogle(idToken: string): Observable { + return this.http.post(`${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 { + const token = this._getToken(); + if (!token) return Promise.resolve(null); + + return firstValueFrom( + this.http.get(`${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 { + const token = this._getToken(); + if (token) { + await this.getCurrentUser(); + } + } +} diff --git a/src/main.ts b/src/main.ts index 190f341..d353944 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,3 +1,4 @@ +import 'zone.js'; import { bootstrapApplication } from '@angular/platform-browser'; import { appConfig } from './app/app.config'; import { App } from './app/app';