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>
209 lines
6.2 KiB
Markdown
209 lines
6.2 KiB
Markdown
# 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 8200–8299 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.
|