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 @@ +
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 @@ + 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 @@ + 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 @@ +Manage programs, events, and donation tiers
+| Day | +Time | +Title | +Host | +Genre | +Actions | +
|---|---|---|---|---|---|
| {{ program.day_label }} | +{{ program.time }} | +{{ program.title }} | +{{ program.host }} | +{{ program.genre }} | ++ + + | +
| No programs yet. Click "Add Program" to create one. | |||||
| Date | +Title | +Location | +Active | +Actions | +
|---|---|---|---|---|
| {{ event.date }} | +{{ event.title }} | +{{ event.location }} | +{{ event.active ? 'Yes' : 'No' }} | ++ + + | +
| No events yet. Click "Add Event" to create one. | ||||
| Order | +Name | +Amount | +Benefits | +Actions | +
|---|---|---|---|---|
| {{ tier.display_order }} | +{{ tier.icon }} {{ tier.name }} | +${{ tier.amount.toFixed(2) }} | +{{ tier.benefits.join(', ') }} | ++ + + | +
| No tiers yet. Click "Add Tier" to create one. | ||||
Admin access for KMountain Flower Radio
+