Merge server changes (SSO, /gsb base path) + add proxy timeout
This commit is contained in:
commit
b5a21764d8
20 changed files with 2816 additions and 51 deletions
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(ssh optical-dev:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
12
.env.example
12
.env.example
|
|
@ -1,2 +1,10 @@
|
|||
POSTGRES_PASSWORD=scope_pass_2024
|
||||
ANTHROPIC_API_KEY=your-api-key-here
|
||||
POSTGRES_PASSWORD=your_strong_password_here
|
||||
ANTHROPIC_API_KEY=your-anthropic-api-key
|
||||
AZURE_TENANT_ID=your-azure-tenant-id
|
||||
AZURE_CLIENT_ID=your-azure-client-id
|
||||
# Set to true to skip SSO in local dev (never use in production)
|
||||
DEV_AUTH_BYPASS=
|
||||
VITE_DEV_AUTH_BYPASS=
|
||||
# Absolute path to the directory containing the GMAL Excel file
|
||||
# Defaults to ./data (relative to repo) if not set
|
||||
DATA_DIR=/var/www/html/gmal-scope-builder/data
|
||||
|
|
|
|||
104
CLAUDE.md
Normal file
104
CLAUDE.md
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Overview
|
||||
|
||||
GMAL Scope Builder is a Dockerized AI-powered scoping tool that matches client deliverables (from uploaded Word/Excel documents) against a standardized GMAL asset database, then builds team ratecards and FTE models. The AI layer uses Claude Opus 4.6 for both document parsing and asset matching.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Docker (primary workflow)
|
||||
```bash
|
||||
docker compose build # Build all images
|
||||
docker compose up -d # Start all services (bg)
|
||||
docker compose logs backend --tail 50 # Backend logs
|
||||
docker compose logs frontend --tail 20 # Frontend logs
|
||||
docker compose down # Stop all services
|
||||
```
|
||||
|
||||
### Services run on
|
||||
- Frontend: http://localhost:3010
|
||||
- Backend API: http://localhost:8001
|
||||
- PostgreSQL: localhost:5433
|
||||
|
||||
### One-time setup
|
||||
```bash
|
||||
cp .env.example .env # Add ANTHROPIC_API_KEY
|
||||
# Place GMAL Excel file in data/ directory
|
||||
curl -X POST http://localhost:8001/api/gmal/ingest # Populate GMAL catalog
|
||||
```
|
||||
|
||||
### Frontend (without Docker)
|
||||
```bash
|
||||
cd frontend && npm install
|
||||
npm run dev # Vite dev server with HMR
|
||||
npm run build # TypeScript compile + production bundle
|
||||
```
|
||||
|
||||
### Backend (without Docker)
|
||||
```bash
|
||||
cd backend && pip install -r requirements.txt
|
||||
uvicorn app.main:app --reload --port 8000
|
||||
```
|
||||
|
||||
### Database operations
|
||||
```bash
|
||||
# Backup
|
||||
docker compose exec db pg_dump -U scope_user -d scope_builder > backups/dump.sql
|
||||
|
||||
# Restore
|
||||
docker compose exec -T db psql -U scope_user -d scope_builder < backups/dump.sql
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Stack
|
||||
- **Frontend**: React 18 + TypeScript + Vite + React Router + Axios
|
||||
- **Backend**: FastAPI + SQLAlchemy (async) + asyncpg + Uvicorn
|
||||
- **Database**: PostgreSQL 16
|
||||
- **AI**: Claude Opus 4.6 via Anthropic SDK (tool_use for structured output)
|
||||
- **Document parsing**: openpyxl, python-docx
|
||||
|
||||
### Backend structure (`backend/app/`)
|
||||
- **`main.py`**: FastAPI app, CORS config, router registration, AI usage/debug endpoints
|
||||
- **`models/`**: SQLAlchemy ORM — `gmal.py` (catalog: GmalAsset, Role, GmalHours, ServiceLine) and `project.py` (workflow: Project, ClientAsset, Match, RatecardLine)
|
||||
- **`services/`**: Core business logic — see flow below
|
||||
- **`api/`**: Route handlers for `gmal`, `ingest`, `projects`, `matching`, `ratecard`
|
||||
- **`schemas/`**: Pydantic request/response models
|
||||
- **`utils/claude_client.py`**: Wraps Anthropic SDK with per-project + global token/cost tracking and a 50-call debug log
|
||||
|
||||
### Frontend structure (`frontend/src/`)
|
||||
- **`App.tsx`**: Router, navigation bar, live AI cost tracker, expandable debug panel
|
||||
- **`pages/`**: Dashboard, NewProject, ProjectView (main workflow), GmalBrowser, GmalEditor, Help
|
||||
- **`api/client.ts`**: Axios instance pointing to backend
|
||||
- **`types/index.ts`**: Shared TS interfaces + `MODEL_TYPE_LABELS` / `CONFIDENCE_COLORS` constants
|
||||
|
||||
### Core data flow
|
||||
|
||||
1. **Ingestion** — Excel file → `excel_parser.py` → `GmalAsset` + `Role` + `GmalHours` (per model type) in PostgreSQL
|
||||
|
||||
2. **Project creation** — User selects one of 5 **model types** (Current, AI-Enhanced, Offshore+, Local, Factory); this key drives which `GmalHours` rows are used throughout
|
||||
|
||||
3. **Document parsing** — Uploaded `.docx`/`.xlsx` → `doc_parser.py` extracts raw text → Claude with `extract_assets` tool returns structured `ClientAsset` list (name, description, volume, complexity hint)
|
||||
|
||||
4. **AI matching** — Each `ClientAsset` → `ai_matching.py` → Claude with `submit_matches` tool → ranked GMAL matches with confidence (`exact`/`close`/`multiple`/`none`), score 0–1, reasoning, caveats. Processed in batches of 10 with cancellation support.
|
||||
|
||||
5. **Ratecard building** — User selects a match per asset → `ratecard_builder.py` looks up `GmalHours[gmal_asset, model_type]`, multiplies by `ClientAsset.volume` → `RatecardLine` rows (one per role per asset)
|
||||
|
||||
6. **Team shape** — `team_shape.py` aggregates hours per role → FTE = total / 1800; efficiency slider (0–90%) is applied to **delivery roles only** (programme roles are not reduced)
|
||||
|
||||
7. **Export** — `export_excel.py` produces multi-tab workbook (ratecard, asset detail, team shape, efficiency); `export_pdf.py` produces caveats report
|
||||
|
||||
### Project status lifecycle
|
||||
`draft` → `parsing` → `matching` → `review` → `building` → `finalized`
|
||||
|
||||
### AI cost tracking
|
||||
Every Claude call records input/output tokens and USD cost (`$3/M` input, `$15/M` output) via `claude_client.py`. Costs are stored on the `Project` model and surfaced globally via `GET /ai/usage`. The frontend polls this and shows a live cost tracker + expandable debug panel.
|
||||
|
||||
## Key design decisions
|
||||
- **All Claude calls use `tool_use`** for structured output — no fragile JSON parsing from free-text responses
|
||||
- **`model_type` is set at project creation** and cannot change — it filters all `GmalHours` lookups
|
||||
- **Programme roles are exempt from efficiency reduction** in team shape calculations (they don't scale with AI productivity)
|
||||
- **Matching is async/batched** — supports cancellation mid-job; poll `/api/projects/{id}/status` for progress
|
||||
- **The GMAL catalog (390 assets)** is ingested from a single Excel file in `data/`; re-run `/api/gmal/ingest` to reload after updating the file
|
||||
|
|
@ -1,28 +1,36 @@
|
|||
import logging
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Depends
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
# Enable app-level logging
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s [%(name)s] %(message)s")
|
||||
|
||||
from app.api import gmal, ingest, projects, matching, ratecard
|
||||
from app.middleware.auth import get_current_user
|
||||
|
||||
app = FastAPI(title="Scope Builder", version="1.0.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:3000", "http://localhost:3001", "http://localhost:3010"],
|
||||
allow_origins=[
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3001",
|
||||
"http://localhost:3010",
|
||||
"https://optical-dev.oliver.solutions",
|
||||
],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(gmal.router, prefix="/api/gmal", tags=["GMAL"])
|
||||
app.include_router(ingest.router, prefix="/api/gmal", tags=["Ingest"])
|
||||
app.include_router(projects.router, prefix="/api/projects", tags=["Projects"])
|
||||
app.include_router(matching.router, prefix="/api/projects", tags=["Matching"])
|
||||
app.include_router(ratecard.router, prefix="/api/projects", tags=["Ratecard"])
|
||||
_auth = Depends(get_current_user)
|
||||
|
||||
app.include_router(gmal.router, prefix="/api/gmal", tags=["GMAL"], dependencies=[_auth])
|
||||
app.include_router(ingest.router, prefix="/api/gmal", tags=["Ingest"], dependencies=[_auth])
|
||||
app.include_router(projects.router, prefix="/api/projects", tags=["Projects"], dependencies=[_auth])
|
||||
app.include_router(matching.router, prefix="/api/projects", tags=["Matching"], dependencies=[_auth])
|
||||
app.include_router(ratecard.router, prefix="/api/projects", tags=["Ratecard"], dependencies=[_auth])
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
|
@ -30,20 +38,20 @@ async def health():
|
|||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/api/ai/usage")
|
||||
@app.get("/api/ai/usage", dependencies=[_auth])
|
||||
async def ai_usage():
|
||||
from app.utils.claude_client import get_usage_stats
|
||||
return get_usage_stats()
|
||||
|
||||
|
||||
@app.post("/api/ai/usage/reset")
|
||||
@app.post("/api/ai/usage/reset", dependencies=[_auth])
|
||||
async def ai_usage_reset():
|
||||
from app.utils.claude_client import reset_usage_stats
|
||||
reset_usage_stats()
|
||||
return {"detail": "Usage stats reset"}
|
||||
|
||||
|
||||
@app.get("/api/ai/debug")
|
||||
@app.get("/api/ai/debug", dependencies=[_auth])
|
||||
async def ai_debug():
|
||||
from app.utils.claude_client import get_debug_log
|
||||
return get_debug_log()
|
||||
|
|
|
|||
0
backend/app/middleware/__init__.py
Normal file
0
backend/app/middleware/__init__.py
Normal file
68
backend/app/middleware/auth.py
Normal file
68
backend/app/middleware/auth.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import os
|
||||
import httpx
|
||||
from functools import lru_cache
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from jose import jwt, JWTError
|
||||
|
||||
TENANT_ID = os.environ.get("AZURE_TENANT_ID", "")
|
||||
CLIENT_ID = os.environ.get("AZURE_CLIENT_ID", "")
|
||||
|
||||
JWKS_URL = f"https://login.microsoftonline.com/{TENANT_ID}/discovery/v2.0/keys"
|
||||
ISSUER = f"https://login.microsoftonline.com/{TENANT_ID}/v2.0"
|
||||
|
||||
bearer_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _fetch_jwks() -> dict:
|
||||
"""Fetch JWKS from Azure. Cached in process memory; restart to refresh."""
|
||||
response = httpx.get(JWKS_URL, timeout=10)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def _get_jwks() -> dict:
|
||||
try:
|
||||
return _fetch_jwks()
|
||||
except Exception:
|
||||
# Clear cache and retry once on failure
|
||||
_fetch_jwks.cache_clear()
|
||||
return _fetch_jwks()
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||
) -> dict:
|
||||
if os.environ.get("DEV_AUTH_BYPASS", "").lower() in ("1", "true", "yes"):
|
||||
return {"oid": "dev-user", "name": "Dev User", "email": "dev@localhost"}
|
||||
|
||||
if credentials is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
|
||||
token = credentials.credentials
|
||||
try:
|
||||
jwks = _get_jwks()
|
||||
header = jwt.get_unverified_header(token)
|
||||
key = next(
|
||||
(k for k in jwks["keys"] if k.get("kid") == header.get("kid")),
|
||||
None,
|
||||
)
|
||||
if key is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unknown signing key")
|
||||
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
key,
|
||||
algorithms=["RS256"],
|
||||
audience=CLIENT_ID,
|
||||
issuer=ISSUER,
|
||||
options={"verify_at_hash": False},
|
||||
)
|
||||
return {
|
||||
"oid": payload.get("oid"),
|
||||
"name": payload.get("name"),
|
||||
"email": payload.get("preferred_username") or payload.get("email"),
|
||||
}
|
||||
except JWTError as e:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=f"Invalid token: {e}")
|
||||
|
|
@ -12,3 +12,5 @@ reportlab==4.2.5
|
|||
pandas==2.2.3
|
||||
pydantic==2.10.4
|
||||
pydantic-settings==2.7.1
|
||||
python-jose[cryptography]==3.3.0
|
||||
httpx==0.28.1
|
||||
|
|
|
|||
|
|
@ -17,4 +17,4 @@ print('Database tables created successfully')
|
|||
"
|
||||
|
||||
echo "Starting FastAPI server..."
|
||||
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2
|
||||
|
|
|
|||
151
deploy.sh
Executable file
151
deploy.sh
Executable file
|
|
@ -0,0 +1,151 @@
|
|||
#!/usr/bin/env bash
|
||||
# deploy.sh — idempotent deploy script for GMAL Scope Builder
|
||||
# Run from the repo root: sudo ./deploy.sh
|
||||
# First-time setup: clone repo to /opt/gmal-scope-builder, create .env, then run this script.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Config ────────────────────────────────────────────────────────────────────
|
||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WEB_DIR="/var/www/html/gmal-scope-builder"
|
||||
APACHE_CONF="/etc/apache2/sites-enabled/optical-dev.oliver.solutions.conf"
|
||||
APACHE_MARKER="gmal-scope-builder" # Used to detect if block already added
|
||||
APP_URL_PATH="/gsb"
|
||||
BACKEND_PORT="8002"
|
||||
HEALTH_URL="http://127.0.0.1:${BACKEND_PORT}/api/health"
|
||||
|
||||
# ── Colours ───────────────────────────────────────────────────────────────────
|
||||
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; NC='\033[0m'
|
||||
log() { echo -e "${GREEN}[deploy]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[ warn ]${NC} $*"; }
|
||||
die() { echo -e "${RED}[error ]${NC} $*" >&2; exit 1; }
|
||||
|
||||
# ── 1. Pre-flight checks ──────────────────────────────────────────────────────
|
||||
log "Running pre-flight checks..."
|
||||
|
||||
[[ -f "$REPO_DIR/.env" ]] || die ".env not found at $REPO_DIR/.env — copy .env.example and fill in values"
|
||||
|
||||
# Check required env vars
|
||||
for var in POSTGRES_PASSWORD ANTHROPIC_API_KEY AZURE_TENANT_ID AZURE_CLIENT_ID; do
|
||||
grep -q "^${var}=" "$REPO_DIR/.env" || die "Missing required variable '$var' in .env"
|
||||
done
|
||||
|
||||
command -v docker &>/dev/null || die "Docker is not installed"
|
||||
sudo docker compose version &>/dev/null || die "Docker Compose plugin not available"
|
||||
|
||||
# Resolve DATA_DIR (from .env or default to persistent web dir)
|
||||
DATA_DIR=$(grep "^DATA_DIR=" "$REPO_DIR/.env" | cut -d= -f2- | tr -d '"' || true)
|
||||
DATA_DIR="${DATA_DIR:-/var/www/html/gmal-scope-builder/data}"
|
||||
sudo mkdir -p "$DATA_DIR"
|
||||
|
||||
# Warn if no GMAL Excel file (non-fatal — data may already be in the DB)
|
||||
if ! sudo ls "${DATA_DIR}/"*.xlsx &>/dev/null 2>&1; then
|
||||
warn "No .xlsx file found in $DATA_DIR — GMAL ingest will need to be triggered manually after deploy"
|
||||
fi
|
||||
|
||||
log "Pre-flight OK"
|
||||
|
||||
# ── 2. Pull latest code ───────────────────────────────────────────────────────
|
||||
log "Pulling latest code from origin/main..."
|
||||
git -C "$REPO_DIR" pull origin main
|
||||
|
||||
# ── 3. Build and start backend services ──────────────────────────────────────
|
||||
log "Building Docker images and starting services (using build cache)..."
|
||||
sudo docker compose -f "$REPO_DIR/docker-compose.yml" --env-file "$REPO_DIR/.env" \
|
||||
up -d --build --remove-orphans
|
||||
|
||||
# ── 4. Wait for backend health ────────────────────────────────────────────────
|
||||
log "Waiting for backend to become healthy..."
|
||||
TIMEOUT=90
|
||||
ELAPSED=0
|
||||
until curl -sf "$HEALTH_URL" > /dev/null 2>&1; do
|
||||
sleep 3
|
||||
ELAPSED=$((ELAPSED + 3))
|
||||
[[ $ELAPSED -ge $TIMEOUT ]] && {
|
||||
warn "Backend logs:"
|
||||
sudo docker compose -f "$REPO_DIR/docker-compose.yml" logs --tail=30 backend
|
||||
die "Backend did not become healthy within ${TIMEOUT}s"
|
||||
}
|
||||
log " Still waiting... (${ELAPSED}s)"
|
||||
done
|
||||
log "Backend healthy at $HEALTH_URL"
|
||||
|
||||
# ── 5. Database migrations ────────────────────────────────────────────────────
|
||||
# create_all() runs automatically inside start.sh on each container start (idempotent).
|
||||
# Uncomment the line below once Alembic migrations are set up:
|
||||
# sudo docker compose -f "$REPO_DIR/docker-compose.yml" exec -T backend alembic upgrade head
|
||||
log "Database schema is managed by start.sh (create_all) — no separate migration step needed"
|
||||
|
||||
# ── 6. Build frontend ─────────────────────────────────────────────────────────
|
||||
log "Building frontend via Node Docker container..."
|
||||
sudo docker run --rm \
|
||||
-v "$REPO_DIR/frontend:/app" \
|
||||
-w /app \
|
||||
node:20-alpine \
|
||||
sh -c "npm ci --prefer-offline && npm run build"
|
||||
|
||||
# ── 7. Deploy frontend static files ──────────────────────────────────────────
|
||||
log "Deploying frontend to $WEB_DIR..."
|
||||
sudo mkdir -p "$WEB_DIR"
|
||||
# Remove only frontend files — preserve data/ subdirectory
|
||||
sudo find "${WEB_DIR:?}" -maxdepth 1 -mindepth 1 ! -name 'data' -exec rm -rf {} +
|
||||
sudo cp -r "$REPO_DIR/frontend/dist/." "$WEB_DIR/"
|
||||
sudo chown -R www-data:www-data "$WEB_DIR"
|
||||
sudo chown -R root:root "$DATA_DIR" # data/ stays root-owned for Docker
|
||||
log "Frontend deployed ($(find "$REPO_DIR/frontend/dist" -type f | wc -l) files)"
|
||||
|
||||
# ── 8. Apache config (idempotent) ─────────────────────────────────────────────
|
||||
if sudo grep -q "$APACHE_MARKER" "$APACHE_CONF" 2>/dev/null; then
|
||||
log "Apache block for $APP_URL_PATH already present — skipping"
|
||||
else
|
||||
log "Adding Apache config block for $APP_URL_PATH ..."
|
||||
sudo python3 - << PYEOF
|
||||
apache_conf = "$APACHE_CONF"
|
||||
block = """
|
||||
# ----------------------------------------------------------------
|
||||
# GMAL Scope Builder — FastAPI backend at :$BACKEND_PORT
|
||||
# ----------------------------------------------------------------
|
||||
ProxyPass $APP_URL_PATH/api/ http://127.0.0.1:$BACKEND_PORT/api/
|
||||
ProxyPassReverse $APP_URL_PATH/api/ http://127.0.0.1:$BACKEND_PORT/api/
|
||||
|
||||
# GMAL Scope Builder SPA
|
||||
Alias $APP_URL_PATH $WEB_DIR
|
||||
|
||||
<Directory $WEB_DIR>
|
||||
Options -Indexes +FollowSymLinks
|
||||
AllowOverride None
|
||||
Require all granted
|
||||
RewriteEngine On
|
||||
RewriteBase $APP_URL_PATH/
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^ index.html [L]
|
||||
</Directory>
|
||||
"""
|
||||
with open(apache_conf) as f:
|
||||
content = f.read()
|
||||
if "$APACHE_MARKER" not in content:
|
||||
content = content.replace("</VirtualHost>", block + "\n</VirtualHost>")
|
||||
with open(apache_conf, "w") as f:
|
||||
f.write(content)
|
||||
print("Apache config updated")
|
||||
else:
|
||||
print("Apache config already has $APACHE_MARKER block (written by another process)")
|
||||
PYEOF
|
||||
fi
|
||||
|
||||
# ── 9. Validate and reload Apache ─────────────────────────────────────────────
|
||||
log "Validating Apache config..."
|
||||
sudo apache2ctl configtest
|
||||
log "Reloading Apache..."
|
||||
sudo systemctl reload apache2
|
||||
|
||||
# ── Done ──────────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${GREEN}╔══════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ Deployment complete! ║${NC}"
|
||||
echo -e "${GREEN}║ https://optical-dev.oliver.solutions${APP_URL_PATH}/ ║${NC}"
|
||||
echo -e "${GREEN}╚══════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
log "If this is the first deploy, trigger GMAL ingest:"
|
||||
log " curl -X POST -H 'Authorization: Bearer <token>' https://optical-dev.oliver.solutions${APP_URL_PATH}/api/gmal/ingest"
|
||||
|
|
@ -6,8 +6,6 @@ services:
|
|||
POSTGRES_DB: scope_builder
|
||||
POSTGRES_USER: scope_user
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-scope_pass_2024}
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
|
|
@ -26,22 +24,13 @@ services:
|
|||
DATABASE_URL: postgresql+asyncpg://scope_user:${POSTGRES_PASSWORD:-scope_pass_2024}@db:5432/scope_builder
|
||||
DATABASE_URL_SYNC: postgresql://scope_user:${POSTGRES_PASSWORD:-scope_pass_2024}@db:5432/scope_builder
|
||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
|
||||
AZURE_TENANT_ID: ${AZURE_TENANT_ID}
|
||||
AZURE_CLIENT_ID: ${AZURE_CLIENT_ID}
|
||||
DEV_AUTH_BYPASS: ${DEV_AUTH_BYPASS:-}
|
||||
ports:
|
||||
- "8001:8000"
|
||||
- "127.0.0.1:8002:8000"
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- ./data:/app/data
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
- "3010:3000"
|
||||
volumes:
|
||||
- ./frontend/src:/app/src
|
||||
- ./frontend/public:/app/public
|
||||
- ${DATA_DIR:-./data}:/app/data
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
|
|
|
|||
2217
frontend/package-lock.json
generated
Normal file
2217
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -9,11 +9,13 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@azure/msal-browser": "^5.6.2",
|
||||
"@azure/msal-react": "^5.2.0",
|
||||
"axios": "^1.7.9",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"axios": "^1.7.9",
|
||||
"lucide-react": "^0.468.0"
|
||||
"react-router-dom": "^6.28.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.16",
|
||||
|
|
|
|||
|
|
@ -305,3 +305,93 @@
|
|||
color: var(--color-danger);
|
||||
border-color: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
/* ── Login page ── */
|
||||
.login-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.login-box {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
padding: 48px 40px;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.login-desc {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 14px;
|
||||
margin-bottom: 28px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 11px 24px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.login-btn:hover { opacity: 0.9; }
|
||||
.login-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
/* ── Nav user / logout ── */
|
||||
.nav-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.nav-user-name {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-logout {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 11px;
|
||||
padding: 3px 8px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.nav-logout:hover {
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-text-secondary);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { BrowserRouter, Routes, Route, Link, useLocation } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMsal } from '@azure/msal-react';
|
||||
import { AuthProvider } from './auth/AuthProvider';
|
||||
import api from './api/client';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import NewProject from './pages/NewProject';
|
||||
|
|
@ -177,6 +179,12 @@ function DebugPanel() {
|
|||
|
||||
function NavBar() {
|
||||
const location = useLocation();
|
||||
const { instance, accounts } = useMsal();
|
||||
const user = accounts[0];
|
||||
|
||||
function handleLogout() {
|
||||
instance.logoutRedirect({ postLogoutRedirectUri: '/gsb' });
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="nav">
|
||||
|
|
@ -198,6 +206,12 @@ function NavBar() {
|
|||
</div>
|
||||
<div className="nav-spacer" />
|
||||
<AiCostTracker />
|
||||
{user && (
|
||||
<div className="nav-user">
|
||||
<span className="nav-user-name">{user.name || user.username}</span>
|
||||
<button className="nav-logout" onClick={handleLogout}>Sign out</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
|
|
@ -205,21 +219,23 @@ function NavBar() {
|
|||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div className="app">
|
||||
<NavBar />
|
||||
<main className="main">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/new" element={<NewProject />} />
|
||||
<Route path="/projects/:id/*" element={<ProjectView />} />
|
||||
<Route path="/gmal" element={<GmalBrowser />} />
|
||||
<Route path="/gmal-editor" element={<GmalEditor />} />
|
||||
<Route path="/help" element={<Help />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<DebugPanel />
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
<AuthProvider>
|
||||
<BrowserRouter basename="/gsb">
|
||||
<div className="app">
|
||||
<NavBar />
|
||||
<main className="main">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/new" element={<NewProject />} />
|
||||
<Route path="/projects/:id/*" element={<ProjectView />} />
|
||||
<Route path="/gmal" element={<GmalBrowser />} />
|
||||
<Route path="/gmal-editor" element={<GmalEditor />} />
|
||||
<Route path="/help" element={<Help />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<DebugPanel />
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,27 @@
|
|||
import axios from 'axios';
|
||||
import { msalInstance, loginRequest } from '../auth/msalConfig';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
baseURL: '/gsb/api',
|
||||
});
|
||||
|
||||
api.interceptors.request.use(async (config) => {
|
||||
if (import.meta.env.VITE_DEV_AUTH_BYPASS === 'true') return config;
|
||||
|
||||
const accounts = msalInstance.getAllAccounts();
|
||||
if (accounts.length === 0) return config;
|
||||
|
||||
try {
|
||||
const result = await msalInstance.acquireTokenSilent({
|
||||
...loginRequest,
|
||||
account: accounts[0],
|
||||
});
|
||||
config.headers.Authorization = `Bearer ${result.accessToken}`;
|
||||
} catch {
|
||||
// Token expired or failed — trigger interactive login
|
||||
await msalInstance.loginRedirect(loginRequest);
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
export default api;
|
||||
|
|
|
|||
61
frontend/src/auth/AuthProvider.tsx
Normal file
61
frontend/src/auth/AuthProvider.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { MsalProvider, useMsal, AuthenticatedTemplate, UnauthenticatedTemplate } from '@azure/msal-react';
|
||||
import { msalInstance, loginRequest } from './msalConfig';
|
||||
|
||||
function LoginPage() {
|
||||
const { instance } = useMsal();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleLogin() {
|
||||
setLoading(true);
|
||||
try {
|
||||
await instance.loginRedirect(loginRequest);
|
||||
} catch {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-box">
|
||||
<div className="login-logo">
|
||||
<span className="logo-icon">S</span>
|
||||
<span>Scope Builder</span>
|
||||
</div>
|
||||
<p className="login-desc">Sign in with your Oliver Agency Microsoft account to continue.</p>
|
||||
<button className="login-btn" onClick={handleLogin} disabled={loading}>
|
||||
{loading ? 'Redirecting…' : 'Sign in with Microsoft'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RedirectHandler({ children }: { children: React.ReactNode }) {
|
||||
const { instance } = useMsal();
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
instance.handleRedirectPromise().then(() => setReady(true)).catch(() => setReady(true));
|
||||
}, [instance]);
|
||||
|
||||
if (!ready) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthenticatedTemplate>{children}</AuthenticatedTemplate>
|
||||
<UnauthenticatedTemplate><LoginPage /></UnauthenticatedTemplate>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
if (import.meta.env.VITE_DEV_AUTH_BYPASS === 'true') {
|
||||
return <>{children}</>;
|
||||
}
|
||||
return (
|
||||
<MsalProvider instance={msalInstance}>
|
||||
<RedirectHandler>{children}</RedirectHandler>
|
||||
</MsalProvider>
|
||||
);
|
||||
}
|
||||
19
frontend/src/auth/msalConfig.ts
Normal file
19
frontend/src/auth/msalConfig.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { Configuration, PublicClientApplication } from '@azure/msal-browser';
|
||||
|
||||
export const msalConfig: Configuration = {
|
||||
auth: {
|
||||
clientId: '9079054c-9620-4757-a256-23413042f1ef',
|
||||
authority: 'https://login.microsoftonline.com/e519c2e6-bc6d-4fdf-8d9c-923c2f002385',
|
||||
redirectUri: 'https://optical-dev.oliver.solutions/gsb',
|
||||
postLogoutRedirectUri: 'https://optical-dev.oliver.solutions/gsb',
|
||||
},
|
||||
cache: {
|
||||
cacheLocation: 'localStorage',
|
||||
},
|
||||
};
|
||||
|
||||
export const loginRequest = {
|
||||
scopes: ['User.Read'],
|
||||
};
|
||||
|
||||
export const msalInstance = new PublicClientApplication(msalConfig);
|
||||
|
|
@ -77,7 +77,7 @@ export default function GmalEditor() {
|
|||
complexity_description: asset.complexity_description || '',
|
||||
caveats: asset.caveats || '',
|
||||
master_adapt: asset.master_adapt || '',
|
||||
ai_efficiency_pct: asset.ai_efficiency_pct,
|
||||
ai_efficiency_pct: asset.ai_efficiency_pct ?? null,
|
||||
ai_enhanced_description: asset.ai_enhanced_description || '',
|
||||
});
|
||||
// Build hour cells from existing data
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
|
|
|
|||
|
|
@ -3,14 +3,16 @@ import react from '@vitejs/plugin-react'
|
|||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: '/gsb/',
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
'/gsb/api': {
|
||||
target: 'http://backend:8000',
|
||||
changeOrigin: true,
|
||||
timeout: 300000,
|
||||
rewrite: (path) => path.replace(/^\/gsb/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue