diff --git a/docker-compose.yml b/docker-compose.yml index 4a96dc9..0df4c60 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,7 +35,8 @@ services: environment: PROJECT_NAME: "Smash or Pass" ALLOWED_ORIGINS: '["http://localhost:8080","http://localhost:5173"]' - ADMIN_ENABLED: "true" + ADMIN_USERNAME: "admin" + ADMIN_PASSWORD: "change-me" DATABASE_URL: "sqlite:///./data/sop.db" S3_ENDPOINT_URL: "http://minio:9000" S3_PUBLIC_URL: "http://localhost:9000" diff --git a/sop-back/.env.example b/sop-back/.env.example index a7c953a..a52f97b 100644 --- a/sop-back/.env.example +++ b/sop-back/.env.example @@ -2,8 +2,10 @@ PROJECT_NAME=Smash or Pass ALLOWED_ORIGINS=["http://localhost:5173","http://localhost:8080"] -# Admin gate -ADMIN_ENABLED=true +# Admin account — set both to enable admin login at /login. +# Leave empty to fully disable admin endpoints. +ADMIN_USERNAME=admin +ADMIN_PASSWORD=change-me # Database (SQLite file path) DATABASE_URL=sqlite:///./data/sop.db diff --git a/sop-back/app/api/routes/auth.py b/sop-back/app/api/routes/auth.py new file mode 100644 index 0000000..c1454b1 --- /dev/null +++ b/sop-back/app/api/routes/auth.py @@ -0,0 +1,37 @@ +import secrets +from fastapi import APIRouter, HTTPException, status +from pydantic import BaseModel + +from app.core.config import settings + +router = APIRouter() + + +class LoginRequest(BaseModel): + username: str + password: str + + +class LoginResponse(BaseModel): + token: str + + +@router.post("/admin/login", response_model=LoginResponse) +def admin_login(body: LoginRequest): + if not settings.ADMIN_USERNAME or not settings.ADMIN_PASSWORD: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin account is not configured", + ) + # constant-time compare to avoid leaking timing info + user_ok = secrets.compare_digest(body.username, settings.ADMIN_USERNAME) + pass_ok = secrets.compare_digest(body.password, settings.ADMIN_PASSWORD) + if not (user_ok and pass_ok): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid credentials", + ) + # The token IS the password. Single account, no expiry, no rotation — + # simple and matches what require_admin() expects in the Authorization + # header. Swap for a signed/expiring token if you ever add multiple users. + return LoginResponse(token=settings.ADMIN_PASSWORD) diff --git a/sop-back/app/api/routes/health.py b/sop-back/app/api/routes/health.py index c1dc409..b4cb63b 100644 --- a/sop-back/app/api/routes/health.py +++ b/sop-back/app/api/routes/health.py @@ -1,6 +1,4 @@ from fastapi import APIRouter -from app.core.config import settings -from app.schemas.schemas import AdminStatus router = APIRouter() @@ -8,8 +6,3 @@ router = APIRouter() @router.get("/health") def health(): return {"status": "ok"} - - -@router.get("/admin/status", response_model=AdminStatus) -def admin_status(): - return AdminStatus(admin_enabled=settings.ADMIN_ENABLED) diff --git a/sop-back/app/core/config.py b/sop-back/app/core/config.py index 657e7e7..23428f4 100644 --- a/sop-back/app/core/config.py +++ b/sop-back/app/core/config.py @@ -9,7 +9,10 @@ class Settings(BaseSettings): VERSION: str = "0.1.0" ALLOWED_ORIGINS: List[str] = ["*"] - ADMIN_ENABLED: bool = False + # Single hardcoded admin account, configured via env. + # If either field is empty, admin login is effectively disabled. + ADMIN_USERNAME: str = "" + ADMIN_PASSWORD: str = "" DATABASE_URL: str = "sqlite:///./data/sop.db" diff --git a/sop-back/app/core/deps.py b/sop-back/app/core/deps.py index 4c9e533..d9ea139 100644 --- a/sop-back/app/core/deps.py +++ b/sop-back/app/core/deps.py @@ -1,10 +1,30 @@ -from fastapi import HTTPException, status +import secrets +from fastapi import Header, HTTPException, status from app.core.config import settings -def require_admin() -> None: - if not settings.ADMIN_ENABLED: +def require_admin(authorization: str | None = Header(default=None)) -> None: + """Gate admin endpoints on a Bearer token equal to ADMIN_PASSWORD. + + The token is issued by POST /admin/login after the username + password + match the values from .env. We keep things deliberately simple — no JWT, + no expiry — because there is exactly one admin account. + """ + if not settings.ADMIN_PASSWORD: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Admin module is disabled", + detail="Admin account is not configured", + ) + if not authorization or not authorization.lower().startswith("bearer "): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing bearer token", + headers={"WWW-Authenticate": "Bearer"}, + ) + token = authorization.split(" ", 1)[1].strip() + if not secrets.compare_digest(token, settings.ADMIN_PASSWORD): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token", + headers={"WWW-Authenticate": "Bearer"}, ) diff --git a/sop-back/app/main.py b/sop-back/app/main.py index 6865027..b177ee6 100644 --- a/sop-back/app/main.py +++ b/sop-back/app/main.py @@ -6,7 +6,7 @@ from app.core.config import settings from app.db.database import Base, engine from app.models import models # noqa: F401 (register models) from app.services.storage import ensure_bucket -from app.api.routes import health, collections, admin +from app.api.routes import health, collections, admin, auth @asynccontextmanager @@ -35,5 +35,6 @@ app.add_middleware( ) app.include_router(health.router, tags=["meta"]) +app.include_router(auth.router, tags=["auth"]) app.include_router(collections.router, prefix="/collections", tags=["collections"]) app.include_router(admin.router, prefix="/admin", tags=["admin"]) diff --git a/sop-back/app/schemas/schemas.py b/sop-back/app/schemas/schemas.py index 69692f0..e124fba 100644 --- a/sop-back/app/schemas/schemas.py +++ b/sop-back/app/schemas/schemas.py @@ -33,7 +33,3 @@ class CollectionOut(BaseModel): class CollectionCreate(BaseModel): name: str - - -class AdminStatus(BaseModel): - admin_enabled: bool diff --git a/sop-front/README.md b/sop-front/README.md index 709a57a..168a891 100644 --- a/sop-front/README.md +++ b/sop-front/README.md @@ -95,20 +95,37 @@ npm run dev ## Customizing colors / fonts -**All design tokens live in [`src/assets/main.css`](src/assets/main.css)** under `@theme`. Add new colors there as `--color-: #hex;` and they become available as Tailwind utilities (`bg-`, `text-`) and as `var(--color-)`. Don't hardcode hex values in components. +**All design tokens live in [`src/assets/main.css`](src/assets/main.css)** under `@theme`. The palette is **graded** (Tailwind-style): each hue has a 50–900 scale, with **500** as the canonical brand value. Add new shades there; don't hardcode hex values in components. -Current palette comes from the user's reference image: +### Scales (`50` lightest → `900/950` darkest) -| Token | Hex | Role | +| Family | 500 (brand) | Use | |---|---|---| -| `--color-clothes` | `#8324DE` | Primary purple (CTAs) | -| `--color-smash` / `--color-tongue` | `#B9D532` | Smash green | -| `--color-pass` / `--color-iris` | `#FF453B` | Pass red | -| `--color-bg` | `#16141A` | Dark grey background | -| `--color-surface` | `#1F1C24` | Card surface | -| `--color-fur` | `#FBF9FD` | Off-white text | +| `--color-purple-{50…900}` | `#8324DE` | Primary brand | +| `--color-red-{50…900}` | `#FF453B` | Pass / errors / destructive | +| `--color-lime-{50…900}` | `#B9D532` | Smash / success | +| `--color-neutral-{50…950}` | `#63586E` | Backgrounds, surfaces, text, borders | -Fonts: **Bebas Neue** (display) + **Inter** (body), loaded via Google Fonts in `main.css`. +Components reference shades like `var(--color-purple-300)`, `var(--color-neutral-800)`, etc. The neutral scale handles dark-mode surfaces (`50` = off-white text, `950` = `#16141A` background). + +### Semantic aliases (prefer these in components) + +These point at scale values — change them once in `main.css` to retheme: + +| Alias | → Scale | Role | +|---|---|---| +| `--color-primary` | `purple-500` | CTAs / brand | +| `--color-primary-hover` | `purple-400` | CTA hover | +| `--color-smash` | `lime-500` | Smash action | +| `--color-pass` | `red-500` | Pass action | +| `--color-bg` | `neutral-950` | App background | +| `--color-surface` | `neutral-900` | Card surface | +| `--color-surface-2` | `neutral-800` | Elevated surface | +| `--color-border` | `neutral-700` | Borders | +| `--color-text` | `neutral-50` | Primary text | +| `--color-text-muted` | `neutral-300` | Secondary text | + +Fonts: **Bebas Neue** (display) + **Roboto** (body), loaded via Google Fonts in `main.css`. --- diff --git a/sop-front/src/api/client.ts b/sop-front/src/api/client.ts index fbf8466..3ecd490 100644 --- a/sop-front/src/api/client.ts +++ b/sop-front/src/api/client.ts @@ -1,3 +1,5 @@ +import { useAuthStore } from '@/stores/auth' + const API_BASE = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8000' export interface Character { @@ -24,6 +26,15 @@ export interface CollectionDetail { async function handle(res: Response): Promise { if (!res.ok) { + // 401 on an admin call means our token is stale — drop it so the UI + // falls back to the unauthenticated state. + if (res.status === 401) { + try { + useAuthStore().clear() + } catch { + /* pinia not ready in non-component context — ignore */ + } + } let detail = res.statusText try { const body = await res.json() @@ -37,13 +48,14 @@ async function handle(res: Response): Promise { return (await res.json()) as T } +function authHeaders(): HeadersInit { + const token = useAuthStore().token + return token ? { Authorization: `Bearer ${token}` } : {} +} + export const api = { base: API_BASE, - async adminStatus(): Promise<{ admin_enabled: boolean }> { - return handle(await fetch(`${API_BASE}/admin/status`)) - }, - async listCollections(): Promise { return handle(await fetch(`${API_BASE}/collections`)) }, @@ -52,11 +64,27 @@ export const api = { return handle(await fetch(`${API_BASE}/collections/${id}`)) }, + async login(username: string, password: string): Promise<{ token: string }> { + return handle( + await fetch(`${API_BASE}/admin/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }), + ) + }, + async createCollection(name: string, files: File[]): Promise { const fd = new FormData() fd.append('name', name) for (const f of files) fd.append('files', f) - return handle(await fetch(`${API_BASE}/admin/collections`, { method: 'POST', body: fd })) + return handle( + await fetch(`${API_BASE}/admin/collections`, { + method: 'POST', + headers: authHeaders(), + body: fd, + }), + ) }, async addCharacters(collectionId: number, files: File[]): Promise { @@ -65,12 +93,18 @@ export const api = { return handle( await fetch(`${API_BASE}/admin/collections/${collectionId}/characters`, { method: 'POST', + headers: authHeaders(), body: fd, }), ) }, async deleteCollection(id: number): Promise { - return handle(await fetch(`${API_BASE}/admin/collections/${id}`, { method: 'DELETE' })) + return handle( + await fetch(`${API_BASE}/admin/collections/${id}`, { + method: 'DELETE', + headers: authHeaders(), + }), + ) }, } diff --git a/sop-front/src/assets/main.css b/sop-front/src/assets/main.css index c8431c6..901f846 100644 --- a/sop-front/src/assets/main.css +++ b/sop-front/src/assets/main.css @@ -1,37 +1,84 @@ @import 'tailwindcss'; -@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Inter:wght@400;500;600;700&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Roboto:wght@400;500;700&display=swap'); /* ============================================================ Smash or Pass — design tokens - Palette source: provided reference image. + Graded scales (50 = lightest, 900/950 = darkest). + The 500 step is the canonical brand value for each hue. + Semantic aliases (primary, smash, pass, bg, surface…) live + at the bottom and point at scale values — change once here + to retheme the whole app. ============================================================ */ @theme { - /* Brand palette */ - --color-tongue: #b9d532; /* smash / accent green */ - --color-iris: #ff453b; /* pass / red */ - --color-paws: #63586e; /* muted */ - --color-fur: #fbf9fd; /* off-white */ - --color-rayures: #f0d9ff; /* soft lilac */ - --color-body: #bfa2db; /* light purple */ - --color-body2: #8363a2; /* mid purple */ - --color-clothes: #8324de; /* primary purple */ + /* Purple — primary brand hue (anchored on #8324DE = 500) */ + --color-purple-50: #faf3ff; + --color-purple-100: #f0d9ff; + --color-purple-200: #dcb3ff; + --color-purple-300: #c189f5; + --color-purple-400: #a256ec; + --color-purple-500: #8324de; + --color-purple-600: #6e1cc2; + --color-purple-700: #57169a; + --color-purple-800: #3d1070; + --color-purple-900: #270a4a; - /* Semantic roles */ - --color-bg: #16141a; /* dark grey background */ - --color-surface: #1f1c24; /* card surface */ - --color-surface-2: #2a2630; /* elevated surface */ - --color-border: #3a3340; - --color-text: #fbf9fd; - --color-text-muted: #b9b1c2; + /* Red — pass (anchored on #FF453B = 500) */ + --color-red-50: #fff1f0; + --color-red-100: #ffd9d6; + --color-red-200: #ffb3ad; + --color-red-300: #ff8b82; + --color-red-400: #ff6961; + --color-red-500: #ff453b; + --color-red-600: #e0291f; + --color-red-700: #b81f17; + --color-red-800: #8a1611; + --color-red-900: #5c0e0a; - --color-primary: #8324de; - --color-primary-hover: #9442e8; - --color-smash: #b9d532; - --color-pass: #ff453b; + /* Lime — smash (anchored on #B9D532 = 500) */ + --color-lime-50: #f6fae3; + --color-lime-100: #eaf3b8; + --color-lime-200: #d9e886; + --color-lime-300: #c8de5f; + --color-lime-400: #c1d948; + --color-lime-500: #b9d532; + --color-lime-600: #94ab26; + --color-lime-700: #6f811d; + --color-lime-800: #4d5914; + --color-lime-900: #2c330b; + + /* Neutral — dark-mode greys with a slight purple tint + (50 = #FBF9FD off-white, 950 = #16141A dark grey background) */ + --color-neutral-50: #fbf9fd; + --color-neutral-100: #ebe8ef; + --color-neutral-200: #d2cdda; + --color-neutral-300: #b9b1c2; + --color-neutral-400: #8e8499; + --color-neutral-500: #63586e; + --color-neutral-600: #4f4659; + --color-neutral-700: #3a3340; + --color-neutral-800: #2a2630; + --color-neutral-900: #1f1c24; + --color-neutral-950: #16141a; + + /* ---------------------------------------------------------- + Semantic aliases — reference these in components, not the + scale values directly, so the theme stays repaintable. + ---------------------------------------------------------- */ + --color-primary: var(--color-purple-500); + --color-primary-hover: var(--color-purple-400); + --color-smash: var(--color-lime-500); + --color-pass: var(--color-red-500); + + --color-bg: var(--color-neutral-950); + --color-surface: var(--color-neutral-900); + --color-surface-2: var(--color-neutral-800); + --color-border: var(--color-neutral-700); + --color-text: var(--color-neutral-100); + --color-text-muted: var(--color-neutral-300); /* Typography */ - --font-sans: 'Inter', system-ui, sans-serif; - --font-display: 'Bebas Neue', 'Inter', sans-serif; + --font-sans: 'Roboto', system-ui, sans-serif; + --font-display: 'Bebas Neue', 'Roboto', sans-serif; /* Radii */ --radius-card: 1.25rem; diff --git a/sop-front/src/components/AdminPanel.vue b/sop-front/src/components/AdminPanel.vue index dd5ac79..e526666 100644 --- a/sop-front/src/components/AdminPanel.vue +++ b/sop-front/src/components/AdminPanel.vue @@ -1,10 +1,20 @@ + + diff --git a/sop-front/src/views/SummaryView.vue b/sop-front/src/views/SummaryView.vue index 0c52036..745f487 100644 --- a/sop-front/src/views/SummaryView.vue +++ b/sop-front/src/views/SummaryView.vue @@ -1,5 +1,5 @@