Replaces a static SPA that shipped an Airtable PAT in the JS bundle.
The new architecture holds all secrets server-side, fronts the app
behind Apache on optical-dev with the shared-vhost split-build pattern,
and is designed for a later Azure AD/MSAL swap-in.
- backend/ FastAPI + uvicorn, local auth (Azure AD stub), Airtable
proxy with TTL cache, Zoho .xlsx/.csv parser, merge
service for utilisation summaries. 28 pytest tests.
- frontend/ React + Vite + TS + Tailwind + Recharts SPA. Login entry
chunk 12.83 KB gzipped; Recharts lazy-loaded. No tokens
or Airtable URLs in the built bundle.
- deploy/ Idempotent deploy.sh (port auto-pick 8200-8299,
.env-persisted) + split-build Apache include template.
- docker-compose.yml pins name: utilisation-dept and binds 127.0.0.1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6.2 KiB
utilisation-dept
L'Oréal Utilisation Dashboard — internal tool that merges Zoho time-log exports with Airtable resource/booking data and renders utilisation charts per department, per person, and per project.
This is a clean rewrite of an earlier static SPA that shipped an Airtable Personal Access Token in the JS bundle. The new architecture keeps all secrets on the backend.
Architecture
Browser ─► Apache (optical-dev.oliver.solutions, shared vhost)
├─ /utilisation-dept/ → static SPA (Vite build in /var/www/html/utilisation-dept/)
└─ /utilisation-dept/api/ → FastAPI container (127.0.0.1:<port>)
│
└─ Airtable REST API (PAT held in .env)
- Backend: FastAPI + uvicorn in a Docker container, bound to 127.0.0.1 only. Apache fronts the public traffic.
- Frontend: React + Vite + TypeScript + Tailwind + Recharts. Vite
base: '/utilisation-dept/'. Built into/var/www/html/utilisation-dept/. - Database: none. Airtable is the source of truth; uploaded Zoho files are parsed in memory and discarded.
- Auth: local admin account today (bcrypt creds in
.env). Designed for an Azure AD/MSAL swap-in later — seeapp/auth/azure.py.
First-time setup
1. Rotate the Airtable PAT (do this before anything else)
The old SPA had its PAT hardcoded in the JS bundle. Assume that token is compromised.
- Go to https://airtable.com/create/tokens.
- Revoke the old token (
patHAB...) if it still exists. - Create a new Personal Access Token:
- Name:
utilisation-dept-backend - Scopes:
data.records:read - Access: limit to base
appoByydxIQANKtShonly, tablesResourceandBooking Resource.
- Name:
- Copy the token. You'll paste it into
.envbelow.
2. Generate secrets
# Session secret (paste output into SESSION_SECRET):
python3 -c "import secrets; print(secrets.token_urlsafe(48))"
# Admin password hash (replace 'your-password' with the real password):
python3 -c "from passlib.hash import bcrypt; print(bcrypt.hash('your-password'))"
Heads-up: bcrypt hashes contain
$characters. Wrap the hash in single quotes in.env, otherwise docker-compose will treat the$...segments as variable substitutions and the container will receive a truncated value:ADMIN_PASSWORD_BCRYPT='$2b$12$abc...xyz'
3. Configure .env
cp .env.example .env
# edit .env and fill in:
# AIRTABLE_PAT (from step 1)
# SESSION_SECRET (from step 2)
# ADMIN_PASSWORD_BCRYPT (from step 2)
Local development
# Backend
cd backend
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload --port 8200
# Frontend (in another terminal)
cd frontend
npm install
npm run dev
# open http://localhost:5173/utilisation-dept/
The Vite dev server proxies /utilisation-dept/api/* to
http://localhost:8200.
To skip auth entirely during dev, set DEV_AUTH_BYPASS=true in .env
(NEVER do this in production — the startup banner will warn loudly).
Local Docker
docker compose up --build
# health: curl http://localhost:8200/api/health
Tests
cd backend && pytest
cd frontend && npm run typecheck && npm run lint
Deployment to optical-dev.oliver.solutions
The server hosts many small apps behind a shared Apache vhost. This app
lives at /opt/utilisation-dept/.
First deploy
ssh user@optical-dev.oliver.solutions
sudo git clone <repo-url> /opt/utilisation-dept
cd /opt/utilisation-dept
# Create .env (see "First-time setup" above for value generation).
sudo cp .env.example .env
sudo vi .env
./deploy/deploy.sh
deploy.sh will:
- Pick a free port in 8200–8299 and persist it to
.env. - Render
deploy/apache-utilisation-dept.conffrom the template. - Build the frontend →
/var/www/html/utilisation-dept/. - Build + start the Docker container.
- Health-poll
/api/health. - Print the Apache
Includeline for the shared vhost.
Wire into the shared vhost (one time only)
Add inside </VirtualHost> of
/etc/apache2/sites-enabled/optical-dev.oliver.solutions.conf:
Include /opt/utilisation-dept/deploy/apache-utilisation-dept.conf
Then:
sudo apachectl configtest && sudo systemctl reload apache2
Smoke-check
curl -sI https://optical-dev.oliver.solutions/utilisation-dept/api/health
curl -sf https://optical-dev.oliver.solutions/utilisation-dept/api/health
# expect 200 and {"ok":true,"version":"0.1.0"}
Then open https://optical-dev.oliver.solutions/utilisation-dept/ in a browser and confirm:
- Login screen renders.
- View-source on the loaded JS: search for
patHAB— must return nothing. - Logging in with the local admin creds shows the dashboard.
Subsequent deploys
ssh user@optical-dev.oliver.solutions
cd /opt/utilisation-dept
./deploy/deploy.sh # --no-pull, --no-build, --no-frontend, --logs available
Operations
Where the auth log lives
/opt/utilisation-dept/backend/logs/auth.log (mounted into the
container at /app/logs/). Rotating, 5 MB × 5 backups.
This is the only audit trail for who logged in when — there's no database.
Rotating the PAT
- Generate a new PAT (same scopes as in setup).
- Edit
.env→ updateAIRTABLE_PAT. docker compose -p utilisation-dept restart backend- Revoke the old PAT in the Airtable portal.
Forgot admin password
- Generate a new bcrypt hash:
python3 -c "from passlib.hash import bcrypt; print(bcrypt.hash('new-password'))" - Edit
.env→ updateADMIN_PASSWORD_BCRYPT. docker compose -p utilisation-dept restart backend
Future work
- Azure AD / MSAL SSO:
app/auth/azure.pyis stubbed. FlipAUTH_MODE=azurein.env, fill inAZURE_TENANT_ID+AZURE_CLIENT_ID, register the SPA in Azure (platform type: Single-page application), wire MSAL on the frontend, send the ID token to the backend. See~/.claude/skills/azure-ad-msal-auth.md. - Database if/when we need history, audit trails, or to cache more aggressively. Today the data lives in Airtable and the Zoho upload is ephemeral.