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>
92 lines
3.3 KiB
Python
92 lines
3.3 KiB
Python
"""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),
|
||
)
|