Merge server changes (SSO, /gsb base path) + add proxy timeout

This commit is contained in:
DJP 2026-03-28 17:06:39 -04:00
commit b5a21764d8
20 changed files with 2816 additions and 51 deletions

View file

@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(ssh optical-dev:*)"
]
}
}

View file

@ -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
View 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 01, 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 (090%) 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

View 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()

View file

View 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}")

View file

@ -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

View file

@ -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
View 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"

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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",

View file

@ -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);
}

View file

@ -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>
);
}

View file

@ -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;

View 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>
);
}

View 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);

View file

@ -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

View file

@ -3,6 +3,7 @@
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",

View file

@ -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/, ''),
},
},
},