loreal-utilisation-dept/backend/app/routers/auth.py
DJP 993e370cea feat: Forecast, Project Type Summary, Time Log Detail, AI Chat, filters v2, stats bar, RBAC
Brings the new app to full parity with the original L'Oréal SPA and
beyond. Backend 59/59 tests (was 40, +19). Frontend typecheck/lint/build
clean. Main entry chunk 15.76 KB gz (budget 30 KB).

Backend — new endpoints + services:
- POST /api/deliverable/parse        — parse Deliverable Summary CSV/XLSX
- POST /api/projectsummary/parse     — parse Project Summary CSV/XLSX
- GET  /api/timelog/rows             — paginated, searchable, sortable view
                                        over the parsed Zoho upload
- GET  /api/forecast                 — 4-week pipeline + capacity decision
- GET  /api/project-types            — hours/asset, duration, concentration
                                        per project type + auto-insights
- POST /api/chat                     — Claude API proxy. 503s gracefully
                                        when ANTHROPIC_API_KEY is unset.
                                        Prompt-cached system prompt;
                                        rate-limited 20/min/IP.
- GET  /api/auth/me now returns role.

Backend — services:
- zoho_parse.py: extracts ~20 fields (brand, division, hub, userRole,
  projectType, assetCount, projectStatus, project start/end dates,
  userAgency, employingCompany, sageJobProfile, …) with back-compat
  aliases so existing callers keep working.
- parse_store.py: in-process TTL-cached registry of parsed uploads keyed
  by content hash. Lets endpoints reference an upload without re-sending it.
- forecast.py: working-day overlap math, exit-rate, weekly throughput
  baseline, capacity decision string mirroring the original wording.
- project_types.py: per-type aggregation + concentration-risk insights.
- timelog_filters.py: server-side filter by brands/divisions/hubs/roles.
- ai_context.py: builds the dashboard context block fed to Claude.

Frontend — new pages + components:
- pages/Forecast.tsx                 — ComposedChart (stacked bars + line)
                                        + capacity-decision banner + table
- pages/ProjectTypeSummary.tsx       — sortable table + small trend chart
- pages/TimeLogDetail.tsx            — virtualised, searchable, sortable
                                        view over all parsed timelog rows
- components/ChatView.tsx            — floating side panel with Claude.
                                        6 preset prompts mirroring the
                                        original. Visible only for roles
                                        with chat access.
- components/ChatToggle.tsx          — bottom-right FAB.
- components/StatsBar.tsx            — always-visible: Time Entries /
                                        People / Projects / Total Hours /
                                        Date Range.
- hooks/useDataContext.tsx           — single source of truth for filter
                                        state + parsed upload + filter
                                        dimensions (brands/divs/hubs/
                                        roles derived from uploads).

Frontend — modified:
- App.tsx, Navbar.tsx                — 7 tabs + role gating per the
                                        original TAB_ACCESS matrix.
- hooks/useAuth.tsx                  — role + canAccess(tab).
- lib/filters.ts, FilterBar.tsx      — Brand / Division / Hub / Role
                                        multiselects added (additive — keep
                                        Department / Name / Billing).
- pages/Department, Resourcing,
  Bookings, Tutorial.tsx             — wired into DataContext; tutorial
                                        is now a single 9-step global tour
                                        mirroring the original's narrative.

Config:
- backend/.env.example: ADMIN_ROLE, ANTHROPIC_API_KEY, ANTHROPIC_MODEL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:40:03 -04:00

92 lines
3.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Auth endpoints: login / logout / me.
Decisions:
- Login is rate-limited 5/min/IP via slowapi keyed on the remote address.
We wire the limiter on this single route, not globally — the rest of the
API is auth-protected anyway.
- Both success and failure attempts are appended to /app/logs/auth.log
via a RotatingFileHandler (5 MB × 5 backups).
- Cookie domain/path/flags handled by app.auth.session.
- We intentionally DO NOT use `from __future__ import annotations` in this
module: slowapi wraps the handler with a decorator that breaks FastAPI's
forward-ref resolution for the `LoginRequest` body parameter under
Python 3.14 / pydantic 2.x.
"""
import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response, status
from slowapi import Limiter
from slowapi.util import get_remote_address
from app.auth.local import verify_password
from app.auth.session import clear_session_cookie, set_session_cookie
from app.config import settings
from app.deps.auth import auth_required
from app.models.schemas import LoginRequest, MeResponse
# ---- auth.log file logger ----
auth_logger = logging.getLogger("ud.auth")
if not auth_logger.handlers:
log_dir = Path("/app/logs")
try:
log_dir.mkdir(parents=True, exist_ok=True)
handler = RotatingFileHandler(
log_dir / "auth.log",
maxBytes=5 * 1024 * 1024,
backupCount=5,
encoding="utf-8",
)
except (PermissionError, OSError):
# Fallback for local dev when /app/logs isn't writable.
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter("%(asctime)sZ %(message)s"))
auth_logger.addHandler(handler)
auth_logger.setLevel(logging.INFO)
auth_logger.propagate = False
limiter = Limiter(key_func=get_remote_address)
router = APIRouter(prefix="/api/auth", tags=["auth"])
def _log_attempt(request: Request, username: str, outcome: str) -> None:
ip = get_remote_address(request)
auth_logger.info("ip=%s user=%s outcome=%s", ip, username, outcome)
@router.post("/login")
@limiter.limit("5/minute")
async def login(request: Request, response: Response, body: LoginRequest = Body(...)):
if settings.DEV_AUTH_BYPASS:
_log_attempt(request, body.username, "bypass")
return {"ok": True, "username": "dev", "mode": "bypass", "role": settings.ADMIN_ROLE}
if not verify_password(body.username, body.password):
_log_attempt(request, body.username, "fail")
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
set_session_cookie(response, body.username)
_log_attempt(request, body.username, "success")
return {"ok": True, "username": body.username, "mode": settings.AUTH_MODE, "role": settings.ADMIN_ROLE}
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(response: Response) -> Response:
clear_session_cookie(response)
# Explicit 204 with no body.
response.status_code = status.HTTP_204_NO_CONTENT
return response
@router.get("/me", response_model=MeResponse)
async def me(user: dict = Depends(auth_required)) -> MeResponse:
return MeResponse(
username=user["username"],
mode=user["mode"],
role=user.get("role", settings.ADMIN_ROLE),
)