loreal-utilisation-dept/README.md
DJP 04edbfdd2c Initial commit: dockerised FastAPI backend + React/Vite frontend rewrite
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>
2026-05-16 12:37:04 -04:00

209 lines
6.2 KiB
Markdown
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.

# 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 — see `app/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.
1. Go to <https://airtable.com/create/tokens>.
2. Revoke the old token (`patHAB...`) if it still exists.
3. Create a new Personal Access Token:
- **Name**: `utilisation-dept-backend`
- **Scopes**: `data.records:read`
- **Access**: limit to base `appoByydxIQANKtSh` only, tables
`Resource` and `Booking Resource`.
4. Copy the token. You'll paste it into `.env` below.
### 2. Generate secrets
```bash
# 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`
```bash
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
```bash
# 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
```bash
docker compose up --build
# health: curl http://localhost:8200/api/health
```
## Tests
```bash
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
```bash
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 82008299 and persist it to `.env`.
- Render `deploy/apache-utilisation-dept.conf` from the template.
- Build the frontend → `/var/www/html/utilisation-dept/`.
- Build + start the Docker container.
- Health-poll `/api/health`.
- Print the Apache `Include` line 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`:
```apache
Include /opt/utilisation-dept/deploy/apache-utilisation-dept.conf
```
Then:
```bash
sudo apachectl configtest && sudo systemctl reload apache2
```
### Smoke-check
```bash
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
```bash
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
1. Generate a new PAT (same scopes as in setup).
2. Edit `.env` → update `AIRTABLE_PAT`.
3. `docker compose -p utilisation-dept restart backend`
4. Revoke the old PAT in the Airtable portal.
### Forgot admin password
1. Generate a new bcrypt hash:
`python3 -c "from passlib.hash import bcrypt; print(bcrypt.hash('new-password'))"`
2. Edit `.env` → update `ADMIN_PASSWORD_BCRYPT`.
3. `docker compose -p utilisation-dept restart backend`
## Future work
- **Azure AD / MSAL SSO**: `app/auth/azure.py` is stubbed. Flip
`AUTH_MODE=azure` in `.env`, fill in `AZURE_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.