Compare commits
19 Commits
feat/wp-di
...
shared-dat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e51024466b | ||
| eefa76e460 | |||
| 5ad3ffa58e | |||
| bdb798efdd | |||
| 151ccea0ac | |||
| 1f8c23c9bb | |||
| 20afb0e565 | |||
| deaf13c724 | |||
| a02f7ec511 | |||
| c010bc22a0 | |||
| 66da5b708a | |||
| 1e31aa535e | |||
| ca8c36a889 | |||
| 39230adf07 | |||
| 2bdb65e580 | |||
| e3ef3b0023 | |||
| c64b5c8b49 | |||
| a32c275f76 | |||
| e5f77846ad |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -14,3 +14,6 @@ wpsuite.db
|
||||
|
||||
# Runtime directories (created by containers)
|
||||
logs/
|
||||
|
||||
# Local server logs
|
||||
*.log
|
||||
|
||||
103
DEPLOY-login-portal.md
Normal file
103
DEPLOY-login-portal.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Deploy: Work Package Suite — login portal update
|
||||
|
||||
Instructions for the **Portainer admin** to take the new secure login portal live.
|
||||
No prior context needed.
|
||||
|
||||
**Repo:** `Project-SDE-WP-Suite` (primegit) — changes are merged to **`main`**.
|
||||
|
||||
**What changed:** the app now has a username/password login. Going live needs:
|
||||
1. one new environment variable,
|
||||
2. a **rebuild** of the stack (not just a restart), and
|
||||
3. creating the first admin account.
|
||||
|
||||
> **Why a rebuild (not a restart):** both the **nginx/webserver** and **api** images
|
||||
> bake the code in at build time (`COPY html/` and `COPY server/` in their
|
||||
> Dockerfiles). A plain restart will **not** pick up the new code — the images must
|
||||
> be **rebuilt** from the latest `main`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Add an environment variable to the stack
|
||||
|
||||
In the stack's **Environment variables** section, add:
|
||||
|
||||
| Name | Value | Notes |
|
||||
|------|-------|-------|
|
||||
| `AUTH_SECRET_KEY` | a long random string | **Required.** Signs the login session cookies. |
|
||||
| `AUTH_SESSION_HOURS` | `12` | *Optional.* Hours a login lasts before re-auth (defaults to 12). |
|
||||
|
||||
Generate the secret on the host with:
|
||||
|
||||
```bash
|
||||
openssl rand -base64 48
|
||||
```
|
||||
|
||||
> If `AUTH_SECRET_KEY` is **not** set, the app still starts but falls back to a random
|
||||
> per-process key — logins then reset on every restart and break across the 2 gunicorn
|
||||
> workers. It must be set to a fixed value.
|
||||
|
||||
The existing database variables (`POSTGRES_*`) are unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 2. Pull latest `main`, rebuild, and redeploy
|
||||
|
||||
- Pull the latest commit on `main` and redeploy the stack **with image rebuild enabled**
|
||||
(e.g. "Re-pull and redeploy" / force rebuild). This rebuilds both the `webserver` and
|
||||
`api` images.
|
||||
- New Python dependencies (`bcrypt`, `PyJWT`) are in `requirements.txt` and install
|
||||
automatically during the rebuild.
|
||||
- The `users` table is created automatically on API startup — **no DB migration needed.**
|
||||
|
||||
---
|
||||
|
||||
## 3. Verify the containers
|
||||
|
||||
- Confirm `wp_api` and the webserver container are both **running**.
|
||||
- If `wp_api` fails to start, check its **Logs**. (A missing `AUTH_SECRET_KEY` only logs a
|
||||
warning — it won't crash — but please confirm it's set.)
|
||||
|
||||
---
|
||||
|
||||
## 4. Create the first admin account
|
||||
|
||||
The login system needs one admin user in the production (Postgres) database. Open the
|
||||
**`wp_api`** container's **Console** (`/bin/sh`) and run:
|
||||
|
||||
```bash
|
||||
python -m server.manage_users create-admin <username> --name "<Full Name>"
|
||||
```
|
||||
|
||||
It prompts for a password (minimum 8 characters) and prints `Created admin: <username>`.
|
||||
|
||||
Non-interactive alternative:
|
||||
|
||||
```bash
|
||||
python -m server.manage_users create-admin <username> --name "<Full Name>" --password "<password>"
|
||||
```
|
||||
|
||||
Other CLI commands (run the same way): `list`, `create <user> --role user`,
|
||||
`reset-password <user>`, `disable <user>`, `enable <user>`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Confirm it works
|
||||
|
||||
1. Load the site's normal URL — it should redirect to a **login page**.
|
||||
2. Sign in with the admin account from step 4.
|
||||
3. That admin can then add all other users from the in-app **Admin → User
|
||||
administration** page (top-right **Admin** link), so no further shell access is needed.
|
||||
|
||||
---
|
||||
|
||||
## Reference — what's in this release
|
||||
|
||||
- `server/auth.py` — bcrypt password hashing, JWT session cookie, the request gate.
|
||||
- `server/app.py` — `/api/auth/*` endpoints + middleware that refuses every `/api` data
|
||||
route without a valid session.
|
||||
- `server/manage_users.py` — the CLI used in step 4.
|
||||
- `html/login.html`, `html/auth-guard.js` — login page and per-page guard.
|
||||
- `html/admin.html` / `admin.js` — Admin Console gated on the admin role, with the user
|
||||
administration UI.
|
||||
- Sessions are stateless: a signed JWT in an **HttpOnly, SameSite=Lax** cookie, marked
|
||||
**Secure** automatically when served over HTTPS (via `X-Forwarded-Proto` from nginx).
|
||||
274
DEPLOYMENT.md
274
DEPLOYMENT.md
@@ -1,81 +1,239 @@
|
||||
# Deployment
|
||||
|
||||
The Work Package Suite has two parts:
|
||||
Audience: the IT admin standing this up inside the firewall. This covers the
|
||||
**SQL-backed deployment** — NGINX serving the static front end and a Python API
|
||||
backed by **PostgreSQL**.
|
||||
|
||||
- a **static front end** (plain HTML/CSS/JS — no build step), and
|
||||
- a **Python API** (FastAPI) backed by **PostgreSQL**, which stores the project
|
||||
SOPs, Work Packages, and comments so they are shared across users instead of
|
||||
living in each person's browser.
|
||||
The repo already contains everything needed to run it as a Docker stack:
|
||||
`Dockerfile`, `docker-compose.yml`, the `nginx/` config, the front end in
|
||||
`html/`, and the API in `server/`. The detailed container reference (endpoints,
|
||||
password rotation, day-to-day commands) lives in
|
||||
[`server/README.md`](server/README.md) — this doc is the start-to-finish guide.
|
||||
|
||||
```
|
||||
browser → NGINX ──serves──> static site (index.html, …)
|
||||
└─proxy /api/─> Python API (uvicorn/gunicorn :8000) → PostgreSQL
|
||||
[ your TLS reverse proxy / traefik ] ← HTTPS terminates here
|
||||
│ (external "proxy" network)
|
||||
┌────▼────┐ internal network ┌──────────┐ ┌────────────┐
|
||||
browser ───────────────────────│ nginx │ ───── /api/ ───────> │ api │ → │ postgres │
|
||||
│ (html/) │ │ FastAPI │ │ (db) │
|
||||
└─────────┘ └──────────┘ └────────────┘
|
||||
```
|
||||
|
||||
Everything runs inside your firewall; the app makes **no outbound internet
|
||||
calls** (the logo and scripts are local and the old Google-Fonts dependency was
|
||||
removed).
|
||||
calls** (logo and scripts are local).
|
||||
|
||||
## 1. Front end (NGINX)
|
||||
> **Architecture note:** all static files live under **`html/`** and are *baked
|
||||
> into the nginx image* at build time (not bind-mounted). So after any front-end
|
||||
> change you rebuild the `webserver` image (see *Updating* below). The API image
|
||||
> is built from the root `Dockerfile`.
|
||||
|
||||
Copy the project files to a web root and serve them over HTTPS. The provided
|
||||
[`nginx-wp-suite.conf`](nginx-wp-suite.conf) serves the static files and proxies
|
||||
`/api/` to the Python API. Set `server_name`, the `ssl_certificate` paths, and
|
||||
`root`, then `sudo nginx -t && sudo systemctl reload nginx`.
|
||||
---
|
||||
|
||||
Serving over real HTTP(S) (not `file://`) also makes the embedded Work Package
|
||||
Creator (`<iframe>`) and any browser-side caching behave reliably.
|
||||
## 1. Prerequisites
|
||||
|
||||
## 2. API + database
|
||||
- A Linux host with **Docker** and **Docker Compose v2** (`docker compose …`).
|
||||
- An external Docker network named `proxy` that your TLS-terminating reverse
|
||||
proxy also sits on (the compose file marks it `external: true`):
|
||||
```bash
|
||||
docker network create proxy
|
||||
```
|
||||
If you don't run a separate reverse proxy, you can instead publish the nginx
|
||||
container's port 80 directly (see the note in step 4) and terminate TLS there.
|
||||
- The repository checked out on the host.
|
||||
|
||||
Full setup — PostgreSQL, the systemd service, and the endpoint reference — is in
|
||||
[`server/README.md`](server/README.md). In short:
|
||||
## 2. Create the database credentials (`.env`)
|
||||
|
||||
1. Create the `wpsuite` Postgres database/user.
|
||||
2. `pip install -r server/requirements.txt` into a venv.
|
||||
3. Set `DATABASE_URL` and run the API as a systemd service on `127.0.0.1:8000`.
|
||||
4. Tables are created automatically on first start.
|
||||
Create a file named `.env` in the **project root** (same folder as
|
||||
`docker-compose.yml`). It is git-ignored and must never be committed.
|
||||
|
||||
Interactive API docs are at `/api/docs` once it's running.
|
||||
|
||||
## 3. Comments / feedback
|
||||
|
||||
Every feedback surface (home *Leave Feedback*, SOP *Step Comments*, WP *Comments*)
|
||||
posts to `/api/feedback`, which the API stores in the `comments` table. The
|
||||
**Export / Import** buttons remain as an offline fallback — a reviewer can export
|
||||
a JSON file and someone can import/merge it — but with the API running, comments
|
||||
are collected centrally with no manual steps.
|
||||
|
||||
> The earlier Power Automate route is **no longer needed** — comments go straight
|
||||
> to Postgres. If you still want a Power App view, point a Power App at the
|
||||
> Postgres `comments` table via the on-prem data gateway, or have a flow read the
|
||||
> table; no change to this app is required.
|
||||
|
||||
### Comment payload shape
|
||||
|
||||
```json
|
||||
{
|
||||
"app": "Work Package Suite",
|
||||
"page": "/work-package-suite.html",
|
||||
"submittedAt": "2026-06-15T18:20:00.000Z",
|
||||
"type": "sop_step_comment",
|
||||
"name": "J. Park",
|
||||
"text": "Consider adding a fiber WP type",
|
||||
"step": 4
|
||||
}
|
||||
```bash
|
||||
# .env — project root
|
||||
POSTGRES_DB=wpsuite
|
||||
POSTGRES_USER=wpsuite
|
||||
POSTGRES_PASSWORD=<strong-random-password>
|
||||
```
|
||||
|
||||
`type` is one of `home_feedback`, `sop_step_comment`, or `wp_review_comment`. The
|
||||
API maps `name`/`author` → the comment author and keeps any extra fields in the
|
||||
row's `extra` JSON column.
|
||||
That's it — the API now builds its own connection string from these three
|
||||
values and **encodes the password automatically**, so a password with special
|
||||
characters (`@ ! # : /` …) works without any manual escaping. `DATABASE_URL`
|
||||
is **optional** and only needed if you want to point the API at some other
|
||||
database; if you do set it, you must URL-encode the password yourself, and it's
|
||||
ignored whenever the three `POSTGRES_*` values are present.
|
||||
|
||||
Generate a strong password with `openssl rand -base64 32`.
|
||||
|
||||
> **Portainer note:** for a Git-based stack these go in the stack's
|
||||
> **Environment variables** section (Portainer doesn't read a local `.env`).
|
||||
> Set `POSTGRES_DB` / `POSTGRES_USER` / `POSTGRES_PASSWORD` there.
|
||||
|
||||
These are the only credentials in the system, and they never appear in the
|
||||
compose file or in git.
|
||||
|
||||
## 3. Point your reverse proxy at the nginx container
|
||||
|
||||
The nginx container listens on port **80** on the `proxy` network and expects
|
||||
TLS to be terminated upstream (by your reverse proxy / traefik). Route your
|
||||
chosen hostname (e.g. `wp-suite.company.local`) to the `nginx_webserver`
|
||||
container on that network. The container already proxies `/api/` to the `api`
|
||||
service internally — no extra app config needed.
|
||||
|
||||
## 4. Bring it up
|
||||
|
||||
From the project root:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build # builds the api + nginx images, starts all three containers
|
||||
docker compose ps # confirm nginx_webserver, wp_api, wp_db are running/healthy
|
||||
docker compose logs -f api # watch the API start (Ctrl-C to stop following)
|
||||
```
|
||||
|
||||
The database schema is **created automatically** on first API start — no manual
|
||||
`CREATE TABLE`. The Postgres data lives in the named volume `pgdata` and
|
||||
survives `docker compose down` (only `down -v` deletes it).
|
||||
|
||||
> No separate reverse proxy? Publish nginx directly by adding a `ports:` mapping
|
||||
> to the `webserver` service (e.g. `"8080:80"`) and terminate TLS at whatever
|
||||
> sits in front of it. The internal `api`/`db` containers should **never** be
|
||||
> published.
|
||||
|
||||
## 5. Verify
|
||||
|
||||
```bash
|
||||
# API liveness (from the host, through the proxy hostname)
|
||||
curl https://wp-suite.company.local/api/health # → {"ok": true}
|
||||
|
||||
# Interactive API docs
|
||||
# https://wp-suite.company.local/api/docs
|
||||
```
|
||||
|
||||
Then load the site in a browser: the home page should prompt to **select or
|
||||
create a project**. Create one, complete an SOP, and confirm a row appears:
|
||||
|
||||
```bash
|
||||
docker compose exec db psql -U wpsuite -d wpsuite -c "select id, name from projects;"
|
||||
```
|
||||
|
||||
### Automated smoke test
|
||||
|
||||
`server/smoketest.py` exercises the whole stack end-to-end (health → project →
|
||||
SOP → Work Package → the AWP issue gate → status → metrics → comments → cascade
|
||||
cleanup). Stdlib only — no pip/jq.
|
||||
|
||||
```bash
|
||||
# Through the proxy (use --insecure for a self-signed internal cert):
|
||||
python3 server/smoketest.py https://wp-suite.company.local --insecure
|
||||
|
||||
# Or from inside the api container (hits FastAPI directly):
|
||||
docker compose exec api python /app/server/smoketest.py http://localhost:8000
|
||||
|
||||
# Add --keep to leave a demo project in the DB so you can open it in the UI.
|
||||
```
|
||||
|
||||
Exit code 0 and "ALL PASS" means the API, the Python logic, and SQL are all
|
||||
working. It cleans up after itself (the test project and its SOP/WPs are
|
||||
deleted via cascade); a single tagged test comment remains (there's no comment
|
||||
delete endpoint).
|
||||
|
||||
### Loadable demo project
|
||||
|
||||
`server/seed_demo.py` populates a realistic **DEMO** project (a complete SOP plus
|
||||
a spread of Work Packages: issued, gated, a multi-discipline master with split
|
||||
instances, an overdue one, an over-threshold draft) so there's data to look at.
|
||||
|
||||
```bash
|
||||
python3 server/seed_demo.py https://wp-suite.company.local --insecure
|
||||
python3 server/seed_demo.py https://wp-suite.company.local --clean # remove it later
|
||||
```
|
||||
|
||||
> **What shows where:** the DEMO **project** is API/SQL-backed, so it appears in
|
||||
> the home-page project picker right away (this is the visible proof that the
|
||||
> projects → SQL path works end-to-end). The DEMO **SOP and Work Packages** are
|
||||
> written to SQL too, but the current front end still reads SOPs/WPs from the
|
||||
> browser, so they won't render in the Creator/Dashboard until the Phase 2
|
||||
> wiring. Inspect them at the SQL layer with `smoketest.py` or:
|
||||
> ```bash
|
||||
> docker compose exec db psql -U wpsuite -d wpsuite \
|
||||
> -c "select number, subject, status from work_packages order by number;"
|
||||
> ```
|
||||
|
||||
---
|
||||
|
||||
## What is stored in SQL today
|
||||
|
||||
Be aware of the current persistence split — the API + Postgres are fully
|
||||
deployed, and:
|
||||
|
||||
| Data | Stored in PostgreSQL today? |
|
||||
|------|------------------------------|
|
||||
| **Projects** | **Yes** — the front end is API-first (`/api/projects`), falling back to the browser only if the API is unreachable. |
|
||||
| **Comments / feedback** | **Yes** — every feedback surface posts to `/api/feedback`. |
|
||||
| **SOPs** | Endpoints exist (`/api/sops`); the front end still keeps the SOP in the browser (namespaced per project). Wiring it to the API is the remaining **Phase 2** step. |
|
||||
| **Work Packages** | Same — `/api/wps` (+ issue/status/metrics) exist and are ready; the creator still saves to the browser per project. |
|
||||
|
||||
So a fresh deployment gives you **shared, server-stored projects and comments
|
||||
immediately**. Moving SOPs and Work Packages off the browser and onto the API
|
||||
(so they're shared across users too) is a front-end change only — the database
|
||||
and endpoints are already in place.
|
||||
|
||||
## Data model (PostgreSQL)
|
||||
|
||||
| Table | Holds | Key columns |
|
||||
|-------|-------|-------------|
|
||||
| `sops` | project SOP baselines | `name`, `number`, `complete`, `data` (full SOP JSON) |
|
||||
| `work_packages` | individual IWPs | `sop_id`, `number`, `subject`, `type`, `status`, `data` (full WP JSON) |
|
||||
| `comments` | feedback from any page | `source`, `sop_id`, `wp_id`, `step`, `author`, `text` |
|
||||
| `projects` | top-level construction projects | `name`, `number`, `client`, `division`, `site`, `sample`, `data` |
|
||||
| `sops` | project SOP baselines | `project_id` → projects, `name`, `number`, `complete`, `data` (full SOP JSON) |
|
||||
| `work_packages` | individual IWPs | `project_id` → projects, `sop_id` → sops, `parent_id` (split instances), `number`, `subject`, `type`, `status`, `issued_at`, `data` (full WP JSON) |
|
||||
| `comments` | feedback from any page | `source`, `sop_id`, `wp_id`, `step`, `author`, `text`, `extra` |
|
||||
|
||||
The complete client document is stored verbatim in each row's `data` column;
|
||||
frequently-listed fields are promoted to real columns for filtering.
|
||||
The complete client document is stored verbatim in each row's `data` JSON
|
||||
column; frequently-listed fields are promoted to real columns for filtering.
|
||||
|
||||
### Endpoints (summary)
|
||||
|
||||
Projects `GET/POST /api/projects`, `GET/DELETE /api/projects/{id}` ·
|
||||
SOPs `GET/POST /api/sops`, `GET /api/sops/latest`, `GET/DELETE /api/sops/{id}` ·
|
||||
Work Packages `GET/POST /api/wps`, `GET/DELETE /api/wps/{id}`,
|
||||
`POST /api/wps/{id}/issue`, `POST /api/wps/{id}/status`, `GET /api/wps/metrics` ·
|
||||
Comments `POST /api/comments` (and `/api/feedback`), `GET /api/comments`.
|
||||
List/latest/metrics accept a `project_id` (and `sop_id`) filter. Full reference
|
||||
and request shapes: `/api/docs` and [`server/README.md`](server/README.md).
|
||||
|
||||
---
|
||||
|
||||
## Updating after a change
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose up -d --build webserver # front-end change (html/) — rebuild the baked image
|
||||
docker compose up -d --build api # backend change (server/)
|
||||
```
|
||||
|
||||
## Backups & retention
|
||||
|
||||
The whole dataset is in the `pgdata` volume — back it up on a schedule:
|
||||
|
||||
```bash
|
||||
# Backup (run from project root)
|
||||
docker compose exec -T db pg_dump -U wpsuite wpsuite > backup-$(date +%F).sql
|
||||
|
||||
# Restore
|
||||
docker compose exec -T db psql -U wpsuite -d wpsuite < backup-YYYY-MM-DD.sql
|
||||
```
|
||||
|
||||
## Schema migrations (important)
|
||||
|
||||
Tables are auto-created on API startup (`Base.metadata.create_all`). This
|
||||
creates **missing tables**, but it does **not** alter existing ones. The
|
||||
multi-project work added the `projects` table and new columns
|
||||
(`sops.project_id`, `work_packages.project_id` / `parent_id` / `issued_at`):
|
||||
|
||||
- On a **fresh** database these appear automatically — nothing to do.
|
||||
- On a database that **already has data** from an older schema, add the new
|
||||
columns with a migration (introduce **Alembic**) or apply them manually with
|
||||
`ALTER TABLE` before deploying — don't rely on `create_all` for column changes.
|
||||
|
||||
## Local trial without Postgres
|
||||
|
||||
For a quick local look, the API falls back to a SQLite file when `DATABASE_URL`
|
||||
is unset (`sqlite:///./wpsuite.db`) — see [`server/README.md`](server/README.md)
|
||||
§ *Local dev*. The front end alone can also be served statically from `html/`
|
||||
(it falls back to browser storage when the API isn't reachable).
|
||||
|
||||
@@ -4,5 +4,7 @@ COPY server/requirements.txt ./server/
|
||||
RUN pip install --no-cache-dir -r server/requirements.txt
|
||||
COPY server/ ./server/
|
||||
EXPOSE 8000
|
||||
CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", \
|
||||
# --preload imports the app once in the master (so create_all runs a single time)
|
||||
# before forking workers, preventing a table-creation race on first startup.
|
||||
CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", "--preload", \
|
||||
"-b", "0.0.0.0:8000", "--workers", "2", "server.app:app"]
|
||||
@@ -19,7 +19,17 @@ services:
|
||||
build: .
|
||||
container_name: wp_api
|
||||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
# Preferred: the API builds its own connection string from these and
|
||||
# encodes the password automatically (no manual URL-encoding needed).
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_HOST: db
|
||||
# Optional full-URL override (must be URL-encoded if used).
|
||||
DATABASE_URL: ${DATABASE_URL:-}
|
||||
# Signs login session cookies. MUST be set (see server/.env.example).
|
||||
AUTH_SECRET_KEY: ${AUTH_SECRET_KEY}
|
||||
AUTH_SESSION_HOURS: ${AUTH_SESSION_HOURS:-12}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
|
||||
159
html/admin.html
Normal file
159
html/admin.html
Normal file
@@ -0,0 +1,159 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Admin Console — Work Package Suite</title>
|
||||
<script src="auth-guard.js"></script>
|
||||
<link rel="icon" href="favicon.ico" sizes="any">
|
||||
<style>
|
||||
:root{ --bg:#f4f5f7; --surface:#fff; --border:#e3e6ec; --border-strong:#d0d5de; --text:#1a2230;
|
||||
--muted:#5a6675; --dim:#9aa3b2; --accent:#2563d6; --green:#15924f; --green-bg:#e4f6ec;
|
||||
--red:#cf3b3b; --red-bg:#fbeaea; --amber:#b87100; --amber-bg:#fdf2e0; --mono:'Cascadia Mono',Consolas,monospace; }
|
||||
*{ box-sizing:border-box; }
|
||||
body{ margin:0; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; background:var(--bg); color:var(--text); }
|
||||
.wrap{ max-width:860px; margin:0 auto; padding:28px 20px 80px; }
|
||||
h1{ font-size:20px; margin:0 0 2px; }
|
||||
.sub{ color:var(--muted); font-size:13px; margin-bottom:18px; }
|
||||
.card{ background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:18px 20px; margin-bottom:16px; }
|
||||
.card h2{ font-size:14px; margin:0 0 12px; text-transform:uppercase; letter-spacing:.03em; color:var(--accent); }
|
||||
button{ font:inherit; font-size:13px; font-weight:600; border-radius:6px; padding:8px 14px; cursor:pointer;
|
||||
border:1px solid var(--border-strong); background:#fff; color:var(--text); }
|
||||
button:hover{ border-color:var(--accent); color:var(--accent); }
|
||||
button.primary{ background:var(--accent); border-color:var(--accent); color:#fff; }
|
||||
button.primary:hover{ background:#1e54bb; color:#fff; }
|
||||
button.danger{ border-color:var(--red); color:var(--red); }
|
||||
button.danger:hover{ background:var(--red-bg); }
|
||||
.row{ display:flex; gap:10px; flex-wrap:wrap; align-items:center; }
|
||||
.banner{ padding:10px 14px; border-radius:8px; font-size:13px; font-weight:600; margin-top:10px; border:1px solid var(--border); background:var(--surface); }
|
||||
.banner.ok{ background:var(--green-bg); color:var(--green); border-color:var(--green); }
|
||||
.banner.bad{ background:var(--red-bg); color:var(--red); border-color:var(--red); }
|
||||
pre.out{ background:#0f1525; color:#d7e0f5; border-radius:8px; padding:12px 14px; font-family:var(--mono);
|
||||
font-size:12px; line-height:1.55; white-space:pre-wrap; max-height:340px; overflow:auto; margin:12px 0 0; }
|
||||
pre.out .p{ color:#56d364; font-weight:700; } pre.out .f{ color:#ff7b72; font-weight:700; }
|
||||
table.kv{ border-collapse:collapse; font-size:13px; margin-top:8px; }
|
||||
table.kv th{ text-align:left; padding:5px 18px 5px 0; color:var(--muted); font-weight:600; }
|
||||
table.kv td{ padding:5px 0; font-variant-numeric:tabular-nums; font-weight:700; }
|
||||
.note{ font-size:12px; color:var(--dim); margin-top:10px; }
|
||||
.gate-overlay{ position:fixed; inset:0; background:var(--bg); display:flex; align-items:center; justify-content:center; padding:20px; }
|
||||
.gate-box{ background:var(--surface); border:1px solid var(--border); border-radius:12px; padding:28px; max-width:380px; width:100%; box-shadow:0 8px 30px rgba(20,30,50,.12); }
|
||||
.gate-box h2{ margin:0 0 4px; font-size:17px; }
|
||||
.gate-box p{ color:var(--muted); font-size:13px; margin:0 0 16px; }
|
||||
.gate-box input{ width:100%; padding:10px 12px; font-size:14px; border:1px solid var(--border-strong); border-radius:6px; margin-bottom:12px; }
|
||||
.gate-msg{ color:var(--red); font-size:12px; min-height:16px; margin-bottom:8px; }
|
||||
.secwarn{ background:var(--amber-bg); color:var(--amber); border:1px solid var(--amber); border-radius:8px; padding:9px 13px; font-size:12px; margin-bottom:16px; }
|
||||
a.home{ color:var(--accent); font-size:13px; text-decoration:none; }
|
||||
.urow{ display:flex; gap:8px; flex-wrap:wrap; align-items:center; }
|
||||
.urow input, .urow select{ padding:8px 10px; font:inherit; font-size:13px; border:1px solid var(--border-strong);
|
||||
border-radius:6px; background:#fff; color:var(--text); }
|
||||
.urow input{ flex:1; min-width:130px; }
|
||||
table.users{ border-collapse:collapse; width:100%; font-size:13px; }
|
||||
table.users th{ text-align:left; padding:7px 10px; color:var(--muted); font-weight:600; border-bottom:1px solid var(--border); white-space:nowrap; }
|
||||
table.users td{ padding:7px 10px; border-bottom:1px solid var(--border); vertical-align:middle; }
|
||||
table.users tr:last-child td{ border-bottom:none; }
|
||||
.tag{ display:inline-block; padding:1px 9px; border-radius:11px; font-size:11px; font-weight:700; }
|
||||
.tag.admin{ background:#e7effe; color:#1d4ed8; } .tag.user{ background:#eef1f6; color:#5a6675; }
|
||||
.tag.on{ background:var(--green-bg); color:var(--green); } .tag.off{ background:var(--red-bg); color:var(--red); }
|
||||
button.mini{ padding:4px 9px; font-size:12px; }
|
||||
.me-tag{ font-size:11px; color:var(--dim); margin-left:6px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ADMINS ONLY (shown if the signed-in account isn't an admin) -->
|
||||
<div class="wrap" id="admin-denied" style="display:none">
|
||||
<div class="card">
|
||||
<h2>Admins only</h2>
|
||||
<p class="sub" style="margin:0 0 12px">Your account doesn’t have admin access. Sign in with an admin account, or ask an administrator to grant you the admin role.</p>
|
||||
<div class="row"><a class="home" href="index.html">← Back to site</a> <button onclick="wpLogout()">Sign out</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CONSOLE -->
|
||||
<div class="wrap" id="admin-main" style="display:none">
|
||||
<div class="row" style="justify-content:space-between">
|
||||
<div><h1>Work Package Suite — Admin Console</h1><div class="sub">Stack diagnostics & tests · talks to <code>/api</code> on this host</div></div>
|
||||
<div class="row"><a class="home" href="index.html">← Site</a></div>
|
||||
</div>
|
||||
|
||||
<!-- CONNECTIVITY -->
|
||||
<div class="card">
|
||||
<h2>API connectivity</h2>
|
||||
<div class="row"><button class="primary" onclick="checkHealth()">Check /api/health</button></div>
|
||||
<div class="banner" id="health-banner">—</div>
|
||||
</div>
|
||||
|
||||
<!-- USER ADMINISTRATION -->
|
||||
<div class="card">
|
||||
<h2>User administration</h2>
|
||||
<div class="sub" style="margin-bottom:10px">Login accounts for the portal. Requires an <strong>admin</strong> role on your own account.</div>
|
||||
<div class="row"><button onclick="loadUsers()">Refresh users</button></div>
|
||||
<div id="users-banner"></div>
|
||||
<div id="users-table" style="margin-top:12px"></div>
|
||||
|
||||
<h2 style="margin-top:22px">Add a user</h2>
|
||||
<div class="urow">
|
||||
<input id="nu-username" placeholder="Username *" autocomplete="off">
|
||||
<input id="nu-fullname" placeholder="Full name" autocomplete="off">
|
||||
<input id="nu-email" placeholder="Email" autocomplete="off">
|
||||
<select id="nu-role"><option value="user">user</option><option value="admin">admin</option></select>
|
||||
<input id="nu-password" type="password" placeholder="Password (min 8)" autocomplete="new-password">
|
||||
<button class="primary" onclick="createUser()">Create user</button>
|
||||
</div>
|
||||
<div id="users-create-msg" class="note"></div>
|
||||
</div>
|
||||
|
||||
<!-- ALL FEEDBACK / COMMENTS -->
|
||||
<div class="card">
|
||||
<h2>All feedback & comments</h2>
|
||||
<div class="sub" style="margin-bottom:10px">Every comment submitted across the suite — who wrote it, what they said, and where they were (page & step) when they commented.</div>
|
||||
<div class="row">
|
||||
<button onclick="loadComments()">Refresh comments</button>
|
||||
<select id="cmt-filter" onchange="renderComments()"><option value="">All sources</option></select>
|
||||
<input id="cmt-search" placeholder="Search text / author…" oninput="renderComments()" style="flex:1;min-width:160px;padding:8px 10px;font:inherit;font-size:13px;border:1px solid var(--border-strong);border-radius:6px;">
|
||||
</div>
|
||||
<div id="comments-admin" class="note" style="margin-top:12px">Click refresh to load.</div>
|
||||
</div>
|
||||
|
||||
<!-- USAGE LOGS -->
|
||||
<div class="card">
|
||||
<h2>Usage logs</h2>
|
||||
<div class="sub" style="margin-bottom:10px">Engagement recorded by the suite — sessions, step views, and actions. Note: stored locally per browser, so this reflects activity on <strong>this</strong> machine.</div>
|
||||
<div class="row">
|
||||
<button onclick="loadUsage()">Refresh</button>
|
||||
<button onclick="downloadUsage()">Download JSON</button>
|
||||
</div>
|
||||
<div id="usage-admin" class="note" style="margin-top:12px">Click refresh to load.</div>
|
||||
</div>
|
||||
|
||||
<!-- DB SNAPSHOT -->
|
||||
<div class="card">
|
||||
<h2>Database snapshot</h2>
|
||||
<div class="row"><button onclick="snapshot()">Refresh counts</button></div>
|
||||
<div id="snapshot-out" class="note">Click refresh to read row counts from SQL via the API.</div>
|
||||
</div>
|
||||
|
||||
<!-- SMOKE TEST -->
|
||||
<div class="card">
|
||||
<h2>End-to-end smoke test</h2>
|
||||
<div class="sub" style="margin-bottom:8px">Creates a throwaway project, exercises the issue gate / status / metrics / comments, then deletes it (cascade). Mirrors <code>server/smoketest.py</code>.</div>
|
||||
<div class="row"><button class="primary" onclick="runSmokeTest()">Run smoke test</button></div>
|
||||
<pre class="out" id="smoke-out">Ready.</pre>
|
||||
</div>
|
||||
|
||||
<!-- DEMO DATA -->
|
||||
<div class="card">
|
||||
<h2>Demo data</h2>
|
||||
<div class="sub" style="margin-bottom:8px">Seed a realistic <code>DEMO</code> project (SOP + a spread of Work Packages) into SQL, or remove all <code>DEMO-</code>/<code>SMOKE-</code> projects.</div>
|
||||
<div class="row">
|
||||
<button class="primary" onclick="seedDemo()">Seed demo project</button>
|
||||
<button class="danger" onclick="cleanDemo()">Clean DEMO / SMOKE projects</button>
|
||||
</div>
|
||||
<pre class="out" id="demo-out">Ready.</pre>
|
||||
<div class="note">Note: the seeded <strong>project</strong> appears in the home picker; its SOP/WPs live in SQL but won't render in the Creator/Dashboard until the front end is wired to the API (Phase 2).</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
386
html/admin.js
Normal file
386
html/admin.js
Normal file
@@ -0,0 +1,386 @@
|
||||
/* Admin console for the Work Package Suite.
|
||||
Browser-side diagnostics + tests that call the same /api on this host.
|
||||
|
||||
ACCESS: the console is gated on the signed-in user's ROLE. auth-guard.js
|
||||
already requires a login (redirecting to login.html otherwise) and publishes
|
||||
window.WP_USER; here we show the console only when that user is an admin, and
|
||||
show an "Admins only" notice otherwise. Every user-management API is also
|
||||
enforced as admin-only server-side, so this is a real gate, not obfuscation. */
|
||||
|
||||
function reveal(){
|
||||
document.getElementById('admin-main').style.display='';
|
||||
checkHealth();
|
||||
loadUsers();
|
||||
loadComments();
|
||||
loadUsage();
|
||||
}
|
||||
function showDenied(){
|
||||
document.getElementById('admin-denied').style.display='';
|
||||
}
|
||||
|
||||
// ── api helper ──────────────────────────────────────────────────────────────
|
||||
async function api(method, path, body){
|
||||
const opt = { method, headers:{ 'Accept':'application/json' } };
|
||||
if(body !== undefined){ opt.headers['Content-Type']='application/json'; opt.body=JSON.stringify(body); }
|
||||
try {
|
||||
const r = await fetch(path, opt);
|
||||
const t = await r.text();
|
||||
let json; try { json = t ? JSON.parse(t) : null; } catch(_){ json = t; }
|
||||
return { status:r.status, json };
|
||||
} catch(e){ return { status:0, json:String(e) }; }
|
||||
}
|
||||
|
||||
// ── connectivity ──────────────────────────────────────────────────────────────
|
||||
async function checkHealth(){
|
||||
const b = document.getElementById('health-banner');
|
||||
b.className='banner'; b.textContent='Checking…';
|
||||
const { status, json } = await api('GET','/api/health');
|
||||
if(status===200 && json && json.ok){
|
||||
b.className='banner ok'; b.textContent='✅ API reachable — /api/health returned ok.';
|
||||
} else if(status===404){
|
||||
b.className='banner bad'; b.textContent='❌ /api/ returns 404 — the reverse proxy is not routing /api/ to the API. The site loads but the API is unreachable from the browser.';
|
||||
} else if(status===0){
|
||||
b.className='banner bad'; b.textContent='❌ Could not reach the server: '+json;
|
||||
} else {
|
||||
b.className='banner bad'; b.textContent='❌ Unexpected response: HTTP '+status;
|
||||
}
|
||||
}
|
||||
|
||||
// ── db snapshot ───────────────────────────────────────────────────────────────
|
||||
async function snapshot(){
|
||||
const out = document.getElementById('snapshot-out'); out.textContent='Loading…';
|
||||
const [p,s,w,c] = await Promise.all([
|
||||
api('GET','/api/projects'), api('GET','/api/sops'),
|
||||
api('GET','/api/wps'), api('GET','/api/comments')]);
|
||||
if(p.status!==200){
|
||||
out.innerHTML = `<div class="banner bad">API not reachable (HTTP ${p.status}). Fix /api/ routing first.</div>`; return;
|
||||
}
|
||||
const n = r => Array.isArray(r.json) ? r.json.length : ('err '+r.status);
|
||||
out.innerHTML = `<table class="kv">
|
||||
<tr><th>Projects</th><td>${n(p)}</td></tr>
|
||||
<tr><th>SOPs</th><td>${n(s)}</td></tr>
|
||||
<tr><th>Work Packages</th><td>${n(w)}</td></tr>
|
||||
<tr><th>Comments</th><td>${n(c)}</td></tr></table>`;
|
||||
}
|
||||
|
||||
// ── smoke test ────────────────────────────────────────────────────────────────
|
||||
function smLog(html){ const o=document.getElementById('smoke-out'); o.innerHTML += html + '\n'; o.scrollTop=o.scrollHeight; }
|
||||
async function runSmokeTest(){
|
||||
const o=document.getElementById('smoke-out'); o.innerHTML=''; let pass=0, fail=0, pid=null;
|
||||
const chk=(name,cond,detail)=>{ if(cond){ pass++; smLog('<span class="p">PASS</span> '+name); }
|
||||
else { fail++; smLog('<span class="f">FAIL</span> '+name+(detail?' ('+detail+')':'')); } return cond; };
|
||||
try {
|
||||
let r = await api('GET','/api/health');
|
||||
if(!chk('health endpoint ok', r.status===200 && r.json && r.json.ok, 'status '+r.status)){
|
||||
smLog('\nAborting — API unreachable (fix /api/ routing).'); return finishSmoke(pass,fail);
|
||||
}
|
||||
r = await api('POST','/api/projects',{name:'ZZ Smoke Test Project',number:'SMOKE-001',client:'Internal QA',created_by:'admin-console'});
|
||||
pid = r.json && r.json.id; chk('create project', r.status===200 && !!pid, 'status '+r.status);
|
||||
r = await api('GET','/api/projects/'+pid); chk('fetch project by id', r.status===200 && r.json.number==='SMOKE-001');
|
||||
r = await api('GET','/api/projects'); chk('project in list', r.status===200 && r.json.some(p=>p.id===pid));
|
||||
r = await api('POST','/api/sops',{project_id:pid,name:'ZZ Smoke SOP',number:'SMOKE-001',complete:true,data:{governance:{disciplines:['Mechanical','Electrical','Tech']}}});
|
||||
const sid = r.json && r.json.id; chk('create SOP linked to project', r.status===200 && !!sid && r.json.project_id===pid);
|
||||
r = await api('GET','/api/sops/latest?project_id='+pid); chk('latest SOP resolves', r.status===200 && r.json.id===sid);
|
||||
r = await api('POST','/api/wps',{project_id:pid,sop_id:sid,number:'WP01-SMOKE',subject:'Smoke test package',type:'Conduit Install',status:'Scheduled',data:{disciplines:['Electrical'],hours:'40',constraints:[{name:'Materials',status:'open',comment:'awaiting delivery'},{name:'Safety',status:'cleared',comment:''}]}});
|
||||
const wid = r.json && r.json.id; chk('create work package', r.status===200 && !!wid);
|
||||
r = await api('POST','/api/wps/'+wid+'/issue'); chk('issue blocked while a constraint is open (409)', r.status===409, 'status '+r.status);
|
||||
await api('POST','/api/wps',{id:wid,project_id:pid,sop_id:sid,number:'WP01-SMOKE',subject:'Smoke test package',type:'Conduit Install',status:'Scheduled',data:{disciplines:['Electrical'],hours:'40',constraints:[{name:'Materials',status:'cleared',comment:''},{name:'Safety',status:'cleared',comment:''}]}});
|
||||
r = await api('POST','/api/wps/'+wid+'/issue'); chk('issue succeeds once cleared', r.status===200 && r.json.status==='Issued', 'status '+r.status);
|
||||
chk('issued_at timestamp set', !!(r.json && r.json.issued_at));
|
||||
r = await api('POST','/api/wps/'+wid+'/status',{status:'In Progress'}); chk('status transition', r.status===200 && r.json.status==='In Progress');
|
||||
r = await api('GET','/api/wps/metrics?project_id='+pid); chk('metrics aggregate', r.status===200 && r.json && r.json.total>=1, JSON.stringify(r.json));
|
||||
r = await api('POST','/api/feedback',{type:'wp_review_comment',name:'admin-console',wp_id:wid,text:'SMOKE TEST comment — safe to delete'}); chk('post comment', r.status===200 && !!(r.json && r.json.id));
|
||||
r = await api('GET','/api/wps?project_id='+pid); chk('list WPs by project', r.status===200 && r.json.some(w=>w.id===wid));
|
||||
} catch(e){ chk('unexpected error', false, String(e)); }
|
||||
finally {
|
||||
if(pid){ const r=await api('DELETE','/api/projects/'+pid); chk('cleanup — delete project (cascades SOP+WPs)', r.status===200, 'status '+r.status); }
|
||||
finishSmoke(pass,fail);
|
||||
}
|
||||
}
|
||||
function finishSmoke(pass,fail){
|
||||
const total=pass+fail;
|
||||
smLog('\n'+pass+'/'+total+' checks passed.');
|
||||
smLog(fail ? '<span class="f">RESULT: FAIL ('+fail+')</span>' : '<span class="p">RESULT: ALL PASS — API, Python logic, and SQL are working.</span>');
|
||||
}
|
||||
|
||||
// ── demo data ─────────────────────────────────────────────────────────────────
|
||||
function demoLog(s){ const o=document.getElementById('demo-out'); o.innerHTML += s + '\n'; o.scrollTop=o.scrollHeight; }
|
||||
function stdConstraints(open){ return ['Safety & Permitting','Quality Control / Inspection','IFC Drawings & Specs','Schedule','Materials (on site, bagged & tagged)']
|
||||
.map(n=>({name:n, status:(open&&open.includes(n))?'open':'cleared', comment:''})); }
|
||||
async function seedDemo(){
|
||||
const o=document.getElementById('demo-out'); o.innerHTML='';
|
||||
let r = await api('GET','/api/health');
|
||||
if(!(r.status===200 && r.json && r.json.ok)){ demoLog('❌ API unreachable — fix /api/ routing first.'); return; }
|
||||
r = await api('POST','/api/projects',{name:'DEMO — Micron INC (test data)',number:'DEMO-001',client:'Micron Technology, Inc.',division:'Semiconductor',site:'Boise, ID — Fab',created_by:'admin-console'});
|
||||
if(r.status!==200){ demoLog('❌ create project failed (HTTP '+r.status+')'); return; }
|
||||
const pid=r.json.id; demoLog('Project created: '+r.json.name);
|
||||
r = await api('POST','/api/sops',{project_id:pid,name:'DEMO SOP',number:'DEMO-001',complete:true,data:{governance:{woFormat:'WP##-[Sector]-[TYPE]',disciplines:['Mechanical','Electrical','Tech'],discMode:'choice',instanceSuffix:'letter',woSize:'Standard — 3–5 days (≈40–80 hrs)',sizeHoursMax:'80'}}});
|
||||
const sid=r.json && r.json.id; demoLog('SOP created (complete).');
|
||||
const mk=async(num,subj,typ,status,data,parent)=>{ const body={project_id:pid,sop_id:sid,number:num,subject:subj,type:typ,status,created_by:'admin-console',data}; if(parent)body.parent_id=parent; const rr=await api('POST','/api/wps',body); demoLog(' WP '+num+' ['+status+']'); return rr.json; };
|
||||
await mk('WP01-1P-CONDUIT','1P horn/strobe conduit','Conduit Install','Issued',{disciplines:['Electrical'],hours:'40',constraints:stdConstraints(),due:'2026-06-30'});
|
||||
await mk('WP02-1P-WIRE','1P wire pull','Wire Pull','Scheduled',{disciplines:['Electrical'],hours:'60',constraints:stdConstraints(['Materials (on site, bagged & tagged)']),due:'2026-07-04'});
|
||||
const masterId='wp_demo_master_chiller';
|
||||
const kids=[['WP03-CHILLER_Mech','Mechanical','A','Mechanical Install','In Progress'],['WP03-CHILLER_Elec','Electrical','B','Wire Pull','Scheduled'],['WP03-CHILLER_Tech','Tech','C','Terminations','Draft']];
|
||||
const kidIds=[];
|
||||
for(const [num,disc,label,typ,status] of kids){ const id='wp_demo_'+label.toLowerCase(); kidIds.push(id);
|
||||
await api('POST','/api/wps',{id,project_id:pid,sop_id:sid,parent_id:masterId,number:num,subject:'Chiller skid — '+disc,type:typ,status,created_by:'admin-console',data:{disciplines:[disc],instanceOf:masterId,instanceLabel:label,parentNumber:'WP03-CHILLER',hours:'50',constraints:stdConstraints(),due:'2026-07-10'}});
|
||||
demoLog(' WP '+num+' ['+status+'] (instance '+label+')'); }
|
||||
await api('POST','/api/wps',{id:masterId,project_id:pid,sop_id:sid,number:'WP03-CHILLER',subject:'Chiller skid (multi-discipline master)',type:'Mechanical Install',status:'Scheduled',created_by:'admin-console',data:{disciplines:['Mechanical','Electrical','Tech'],split:true,children:kidIds,hours:'150',constraints:stdConstraints(),due:'2026-07-10'}});
|
||||
demoLog(' WP WP03-CHILLER [master, split into A/B/C]');
|
||||
await mk('WP04-2P-TERM','2P terminations','Terminations','In Progress',{disciplines:['Tech'],hours:'30',actualHrs:'20',constraints:stdConstraints(),due:'2026-06-10'});
|
||||
await mk('WP05-3P-PANEL','3P panel install','Panel Install','Draft',{disciplines:['Electrical'],hours:'120',constraints:stdConstraints(['Schedule']),due:'2026-07-20'});
|
||||
r = await api('GET','/api/wps/metrics?project_id='+pid);
|
||||
demoLog('\nMetrics (masters excluded): '+JSON.stringify(r.json));
|
||||
demoLog('\n✅ Done — "DEMO — Micron INC (test data)" now appears in the home picker.');
|
||||
snapshot();
|
||||
}
|
||||
async function cleanDemo(){
|
||||
if(!confirm('Delete ALL projects whose number starts with DEMO- or SMOKE- (and their SOPs/WPs via cascade)?')) return;
|
||||
const o=document.getElementById('demo-out'); o.innerHTML='';
|
||||
const r = await api('GET','/api/projects');
|
||||
if(r.status!==200){ demoLog('❌ API unreachable (HTTP '+r.status+').'); return; }
|
||||
const targets=(r.json||[]).filter(p=>/^(DEMO-|SMOKE-)/.test(String(p.number||'')));
|
||||
if(!targets.length){ demoLog('Nothing to remove.'); return; }
|
||||
for(const p of targets){ await api('DELETE','/api/projects/'+p.id); demoLog('Deleted: '+p.name+' ('+p.number+')'); }
|
||||
demoLog('\n✅ Removed '+targets.length+' project(s).');
|
||||
snapshot();
|
||||
}
|
||||
|
||||
// ── user administration ────────────────────────────────────────────────────────
|
||||
function uesc(v){ return v==null ? '' : String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
|
||||
async function currentUserId(){
|
||||
if(window.WP_USER && window.WP_USER.id) return window.WP_USER.id;
|
||||
const { status, json } = await api('GET','/api/auth/me');
|
||||
return (status===200 && json && json.user) ? json.user.id : null;
|
||||
}
|
||||
|
||||
async function loadUsers(){
|
||||
const banner=document.getElementById('users-banner');
|
||||
const wrap=document.getElementById('users-table');
|
||||
banner.className='banner'; banner.textContent='Loading…'; banner.style.display='';
|
||||
const { status, json } = await api('GET','/api/auth/users');
|
||||
if(status===403){
|
||||
banner.className='banner bad';
|
||||
banner.textContent='❌ Your account is not an admin, so you can’t manage users. Ask an admin, or use the CLI: python -m server.manage_users';
|
||||
wrap.innerHTML=''; return;
|
||||
}
|
||||
if(status===401){
|
||||
banner.className='banner bad'; banner.textContent='❌ Not signed in. Reload and log in again.'; wrap.innerHTML=''; return;
|
||||
}
|
||||
if(status!==200 || !Array.isArray(json)){
|
||||
banner.className='banner bad'; banner.textContent='❌ Could not load users (HTTP '+status+').'; wrap.innerHTML=''; return;
|
||||
}
|
||||
banner.style.display='none';
|
||||
const meId = await currentUserId();
|
||||
renderUsers(json, meId);
|
||||
}
|
||||
|
||||
function renderUsers(list, meId){
|
||||
const wrap=document.getElementById('users-table');
|
||||
if(!list.length){ wrap.innerHTML='<div class="note">No users yet.</div>'; return; }
|
||||
const fmt = s => s ? new Date(s).toLocaleString() : '—';
|
||||
let rows = list.map(u=>{
|
||||
const me = u.id===meId;
|
||||
const active = u.is_active;
|
||||
const disableBtn = me
|
||||
? '<button class="mini" disabled title="You can’t disable yourself">—</button>'
|
||||
: '<button class="mini" onclick="toggleActive(\''+u.id+'\','+(!active)+')">'+(active?'Disable':'Enable')+'</button>';
|
||||
const delBtn = me
|
||||
? ''
|
||||
: '<button class="mini danger" onclick="deleteUser(\''+u.id+'\',\''+uesc(u.username).replace(/'/g,"\\'")+'\')">Delete</button>';
|
||||
return '<tr>'+
|
||||
'<td><strong>'+uesc(u.username)+'</strong>'+(me?'<span class="me-tag">you</span>':'')+'</td>'+
|
||||
'<td>'+uesc(u.full_name||'')+'</td>'+
|
||||
'<td>'+uesc(u.email||'')+'</td>'+
|
||||
'<td><span class="tag '+(u.role==='admin'?'admin':'user')+'">'+uesc(u.role)+'</span></td>'+
|
||||
'<td><span class="tag '+(active?'on':'off')+'">'+(active?'active':'disabled')+'</span></td>'+
|
||||
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(u.last_login_at)+'</td>'+
|
||||
'<td style="white-space:nowrap"><div class="row" style="gap:6px">'+
|
||||
'<button class="mini" onclick="manageProjects(\''+u.id+'\',\''+uesc(u.username).replace(/'/g,"\\'")+'\')">Projects</button>'+
|
||||
'<button class="mini" onclick="resetPw(\''+u.id+'\',\''+uesc(u.username).replace(/'/g,"\\'")+'\')">Reset password</button>'+
|
||||
disableBtn+delBtn+
|
||||
'</div></td>'+
|
||||
'</tr>';
|
||||
}).join('');
|
||||
wrap.innerHTML='<table class="users"><thead><tr>'+
|
||||
'<th>Username</th><th>Name</th><th>Email</th><th>Role</th><th>Status</th><th>Last login</th><th>Actions</th>'+
|
||||
'</tr></thead><tbody>'+rows+'</tbody></table>';
|
||||
}
|
||||
|
||||
async function createUser(){
|
||||
const msg=document.getElementById('users-create-msg');
|
||||
const username=document.getElementById('nu-username').value.trim();
|
||||
const full_name=document.getElementById('nu-fullname').value.trim();
|
||||
const email=document.getElementById('nu-email').value.trim();
|
||||
const role=document.getElementById('nu-role').value;
|
||||
const password=document.getElementById('nu-password').value;
|
||||
if(!username){ msg.style.color='var(--red)'; msg.textContent='Username is required.'; return; }
|
||||
if(password.length<8){ msg.style.color='var(--red)'; msg.textContent='Password must be at least 8 characters.'; return; }
|
||||
msg.style.color='var(--muted)'; msg.textContent='Creating…';
|
||||
const { status, json } = await api('POST','/api/auth/users',{username,full_name,email,role,password});
|
||||
if(status===200){
|
||||
msg.style.color='var(--green)'; msg.textContent='✅ Created '+username+'.';
|
||||
['nu-username','nu-fullname','nu-email','nu-password'].forEach(id=>document.getElementById(id).value='');
|
||||
loadUsers();
|
||||
} else {
|
||||
msg.style.color='var(--red)';
|
||||
msg.textContent='❌ '+((json && json.detail) ? json.detail : ('Failed (HTTP '+status+').'));
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPw(id, username){
|
||||
const pw=prompt('New password for "'+username+'" (min 8 characters):');
|
||||
if(pw===null) return;
|
||||
if(pw.length<8){ alert('Password must be at least 8 characters.'); return; }
|
||||
const { status, json } = await api('POST','/api/auth/users/'+id+'/password',{new_password:pw});
|
||||
if(status===200) alert('Password reset for '+username+'.');
|
||||
else alert('Failed: '+((json && json.detail)||('HTTP '+status)));
|
||||
}
|
||||
|
||||
async function toggleActive(id, makeActive){
|
||||
const { status, json } = await api('POST','/api/auth/users/'+id+'/active',{is_active:makeActive});
|
||||
if(status===200) loadUsers();
|
||||
else alert('Failed: '+((json && json.detail)||('HTTP '+status)));
|
||||
}
|
||||
|
||||
async function deleteUser(id, username){
|
||||
if(!confirm('Delete user "'+username+'"? This cannot be undone.')) return;
|
||||
const { status, json } = await api('DELETE','/api/auth/users/'+id);
|
||||
if(status===200) loadUsers();
|
||||
else alert('Failed: '+((json && json.detail)||('HTTP '+status)));
|
||||
}
|
||||
|
||||
// ── project access assignment ───────────────────────────────────────────────────
|
||||
async function manageProjects(id, username){
|
||||
const { status, json } = await api('GET','/api/auth/users/'+id+'/projects');
|
||||
if(status!==200 || !json){ alert('Could not load projects (HTTP '+status+').'); return; }
|
||||
openProjectModal(id, username, json.projects||[], new Set(json.assigned||[]), json.user);
|
||||
}
|
||||
function closeProjectModal(){ const m=document.getElementById('proj-modal'); if(m) m.remove(); }
|
||||
function openProjectModal(userId, username, projects, assigned, userObj){
|
||||
closeProjectModal();
|
||||
const isAdmin = userObj && userObj.role==='admin';
|
||||
const items = projects.length ? projects.map(p =>
|
||||
'<label style="display:flex;align-items:center;gap:8px;padding:7px 4px;border-bottom:1px solid var(--border);font-size:13px;cursor:pointer;">'+
|
||||
'<input type="checkbox" value="'+uesc(p.id)+'"'+(assigned.has(p.id)?' checked':'')+(isAdmin?' disabled':'')+'>'+
|
||||
'<span><strong>'+uesc(p.name||'(unnamed)')+'</strong>'+(p.number?' <span style="color:var(--muted)">'+uesc(p.number)+'</span>':'')+'</span>'+
|
||||
'</label>').join('') : '<div class="note">No projects exist yet.</div>';
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'proj-modal';
|
||||
modal.style.cssText = 'position:fixed;inset:0;background:rgba(20,30,50,.5);display:flex;align-items:center;justify-content:center;z-index:10002;padding:20px;';
|
||||
modal.innerHTML =
|
||||
'<div style="background:#fff;border-radius:10px;max-width:460px;width:100%;max-height:82vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 12px 40px rgba(20,30,50,.3);">'+
|
||||
'<div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:700;">Project access — '+uesc(username)+'</div>'+
|
||||
'<div style="padding:14px 18px;overflow:auto;">'+
|
||||
(isAdmin ? '<div class="banner" style="margin:0 0 10px">This user is an <strong>admin</strong> and can access every project regardless of assignment.</div>' : '<div class="note" style="margin:0 0 10px">Tick the projects this user may access.</div>')+
|
||||
'<div id="proj-list">'+items+'</div>'+
|
||||
'</div>'+
|
||||
'<div style="padding:12px 18px;border-top:1px solid var(--border);display:flex;gap:8px;justify-content:flex-end;">'+
|
||||
'<button onclick="closeProjectModal()">Cancel</button>'+
|
||||
(isAdmin ? '' : '<button class="primary" id="proj-save">Save</button>')+
|
||||
'</div>'+
|
||||
'</div>';
|
||||
modal.addEventListener('click', e => { if(e.target===modal) closeProjectModal(); });
|
||||
document.body.appendChild(modal);
|
||||
const saveBtn = document.getElementById('proj-save');
|
||||
if(saveBtn) saveBtn.onclick = async () => {
|
||||
const ids = [...modal.querySelectorAll('#proj-list input[type=checkbox]:checked')].map(c=>c.value);
|
||||
const { status } = await api('PUT','/api/auth/users/'+userId+'/projects',{project_ids:ids});
|
||||
if(status===200) closeProjectModal();
|
||||
else alert('Save failed (HTTP '+status+').');
|
||||
};
|
||||
}
|
||||
|
||||
// ── all feedback / comments ─────────────────────────────────────────────────────
|
||||
let _comments = [];
|
||||
async function loadComments(){
|
||||
const box = document.getElementById('comments-admin');
|
||||
box.textContent = 'Loading…';
|
||||
const { status, json } = await api('GET','/api/comments');
|
||||
if(status!==200 || !Array.isArray(json)){
|
||||
box.innerHTML = '<div class="banner bad">Could not load comments (HTTP '+status+').</div>'; return;
|
||||
}
|
||||
_comments = json;
|
||||
const sel = document.getElementById('cmt-filter'); const cur = sel.value;
|
||||
const sources = [...new Set(json.map(c=>c.source).filter(Boolean))].sort();
|
||||
sel.innerHTML = '<option value="">All sources</option>' + sources.map(s=>'<option value="'+uesc(s)+'">'+uesc(s)+'</option>').join('');
|
||||
sel.value = cur;
|
||||
renderComments();
|
||||
}
|
||||
function renderComments(){
|
||||
const box = document.getElementById('comments-admin');
|
||||
const src = document.getElementById('cmt-filter').value;
|
||||
const q = (document.getElementById('cmt-search').value||'').toLowerCase();
|
||||
let rows = _comments.filter(c => (!src || c.source===src) &&
|
||||
(!q || ((c.text||'')+' '+(c.author||'')).toLowerCase().indexOf(q)>=0));
|
||||
if(!rows.length){ box.innerHTML = '<div class="note">No comments'+((src||q)?' match the filter.':' yet.')+'</div>'; return; }
|
||||
rows = rows.slice().sort((a,b)=> String(b.created_at||'').localeCompare(String(a.created_at||'')));
|
||||
const fmt = s => s ? new Date(s).toLocaleString() : '—';
|
||||
const where = c => {
|
||||
const bits = [];
|
||||
if(c.page) bits.push(uesc(c.page));
|
||||
if(c.step!=null) bits.push('step '+c.step);
|
||||
if(c.sop_id) bits.push('SOP '+uesc(c.sop_id));
|
||||
if(c.wp_id) bits.push('WP '+uesc(c.wp_id));
|
||||
return bits.join(' · ') || '—';
|
||||
};
|
||||
box.innerHTML = '<table class="users"><thead><tr><th>When</th><th>Who</th><th>Source</th><th>Where</th><th>Comment</th></tr></thead><tbody>'+
|
||||
rows.map(c => '<tr>'+
|
||||
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(c.created_at)+'</td>'+
|
||||
'<td><strong>'+uesc(c.author||'Anonymous')+'</strong></td>'+
|
||||
'<td>'+uesc(c.source||'—')+'</td>'+
|
||||
'<td style="color:var(--muted)">'+where(c)+'</td>'+
|
||||
'<td>'+uesc(c.text||'')+'</td>'+
|
||||
'</tr>').join('')+'</tbody></table>';
|
||||
}
|
||||
|
||||
// ── usage logs (read from this browser's localStorage) ──────────────────────────
|
||||
const USAGE_KEY = 'wp_suite_analytics_v1';
|
||||
function usageLoad(){ try { return JSON.parse(localStorage.getItem(USAGE_KEY)) || {events:[]}; } catch(e){ return {events:[]}; } }
|
||||
function loadUsage(){
|
||||
const box = document.getElementById('usage-admin');
|
||||
const evs = (usageLoad().events) || [];
|
||||
if(!evs.length){ box.innerHTML = '<div class="note">No usage recorded in this browser yet.</div>'; return; }
|
||||
const byEvent = {}, byStep = {}, sessions = new Set();
|
||||
let first = evs[0].ts, last = evs[0].ts;
|
||||
evs.forEach(e => {
|
||||
byEvent[e.event] = (byEvent[e.event]||0)+1;
|
||||
if(e.session) sessions.add(e.session);
|
||||
if(e.event==='step_view' && e.detail) byStep[e.detail.step] = (byStep[e.detail.step]||0)+1;
|
||||
if(e.ts < first) first = e.ts; if(e.ts > last) last = e.ts;
|
||||
});
|
||||
const fmt = s => s ? new Date(s).toLocaleString() : '—';
|
||||
let html = '<table class="kv">'+
|
||||
'<tr><th>Sessions</th><td>'+sessions.size+'</td></tr>'+
|
||||
'<tr><th>Events</th><td>'+evs.length+'</td></tr>'+
|
||||
'<tr><th>Range</th><td style="font-weight:600">'+fmt(first)+' → '+fmt(last)+'</td></tr></table>';
|
||||
html += '<h2 style="margin-top:16px">Step views</h2><table class="users"><thead><tr><th>Step</th><th>Views</th></tr></thead><tbody>';
|
||||
for(let i=1;i<=10;i++) html += '<tr><td>Step '+i+'</td><td>'+(byStep[i]||0)+'</td></tr>';
|
||||
html += '</tbody></table>';
|
||||
html += '<h2 style="margin-top:16px">Actions</h2><table class="users"><thead><tr><th>Event</th><th>Count</th></tr></thead><tbody>';
|
||||
Object.keys(byEvent).sort().forEach(k => html += '<tr><td>'+uesc(k)+'</td><td>'+byEvent[k]+'</td></tr>');
|
||||
html += '</tbody></table>';
|
||||
box.innerHTML = html;
|
||||
}
|
||||
function downloadUsage(){
|
||||
const blob = new Blob([JSON.stringify(usageLoad(),null,2)], {type:'application/json'});
|
||||
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
|
||||
a.download = 'wp-suite-usage-' + new Date().toISOString().slice(0,10) + '.json';
|
||||
a.click(); setTimeout(()=>URL.revokeObjectURL(a.href), 1000);
|
||||
}
|
||||
|
||||
// ── access control: admins only ─────────────────────────────────────────────────
|
||||
// auth-guard.js requires a login and sets window.WP_USER (firing 'wp-auth-ready').
|
||||
// Show the console for admins; otherwise show the "Admins only" notice.
|
||||
let _adminGated = false;
|
||||
function gateByRole(){
|
||||
if(_adminGated) return;
|
||||
const u = window.WP_USER;
|
||||
if(!u) return; // not resolved yet — wait for wp-auth-ready
|
||||
_adminGated = true;
|
||||
if(u.role === 'admin') reveal();
|
||||
else showDenied();
|
||||
}
|
||||
document.addEventListener('wp-auth-ready', gateByRole);
|
||||
gateByRole(); // in case WP_USER was already set before this ran
|
||||
153
html/auth-guard.js
Normal file
153
html/auth-guard.js
Normal file
@@ -0,0 +1,153 @@
|
||||
/* Auth guard for the Work Package Suite.
|
||||
Included in the <head> of every protected page (before other scripts). It
|
||||
confirms there is a valid session by calling /api/auth/me; if not, it sends
|
||||
the user to the login page. The real protection is server-side (the API
|
||||
refuses data requests without a session) — this guard is for UX so people
|
||||
land on the login screen instead of an empty app.
|
||||
|
||||
It also exposes:
|
||||
window.WP_USER the logged-in user object (set once verified)
|
||||
window.wpLogout() clears the session and returns to the login page
|
||||
and dispatches a 'wp-auth-ready' event on document once WP_USER is set. */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var inIframe = (function () { try { return window.top !== window.self; } catch (e) { return true; } })();
|
||||
|
||||
// Hide the page until we know the user is allowed, to avoid a flash of the app
|
||||
// before a redirect. A safety timer reveals it even if the check hangs.
|
||||
var root = document.documentElement;
|
||||
var style = document.createElement('style');
|
||||
style.textContent = '.wp-auth-pending body{visibility:hidden!important}';
|
||||
(document.head || root).appendChild(style);
|
||||
root.className += ' wp-auth-pending';
|
||||
function reveal() { root.className = root.className.replace(/\bwp-auth-pending\b/, ''); }
|
||||
var safety = setTimeout(reveal, 4000);
|
||||
|
||||
function goToLogin() {
|
||||
clearTimeout(safety);
|
||||
var next = encodeURIComponent(location.pathname + location.search);
|
||||
var url = 'login.html?next=' + next;
|
||||
// If we're inside the WP-creator iframe, redirect the whole window.
|
||||
var w = inIframe ? window.top : window;
|
||||
try { w.location.replace(url); } catch (e) { window.location.replace(url); }
|
||||
}
|
||||
|
||||
window.wpLogout = function () {
|
||||
fetch('/api/auth/logout', { method: 'POST' })
|
||||
.catch(function () {})
|
||||
.then(function () { window.location.replace('login.html'); });
|
||||
};
|
||||
|
||||
// Change-password dialog (uses POST /api/auth/password, which requires the
|
||||
// current password). Available from the top-right pill on any page.
|
||||
window.wpChangePassword = function () {
|
||||
if (document.getElementById('wp-pw-modal')) return;
|
||||
var ov = document.createElement('div');
|
||||
ov.id = 'wp-pw-modal';
|
||||
ov.style.cssText = 'position:fixed;inset:0;background:rgba(20,30,50,.5);display:flex;align-items:center;' +
|
||||
'justify-content:center;z-index:10002;padding:20px;font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;';
|
||||
var inp = 'width:100%;padding:9px 10px;margin-bottom:12px;border:1px solid #8d8d8d;border-radius:4px;font-size:14px;';
|
||||
var lbl = 'display:block;font-size:12px;color:#525252;margin-bottom:4px;';
|
||||
ov.innerHTML =
|
||||
'<div style="background:#fff;color:#161616;border-radius:10px;max-width:380px;width:100%;box-shadow:0 12px 40px rgba(20,30,50,.3);overflow:hidden;">' +
|
||||
'<div style="padding:14px 18px;border-bottom:1px solid #e0e0e0;font-weight:700;">Change password</div>' +
|
||||
'<div style="padding:16px 18px;">' +
|
||||
'<div id="wp-pw-msg" style="display:none;font-size:12.5px;padding:8px 10px;border-radius:6px;margin-bottom:12px;"></div>' +
|
||||
'<label style="' + lbl + '">Current password</label>' +
|
||||
'<input id="wp-pw-cur" type="password" autocomplete="current-password" style="' + inp + '">' +
|
||||
'<label style="' + lbl + '">New password (at least 8 characters)</label>' +
|
||||
'<input id="wp-pw-new" type="password" autocomplete="new-password" style="' + inp + '">' +
|
||||
'<label style="' + lbl + '">Confirm new password</label>' +
|
||||
'<input id="wp-pw-new2" type="password" autocomplete="new-password" style="' + inp + 'margin-bottom:0;">' +
|
||||
'</div>' +
|
||||
'<div style="padding:12px 18px;border-top:1px solid #e0e0e0;display:flex;gap:8px;justify-content:flex-end;">' +
|
||||
'<button type="button" id="wp-pw-cancel" style="padding:8px 14px;border:1px solid #8d8d8d;background:#fff;border-radius:6px;cursor:pointer;font-weight:600;">Cancel</button>' +
|
||||
'<button type="button" id="wp-pw-save" style="padding:8px 14px;border:none;background:#0f62fe;color:#fff;border-radius:6px;cursor:pointer;font-weight:600;">Update password</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
function close() { var m = document.getElementById('wp-pw-modal'); if (m) m.remove(); }
|
||||
function msg(text, ok) {
|
||||
var el = document.getElementById('wp-pw-msg');
|
||||
el.style.display = 'block'; el.textContent = text;
|
||||
el.style.background = ok ? '#defbe6' : '#fff1f1'; el.style.color = ok ? '#0e6027' : '#da1e28';
|
||||
}
|
||||
ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
|
||||
document.body.appendChild(ov);
|
||||
document.getElementById('wp-pw-cancel').onclick = close;
|
||||
document.getElementById('wp-pw-cur').focus();
|
||||
document.getElementById('wp-pw-save').onclick = function () {
|
||||
var cur = document.getElementById('wp-pw-cur').value;
|
||||
var n1 = document.getElementById('wp-pw-new').value;
|
||||
var n2 = document.getElementById('wp-pw-new2').value;
|
||||
if (!cur || !n1) { msg('Please fill in every field.', false); return; }
|
||||
if (n1.length < 8) { msg('New password must be at least 8 characters.', false); return; }
|
||||
if (n1 !== n2) { msg('New passwords do not match.', false); return; }
|
||||
fetch('/api/auth/password', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ current_password: cur, new_password: n1 })
|
||||
})
|
||||
.then(function (r) { return r.json().catch(function () { return null; }).then(function (j) { return { ok: r.ok, status: r.status, j: j }; }); })
|
||||
.then(function (res) {
|
||||
if (res.ok) { msg('Password updated.', true); setTimeout(close, 1200); }
|
||||
else { msg((res.j && res.j.detail) || ('Could not update (HTTP ' + res.status + ').'), false); }
|
||||
})
|
||||
.catch(function () { msg('Could not reach the server.', false); });
|
||||
};
|
||||
};
|
||||
|
||||
function addLogoutPill(user) {
|
||||
if (inIframe) return; // the parent page already shows it
|
||||
if (document.getElementById('wp-logout-pill')) return;
|
||||
var pill = document.createElement('div');
|
||||
pill.id = 'wp-logout-pill';
|
||||
pill.style.cssText = 'position:fixed;top:12px;right:12px;z-index:10001;' +
|
||||
'display:flex;align-items:center;gap:8px;background:#fff;border:1px solid #e0e0e0;' +
|
||||
'box-shadow:0 1px 4px rgba(0,0,0,.16);border-radius:16px;padding:5px 12px;' +
|
||||
'font:500 12px/1.2 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;color:#525252;';
|
||||
function sep() { var s = document.createElement('span'); s.textContent = '·'; s.style.color = '#a8a8a8'; return s; }
|
||||
|
||||
var who = document.createElement('span');
|
||||
who.textContent = user.full_name || user.username;
|
||||
pill.appendChild(who);
|
||||
|
||||
// Admins get a link to the Admin Console (hidden when already on it).
|
||||
var onAdmin = /(^|\/)admin\.html$/.test(location.pathname);
|
||||
if (user.role === 'admin' && !onAdmin) {
|
||||
var adm = document.createElement('a');
|
||||
adm.href = 'admin.html'; adm.textContent = 'Admin';
|
||||
adm.style.cssText = 'color:#0f62fe;text-decoration:none;font-weight:600;';
|
||||
pill.appendChild(sep()); pill.appendChild(adm);
|
||||
}
|
||||
|
||||
var pw = document.createElement('a');
|
||||
pw.href = '#'; pw.textContent = 'Password';
|
||||
pw.style.cssText = 'color:#0f62fe;text-decoration:none;font-weight:600;';
|
||||
pw.addEventListener('click', function (e) { e.preventDefault(); window.wpChangePassword(); });
|
||||
pill.appendChild(sep()); pill.appendChild(pw);
|
||||
|
||||
var out = document.createElement('a');
|
||||
out.href = '#'; out.textContent = 'Sign out';
|
||||
out.style.cssText = 'color:#0f62fe;text-decoration:none;font-weight:600;';
|
||||
out.addEventListener('click', function (e) { e.preventDefault(); window.wpLogout(); });
|
||||
pill.appendChild(sep()); pill.appendChild(out);
|
||||
document.body.appendChild(pill);
|
||||
}
|
||||
|
||||
fetch('/api/auth/me', { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) {
|
||||
if (r.status === 401 || r.status === 403) { goToLogin(); return; }
|
||||
if (!r.ok) { reveal(); clearTimeout(safety); return; } // unexpected; show page rather than trap
|
||||
return r.json().then(function (data) {
|
||||
clearTimeout(safety);
|
||||
window.WP_USER = data && data.user;
|
||||
reveal();
|
||||
if (window.WP_USER) {
|
||||
try { document.dispatchEvent(new CustomEvent('wp-auth-ready', { detail: window.WP_USER })); } catch (e) {}
|
||||
if (document.body) addLogoutPill(window.WP_USER);
|
||||
else document.addEventListener('DOMContentLoaded', function () { addLogoutPill(window.WP_USER); });
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(function () { goToLogin(); }); // API unreachable → send to login
|
||||
})();
|
||||
474
html/help.js
Normal file
474
html/help.js
Normal file
@@ -0,0 +1,474 @@
|
||||
/* Shared Help center + tooltip module for the Work Package Suite.
|
||||
Included by the home page, the suite, and the embedded creator. It injects:
|
||||
- tooltip styles for the .help-tip (ⓘ) component and [data-tip] hovers
|
||||
- a searchable, multi-topic Help center modal opened via window.openHelp()
|
||||
- a floating "?" launcher on any page that doesn't already have a Help button
|
||||
|
||||
API (unchanged + extended):
|
||||
openHelp() open the help center
|
||||
openHelp('topicId') open and jump to a topic (e.g. openHelp('constraints'))
|
||||
closeHelp() close it
|
||||
Add a Help button anywhere with onclick="openHelp()". */
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
// ── styles ────────────────────────────────────────────────────────────────
|
||||
var css = `
|
||||
.help-tip{ display:inline-flex; align-items:center; justify-content:center; width:15px; height:15px;
|
||||
margin-left:5px; border-radius:50%; background:#5a6675; color:#fff; font-size:10px; font-weight:700;
|
||||
font-family:ui-sans-serif,system-ui,sans-serif; cursor:help; vertical-align:middle; position:relative; }
|
||||
.help-tip::after{ content:attr(data-tip); position:absolute; bottom:130%; left:50%; transform:translateX(-50%);
|
||||
background:#1a2230; color:#fff; padding:7px 10px; border-radius:6px; font-size:12px; font-weight:400;
|
||||
line-height:1.4; white-space:normal; width:max-content; max-width:260px; text-align:left; z-index:9999;
|
||||
opacity:0; pointer-events:none; transition:opacity .12s; box-shadow:0 4px 14px rgba(20,30,50,.22); }
|
||||
.help-tip::before{ content:''; position:absolute; bottom:130%; left:50%; transform:translate(-50%,95%);
|
||||
border:5px solid transparent; border-top-color:#1a2230; opacity:0; transition:opacity .12s; z-index:9999; }
|
||||
.help-tip:hover::after, .help-tip:hover::before, .help-tip:focus::after, .help-tip:focus::before{ opacity:1; }
|
||||
|
||||
.ui-help-overlay{ position:fixed; inset:0; background:rgba(20,30,50,.5); display:none; align-items:center;
|
||||
justify-content:center; z-index:10000; padding:4vh 16px; }
|
||||
.ui-help-overlay.open{ display:flex; }
|
||||
.ui-help-modal{ background:#fff; color:#1a2230; max-width:980px; width:100%; height:88vh; max-height:880px;
|
||||
border-radius:10px; box-shadow:0 12px 40px rgba(20,30,50,.3); display:flex; flex-direction:column; overflow:hidden;
|
||||
font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif; }
|
||||
.ui-help-head{ display:flex; align-items:center; gap:14px; padding:13px 18px; border-bottom:1px solid #e3e6ec; flex:none; }
|
||||
.ui-help-head .ui-help-title{ font-size:15px; font-weight:700; white-space:nowrap; }
|
||||
.ui-help-search{ flex:1; position:relative; max-width:420px; }
|
||||
.ui-help-search input{ width:100%; padding:8px 12px; border:1px solid #d0d5de; border-radius:7px;
|
||||
font-size:13px; outline:none; background:#f7f8fa; }
|
||||
.ui-help-search input:focus{ border-color:#2563d6; background:#fff; box-shadow:0 0 0 2px rgba(37,99,214,.15); }
|
||||
.ui-help-head .ui-help-x{ margin-left:auto; background:none; border:none; font-size:20px; cursor:pointer; color:#5a6675; line-height:1; }
|
||||
.ui-help-wrap{ display:flex; flex:1; min-height:0; }
|
||||
.ui-help-nav{ width:230px; flex:none; border-right:1px solid #e3e6ec; overflow:auto; padding:10px 8px; background:#fafbfc; }
|
||||
.ui-help-nav a{ display:block; padding:7px 10px; border-radius:6px; color:#27313f; text-decoration:none; font-size:13px;
|
||||
cursor:pointer; margin-bottom:1px; }
|
||||
.ui-help-nav a:hover{ background:#eef1f6; }
|
||||
.ui-help-nav a.active{ background:#e7effe; color:#1d4ed8; font-weight:600; }
|
||||
.ui-help-nav a.nohit{ display:none; }
|
||||
.ui-help-content{ flex:1; overflow:auto; padding:22px 28px; scroll-behavior:smooth; }
|
||||
.ui-help-sec{ margin-bottom:30px; }
|
||||
.ui-help-sec.hide{ display:none; }
|
||||
.ui-help-sec h3{ font-size:18px; margin:0 0 10px; color:#16213a; scroll-margin-top:10px; }
|
||||
.ui-help-sec h4{ margin:18px 0 6px; font-size:12px; text-transform:uppercase; letter-spacing:.04em; color:#2563d6; }
|
||||
.ui-help-content p{ font-size:13.5px; line-height:1.62; margin:0 0 9px; color:#27313f; }
|
||||
.ui-help-content ol, .ui-help-content ul{ margin:0 0 10px; padding-left:20px; font-size:13.5px; line-height:1.6; }
|
||||
.ui-help-content li{ margin-bottom:5px; }
|
||||
.ui-help-content code{ background:#eef1f6; padding:1px 5px; border-radius:4px; font-size:12px; }
|
||||
.ui-help-content table{ border-collapse:collapse; width:100%; font-size:12.5px; margin:6px 0 12px; }
|
||||
.ui-help-content th, .ui-help-content td{ border:1px solid #e3e6ec; padding:6px 9px; text-align:left; vertical-align:top; }
|
||||
.ui-help-content th{ background:#f4f6f9; font-weight:600; }
|
||||
.ui-help-pill{ display:inline-block; padding:1px 8px; border-radius:11px; font-size:11px; font-weight:600; }
|
||||
.pill-draft{ background:#eef1f6; color:#5a6675; } .pill-sched{ background:#e7effe; color:#1d4ed8; }
|
||||
.pill-prog{ background:#fef3e0; color:#b45309; } .pill-issued{ background:#e4f6ec; color:#15924f; }
|
||||
.pill-qc{ background:#f3e8ff; color:#7c3aed; } .pill-closed{ background:#e2e8f0; color:#334155; }
|
||||
.pill-hold{ background:#fde8e8; color:#c0392b; }
|
||||
.ui-help-callout{ background:#f4f8ff; border-left:3px solid #2563d6; padding:10px 14px; border-radius:0 6px 6px 0;
|
||||
font-size:13px; line-height:1.55; margin:10px 0; }
|
||||
.ui-help-noresult{ display:none; color:#5a6675; font-size:14px; padding:10px 2px; }
|
||||
.ui-help-content mark{ background:#fff1a8; color:inherit; border-radius:2px; padding:0 1px; }
|
||||
.ui-help-fab{ position:fixed; bottom:12px; left:12px; z-index:9998; width:38px; height:38px; border-radius:50%;
|
||||
border:none; background:#2563d6; color:#fff; font-size:18px; font-weight:700; cursor:pointer;
|
||||
box-shadow:0 2px 10px rgba(20,30,50,.28); }
|
||||
.ui-help-fab:hover{ background:#1d4ed8; }
|
||||
@media (max-width:760px){
|
||||
.ui-help-modal{ height:92vh; } .ui-help-wrap{ flex-direction:column; }
|
||||
.ui-help-nav{ width:auto; display:flex; flex-wrap:wrap; gap:4px; border-right:none; border-bottom:1px solid #e3e6ec; }
|
||||
.ui-help-nav a{ margin:0; font-size:12px; padding:5px 9px; }
|
||||
.ui-help-head{ flex-wrap:wrap; }
|
||||
}`;
|
||||
var style = document.createElement('style');
|
||||
style.textContent = css;
|
||||
(document.head || document.documentElement).appendChild(style);
|
||||
|
||||
// ── content ─────────────────────────────────────────────────────────────────
|
||||
// Each topic: { id, title, body(HTML) }. Order here is the nav order.
|
||||
var TOPICS = [
|
||||
{ id: 'overview', title: 'Getting started', body: `
|
||||
<h3>Getting started</h3>
|
||||
<p>The Work Package Suite turns a project's standard procedure into release-ready <strong>Installation Work Packages (IWPs)</strong>. You work in three stages, always in the same order:</p>
|
||||
<ol>
|
||||
<li><strong>Pick or create a Project</strong> on the home page. Each project keeps its own SOP and its own Work Packages, so you can run many jobs at once.</li>
|
||||
<li><strong>SOP Configuration</strong> — set the project baseline in 10 steps (team, sign-offs, WP types, governance & sizing, quality, platforms, sequence, constraints, sources). Every Work Package inherits these defaults. The Creator stays locked until the SOP is marked complete.</li>
|
||||
<li><strong>Work Package Creation</strong> — author individual IWPs against the SOP, clear their constraints, and issue them to the field.</li>
|
||||
<li><strong>Dashboard</strong> — track status, hours, due dates, and what's gating each package across the project.</li>
|
||||
</ol>
|
||||
<h4>Moving around</h4>
|
||||
<p>From the home page, open <strong>SOP Configuration</strong>, the <strong>Work Package Creator</strong>, or the <strong>Dashboard</strong>. Inside the suite, switch any time using the top tabs: <strong>⚙️ SOP Configuration</strong>, <strong>📋 Work Package Creation</strong>, and <strong>📊 Dashboard</strong>. The active project and SOP follow you across all of them.</p>
|
||||
<div class="ui-help-callout">New here? On the home page choose the <strong>Sample Project</strong>, then click <strong>⭐ Load Sample</strong> in the suite to see a fully filled-out SOP and an example Work Package.</div>` },
|
||||
|
||||
{ id: 'projects', title: 'Projects', body: `
|
||||
<h3>Projects</h3>
|
||||
<p>A <strong>project</strong> is the top-level container — every SOP and Work Package belongs to one. Create or select projects on the home page.</p>
|
||||
<h4>Project fields</h4>
|
||||
<ul>
|
||||
<li><strong>Project Name</strong> (required)</li>
|
||||
<li><strong>Project Number</strong></li>
|
||||
<li><strong>Client</strong></li>
|
||||
<li><strong>Division / Sector</strong></li>
|
||||
<li><strong>Site / Location</strong></li>
|
||||
</ul>
|
||||
<h4>The active project</h4>
|
||||
<p>The <strong>active project</strong> is the one you're currently working in. All SOP and Work Package data is scoped (namespaced) to it, so switching projects loads that project's own configuration and packages — nothing leaks between jobs. Use the <em>change</em> link next to the active project name to switch.</p>
|
||||
<div class="ui-help-callout">Projects are stored centrally via the API and mirrored to your browser, so the suite still works offline; it re-syncs when the connection returns.</div>` },
|
||||
|
||||
{ id: 'sop', title: 'SOP Configuration', body: `
|
||||
<h3>SOP Configuration (10 steps)</h3>
|
||||
<p>The SOP is the project baseline. Walk the 10 steps with <strong>← Back</strong> / <strong>Next →</strong>, or jump using the step indicators. The final step is <strong>✓ SOP Complete</strong> — saving it unlocks the Work Package Creator and turns the home-page card green.</p>
|
||||
<ol>
|
||||
<li><strong>Project Basics</strong> — name, number, client, division/sector, site. Inherited by every WP.</li>
|
||||
<li><strong>Project Team Leadership</strong> — PM, APM, CM, QM, plus any additional members (<em>+ Add Team Member</em>).</li>
|
||||
<li><strong>Required Sign-Off Roles</strong> — Superintendent and Foreman are always required; add optional roles (HSE, Quality Rep, Planner, etc.) with <em>+ Add Role</em>.</li>
|
||||
<li><strong>Work Package Types</strong> — enable the install types this project uses (Conduit Install, Wire Pull, Terminations, …). Enabled types populate the WP type picker.</li>
|
||||
<li><strong>Governance & WP Numbering</strong> — the WP <strong>number format</strong> (e.g. <code>WP##-[Sector]-[TYPE]</code>), issuance strategy, the project's <strong>disciplines</strong>, the <strong>discipline strategy</strong>, and <strong>WP sizing</strong> (see <a data-help-jump="sizing">Sizing</a> and <a data-help-jump="disciplines">Disciplines</a>).</li>
|
||||
<li><strong>Quality & Inspection Strategy</strong> — QC requirement, photo/documentation standard, and hold/witness points.</li>
|
||||
<li><strong>Tracking & Commissioning Platforms</strong> — e.g. CxAlloy, Procore, ACC.</li>
|
||||
<li><strong>Construction Sequence</strong> — the install flow; reorder by dragging (⠿), edit labels, add <em>◆ QC Hold</em> gates or custom steps. These feed the WP "predecessor" picker.</li>
|
||||
<li><strong>Release Gate Constraints</strong> — choose which standard AWP constraints apply and add custom ones (see <a data-help-jump="constraints">Constraints</a>).</li>
|
||||
<li><strong>Engineering Sources & References</strong> — labelled links (Design Drawings, Specs, …) that appear as quick-access buttons in the WP Creator's <em>Drawings & Attachments</em>.</li>
|
||||
</ol>
|
||||
<div class="ui-help-callout">Fields a WP inherits from the SOP show a <strong>"from SOP"</strong> tag and are locked. You can override a locked field with <strong>🔒 Edit</strong>, which requires a logged reason.</div>` },
|
||||
|
||||
{ id: 'wps', title: 'Work Packages', body: `
|
||||
<h3>Creating Work Packages</h3>
|
||||
<p>In the Creator, start a package with <strong>+ New</strong> (blank, auto-numbered) or <strong>⧉ Duplicate</strong> (copies a saved package and increments the number). The <strong>WP Number</strong> is built automatically from the SOP number format plus your scope fields, the WP type, and a counter — it's read-only.</p>
|
||||
<h4>Key fields</h4>
|
||||
<ul>
|
||||
<li><strong>Subject / Title</strong> (required) and <strong>WP Type</strong> (required, from the SOP).</li>
|
||||
<li><strong>Assets</strong> — link each controls.dev asset the package covers.</li>
|
||||
<li><strong>Disciplines</strong> — which trades the package covers (see <a data-help-jump="disciplines">Disciplines & Split</a>).</li>
|
||||
<li><strong>Scope & Work</strong> — the sequenced steps the crew performs (per-discipline in multi-discipline mode).</li>
|
||||
<li><strong>Labor – Est. Hrs.</strong> — drives the sizing check (see <a data-help-jump="sizing">Sizing</a>).</li>
|
||||
<li><strong>Material List</strong> — the bill of materials; import from CSV/Excel or add lines manually.</li>
|
||||
<li><strong>Drawings & Attachments</strong> — documents and SOP source-folder links.</li>
|
||||
<li><strong>Kitting & Material Movement (MIMO)</strong> — kitting status, warehouse owner, move date/location.</li>
|
||||
<li><strong>Constraints</strong> — the release gate (see <a data-help-jump="constraints">Constraints</a>).</li>
|
||||
<li><strong>Quality / Hold Points</strong>, <strong>Approvals & Sign-offs</strong>, and <strong>Closeout</strong> (actual hours, as-builts, lessons learned — shown at QC/Closed).</li>
|
||||
</ul>
|
||||
<h4>Saving</h4>
|
||||
<p><strong>Save Draft</strong> stores the package; <strong>⚡ Save & View</strong> saves and renders the print-ready output. Drafts auto-save to your browser as you type, so nothing is lost if you close the tab.</p>` },
|
||||
|
||||
{ id: 'statuses', title: 'Statuses', body: `
|
||||
<h3>Work Package statuses</h3>
|
||||
<table>
|
||||
<tr><th>Status</th><th>Meaning</th></tr>
|
||||
<tr><td><span class="ui-help-pill pill-draft">Draft</span></td><td>Work in progress; not yet released.</td></tr>
|
||||
<tr><td><span class="ui-help-pill pill-sched">Scheduled</span></td><td>Planned and scheduled; upcoming.</td></tr>
|
||||
<tr><td><span class="ui-help-pill pill-issued">Issued</span></td><td>Released to the field. Requires <em>all constraints Cleared or N/A</em>.</td></tr>
|
||||
<tr><td><span class="ui-help-pill pill-prog">In Progress</span></td><td>Actively being worked.</td></tr>
|
||||
<tr><td><span class="ui-help-pill pill-qc">QC</span></td><td>In quality check / inspection.</td></tr>
|
||||
<tr><td><span class="ui-help-pill pill-closed">Closed</span></td><td>Completed.</td></tr>
|
||||
<tr><td><span class="ui-help-pill pill-hold">Issue (Hold)</span></td><td>A constraint reopened after release — work is paused until it's resolved.</td></tr>
|
||||
</table>
|
||||
<div class="ui-help-callout">A package <strong>cannot move to Issued</strong> while any constraint is Open. If a constraint reopens after a package is Issued, its status automatically drops to <strong>Issue (Hold)</strong> and the suite makes you log what happened.</div>
|
||||
<p>On a multi-discipline package, each discipline carries its own status and the overall status <strong>rolls up to the least-advanced discipline</strong> — so a package is never "Closed" while one trade still lags.</p>` },
|
||||
|
||||
{ id: 'constraints', title: 'Constraints & release', body: `
|
||||
<h3>Constraints & release readiness</h3>
|
||||
<p>Constraints are the readiness checklist that gates a package's release to the field. They follow Advanced Work Packaging (AWP Vol II §2.3.2). The standard set:</p>
|
||||
<ol>
|
||||
<li>Safety & Permitting</li><li>Quality Control / Inspection</li><li>IFC Drawings & Specs</li>
|
||||
<li>Schedule</li><li>Materials (on site, bagged & tagged)</li><li>Prefabrication</li>
|
||||
<li>Work Access & Laydown</li><li>Craft Availability</li><li>Construction Equipment & Tools</li>
|
||||
<li>Scaffolding / Access Equipment</li>
|
||||
</ol>
|
||||
<p>Pick which apply (and add custom ones) in <strong>SOP Step 9</strong>. Each constraint on a package has one of three states:</p>
|
||||
<table>
|
||||
<tr><th>State</th><th>Effect</th></tr>
|
||||
<tr><td><strong>Open</strong></td><td>Not yet cleared — <em>blocks release</em>.</td></tr>
|
||||
<tr><td><strong>Cleared</strong></td><td>Requirement met — counts toward release-ready.</td></tr>
|
||||
<tr><td><strong>N/A</strong></td><td>Not applicable to this package — counts as cleared.</td></tr>
|
||||
</table>
|
||||
<h4>The release gate</h4>
|
||||
<ul>
|
||||
<li>A package is <strong>release-ready</strong> when every constraint is Cleared or N/A. The sticky banner shows green when ready, amber when constraints are still open, and red when on hold.</li>
|
||||
<li>When the last open constraint clears, the suite offers to mark the package <strong>Issued</strong>.</li>
|
||||
<li>If a constraint reopens after the package is Issued, you log the hold (what reopened, details, optional doc link & photo) and the status drops to <strong>Issue (Hold)</strong>.</li>
|
||||
</ul>` },
|
||||
|
||||
{ id: 'disciplines', title: 'Disciplines & Split', body: `
|
||||
<h3>Disciplines & Split by Discipline</h3>
|
||||
<p>Disciplines are trades (Mechanical, Electrical, Tech, …) set in <strong>SOP Step 5</strong>. The <strong>discipline strategy</strong> controls how packages handle them:</p>
|
||||
<ul>
|
||||
<li><strong>Let the planner choose per package</strong> (recommended) — pick one discipline (flat scope) or several (per-discipline scope + the <em>Split</em> option).</li>
|
||||
<li><strong>One discipline per package</strong> — each WP is single-discipline.</li>
|
||||
<li><strong>Multiple disciplines per package</strong> — scope is always split by discipline.</li>
|
||||
</ul>
|
||||
<h4>Split by Discipline</h4>
|
||||
<p>When a package covers 2+ disciplines, the <strong>⎘ Split by Discipline</strong> button breaks it into one numbered instance per discipline — <code>WP01A</code>, <code>WP01B</code>, <code>WP01C</code> (or <code>_MECH</code>/<code>_ELEC</code> suffixes, set in the SOP). The original is kept as a <strong>master / roll-up</strong>; each instance:</p>
|
||||
<ul>
|
||||
<li>becomes its own single-discipline package, issued independently;</li>
|
||||
<li>receives only the <strong>scope steps</strong> and <strong>materials tagged to that discipline</strong>;</li>
|
||||
<li>stays linked back to the master.</li>
|
||||
</ul>
|
||||
<div class="ui-help-callout">Tag material rows to a discipline <em>before</em> splitting. <strong>Untagged rows stay on the master only</strong> and won't be routed to any instance. Masters are excluded from dashboard counts so hours aren't double-counted.</div>` },
|
||||
|
||||
{ id: 'sizing', title: 'Sizing', body: `
|
||||
<h3>Work Package sizing</h3>
|
||||
<p>In <strong>SOP Step 5</strong> you set a typical WP <strong>size band</strong>, which sets a <strong>split threshold</strong> (max labor hours):</p>
|
||||
<table>
|
||||
<tr><th>Size band</th><th>Split threshold</th></tr>
|
||||
<tr><td>Small — 1–2 days (≈8–24 hrs)</td><td>24 hrs</td></tr>
|
||||
<tr><td>Standard — 3–5 days (≈40–80 hrs)</td><td>80 hrs</td></tr>
|
||||
<tr><td>Large — 1–2 weeks (≈80–160 hrs)</td><td>160 hrs</td></tr>
|
||||
<tr><td>Custom…</td><td>you set it</td></tr>
|
||||
</table>
|
||||
<p>In the Creator, the <strong>Est. Hrs.</strong> field is checked live against the threshold. Within range you see the target band; over it you get an amber warning — <em>"⚠ … exceeds the …-hr split threshold — consider breaking this package down"</em> — and a nudge to split by discipline where that applies. It's a guide, not a hard block: you can proceed if it's intentional.</p>` },
|
||||
|
||||
{ id: 'dashboard', title: 'Dashboard', body: `
|
||||
<h3>Dashboard & metrics</h3>
|
||||
<p>The dashboard aggregates every (non-master) package in the active project. Open it from the home page, the suite's <strong>📊 Dashboard</strong> tab, or the Creator header.</p>
|
||||
<h4>Metric cards (click to filter)</h4>
|
||||
<ul>
|
||||
<li><strong>Total WPs</strong>, <strong>Release-ready</strong>, <strong>On hold</strong>, <strong>Overdue</strong></li>
|
||||
<li><strong>Est. hrs</strong> and <strong>Actual hrs</strong> (summed)</li>
|
||||
</ul>
|
||||
<h4>Breakdowns & gates</h4>
|
||||
<ul>
|
||||
<li><strong>By status</strong> and <strong>by discipline</strong> chips.</li>
|
||||
<li><strong>⛔ Gating constraints</strong> — lists every blocked package and exactly which constraints are holding it.</li>
|
||||
</ul>
|
||||
<h4>The table</h4>
|
||||
<p>Shows WP #, subject, type, discipline, status, <strong>Gates</strong> (<em>clear</em>, <em>n open</em>, or <em>master</em>), due date (red if overdue), and hours. Row actions: <strong>issue</strong> (when release-ready), <strong>view</strong>, and <strong>edit</strong>. Filter with the search box and the status / discipline dropdowns.</p>
|
||||
<div class="ui-help-callout">Split <strong>masters</strong> are labelled and excluded from the counts; you issue their instances one at a time as each becomes release-ready.</div>` },
|
||||
|
||||
{ id: 'data', title: 'Samples, sharing & comments', body: `
|
||||
<h3>Samples, import / export & comments</h3>
|
||||
<h4>Load Sample</h4>
|
||||
<p><strong>⭐ Load Sample</strong> is context-aware: on the SOP tab it loads a complete sample SOP; on the WP tab it loads an example Work Package. Great for learning the tool or demoing.</p>
|
||||
<h4>Import / Export</h4>
|
||||
<ul>
|
||||
<li><strong>Work Packages</strong> — <em>⤓ Export (JSON)</em> downloads all saved packages; import restores them.</li>
|
||||
<li><strong>SOP</strong> — the Creator can import a SOP <code>.json</code> (via <em>⤒ Import SOP</em>) or load the sample SOP.</li>
|
||||
<li><strong>Materials</strong> — import a bill of materials from Excel/CSV, or download a template.</li>
|
||||
</ul>
|
||||
<h4>Comments & feedback</h4>
|
||||
<p>Leave feedback from the home page, per-step comments in the SOP tool (<strong>💬 Step Comments</strong>), or package comments in the Creator's <strong>💬 Comments</strong> drawer. Comments are saved and can be exported/imported as <code>.json</code> so reviewers can share them — and, when the API is reachable, they're collected centrally too.</p>
|
||||
<h4>Usage logs</h4>
|
||||
<p><strong>📊 Usage Logs</strong> / <strong>▤ Usage Data</strong> shows session and event counts and can export the full log. A <strong>dev-mode</strong> toggle pauses tracking during demos.</p>` },
|
||||
|
||||
{ id: 'shortcuts', title: 'Tips & shortcuts', body: `
|
||||
<h3>Tips & keyboard shortcuts</h3>
|
||||
<ul>
|
||||
<li><strong>Enter</strong> in a sequence, constraint, or material input adds/saves that row.</li>
|
||||
<li><strong>Esc</strong> closes any modal — this help center, comments, the constraint library, and the hold-log dialog.</li>
|
||||
<li>Hover any <span class="help-tip" data-tip="Like this one — hover any ⓘ for a hint.">i</span> icon for an inline hint.</li>
|
||||
<li>Data is kept <strong>per project</strong> — switch projects from the home page.</li>
|
||||
<li>Your work <strong>auto-saves</strong> to the browser as you type; <em>Save & View</em> produces the print-ready output.</li>
|
||||
<li>Click a metric card or status chip on the <strong>Dashboard</strong> to filter the table.</li>
|
||||
</ul>` },
|
||||
|
||||
{ id: 'glossary', title: 'Glossary', body: `
|
||||
<h3>Glossary</h3>
|
||||
<table>
|
||||
<tr><th>Term</th><th>Meaning</th></tr>
|
||||
<tr><td><strong>IWP</strong></td><td>Installation Work Package — the field-level package this tool produces.</td></tr>
|
||||
<tr><td><strong>AWP</strong></td><td>Advanced Work Packaging — the methodology behind the constraint set and release gate.</td></tr>
|
||||
<tr><td><strong>SOP</strong></td><td>Standard Operating Procedure — the project baseline every WP inherits.</td></tr>
|
||||
<tr><td><strong>Constraint</strong></td><td>A readiness item (Open / Cleared / N/A) that gates release.</td></tr>
|
||||
<tr><td><strong>Release-ready</strong></td><td>All constraints Cleared or N/A — the package can be Issued.</td></tr>
|
||||
<tr><td><strong>Issued</strong></td><td>Released to the field.</td></tr>
|
||||
<tr><td><strong>Issue (Hold)</strong></td><td>A released package paused because a constraint reopened.</td></tr>
|
||||
<tr><td><strong>Discipline</strong></td><td>A trade (Mechanical, Electrical, Tech, …).</td></tr>
|
||||
<tr><td><strong>Split / Master / Instance</strong></td><td>Breaking a multi-discipline package (master/roll-up) into single-discipline instances (WP01A/B/C).</td></tr>
|
||||
<tr><td><strong>Scope</strong></td><td>The sequenced steps the crew performs.</td></tr>
|
||||
<tr><td><strong>Sequence</strong></td><td>SOP-defined construction phases; a WP can name a predecessor step.</td></tr>
|
||||
<tr><td><strong>Bagged & tagged</strong></td><td>Materials on site, kitted, and labelled — part of the Materials constraint.</td></tr>
|
||||
<tr><td><strong>MIMO</strong></td><td>Material In / Material Out — kitting and staging logistics.</td></tr>
|
||||
<tr><td><strong>Asset</strong></td><td>A controls.dev record (equipment/system) a package is built around.</td></tr>
|
||||
<tr><td><strong>Hold / Witness point</strong></td><td>Hold = work stops until inspection sign-off; Witness = inspection offered but work may proceed.</td></tr>
|
||||
<tr><td><strong>Active project</strong></td><td>The currently selected project; all data is scoped to it.</td></tr>
|
||||
</table>` },
|
||||
|
||||
{ id: 'faq', title: 'FAQ', body: `
|
||||
<h3>Frequently asked questions</h3>
|
||||
<h4>The Work Package Creator is locked — why?</h4>
|
||||
<p>The SOP for the active project isn't complete yet. Finish SOP Configuration and click <strong>✓ SOP Complete</strong> on the last step; the Creator unlocks and the home card turns green.</p>
|
||||
<h4>Why can't I set a package to Issued?</h4>
|
||||
<p>At least one constraint is still <strong>Open</strong>. Clear or mark N/A every constraint — the release banner turns green — and the suite will offer to issue it.</p>
|
||||
<h4>My package's materials didn't all carry over when I split it.</h4>
|
||||
<p>Only material rows <strong>tagged to a discipline</strong> are routed to that instance. Untagged rows stay on the master. Tag them before splitting.</p>
|
||||
<h4>Why don't split masters show in the dashboard totals?</h4>
|
||||
<p>Masters are roll-ups; counting them would double-count their hours and packages. The individual instances are counted instead.</p>
|
||||
<h4>Will I lose my work if I close the browser?</h4>
|
||||
<p>No — drafts auto-save locally per project and reload next time. Use <em>Export (JSON)</em> for a backup or to share with a teammate.</p>
|
||||
<h4>Does each project keep its own data?</h4>
|
||||
<p>Yes. SOP and Work Packages are scoped to the active project; switching projects loads that project's own set.</p>
|
||||
<h4>How do I report a problem or suggestion?</h4>
|
||||
<p>Use the feedback / comments features (home page, SOP <em>Step Comments</em>, or the Creator's <em>Comments</em> drawer).</p>` }
|
||||
];
|
||||
|
||||
// ── build ─────────────────────────────────────────────────────────────────
|
||||
function buildModal() {
|
||||
if (document.getElementById('ui-help-overlay')) return;
|
||||
|
||||
var overlay = document.createElement('div');
|
||||
overlay.className = 'ui-help-overlay';
|
||||
overlay.id = 'ui-help-overlay';
|
||||
|
||||
var nav = TOPICS.map(function (t) {
|
||||
return '<a data-help-target="' + t.id + '">' + t.title + '</a>';
|
||||
}).join('');
|
||||
|
||||
var sections = TOPICS.map(function (t) {
|
||||
return '<section class="ui-help-sec" id="ui-help-sec-' + t.id + '">' + t.body + '</section>';
|
||||
}).join('');
|
||||
|
||||
overlay.innerHTML =
|
||||
'<div class="ui-help-modal" role="dialog" aria-modal="true" aria-label="Help center">' +
|
||||
'<div class="ui-help-head">' +
|
||||
'<span class="ui-help-title">Help — Work Package Suite</span>' +
|
||||
'<span class="ui-help-search"><input id="ui-help-q" type="search" placeholder="Search help…" aria-label="Search help"></span>' +
|
||||
'<button type="button" class="ui-help-x" onclick="closeHelp()" aria-label="Close help">✕</button>' +
|
||||
'</div>' +
|
||||
'<div class="ui-help-wrap">' +
|
||||
'<nav class="ui-help-nav" id="ui-help-nav">' + nav + '</nav>' +
|
||||
'<div class="ui-help-content" id="ui-help-content">' +
|
||||
'<p class="ui-help-noresult" id="ui-help-noresult">No matches. Try another word.</p>' +
|
||||
sections +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
overlay.addEventListener('click', function (e) { if (e.target === overlay) closeHelp(); });
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// nav clicks + in-content cross-links jump to a section
|
||||
overlay.addEventListener('click', function (e) {
|
||||
var t = e.target.closest('[data-help-target],[data-help-jump]');
|
||||
if (!t) return;
|
||||
e.preventDefault();
|
||||
jumpTo(t.getAttribute('data-help-target') || t.getAttribute('data-help-jump'));
|
||||
});
|
||||
|
||||
// search
|
||||
var q = overlay.querySelector('#ui-help-q');
|
||||
q.addEventListener('input', function () { runSearch(q.value); });
|
||||
|
||||
// highlight nav as you scroll
|
||||
var content = overlay.querySelector('#ui-help-content');
|
||||
content.addEventListener('scroll', syncActiveNav, { passive: true });
|
||||
}
|
||||
|
||||
function jumpTo(id) {
|
||||
var sec = document.getElementById('ui-help-sec-' + id);
|
||||
if (!sec) return;
|
||||
// Clear any active search filter so the target is visible.
|
||||
var q = document.getElementById('ui-help-q');
|
||||
if (q && q.value) { q.value = ''; runSearch(''); }
|
||||
sec.scrollIntoView({ block: 'start' });
|
||||
setActiveNav(id);
|
||||
}
|
||||
|
||||
function setActiveNav(id) {
|
||||
var nav = document.getElementById('ui-help-nav');
|
||||
if (!nav) return;
|
||||
nav.querySelectorAll('a').forEach(function (a) {
|
||||
a.classList.toggle('active', a.getAttribute('data-help-target') === id);
|
||||
});
|
||||
}
|
||||
|
||||
function syncActiveNav() {
|
||||
var content = document.getElementById('ui-help-content');
|
||||
if (!content) return;
|
||||
var top = content.scrollTop, best = null, bestDist = Infinity;
|
||||
TOPICS.forEach(function (t) {
|
||||
var sec = document.getElementById('ui-help-sec-' + t.id);
|
||||
if (!sec || sec.classList.contains('hide')) return;
|
||||
var d = Math.abs(sec.offsetTop - top);
|
||||
if (sec.offsetTop - top <= 40 && d < bestDist) { bestDist = d; best = t.id; }
|
||||
});
|
||||
if (best) setActiveNav(best);
|
||||
}
|
||||
|
||||
// ── search: filter sections + highlight matches ───────────────────────────
|
||||
function clearMarks(root) {
|
||||
root.querySelectorAll('mark').forEach(function (m) {
|
||||
var txt = document.createTextNode(m.textContent);
|
||||
m.parentNode.replaceChild(txt, m);
|
||||
});
|
||||
root.normalize();
|
||||
}
|
||||
|
||||
function markMatches(el, query) {
|
||||
var lower = query.toLowerCase();
|
||||
var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode: function (node) {
|
||||
if (!node.nodeValue.trim()) return NodeFilter.FILTER_REJECT;
|
||||
var p = node.parentNode.nodeName;
|
||||
if (p === 'MARK' || p === 'STYLE' || p === 'SCRIPT') return NodeFilter.FILTER_REJECT;
|
||||
return node.nodeValue.toLowerCase().indexOf(lower) >= 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
});
|
||||
var nodes = [], n;
|
||||
while ((n = walker.nextNode())) nodes.push(n);
|
||||
nodes.forEach(function (node) {
|
||||
var val = node.nodeValue, low = val.toLowerCase(), frag = document.createDocumentFragment(), i = 0, idx;
|
||||
while ((idx = low.indexOf(lower, i)) >= 0) {
|
||||
if (idx > i) frag.appendChild(document.createTextNode(val.slice(i, idx)));
|
||||
var mk = document.createElement('mark');
|
||||
mk.textContent = val.slice(idx, idx + query.length);
|
||||
frag.appendChild(mk);
|
||||
i = idx + query.length;
|
||||
}
|
||||
if (i < val.length) frag.appendChild(document.createTextNode(val.slice(i)));
|
||||
node.parentNode.replaceChild(frag, node);
|
||||
});
|
||||
}
|
||||
|
||||
function runSearch(query) {
|
||||
var content = document.getElementById('ui-help-content');
|
||||
var nav = document.getElementById('ui-help-nav');
|
||||
var noresult = document.getElementById('ui-help-noresult');
|
||||
if (!content) return;
|
||||
query = (query || '').trim();
|
||||
var hits = 0;
|
||||
|
||||
TOPICS.forEach(function (t) {
|
||||
var sec = document.getElementById('ui-help-sec-' + t.id);
|
||||
var navItem = nav.querySelector('[data-help-target="' + t.id + '"]');
|
||||
clearMarks(sec);
|
||||
var match = !query || sec.textContent.toLowerCase().indexOf(query.toLowerCase()) >= 0;
|
||||
sec.classList.toggle('hide', !match);
|
||||
if (navItem) navItem.classList.toggle('nohit', !!query && !match);
|
||||
if (match) {
|
||||
hits++;
|
||||
if (query) markMatches(sec, query);
|
||||
}
|
||||
});
|
||||
|
||||
noresult.style.display = (query && hits === 0) ? 'block' : 'none';
|
||||
if (query) { content.scrollTop = 0; }
|
||||
else { syncActiveNav(); }
|
||||
}
|
||||
|
||||
// ── public API ──────────────────────────────────────────────────────────────
|
||||
global.openHelp = function (topicId) {
|
||||
buildModal();
|
||||
document.getElementById('ui-help-overlay').classList.add('open');
|
||||
var q = document.getElementById('ui-help-q');
|
||||
if (topicId && typeof topicId === 'string') jumpTo(topicId);
|
||||
else { setActiveNav(TOPICS[0].id); if (q) setTimeout(function () { q.focus(); }, 30); }
|
||||
};
|
||||
global.closeHelp = function () {
|
||||
var o = document.getElementById('ui-help-overlay');
|
||||
if (o) o.classList.remove('open');
|
||||
};
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') global.closeHelp();
|
||||
});
|
||||
|
||||
// ── floating launcher on pages without their own Help button ────────────────
|
||||
function maybeAddFab() {
|
||||
var inIframe = (function () { try { return window.top !== window.self; } catch (e) { return true; } })();
|
||||
if (inIframe || global.WP_HELP_NO_FAB) return; // suite shows the parent's button
|
||||
if (document.querySelector('[onclick*="openHelp"]')) return; // page already has a Help trigger
|
||||
if (document.getElementById('ui-help-fab')) return;
|
||||
var b = document.createElement('button');
|
||||
b.id = 'ui-help-fab'; b.className = 'ui-help-fab'; b.type = 'button';
|
||||
b.title = 'Help'; b.setAttribute('aria-label', 'Open help'); b.textContent = '?';
|
||||
b.addEventListener('click', function () { global.openHelp(); });
|
||||
document.body.appendChild(b);
|
||||
}
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', maybeAddFab);
|
||||
else maybeAddFab();
|
||||
})(window);
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Work Package Suite — Prime Controls</title>
|
||||
<script src="auth-guard.js"></script>
|
||||
<link rel="icon" href="favicon.ico" sizes="any">
|
||||
<link rel="stylesheet" href="theme-light.css">
|
||||
<style>
|
||||
@@ -386,6 +387,7 @@
|
||||
<nav class="header-nav">
|
||||
<a href="#overview">Overview</a>
|
||||
<a href="#comments">Feedback</a>
|
||||
<a href="#" onclick="openHelp();return false;">Help</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
@@ -470,11 +472,12 @@
|
||||
|
||||
<!-- FOOTER -->
|
||||
<footer class="footer">
|
||||
<p>Work Package Suite v1.0 | Prime Controls | All files work offline with local browser storage</p>
|
||||
<p>Work Package Suite v1.0 | Prime Controls - Business Technology Group | Pilot Use Only</p>
|
||||
</footer>
|
||||
|
||||
<script src="feedback-config.js"></script>
|
||||
<script src="project-data.js"></script>
|
||||
<script src="help.js"></script>
|
||||
<script>
|
||||
// ── PROJECT SELECTION ─────────────────────────────────────────────────────
|
||||
const esc = ProjectData.esc;
|
||||
@@ -592,7 +595,10 @@
|
||||
if(info) info.innerHTML = `<div class="proj-active">✓ Active project: <strong>${esc(active.name||'')}</strong>${active.number?' ('+esc(active.number)+')':''}
|
||||
<button class="link-like" onclick="clearActiveProject()">change</button></div>`;
|
||||
|
||||
reflectSOPStatus(active);
|
||||
// Pull the project's shared SOP from the server into the local cache first,
|
||||
// so the SOP "Complete / Review" status reflects what other users have done.
|
||||
if(ProjectData.pullProject){ ProjectData.pullProject(active.id).then(()=>reflectSOPStatus(active)).catch(()=>reflectSOPStatus(active)); }
|
||||
else reflectSOPStatus(active);
|
||||
}
|
||||
|
||||
function clearActiveProject(){ ProjectData.setActive(null); renderProjectPicker(); applyActiveProject(); }
|
||||
@@ -662,9 +668,7 @@
|
||||
if (window.postFeedback) window.postFeedback({ type: 'home_feedback', ...comment });
|
||||
|
||||
document.getElementById('comment-text').value = '';
|
||||
document.getElementById('commenter-name').value = '';
|
||||
loadComments();
|
||||
alert('Thank you! Feedback submitted.');
|
||||
}
|
||||
|
||||
function exportFeedback() {
|
||||
@@ -703,22 +707,36 @@
|
||||
r.readAsText(f);
|
||||
}
|
||||
|
||||
function loadComments() {
|
||||
const saved = localStorage.getItem('wp_suite_index_comments');
|
||||
if (saved) allComments = JSON.parse(saved);
|
||||
|
||||
function renderComments() {
|
||||
const list = document.getElementById('comments-list');
|
||||
if (allComments.length === 0) {
|
||||
list.innerHTML = '<div style="color: var(--cds-text-secondary); font-style: italic; font-size: 12px;">No feedback yet. Be the first to share!</div>';
|
||||
} else {
|
||||
list.innerHTML = allComments.map(c => `
|
||||
<div class="comment-item">
|
||||
<div class="comment-meta"><strong>${c.name}</strong> • ${c.timestamp}</div>
|
||||
<div class="comment-text">${c.text.replace(/</g,'<').replace(/>/g,'>')}</div>
|
||||
<div class="comment-meta"><strong>${(c.name||'Anonymous').replace(/</g,'<')}</strong> • ${c.timestamp||''}</div>
|
||||
<div class="comment-text">${(c.text||'').replace(/</g,'<').replace(/>/g,'>')}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
function loadComments() {
|
||||
// Server is authoritative (so feedback is shared across users); fall back to
|
||||
// the local cache if the API is unreachable.
|
||||
const saved = localStorage.getItem('wp_suite_index_comments');
|
||||
if (saved) { try { allComments = JSON.parse(saved) || []; } catch(e) { allComments = []; } }
|
||||
renderComments();
|
||||
fetch('/api/comments?source=home_feedback', { headers: { 'Accept': 'application/json' } })
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(rows => {
|
||||
if (Array.isArray(rows)) {
|
||||
allComments = rows.map(c => ({ name: c.author, text: c.text, timestamp: c.created_at ? new Date(c.created_at).toLocaleString() : '' }));
|
||||
renderComments();
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
109
html/login.html
Normal file
109
html/login.html
Normal file
@@ -0,0 +1,109 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sign in — Work Package Suite</title>
|
||||
<link rel="icon" href="favicon.ico" sizes="any">
|
||||
<link rel="stylesheet" href="theme-light.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--cds-background);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: var(--cds-layer);
|
||||
border: 1px solid var(--cds-border-subtle);
|
||||
box-shadow: 0 2px 6px var(--cds-shadow);
|
||||
padding: 2.5rem 2rem;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.brand img { height: 36px; width: auto; }
|
||||
.brand .name { font-weight: 700; font-size: 0.95rem; color: var(--cds-text-primary); }
|
||||
h1 { font-size: 1.5rem; margin-bottom: 0.25rem; }
|
||||
.sub { color: var(--cds-text-secondary); font-size: 0.875rem; margin-bottom: 1.75rem; }
|
||||
label { display: block; font-size: 0.75rem; color: var(--cds-text-secondary); margin-bottom: 0.375rem; }
|
||||
.field { margin-bottom: 1.25rem; }
|
||||
input[type=text], input[type=password] {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
font-size: 1rem;
|
||||
background: var(--cds-field);
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--cds-border-strong);
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
input:focus { outline: 2px solid var(--cds-focus); background: var(--cds-field-hover); }
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 0.875rem 1rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--cds-text-on-color);
|
||||
background: var(--cds-button-primary);
|
||||
border: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
button:hover:not(:disabled) { background: var(--cds-hover-primary); }
|
||||
button:disabled { background: var(--cds-disabled-02); cursor: not-allowed; }
|
||||
.error {
|
||||
display: none;
|
||||
background: #fff1f1;
|
||||
border-left: 3px solid var(--cds-support-error);
|
||||
color: var(--cds-text-error);
|
||||
padding: 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.error.show { display: block; }
|
||||
.foot { margin-top: 1.5rem; font-size: 0.75rem; color: var(--cds-text-helper); text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="card">
|
||||
<div class="brand">
|
||||
<img src="prime-controls-logo.jpg" alt="Prime Controls" onerror="this.style.display='none'">
|
||||
</div>
|
||||
<h1>Sign in</h1>
|
||||
<p class="sub">Work Package Suite</p>
|
||||
|
||||
<div id="error" class="error" role="alert"></div>
|
||||
|
||||
<form id="login-form" autocomplete="on">
|
||||
<div class="field">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" name="username" type="text" autocomplete="username" autofocus required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||
</div>
|
||||
<button id="submit" type="submit">Sign in</button>
|
||||
</form>
|
||||
|
||||
<p style="margin-top:1.25rem; text-align:center; font-size:0.8125rem;">
|
||||
<a href="#" id="forgot-link" style="color:var(--cds-link-primary); text-decoration:none;">Forgot password?</a>
|
||||
</p>
|
||||
<div id="forgot-msg" style="display:none; margin-top:0.5rem; font-size:0.8125rem; color:var(--cds-text-secondary); background:var(--cds-layer-accent); border-left:3px solid var(--cds-link-primary); padding:0.75rem; border-radius:0 6px 6px 0;">
|
||||
Password resets are handled by an administrator. Contact your project admin and they'll set a new one for you. Once you're signed in, you can change it yourself anytime from the menu in the top-right corner.
|
||||
</div>
|
||||
|
||||
<p class="foot">Authorized use only · BTG / Pilot</p>
|
||||
</main>
|
||||
|
||||
<script src="login.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
68
html/login.js
Normal file
68
html/login.js
Normal file
@@ -0,0 +1,68 @@
|
||||
/* Login page logic for the Work Package Suite.
|
||||
Posts credentials to /api/auth/login. On success the server sets an HttpOnly
|
||||
session cookie (not readable here — that's the point) and we redirect to the
|
||||
page the user was trying to reach, or the home page. */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var form = document.getElementById('login-form');
|
||||
var errorBox = document.getElementById('error');
|
||||
var submitBtn = document.getElementById('submit');
|
||||
|
||||
// Where to go after signing in: the ?next= param if it's a safe same-site
|
||||
// path, otherwise the home page. (Reject absolute/scheme URLs to avoid an
|
||||
// open-redirect.)
|
||||
function nextTarget() {
|
||||
try {
|
||||
var next = new URLSearchParams(location.search).get('next') || '';
|
||||
if (next && next.charAt(0) === '/' && next.charAt(1) !== '/') return next;
|
||||
} catch (e) {}
|
||||
return 'index.html';
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
errorBox.textContent = msg;
|
||||
errorBox.classList.add('show');
|
||||
}
|
||||
|
||||
var forgot = document.getElementById('forgot-link');
|
||||
if (forgot) {
|
||||
forgot.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
var m = document.getElementById('forgot-msg');
|
||||
if (m) m.style.display = 'block';
|
||||
});
|
||||
}
|
||||
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
errorBox.classList.remove('show');
|
||||
var username = document.getElementById('username').value.trim();
|
||||
var password = document.getElementById('password').value;
|
||||
if (!username || !password) { showError('Enter your username and password.'); return; }
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Signing in…';
|
||||
|
||||
fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: username, password: password })
|
||||
})
|
||||
.then(function (r) {
|
||||
if (r.ok) { location.replace(nextTarget()); return null; }
|
||||
return r.json().catch(function () { return null; }).then(function (j) {
|
||||
if (r.status === 401) showError('Invalid username or password.');
|
||||
else if (r.status === 403) showError((j && j.detail) || 'Your account is disabled.');
|
||||
else showError((j && j.detail) || ('Sign-in failed (HTTP ' + r.status + ').'));
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Sign in';
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
showError('Could not reach the server. Check your connection and try again.');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Sign in';
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -25,7 +25,7 @@
|
||||
function cacheRemove(id) { writeLocal(readLocal().filter(function (x) { return x.id !== id; })); }
|
||||
|
||||
var SAMPLE_PROJECT = {
|
||||
name: 'Micron — INC Construction Work Packages', number: '26-67-008',
|
||||
name: 'Micron FMCS Install (sample)', number: '26-67-008',
|
||||
client: 'Micron Technology, Inc.', division: 'Semiconductor',
|
||||
site: 'Boise, ID — Fab', sample: true
|
||||
};
|
||||
@@ -81,6 +81,107 @@
|
||||
key: function (base) { var id = this.getActiveId(); return id ? base + '__' + id : base; }
|
||||
};
|
||||
|
||||
// ── Server sync for SOPs and Work Packages ─────────────────────────────────
|
||||
// SOPs and WPs are authoritative on the server (so every user of a project sees
|
||||
// the same data). To avoid rewriting the two apps, we keep their existing
|
||||
// localStorage keys as a per-browser CACHE: pullProject() hydrates those exact
|
||||
// keys from the API on page load, and the push* helpers write through to the
|
||||
// API whenever the apps save. The apps' own (synchronous) reads are unchanged.
|
||||
function nsKey(base, id) { return id ? base + '__' + id : base; }
|
||||
function currentUser() {
|
||||
try { return (window.WP_USER && (window.WP_USER.username || window.WP_USER.full_name)) || ''; } catch (e) { return ''; }
|
||||
}
|
||||
|
||||
// A saved Work Package is a flat object in the browser; the API splits it into
|
||||
// promoted columns + a `data` blob. We store the whole flat object in `data`
|
||||
// for perfect round-tripping, and mirror the few fields the API promotes.
|
||||
function pkgToServer(p, projectId) {
|
||||
return {
|
||||
id: p.id,
|
||||
project_id: p.projectId || projectId || null,
|
||||
parent_id: p.instanceOf || null,
|
||||
number: p.number || '',
|
||||
subject: p.subject || '',
|
||||
type: p.type || '',
|
||||
status: p.status || 'Draft',
|
||||
created_by: p.createdBy || currentUser(),
|
||||
data: p
|
||||
};
|
||||
}
|
||||
function serverToPkg(row) {
|
||||
var p = Object.assign({}, row.data || {}); // full flat object lives in data
|
||||
p.id = row.id;
|
||||
p.projectId = row.project_id || p.projectId || '';
|
||||
if (row.number) p.number = row.number;
|
||||
if (row.subject != null) p.subject = row.subject;
|
||||
if (row.type != null) p.type = row.type;
|
||||
if (row.status) p.status = row.status; // honor server-side status changes
|
||||
if (row.parent_id) p.instanceOf = row.parent_id;
|
||||
return p;
|
||||
}
|
||||
|
||||
// Pull this project's SOP + WPs from the API into the localStorage keys the
|
||||
// apps read. Resolves even on failure (offline / no API) so boot continues.
|
||||
ProjectData.pullProject = function (projectId) {
|
||||
if (!projectId) return Promise.resolve();
|
||||
var jobs = [];
|
||||
jobs.push(
|
||||
fetch(API + '/sops/latest?complete=true&project_id=' + encodeURIComponent(projectId), { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (sopRow) {
|
||||
if (sopRow && sopRow.data) {
|
||||
var d = sopRow.data; // { sop, state } as written by pushSOP
|
||||
if (d.sop) localStorage.setItem(nsKey('wp_suite_sop', projectId), JSON.stringify(d.sop));
|
||||
if (d.state) localStorage.setItem(nsKey('wp_suite_state', projectId), JSON.stringify(d.state));
|
||||
localStorage.setItem(nsKey('wp_suite_sop_complete', projectId), '1');
|
||||
}
|
||||
}).catch(function () {})
|
||||
);
|
||||
jobs.push(
|
||||
fetch(API + '/wps?full=true&project_id=' + encodeURIComponent(projectId), { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (rows) {
|
||||
if (Array.isArray(rows)) {
|
||||
localStorage.setItem(nsKey('wp_iwp_v1', projectId), JSON.stringify(rows.map(serverToPkg)));
|
||||
}
|
||||
}).catch(function () {})
|
||||
);
|
||||
return Promise.all(jobs).then(function () {});
|
||||
};
|
||||
|
||||
// Write a completed SOP (plus the builder's raw state) to the API. Uses a
|
||||
// deterministic id per project so re-completing updates the same row.
|
||||
ProjectData.pushSOP = function (projectId, sop, state) {
|
||||
if (!projectId) return Promise.resolve(null);
|
||||
var body = {
|
||||
id: 'sop__' + projectId,
|
||||
project_id: projectId,
|
||||
name: (sop && sop.project && sop.project.name) || 'SOP',
|
||||
number: (sop && sop.project && sop.project.number) || '',
|
||||
complete: true,
|
||||
created_by: currentUser(),
|
||||
data: { sop: sop, state: state }
|
||||
};
|
||||
return fetch(API + '/sops', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
|
||||
}).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
|
||||
};
|
||||
|
||||
// Upsert a single Work Package to the API (fire-and-forget from the caller's
|
||||
// perspective; the local cache is the source of truth for immediate rendering).
|
||||
ProjectData.pushWP = function (p, projectId) {
|
||||
if (!p || !p.id) return Promise.resolve(null);
|
||||
return fetch(API + '/wps', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(pkgToServer(p, projectId))
|
||||
}).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
|
||||
};
|
||||
|
||||
ProjectData.removeWP = function (id) {
|
||||
if (!id) return Promise.resolve();
|
||||
return fetch(API + '/wps/' + encodeURIComponent(id), { method: 'DELETE' })
|
||||
.then(function () {}).catch(function () {});
|
||||
};
|
||||
|
||||
// One-time discard of pre-multi-project (un-namespaced) SOP/WP data so stale
|
||||
// global state can't leak across projects. (User chose: discard, don't migrate.)
|
||||
try {
|
||||
|
||||
@@ -148,17 +148,33 @@ window.addEventListener('DOMContentLoaded',()=>{
|
||||
// Resolve the active project FIRST so per-project storage keys are correct
|
||||
// before we restore this project's SOP.
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const projId = params.get('project') || (typeof ProjectData!=='undefined' && ProjectData.getActiveId && ProjectData.getActiveId()) || '';
|
||||
applyProjectContext(params.get('project'));
|
||||
restoreSavedSOP();
|
||||
updateStepUI();
|
||||
updateProjectDisplay();
|
||||
|
||||
// Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard from the home page.
|
||||
const tab = params.get('tab');
|
||||
if(params.get('view') === 'dashboard') switchTool('dashboard');
|
||||
else if(tab === 'wp' || tab === 'sop') switchTool(tab);
|
||||
// Pull the project's shared SOP from the server into the local cache, THEN
|
||||
// restore it. Falls back to the local cache if offline.
|
||||
function afterPull(){
|
||||
restoreSavedSOP();
|
||||
updateStepUI();
|
||||
updateProjectDisplay();
|
||||
// Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard from the home page.
|
||||
const tab = params.get('tab');
|
||||
if(params.get('view') === 'dashboard') switchTool('dashboard');
|
||||
else if(tab === 'wp' || tab === 'sop') switchTool(tab);
|
||||
}
|
||||
if(projId && typeof ProjectData!=='undefined' && ProjectData.pullProject){
|
||||
ProjectData.pullProject(projId).then(afterPull).catch(afterPull);
|
||||
} else {
|
||||
afterPull();
|
||||
}
|
||||
|
||||
track('app_open');
|
||||
|
||||
// Feedback author auto-populates from the signed-in user (auth-guard sets
|
||||
// window.WP_USER and fires 'wp-auth-ready'); the field is read-only.
|
||||
setCommenterName();
|
||||
document.addEventListener('wp-auth-ready', setCommenterName);
|
||||
|
||||
let _fieldTimer;
|
||||
document.addEventListener('input', e=>{
|
||||
const t = e.target;
|
||||
@@ -477,18 +493,45 @@ function removeRole(i){
|
||||
renderOptionalRoles();
|
||||
}
|
||||
|
||||
// Seed the standard 10 once; after that, render reflects state.constraints
|
||||
// (checkbox = whether each standard one is active) and never clobbers customs.
|
||||
let _constraintsSeeded = false;
|
||||
function renderStandardConstraints(){
|
||||
const container = document.getElementById('standard-constraints');
|
||||
if(!_constraintsSeeded){
|
||||
if(!state.constraints || !state.constraints.length){
|
||||
state.constraints = STANDARD_10_CONSTRAINTS.map(c=>({...c}));
|
||||
}
|
||||
_constraintsSeeded = true;
|
||||
}
|
||||
const active = name => state.constraints.some(c=>c.name===name);
|
||||
container.innerHTML = STANDARD_10_CONSTRAINTS.map(c=>`
|
||||
<div style="display:flex; align-items:start; gap:0.75rem; padding:0.75rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; margin-bottom:0.5rem;">
|
||||
<input type="checkbox" id="const_${c.name}" checked onchange="toggleConstraint('${c.name}')" style="width:18px; height:18px; cursor:pointer; margin-top:0.2rem;">
|
||||
<input type="checkbox" id="const_${c.name}" ${active(c.name)?'checked':''} onchange="toggleConstraint('${c.name}')" style="width:18px; height:18px; cursor:pointer; margin-top:0.2rem;">
|
||||
<div style="flex:1;">
|
||||
<label for="const_${c.name}" style="margin:0; font-weight:600; display:block; cursor:pointer;">${c.name}</label>
|
||||
<div style="font-size:12px; color:var(--text-dim); margin-top:0.25rem;">${c.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
state.constraints = STANDARD_10_CONSTRAINTS.map(c=>({...c}));
|
||||
renderCustomConstraints();
|
||||
}
|
||||
|
||||
// Render the custom (non-standard) constraints into their own list with remove buttons.
|
||||
function renderCustomConstraints(){
|
||||
const el = document.getElementById('custom-constraints-list'); if(!el) return;
|
||||
const stdNames = STANDARD_10_CONSTRAINTS.map(c=>c.name);
|
||||
const customs = state.constraints.filter(c=>!stdNames.includes(c.name));
|
||||
el.innerHTML = customs.length ? customs.map(c=>`
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:0.75rem; padding:0.6rem 0.75rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; margin-bottom:0.5rem;">
|
||||
<strong>${escAttr(c.name)}</strong>
|
||||
<button onclick="removeCustomConstraint('${c.name.replace(/'/g,"\\'")}')" title="Remove" style="background:var(--danger); color:#fff; border:none; border-radius:4px; width:28px; height:28px; cursor:pointer; font-weight:600;">✕</button>
|
||||
</div>`).join('') : `<div style="font-size:12px; color:var(--text-dim);">No custom constraints added yet.</div>`;
|
||||
}
|
||||
|
||||
function removeCustomConstraint(name){
|
||||
state.constraints = state.constraints.filter(c=>c.name!==name);
|
||||
renderCustomConstraints();
|
||||
}
|
||||
|
||||
function toggleConstraint(name){
|
||||
@@ -514,13 +557,24 @@ function closeConstraintModal(){
|
||||
}
|
||||
|
||||
function addCustomConstraint(name){
|
||||
if(!state.constraints.find(c=>c.name===name)){
|
||||
if(name && !state.constraints.find(c=>c.name===name)){
|
||||
state.constraints.push({name,description:''});
|
||||
}
|
||||
closeConstraintModal();
|
||||
renderStandardConstraints();
|
||||
}
|
||||
|
||||
// Free-text custom constraint from the modal's input.
|
||||
function addCustomConstraintText(){
|
||||
const inp = document.getElementById('custom-constraint-input');
|
||||
const name = (inp && inp.value || '').trim();
|
||||
if(!name){ if(inp) inp.focus(); return; }
|
||||
if(state.constraints.find(c=>c.name===name)){ alert('That constraint is already in the list.'); return; }
|
||||
state.constraints.push({name, description:''});
|
||||
if(inp) inp.value='';
|
||||
renderStandardConstraints();
|
||||
}
|
||||
|
||||
const DEFAULT_SEQUENCE = ['Layout','Conduit Install','Tray Install','Wire Pull','Device Install','Termination','QC Inspection','Commissioning'];
|
||||
|
||||
let seqDragIndex = null;
|
||||
@@ -600,20 +654,30 @@ const DEFAULT_SOURCES = [
|
||||
function escAttr(v){ return String(v==null?'':v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
function renderSources(){
|
||||
const container = document.getElementById('sources-list');
|
||||
if(!state.sources.length) state.sources = DEFAULT_SOURCES.map(s=>({label:s.label, system:'', notes:'', link:'', ph:s.ph}));
|
||||
container.innerHTML = state.sources.map((s,i)=>`
|
||||
<div style="display:grid; grid-template-columns:150px 150px 250px 150px 30px; gap:1rem; align-items:center; padding:1rem; background:var(--bg); border-radius:6px; margin-bottom:0.5rem; border:1px solid var(--border);">
|
||||
<input type="text" value="${escAttr(s.label)}" placeholder="Label" onchange="state.sources[${i}].label=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;">
|
||||
<input type="text" value="${escAttr(s.system)}" placeholder="${escAttr(s.ph||'System of record')}" onchange="state.sources[${i}].system=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;">
|
||||
<input type="text" value="${escAttr(s.link)}" placeholder="Paste SharePoint 'Copy Link' URL" onchange="state.sources[${i}].link=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;">
|
||||
<input type="text" value="${escAttr(s.notes)}" placeholder="Notes" onchange="state.sources[${i}].notes=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;">
|
||||
<button style="background:var(--danger); color:white; padding:0.25rem; width:30px; height:30px; border:none; border-radius:4px; cursor:pointer; font-weight:600;" onclick="state.sources.splice(${i},1); renderSources()">✕</button>
|
||||
</div>
|
||||
`).join('');
|
||||
if(!state.sources.length) state.sources = DEFAULT_SOURCES.map(s=>({label:s.label, system:'', notes:'', link:'', ph:s.ph, preset:true}));
|
||||
const grid = "display:grid; grid-template-columns:170px 170px 1fr 160px 30px; gap:1rem; align-items:center;";
|
||||
const inStyle = "padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;";
|
||||
const header = `<div style="${grid} padding:0 1rem 0.4rem; font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.03em; color:var(--text-dim);">
|
||||
<div>Data Type</div><div>Location / Platform</div><div>URL</div><div>Notes</div><div></div>
|
||||
</div>`;
|
||||
container.innerHTML = header + state.sources.map((s,i)=>{
|
||||
// Preset data types are fixed labels; custom rows (Add Source) get an editable name.
|
||||
const dataType = s.preset
|
||||
? `<div style="font-weight:600; font-size:13px;">${escAttr(s.label)}</div>`
|
||||
: `<input type="text" value="${escAttr(s.label)}" placeholder="Custom data type" onchange="state.sources[${i}].label=this.value" style="${inStyle} font-weight:600;">`;
|
||||
return `<div style="${grid} padding:1rem; background:var(--bg); border-radius:6px; margin-bottom:0.5rem; border:1px solid var(--border);">
|
||||
${dataType}
|
||||
<input type="text" value="${escAttr(s.system)}" placeholder="${escAttr(s.ph||'Procore / Bluebeam / SharePoint…')}" onchange="state.sources[${i}].system=this.value" style="${inStyle}">
|
||||
<input type="text" value="${escAttr(s.link)}" placeholder="Paste the 'Copy Link' URL" onchange="state.sources[${i}].link=this.value" style="${inStyle}">
|
||||
<input type="text" value="${escAttr(s.notes)}" placeholder="Notes" onchange="state.sources[${i}].notes=this.value" style="${inStyle}">
|
||||
<button style="background:var(--danger); color:white; padding:0.25rem; width:30px; height:30px; border:none; border-radius:4px; cursor:pointer; font-weight:600;" onclick="state.sources.splice(${i},1); renderSources()" title="Remove">✕</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function addSource(){
|
||||
state.sources.push({label:'',system:'',notes:'',link:''});
|
||||
// Added rows are custom — the user types their own data type here.
|
||||
state.sources.push({label:'', system:'', notes:'', link:'', preset:false});
|
||||
renderSources();
|
||||
}
|
||||
|
||||
@@ -788,6 +852,12 @@ function completeSOP(){
|
||||
localStorage.setItem(SK('wp_suite_sop_complete'), '1');
|
||||
} catch(e){}
|
||||
|
||||
// Share the SOP to the server so every user of this project gets it.
|
||||
try {
|
||||
const pid = (typeof ProjectData!=='undefined' && ProjectData.getActiveId && ProjectData.getActiveId()) || sop.projectId || '';
|
||||
if(pid && ProjectData.pushSOP) ProjectData.pushSOP(pid, sop, state);
|
||||
} catch(e){}
|
||||
|
||||
track('sop_generated', {woTypes: sop.woTypes.length, constraints: sop.constraints.length});
|
||||
|
||||
// Hand the SOP to the embedded Work Package Creator and unlock its tab (in case
|
||||
@@ -805,8 +875,17 @@ function toggleComments(){
|
||||
if(panel.style.display === 'block') loadStepComments();
|
||||
}
|
||||
|
||||
function currentUserName(){
|
||||
const u = window.WP_USER;
|
||||
return (u && (u.full_name || u.username)) || '';
|
||||
}
|
||||
function setCommenterName(){
|
||||
const el = document.getElementById('commenter-name');
|
||||
if(el) el.value = currentUserName();
|
||||
}
|
||||
|
||||
function submitComment(){
|
||||
const name = document.getElementById('commenter-name').value || 'Anonymous';
|
||||
const name = document.getElementById('commenter-name').value || currentUserName() || 'Anonymous';
|
||||
const text = document.getElementById('comment-text').value.trim();
|
||||
|
||||
if(!text){ alert('Please enter a comment.'); return; }
|
||||
@@ -823,9 +902,7 @@ function submitComment(){
|
||||
if(window.postFeedback) window.postFeedback({type:'sop_step_comment', ...comment});
|
||||
|
||||
document.getElementById('comment-text').value = '';
|
||||
document.getElementById('commenter-name').value = '';
|
||||
loadStepComments();
|
||||
alert('✓ Comment submitted!');
|
||||
}
|
||||
|
||||
function exportComments(){
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Work Package Suite</title>
|
||||
<script src="auth-guard.js"></script>
|
||||
<link rel="icon" href="favicon.ico" sizes="any">
|
||||
<link rel="stylesheet" href="theme-light.css">
|
||||
<link rel="stylesheet" href="work-package-suite-styles.css">
|
||||
@@ -22,9 +23,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button id="load-sample-btn" class="header-button" onclick="loadSampleData()" title="Load sample data for the current tool (SOP or Work Package)">⭐ Load Sample</button>
|
||||
<button class="header-button" onclick="toggleComments()" title="View and add comments for the current step">💬 Step Comments</button>
|
||||
<button class="header-button" onclick="showAnalytics()" title="Review usage logs for this tool">📊 Usage Logs</button>
|
||||
<button id="load-sample-btn" class="header-button" onclick="loadSampleData()" title="Load sample data for the current tool (SOP or Work Package)">Load Sample</button>
|
||||
<button class="header-button" onclick="toggleComments()" title="Leave feedback for the current step">Feedback</button>
|
||||
<button class="header-button" onclick="openHelp()" title="How the suite works + key concepts">Help</button>
|
||||
<span class="step-counter"><span id="current-step">1</span> / <span id="total-steps">10</span></span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -32,13 +33,13 @@
|
||||
<!-- MAIN NAVIGATION -->
|
||||
<div class="main-nav">
|
||||
<button class="nav-tab active" data-tab="sop" onclick="switchTool('sop')">
|
||||
<span class="tab-icon">⚙️</span> SOP Configuration
|
||||
SOP Configuration
|
||||
</button>
|
||||
<button class="nav-tab" data-tab="wp" onclick="switchTool('wp')">
|
||||
<span class="tab-icon">📋</span> Work Package Creation
|
||||
Work Package Creation
|
||||
</button>
|
||||
<button class="nav-tab" data-tab="dashboard" onclick="switchTool('dashboard')">
|
||||
<span class="tab-icon">📊</span> Dashboard
|
||||
Dashboard
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -171,14 +172,23 @@
|
||||
<small>Use ## for counter, [Sector] [TYPE] as variables</small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Issuance Strategy</label>
|
||||
<select id="gov_issuance" multiple size="3">
|
||||
<label>Issuance Strategy<span class="help-tip" data-tip="How Work Packages are grouped and released on this project. Pick one or more — most projects combine 'By Sector / Area' with 'By Phase / Sequence'.">i</span></label>
|
||||
<select id="gov_issuance" multiple size="4">
|
||||
<option selected>By Sector / Area</option>
|
||||
<option>By Discipline</option>
|
||||
<option>By Phase / Sequence</option>
|
||||
<option>By Resource Availability</option>
|
||||
</select>
|
||||
<small>Hold Ctrl to select multiple</small>
|
||||
<small>Hold Ctrl (Cmd on Mac) to select multiple.</small>
|
||||
<div class="notice" style="margin-top:0.6rem; font-size:12px;">
|
||||
<strong>Examples:</strong>
|
||||
<ul style="margin:0.35rem 0 0; padding-left:1.1rem;">
|
||||
<li><strong>By Sector / Area</strong> — one package per physical area, e.g. <em>all work in Sector 1P, Level 2 chase</em>.</li>
|
||||
<li><strong>By Discipline</strong> — separate packages per trade, e.g. <em>Electrical wire-pull</em> vs <em>Mechanical install</em>.</li>
|
||||
<li><strong>By Phase / Sequence</strong> — follow the build order, e.g. <em>rough-in → wire pull → terminations</em>.</li>
|
||||
<li><strong>By Resource Availability</strong> — size to a crew/equipment window, e.g. <em>one boom-lift crew's week</em>.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -191,7 +201,7 @@
|
||||
<small>Comma-separated. These appear as scope sections and instance suffixes in the Creator.</small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Discipline strategy *</label>
|
||||
<label>Discipline strategy *<span class="help-tip" data-tip="Decides whether a package can carry several disciplines (scope split per discipline) or one each. 'Let the planner choose' allows building a big multi-discipline package and splitting it later.">i</span></label>
|
||||
<select id="gov_discmode">
|
||||
<option value="choice">Let the planner choose per package (recommended)</option>
|
||||
<option value="single">One discipline per package (many small packages)</option>
|
||||
@@ -216,7 +226,7 @@
|
||||
<small>Sets the split threshold automatically; choose Custom to enter your own.</small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Split threshold — max labor hours</label>
|
||||
<label>Split threshold — max labor hours<span class="help-tip" data-tip="The Work Package Creator flags any package whose estimated hours exceed this so the planner can break it down. Auto-set by the size band; override if needed.">i</span></label>
|
||||
<input type="number" id="gov_size_hours_max" min="0" step="1" placeholder="e.g., 80">
|
||||
<small>Auto-set from the size above (editable). The Creator flags packages over this so they can be split.</small>
|
||||
</div>
|
||||
@@ -287,7 +297,7 @@
|
||||
<div style="display:flex; gap:0.5rem; margin-top:1rem; flex-wrap:wrap;">
|
||||
<input type="text" id="seq-add-input" placeholder="New step name" onkeydown="if(event.key==='Enter'){addSequenceStep();}" style="flex:1; min-width:200px; padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
|
||||
<button class="add-btn" onclick="addSequenceStep()">+ Add Step</button>
|
||||
<button class="add-btn" onclick="addSequenceGate()" style="background:var(--warning);">◆ Add QC Hold</button>
|
||||
<button class="add-btn" onclick="addSequenceGate()" style="background:var(--warning);">Add QC Hold</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -318,9 +328,9 @@
|
||||
|
||||
<!-- SOP NAVIGATION -->
|
||||
<div class="step-navigation">
|
||||
<button class="nav-btn" id="sop-prev-btn" onclick="previousStep()">← Back</button>
|
||||
<button class="nav-btn" id="sop-next-btn" onclick="nextStep()">Next →</button>
|
||||
<button class="nav-btn primary" id="sop-complete-btn" onclick="completeSOP()" style="display: none;">✓ SOP Complete</button>
|
||||
<button class="nav-btn" id="sop-prev-btn" onclick="previousStep()">Back</button>
|
||||
<button class="nav-btn" id="sop-next-btn" onclick="nextStep()">Next</button>
|
||||
<button class="nav-btn primary" id="sop-complete-btn" onclick="completeSOP()" style="display: none;">SOP Complete</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -331,9 +341,9 @@
|
||||
<div id="tool-wp" class="tool">
|
||||
<!-- Shown until the SOP is complete -->
|
||||
<div id="wp-gate" style="padding: 3rem 2rem; text-align: center;">
|
||||
<h2>📋 Work Package Creation</h2>
|
||||
<h2>Work Package Creation</h2>
|
||||
<p style="color: var(--text-light); margin: 1rem 0;">Complete the SOP Configuration first to enable Work Package creation. Once the SOP is finished, the full creator loads here with your project defaults pre-populated.</p>
|
||||
<button class="nav-btn primary" onclick="switchTool('sop')" style="margin-top: 1rem;">← Go to SOP Configuration</button>
|
||||
<button class="nav-btn primary" onclick="switchTool('sop')" style="margin-top: 1rem;">Go to SOP Configuration</button>
|
||||
</div>
|
||||
<!-- The real Work Package Creator, embedded once the SOP is complete -->
|
||||
<iframe id="wp-frame" title="Work Package Creator" style="display:none; width:100%; border:0; min-height: calc(100vh - 200px);"></iframe>
|
||||
@@ -346,12 +356,12 @@
|
||||
<!-- STEP COMMENTS DROPDOWN (toggled from header) -->
|
||||
<div id="comments-panel" class="comments-dropdown" style="display: none;">
|
||||
<div class="comments-dropdown-header">
|
||||
<strong>💬 Step Comments</strong>
|
||||
<strong>Feedback</strong>
|
||||
<button onclick="toggleComments()" class="comments-dropdown-close" title="Close">✕</button>
|
||||
</div>
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label style="font-weight: 600; font-size: 13px;">Your Name (optional)</label>
|
||||
<input type="text" id="commenter-name" placeholder="e.g., your name" style="width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; margin-top: 0.25rem;">
|
||||
<label style="font-weight: 600; font-size: 13px;">Your Name</label>
|
||||
<input type="text" id="commenter-name" placeholder="(signed-in user)" readonly title="Taken from your sign-in" style="width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; margin-top: 0.25rem; background: var(--bg);">
|
||||
</div>
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label style="font-weight: 600; font-size: 13px;">Feedback</label>
|
||||
@@ -359,8 +369,8 @@
|
||||
</div>
|
||||
<div style="display:flex; gap:0.5rem; flex-wrap:wrap;">
|
||||
<button onclick="submitComment()" style="background: var(--primary); color: white; padding: 0.5rem 1rem; border: none; border-radius: 4px; cursor: pointer; font-weight: 600;">Submit</button>
|
||||
<button onclick="exportComments()" style="background: var(--bg); color: var(--text); border: 1px solid var(--border); padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; font-weight: 600;">⤓ Export</button>
|
||||
<button onclick="document.getElementById('sop-comments-import').click()" style="background: var(--bg); color: var(--text); border: 1px solid var(--border); padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; font-weight: 600;">⤒ Import</button>
|
||||
<button onclick="exportComments()" style="background: var(--bg); color: var(--text); border: 1px solid var(--border); padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; font-weight: 600;">Export</button>
|
||||
<button onclick="document.getElementById('sop-comments-import').click()" style="background: var(--bg); color: var(--text); border: 1px solid var(--border); padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; font-weight: 600;">Import</button>
|
||||
<input type="file" id="sop-comments-import" accept="application/json" style="display:none" onchange="importComments(event)">
|
||||
</div>
|
||||
<div id="comments-list" style="margin-top: 1rem; max-height: 240px; overflow-y: auto;"></div>
|
||||
@@ -373,13 +383,19 @@
|
||||
<h3>Add Custom Constraint</h3>
|
||||
<button class="modal-close" onclick="closeConstraintModal()">✕</button>
|
||||
</div>
|
||||
<div id="constraint-library" style="max-height: 400px; overflow-y: auto; margin: 1rem 0;"></div>
|
||||
<div style="display:flex; gap:0.5rem; margin:1rem 0 0.5rem;">
|
||||
<input type="text" id="custom-constraint-input" placeholder="Type a custom constraint name…" style="flex:1; padding:0.55rem 0.65rem; border:1px solid var(--border); border-radius:4px;" onkeydown="if(event.key==='Enter'){addCustomConstraintText();event.preventDefault();}">
|
||||
<button class="add-btn" onclick="addCustomConstraintText()">Add</button>
|
||||
</div>
|
||||
<div style="font-size:12px; color:var(--text-dim); margin-bottom:0.5rem;">…or pick from the library:</div>
|
||||
<div id="constraint-library" style="max-height: 320px; overflow-y: auto; margin: 0 0 1rem;"></div>
|
||||
<button class="nav-btn" onclick="closeConstraintModal()">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="feedback-config.js"></script>
|
||||
<script src="project-data.js"></script>
|
||||
<script src="help.js"></script>
|
||||
<script src="work-package-suite-app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -529,6 +529,20 @@ function setConstraint(i,val){
|
||||
if(val==='open' && STATUS_ORDER.indexOf(getRadio('status'))>=ISSUED_IDX){
|
||||
prevStatus=getRadio('status'); holdContext={index:i, before};
|
||||
openHoldModal(pkgConstraints[i].name, true);
|
||||
return;
|
||||
}
|
||||
// Clearing the LAST open constraint makes the package release-ready — offer to
|
||||
// issue it and scroll up to the status control so the change is visible.
|
||||
if(before==='open' && val!=='open' && readiness().open===0){
|
||||
const st=getRadio('status');
|
||||
if(STATUS_ORDER.indexOf(st) < ISSUED_IDX){
|
||||
if(confirm('All constraints are cleared — this Work Package is release-ready.\n\nMark it as Issued now?')){
|
||||
setRadio('status','Issued'); prevStatus='Issued'; updateReleaseBanner();
|
||||
track('status_change',{status:'Issued',via:'constraint_clear'});
|
||||
}
|
||||
const sg=document.getElementById('status-group');
|
||||
if(sg) sg.scrollIntoView({behavior:'smooth', block:'center'});
|
||||
}
|
||||
}
|
||||
}
|
||||
function readiness(){ const open=pkgConstraints.filter(c=>c.status==='open').length; return {open, total:pkgConstraints.length, cleared:pkgConstraints.filter(c=>c.status==='cleared').length, ready:open===0}; }
|
||||
@@ -539,6 +553,7 @@ function updateReleaseBanner(){
|
||||
else if(r.ready){ cls='rb-ready'; txt=`✓ Release-ready — all ${r.total} constraints cleared or N/A.`; }
|
||||
else { cls='rb-notready'; txt=`⚠ Not release-ready — ${r.open} of ${r.total} constraint${r.open===1?'':'s'} still open.`; }
|
||||
b.innerHTML=`<div class="rb-inner ${cls}">${txt}</div>`;
|
||||
updateStickyStatus();
|
||||
}
|
||||
function onStatusChange(target){
|
||||
const idx=STATUS_ORDER.indexOf(target);
|
||||
@@ -672,6 +687,7 @@ function savePackage(view){
|
||||
const ix=savedPackages.findIndex(p=>p.id===pkg.id);
|
||||
if(ix>=0) savedPackages[ix]=pkg; else savedPackages.push(pkg);
|
||||
editingId=pkg.id; saveStore(); renderSavedList(); track('package_saved',{status:pkg.status});
|
||||
if(typeof ProjectData!=='undefined' && ProjectData.pushWP) ProjectData.pushWP(pkg, activeProjectId); // share to server
|
||||
document.getElementById('loadingOverlay').classList.add('active');
|
||||
setTimeout(()=>{ document.getElementById('loadingOverlay').classList.remove('active'); if(view) renderPackage(pkg); }, 400);
|
||||
}
|
||||
@@ -761,8 +777,54 @@ function printPackage(){
|
||||
|
||||
// ── VIEWS ────────────────────────────────────────────────────────────────────
|
||||
function hideDashboard(){ const dv=document.getElementById('dashboard-view'); if(dv) dv.style.display='none'; }
|
||||
function showOutput(){ hideDashboard(); document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='none'); document.getElementById('pkg-output').style.display=''; renderSavedList(); currentView='Package View'; window.scrollTo({top:0,behavior:'smooth'}); }
|
||||
function showForm(){ hideDashboard(); document.querySelectorAll('.main > .card').forEach(e=>e.style.display=''); document.querySelector('.main > .nav-row').style.display='flex'; document.getElementById('pkg-output').style.display='none'; document.getElementById('saved-card').style.display = savedPackages.length?'':'none'; buildDisciplinePicker(); renderScope(); currentView='Work Package Form'; window.scrollTo({top:0,behavior:'smooth'}); }
|
||||
function showOutput(){ hideDashboard(); setFormChrome(false); document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='none'); document.getElementById('pkg-output').style.display=''; renderSavedList(); currentView='Package View'; window.scrollTo({top:0,behavior:'smooth'}); }
|
||||
function showForm(){ hideDashboard(); document.querySelectorAll('.main > .card').forEach(e=>e.style.display=''); document.querySelector('.main > .nav-row').style.display='flex'; document.getElementById('pkg-output').style.display='none'; document.getElementById('saved-card').style.display = savedPackages.length?'':'none'; buildDisciplinePicker(); renderScope(); setFormChrome(true); currentView='Work Package Form'; window.scrollTo({top:0,behavior:'smooth'}); }
|
||||
|
||||
// Sticky save bar + section-nav chrome (shown only on the editable form view).
|
||||
function setFormChrome(on){
|
||||
const nav=document.getElementById('section-nav'), save=document.getElementById('sticky-save');
|
||||
if(nav) nav.style.display = on ? '' : 'none';
|
||||
if(save) save.style.display = on ? 'flex' : 'none';
|
||||
document.body.classList.toggle('has-sticky-save', !!on);
|
||||
if(on){ buildSectionNav(); updateStickyStatus(); makeCollapsible(); }
|
||||
}
|
||||
// Make each form card collapsible by clicking its heading (idempotent).
|
||||
function makeCollapsible(){
|
||||
document.querySelectorAll('.main > .card').forEach(card=>{
|
||||
if(card.id==='saved-card') return;
|
||||
const head=card.querySelector('.section-header, .sub-heading');
|
||||
if(!head || head.dataset.collapsible) return;
|
||||
head.dataset.collapsible='1';
|
||||
head.style.cursor='pointer';
|
||||
const chev=document.createElement('span'); chev.className='collapse-chev'; chev.textContent='▾';
|
||||
head.insertBefore(chev, head.firstChild);
|
||||
head.addEventListener('click', e=>{
|
||||
if(['INPUT','SELECT','TEXTAREA','BUTTON','A'].includes(e.target.tagName) || e.target.classList.contains('help-tip')) return;
|
||||
const collapsed=card.classList.toggle('collapsed');
|
||||
chev.textContent = collapsed ? '▸' : '▾';
|
||||
});
|
||||
});
|
||||
}
|
||||
function buildSectionNav(){
|
||||
const nav=document.getElementById('section-nav'); if(!nav) return;
|
||||
const chips=[];
|
||||
document.querySelectorAll('.main > .card').forEach((card,i)=>{
|
||||
if(card.id==='saved-card' || card.style.display==='none') return;
|
||||
const h=card.querySelector('.section-title, .sub-heading'); if(!h) return;
|
||||
const clone=h.cloneNode(true); clone.querySelectorAll('.help-tip').forEach(x=>x.remove());
|
||||
const label=clone.textContent.trim().replace(/\s+/g,' '); if(!label) return;
|
||||
if(!card.id) card.id='sec-'+i;
|
||||
chips.push(`<span class="sec-chip" onclick="document.getElementById('${card.id}').scrollIntoView({behavior:'smooth',block:'start'})">${esc(label)}</span>`);
|
||||
});
|
||||
nav.innerHTML=chips.join('');
|
||||
}
|
||||
function updateStickyStatus(){
|
||||
const el=document.getElementById('sticky-status'); if(!el) return;
|
||||
const r=readiness(); const st=getRadio('status');
|
||||
if(st==='Issue'){ el.className='sticky-status ss-hold'; el.textContent=`⛔ On hold — ${r.open} constraint${r.open===1?'':'s'} reopened`; }
|
||||
else if(r.ready){ el.className='sticky-status ss-ready'; el.textContent=`✓ Release-ready — all ${r.total} constraints cleared`; }
|
||||
else { el.className='sticky-status ss-notready'; el.textContent=`⚠ ${r.open} of ${r.total} constraint${r.open===1?'':'s'} open`; }
|
||||
}
|
||||
|
||||
// ── SAVED PACKAGES ───────────────────────────────────────────────────────────
|
||||
const STORE_KEY='wp_iwp_v1';
|
||||
@@ -780,13 +842,13 @@ function renderSavedList(){
|
||||
const tag = p.split?' <span class="badge badge-O">master</span>':(p.instanceOf?` <span class="badge badge-Y">${esc(p.instanceLabel||'instance')}</span>`:'');
|
||||
const disc = (p.disciplines&&p.disciplines.length)?`<div style="font-size:10px;color:var(--text-dim)">${esc(p.disciplines.join(', '))}</div>`:'';
|
||||
return `<tr><td class="row-label">${esc(p.number||'—')}${tag}${disc}</td><td>${esc(p.type||'')}</td><td>${esc(p.subject||'')}</td>
|
||||
<td>${esc(p.status||'')}</td><td>${ready}</td>
|
||||
<td>${statusPill(p.status)}</td><td>${ready}</td>
|
||||
<td class="center"><button class="link-btn" onclick="editPackage(${i})">edit</button> <button class="link-btn" onclick="viewPackage(${i})">view</button> <button class="row-del" onclick="deletePackage(${i})">✕</button></td></tr>`;
|
||||
}).join('');
|
||||
}
|
||||
function viewPackage(i){ if(savedPackages[i]) renderPackage(savedPackages[i]); }
|
||||
function deletePackage(i){ const p=savedPackages[i]; if(!p) return; if(!confirm('Delete work package "'+(p.number||p.subject||'untitled')+'"? This cannot be undone.')) return; savedPackages.splice(i,1); saveStore(); renderSavedList(); track('package_deleted'); }
|
||||
function clearSaved(){ if(!savedPackages.length) return; if(!confirm('Delete all '+savedPackages.length+' saved packages on this device?')) return; savedPackages=[]; saveStore(); renderSavedList(); }
|
||||
function deletePackage(i){ const p=savedPackages[i]; if(!p) return; if(!confirm('Delete work package "'+(p.number||p.subject||'untitled')+'"? This cannot be undone.')) return; const delId=p.id; savedPackages.splice(i,1); saveStore(); renderSavedList(); track('package_deleted'); if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ProjectData.removeWP(delId); }
|
||||
function clearSaved(){ if(!savedPackages.length) return; if(!confirm('Delete all '+savedPackages.length+' saved packages?')) return; const ids=savedPackages.map(p=>p.id); savedPackages=[]; saveStore(); renderSavedList(); if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ids.forEach(id=>ProjectData.removeWP(id)); }
|
||||
function editPackage(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; loadPackageIntoForm(p); }
|
||||
function loadExample(){ loadPackageIntoForm(JSON.parse(JSON.stringify(EXAMPLE_PKG))); editingId=null; toast('Example work package loaded'); track('example_loaded'); }
|
||||
function loadPackageIntoForm(p){
|
||||
@@ -855,6 +917,7 @@ function duplicateWP(){
|
||||
savedPackages.push(c); made.push(c);
|
||||
}
|
||||
editingId=null; saveStore(); renderSavedList();
|
||||
if(typeof ProjectData!=='undefined' && ProjectData.pushWP) made.forEach(m=>ProjectData.pushWP(m, activeProjectId)); // share to server
|
||||
toast('Created '+n+' duplicate'+(n>1?'s':''));
|
||||
track('wp_duplicated',{count:n});
|
||||
alert('Created '+n+' duplicate'+(n>1?'s':'')+':\n\n• '+made.map(m=>m.number).join('\n• ')+'\n\nThey are in the Saved Work Packages list — edit each as needed.');
|
||||
@@ -890,21 +953,36 @@ function exportPackages(){
|
||||
// Data adapter: localStorage today. In Phase 2 swap list()/issue()/setStatus()
|
||||
// bodies for fetch() calls to /api/wps — the dashboard UI doesn't change.
|
||||
const WPData = {
|
||||
list(){ return savedPackages.slice(); }, // → GET /api/wps
|
||||
get(id){ return savedPackages.find(p=>p.id===id); }, // → GET /api/wps/{id}
|
||||
list(){ return savedPackages.slice(); }, // hydrated from GET /api/wps on boot
|
||||
get(id){ return savedPackages.find(p=>p.id===id); },
|
||||
issue(id){ const p=savedPackages.find(x=>x.id===id); if(!p) return false;
|
||||
p.status='Issued'; p.issuedAt=new Date().toISOString(); p.updatedAt=p.issuedAt; saveStore(); return true; }, // → POST /api/wps/{id}/issue
|
||||
p.status='Issued'; p.issuedAt=new Date().toISOString(); p.updatedAt=p.issuedAt; saveStore();
|
||||
if(typeof ProjectData!=='undefined' && ProjectData.pushWP) ProjectData.pushWP(p, activeProjectId); return true; },
|
||||
setStatus(id,status){ const p=savedPackages.find(x=>x.id===id); if(!p) return false;
|
||||
p.status=status; p.updatedAt=new Date().toISOString(); saveStore(); return true; }, // → POST /api/wps/{id}/status
|
||||
p.status=status; p.updatedAt=new Date().toISOString(); saveStore();
|
||||
if(typeof ProjectData!=='undefined' && ProjectData.pushWP) ProjectData.pushWP(p, activeProjectId); return true; },
|
||||
};
|
||||
|
||||
let dashFilter={status:'',discipline:'',q:''};
|
||||
let dashFilter={status:'',discipline:'',q:'',flag:''};
|
||||
function dashToggleFlag(f){
|
||||
if(f==='all'){ dashFilter={status:'',discipline:'',q:'',flag:''}; }
|
||||
else { dashFilter.flag = dashFilter.flag===f ? '' : f; }
|
||||
renderDashboard();
|
||||
}
|
||||
function dashSetStatus(s){ dashFilter.status = dashFilter.status===s ? '' : s; renderDashboard(); }
|
||||
// Consistent colored status pill, reused by the dashboard board and the saved list.
|
||||
function statusPill(s){
|
||||
const map={'Draft':'badge-NA','Scheduled':'badge-O','Issued':'badge-Y','In Progress':'badge-O','QC':'badge-O','Closed':'badge-Y','Issue':'badge-N'};
|
||||
const label = s==='Issue' ? 'Issue (Hold)' : (s||'—');
|
||||
return `<span class="badge ${map[s]||'badge-NA'}">${esc(label)}</span>`;
|
||||
}
|
||||
function wpOpenConstraints(p){ return (p.constraints||[]).filter(c=>c.status==='open'); }
|
||||
function isOverdue(p){ return !!(p.due && p.status!=='Closed' && p.due < todayStr()); }
|
||||
// Masters are roll-ups of their instances — exclude them from counts so work isn't double-counted.
|
||||
function countableWPs(){ return WPData.list().filter(p=>!p.split); }
|
||||
|
||||
function showDashboard(){
|
||||
setFormChrome(false);
|
||||
document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='none');
|
||||
document.getElementById('pkg-output').style.display='none';
|
||||
const dv=document.getElementById('dashboard-view'); if(dv) dv.style.display='';
|
||||
@@ -923,19 +1001,25 @@ function renderDashboard(){
|
||||
if(isOverdue(p)) overdue++;
|
||||
(p.disciplines&&p.disciplines.length?p.disciplines:['(none)']).forEach(d=>byDisc[d]=(byDisc[d]||0)+1);
|
||||
});
|
||||
const card=(label,val,cls)=>`<div class="dash-metric ${cls||''}"><div class="dm-val">${val}</div><div class="dm-label">${esc(label)}</div></div>`;
|
||||
// Clickable metric cards filter the board (flag-based); a card with no flag is static.
|
||||
const card=(label,val,cls,flag)=>{
|
||||
const active = flag && dashFilter.flag===flag ? ' dm-active' : '';
|
||||
const attr = flag ? ` onclick="dashToggleFlag('${flag}')" title="Click to filter the board"` : '';
|
||||
return `<div class="dash-metric ${cls||''}${active}"${attr}><div class="dm-val">${val}</div><div class="dm-label">${esc(label)}</div></div>`;
|
||||
};
|
||||
let h=`<div class="dash-metrics">
|
||||
${card('Total WPs', all.length)}
|
||||
${card('Release-ready', ready, ready?'dm-green':'')}
|
||||
${card('On hold', hold, hold?'dm-red':'')}
|
||||
${card('Overdue', overdue, overdue?'dm-red':'')}
|
||||
${card('Total WPs', all.length, '', 'all')}
|
||||
${card('Release-ready', ready, ready?'dm-green':'', 'ready')}
|
||||
${card('On hold', hold, hold?'dm-red':'', 'onhold')}
|
||||
${card('Overdue', overdue, overdue?'dm-red':'', 'overdue')}
|
||||
${card('Est. hrs', Math.round(estH))}
|
||||
${card('Actual hrs', Math.round(actH))}
|
||||
</div>`;
|
||||
|
||||
// status + discipline breakdown chips
|
||||
const statusChips=STATUS_ORDER.filter(s=>byStatus[s]).map(s=>`<span class="dash-chip">${esc(s)}: <b>${byStatus[s]}</b></span>`).join('')
|
||||
+ (byStatus['Issue']?`<span class="dash-chip chip-red">On Hold: <b>${byStatus['Issue']}</b></span>`:'');
|
||||
// status + discipline breakdown chips (status chips also filter the board)
|
||||
const statusChip=(label,count,cls,status)=>`<span class="dash-chip${cls?' '+cls:''}${dashFilter.status===status?' chip-active':''}" onclick="dashSetStatus('${status}')" title="Click to filter the board">${esc(label)}: <b>${count}</b></span>`;
|
||||
const statusChips=STATUS_ORDER.filter(s=>byStatus[s]).map(s=>statusChip(s,byStatus[s],'',s)).join('')
|
||||
+ (byStatus['Issue']?statusChip('On Hold',byStatus['Issue'],'chip-red','Issue'):'');
|
||||
const discChips=Object.keys(byDisc).map(d=>`<span class="dash-chip">${esc(d)}: <b>${byDisc[d]}</b></span>`).join('')||'<span class="dash-chip">—</span>';
|
||||
h+=`<div class="dash-breakdown"><div><div class="dash-bd-title">By status</div>${statusChips||'—'}</div>
|
||||
<div><div class="dash-bd-title">By discipline</div>${discChips}</div></div>`;
|
||||
@@ -965,6 +1049,9 @@ function renderDashboard(){
|
||||
if(dashFilter.status && p.status!==dashFilter.status) return false;
|
||||
if(dashFilter.discipline && !((p.disciplines||[]).includes(dashFilter.discipline))) return false;
|
||||
if(q && !((p.number||'')+' '+(p.subject||'')).toLowerCase().includes(q)) return false;
|
||||
if(dashFilter.flag==='ready' && !(!p.split && wpOpenConstraints(p).length===0 && p.status!=='Closed' && p.status!=='Issue')) return false;
|
||||
if(dashFilter.flag==='onhold' && p.status!=='Issue') return false;
|
||||
if(dashFilter.flag==='overdue' && !isOverdue(p)) return false;
|
||||
return true;
|
||||
});
|
||||
h+=`<div class="dash-panel"><div class="dash-panel-title">Work Packages (${rows.length})</div>
|
||||
@@ -980,7 +1067,7 @@ function renderDashboard(){
|
||||
h+=`<tr><td class="row-label">${esc(p.number||'—')}${p.instanceOf?` <span class="badge badge-Y">${esc(p.instanceLabel||'')}</span>`:''}</td>
|
||||
<td>${esc(p.subject||'')}</td><td>${esc(p.type||'')}</td>
|
||||
<td style="font-size:11px">${esc((p.disciplines||[]).join(', '))||ns()}</td>
|
||||
<td>${esc(p.status||'')}</td><td>${gates}</td><td>${due}</td><td>${cell(p.hours)}</td>
|
||||
<td>${statusPill(p.status)}</td><td>${gates}</td><td>${due}</td><td>${cell(p.hours)}</td>
|
||||
<td class="center" style="white-space:nowrap">${issueBtn} <button class="link-btn" onclick="dashView(${ix})">view</button> <button class="link-btn" onclick="dashEdit(${ix})">edit</button></td></tr>`;
|
||||
});
|
||||
h+=`</tbody></table></div>`;
|
||||
@@ -1061,10 +1148,10 @@ document.querySelectorAll('#status-group .radio-pill').forEach(p=>p.addEventList
|
||||
ProjectData.setActive(cached && cached.id===activeProjectId ? cached : { id: activeProjectId });
|
||||
}
|
||||
})();
|
||||
loadStore();
|
||||
(function bootSOP(){
|
||||
function bootSOP(){
|
||||
// When embedded in the Suite, hide the SOP import/sample controls (SOP is injected)
|
||||
// and prefer the SOP the Suite just completed (persisted to localStorage).
|
||||
// and prefer the SOP the Suite just completed (persisted to localStorage, which
|
||||
// we've already hydrated from the server for this project).
|
||||
const params = new URLSearchParams(location.search);
|
||||
if(params.get('embedded')) document.body.classList.add('embedded');
|
||||
try {
|
||||
@@ -1079,11 +1166,22 @@ loadStore();
|
||||
// state so it's clear the project's SOP must be completed first.
|
||||
if(activeProjectId){ SOP=null; renderCtxBar(); newPackage(); }
|
||||
else { loadSampleSOP(); }
|
||||
})();
|
||||
setRadio('status','Draft');
|
||||
renderSavedList();
|
||||
cmtInit();
|
||||
// Deep-link: open straight to the dashboard when requested (?view=dashboard or #dashboard).
|
||||
(function(){ const p=new URLSearchParams(location.search); if(p.get('view')==='dashboard' || location.hash==='#dashboard'){ showDashboard(); } })();
|
||||
}
|
||||
function bootData(){
|
||||
loadStore(); // reads the localStorage cache (hydrated from the server below)
|
||||
bootSOP();
|
||||
setRadio('status','Draft');
|
||||
renderSavedList();
|
||||
cmtInit();
|
||||
// Deep-link: open straight to the dashboard when requested (?view=dashboard or #dashboard).
|
||||
const p=new URLSearchParams(location.search); if(p.get('view')==='dashboard' || location.hash==='#dashboard'){ showDashboard(); }
|
||||
track('app_open');
|
||||
}
|
||||
window.addEventListener('hashchange',()=>{ if(location.hash==='#dashboard') showDashboard(); });
|
||||
track('app_open');
|
||||
// Pull this project's shared SOP + Work Packages from the server first, then boot
|
||||
// off the refreshed cache. Falls back to whatever is cached locally if offline.
|
||||
if(activeProjectId && typeof ProjectData!=='undefined' && ProjectData.pullProject){
|
||||
ProjectData.pullProject(activeProjectId).then(bootData).catch(bootData);
|
||||
} else {
|
||||
bootData();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Work Package (IWP) — Prime Controls</title>
|
||||
<script src="auth-guard.js"></script>
|
||||
<link rel="icon" href="favicon.ico" sizes="any">
|
||||
<link rel="stylesheet" href="theme-light.css">
|
||||
<link rel="stylesheet" href="wp-creation-styles.css">
|
||||
@@ -38,6 +39,9 @@
|
||||
<!-- RELEASE READINESS BANNER -->
|
||||
<div class="release-banner" id="release-banner"></div>
|
||||
|
||||
<!-- SECTION NAV (jump links, built from the form cards) -->
|
||||
<div class="section-nav-bar" id="section-nav"></div>
|
||||
|
||||
<div class="main">
|
||||
|
||||
<!-- GENERAL INFORMATION -->
|
||||
@@ -45,7 +49,7 @@
|
||||
<div class="section-header"><div class="section-title">General Information</div>
|
||||
<div class="section-desc">Parameters in <span style="color:var(--accent)">blue</span> are inherited from the project SOP. Fill the rest for this package.</div></div>
|
||||
<div class="field-grid">
|
||||
<div class="field"><label>WP Number <span class="auto-tag">auto</span></label><input type="text" id="wp_number" readonly class="locked-field" placeholder="auto-built"><div class="field-hint sop-hint" id="wp_number_hint"></div></div>
|
||||
<div class="field"><label>WP Number <span class="auto-tag">auto</span><span class="help-tip" data-tip="Built automatically from the SOP number format — the scope fields below (e.g. Sector) plus the WP type and a sequence counter.">i</span></label><input type="text" id="wp_number" readonly class="locked-field" placeholder="auto-built"><div class="field-hint sop-hint" id="wp_number_hint"></div></div>
|
||||
<div class="field"><label>Status</label>
|
||||
<div class="radio-group" id="status-group" style="margin-bottom:0">
|
||||
<label class="radio-pill" data-val="Draft"><input type="radio" name="status"><span class="dot"></span>Draft</label>
|
||||
@@ -88,14 +92,14 @@
|
||||
|
||||
<!-- DISCIPLINES -->
|
||||
<div class="card" id="discipline-card" style="display:none">
|
||||
<div class="sub-heading">Disciplines</div>
|
||||
<div class="sub-heading">Disciplines<span class="help-tip" data-tip="Pick every discipline this package covers. Choosing two or more turns Scope into per-discipline sections and enables Split by Discipline.">i</span></div>
|
||||
<div class="notice" id="discipline-note"></div>
|
||||
<div class="disc-picker" id="discipline-picker"></div>
|
||||
</div>
|
||||
|
||||
<!-- SCOPE & WORK -->
|
||||
<div class="card">
|
||||
<div class="sub-heading">Scope & Work</div>
|
||||
<div class="sub-heading">Scope & Work<span class="help-tip" data-tip="Ordered steps the crew performs. With multiple disciplines selected, each gets its own scope section and status. Use Split by Discipline to break a large package into WP01A / WP01B / WP01C instances.">i</span></div>
|
||||
<div id="flat-scope">
|
||||
<div class="field"><label>Description of Work (sequenced steps)</label>
|
||||
<div class="notice">Enter the work as ordered steps — added in sequence, the way the crew performs them.</div>
|
||||
@@ -113,7 +117,7 @@
|
||||
|
||||
<!-- MATERIAL LIST -->
|
||||
<div class="card">
|
||||
<div class="sub-heading">Material List</div>
|
||||
<div class="sub-heading">Material List<span class="help-tip" data-tip="Bill of materials — feeds kitting. On a multi-discipline package each line can be tagged to a discipline so a split routes each instance only its own materials. Import from CSV is supported.">i</span></div>
|
||||
<div class="notice">Structured bill of materials. Feeds kitting and the delivery forecast. Unit is from the Acumatica unit list.</div>
|
||||
<div class="table-wrap"><table><thead><tr><th style="width:90px">Qty</th><th style="width:120px">Unit</th><th>Description</th><th id="mat-disc-th" style="width:140px;display:none">Discipline</th><th style="width:44px"></th></tr></thead><tbody id="material-body"></tbody></table></div>
|
||||
<div class="material-actions">
|
||||
@@ -157,7 +161,7 @@
|
||||
|
||||
<!-- CONSTRAINTS / RELEASE READINESS -->
|
||||
<div class="card" id="constraint-card">
|
||||
<div class="sub-heading">Constraints — Release Readiness</div>
|
||||
<div class="sub-heading">Constraints — Release Readiness<span class="help-tip" data-tip="A package can't be Issued until every constraint is Cleared or N/A. If one reopens after release, the package drops to Issue (Hold).">i</span></div>
|
||||
<div class="notice">Per AWP, a package is not released to the field until every constraint is <strong>Cleared</strong> or <strong>N/A</strong>. If a constraint reopens after release, status drops to <strong>Issue (Hold)</strong>.</div>
|
||||
<div class="table-wrap"><table><thead><tr><th>Constraint</th><th style="width:230px">Status</th><th>Comment</th></tr></thead><tbody id="constraint-body"></tbody></table></div>
|
||||
</div>
|
||||
@@ -276,8 +280,18 @@
|
||||
<input type="file" id="cmt-import" accept="application/json" style="display:none" onchange="importComments(event)"></div></div>
|
||||
</aside>
|
||||
|
||||
<!-- STICKY SAVE BAR (always-visible save + release status) -->
|
||||
<div class="sticky-save" id="sticky-save" style="display:none">
|
||||
<span class="sticky-status" id="sticky-status"></span>
|
||||
<div class="sticky-actions">
|
||||
<button class="btn btn-ghost" onclick="savePackage(false)">Save Draft</button>
|
||||
<button class="btn btn-generate" onclick="savePackage(true)">⚡ Save & View</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="feedback-config.js"></script>
|
||||
<script src="project-data.js"></script>
|
||||
<script src="help.js"></script>
|
||||
<script src="wp-creation-app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -564,6 +564,31 @@
|
||||
.so-date { font-size:13px; font-variant-numeric:tabular-nums; }
|
||||
.so-ovr { margin-left:8px; font-size:11px; }
|
||||
|
||||
/* Collapsible form sections */
|
||||
.collapse-chev { display:inline-block; width:1em; margin-right:7px; color:var(--text-muted); font-size:11px; user-select:none; }
|
||||
.card.collapsed > :not(.section-header):not(.sub-heading) { display:none !important; }
|
||||
.card.collapsed .section-desc { display:none; }
|
||||
|
||||
/* Section nav (jump chips) */
|
||||
.section-nav-bar{ position:sticky; top:0; z-index:30; display:flex; flex-wrap:wrap; gap:6px;
|
||||
padding:8px 12px; background:rgba(255,255,255,.92); backdrop-filter:blur(4px);
|
||||
border-bottom:1px solid var(--border); }
|
||||
.section-nav-bar:empty{ display:none; }
|
||||
.sec-chip{ font-size:12px; font-weight:600; color:var(--text-muted); background:var(--surface2);
|
||||
border:1px solid var(--border); border-radius:14px; padding:4px 11px; cursor:pointer; white-space:nowrap; }
|
||||
.sec-chip:hover{ border-color:var(--accent); color:var(--accent); }
|
||||
|
||||
/* Sticky save bar */
|
||||
.sticky-save{ position:fixed; left:0; right:0; bottom:0; z-index:40; display:flex; align-items:center;
|
||||
justify-content:space-between; gap:14px; padding:10px 20px; background:#fff;
|
||||
border-top:1px solid var(--border-strong); box-shadow:0 -2px 10px rgba(20,30,50,.08); }
|
||||
.sticky-save .sticky-status{ font-size:13px; font-weight:600; }
|
||||
.sticky-save .sticky-actions{ display:flex; gap:10px; }
|
||||
.ss-ready{ color:var(--accent-green); }
|
||||
.ss-notready{ color:var(--accent-amber); }
|
||||
.ss-hold{ color:var(--red); }
|
||||
body.has-sticky-save .main{ padding-bottom:74px; }
|
||||
|
||||
/* Disciplines + per-discipline scope */
|
||||
.disc-picker { display:flex; flex-wrap:wrap; gap:8px; }
|
||||
.disc-pill { display:flex; align-items:center; gap:7px; padding:7px 13px; border:1px solid var(--border-strong);
|
||||
@@ -586,6 +611,11 @@
|
||||
.dash-metric .dm-label { font-size:11px; color:var(--text-muted); margin-top:6px; text-transform:uppercase; letter-spacing:.03em; }
|
||||
.dash-metric.dm-green .dm-val { color:var(--accent-green); }
|
||||
.dash-metric.dm-red .dm-val { color:var(--red); }
|
||||
.dash-metric[onclick] { cursor:pointer; transition:border-color .12s, box-shadow .12s; }
|
||||
.dash-metric[onclick]:hover { border-color:var(--accent); }
|
||||
.dash-metric.dm-active { border-color:var(--accent); box-shadow:0 0 0 2px var(--accent-dim); }
|
||||
.dash-chip[onclick] { cursor:pointer; }
|
||||
.dash-chip.chip-active { border-color:var(--accent); color:var(--accent); background:var(--accent-dim); }
|
||||
.dash-breakdown { display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-bottom:16px; }
|
||||
.dash-bd-title { font-size:11px; font-weight:700; text-transform:uppercase; color:var(--text-muted); margin-bottom:6px; }
|
||||
.dash-chip { display:inline-block; font-size:12px; background:var(--surface2); border:1px solid var(--border); border-radius:14px; padding:3px 10px; margin:0 6px 6px 0; }
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
server_name wp.controls.dev;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
@@ -10,3 +10,13 @@ DATABASE_URL=postgresql+psycopg://wpsuite:CHANGE_ME@localhost:5432/wpsuite
|
||||
# Only needed for CROSS-ORIGIN local development (comma-separated). In
|
||||
# production the site is same-origin via NGINX, so leave this unset.
|
||||
# CORS_ORIGINS=http://localhost:5500
|
||||
|
||||
# ── Authentication ────────────────────────────────────────────────────────────
|
||||
# Secret used to sign session cookies (JWTs). REQUIRED in production: if unset,
|
||||
# the API falls back to a random per-process key, so logins reset on every
|
||||
# restart and break across multiple gunicorn workers. Generate a strong one:
|
||||
# python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
AUTH_SECRET_KEY=CHANGE_ME_run_the_command_above
|
||||
|
||||
# How long a login lasts before re-authentication (hours). Default 12.
|
||||
# AUTH_SESSION_HOURS=12
|
||||
|
||||
@@ -13,7 +13,14 @@ browser → NGINX ──serves──> static site (index.html, …)
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| GET | `/api/health` | liveness check |
|
||||
| GET | `/api/health` | liveness check (unauthenticated) |
|
||||
| POST | `/api/auth/login` | sign in (`{username, password}`) — sets the session cookie |
|
||||
| POST | `/api/auth/logout` | clear the session cookie |
|
||||
| GET | `/api/auth/me` | the logged-in user |
|
||||
| POST | `/api/auth/password` | change your own password |
|
||||
| GET | `/api/auth/users` | list accounts (**admin**) |
|
||||
| POST | `/api/auth/users` | create an account (**admin**) |
|
||||
| DELETE | `/api/auth/users/{id}` | delete an account (**admin**) |
|
||||
| POST | `/api/sops` | create/update a SOP (upsert by `id`) |
|
||||
| GET | `/api/sops` | list SOP summaries |
|
||||
| GET | `/api/sops/latest?complete=true` | most recent (complete) SOP |
|
||||
@@ -33,6 +40,53 @@ fields (name, number, status, …) are promoted to columns for listing/filtering
|
||||
|
||||
---
|
||||
|
||||
## Login portal (user accounts)
|
||||
|
||||
The suite is gated by a username/password login. Sign-in issues a signed JWT
|
||||
that rides in an **HttpOnly, SameSite=Lax** cookie (`wp_session`); the cookie is
|
||||
marked **Secure** automatically whenever the request arrives over HTTPS (via
|
||||
NGINX's `X-Forwarded-Proto`). There is no server-side session store — each
|
||||
request is validated by checking the cookie's signature and expiry.
|
||||
|
||||
**The real security boundary is the API:** every `/api/` data route is refused
|
||||
with `401` unless a valid session cookie is present (see `auth_gate` in
|
||||
`app.py`). The static pages additionally include `auth-guard.js`, which redirects
|
||||
to `login.html` when there's no session — that's for UX, not protection.
|
||||
|
||||
Passwords are stored only as **bcrypt** hashes (`server/auth.py`). Roles are
|
||||
`admin` (may manage users) and `user`.
|
||||
|
||||
### Set the signing secret
|
||||
|
||||
Add `AUTH_SECRET_KEY` to `.env` (see `.env.example`). **Required in production** —
|
||||
without it the API uses a random per-process key, so logins reset on restart.
|
||||
|
||||
```bash
|
||||
python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
```
|
||||
|
||||
### Create the first admin
|
||||
|
||||
The `/api/auth/users` endpoint needs an existing admin, so bootstrap one from a
|
||||
shell (run from the **project root**, like uvicorn):
|
||||
|
||||
```bash
|
||||
python -m server.manage_users create-admin alice --name "Alice Smith"
|
||||
# prompts for a password (min 8 chars)
|
||||
```
|
||||
|
||||
In Docker:
|
||||
|
||||
```bash
|
||||
docker compose exec api python -m server.manage_users create-admin alice --name "Alice Smith"
|
||||
```
|
||||
|
||||
Other commands: `create <user> --role user`, `list`, `reset-password <user>`,
|
||||
`disable <user>`, `enable <user>`. After that, admins can add users through the
|
||||
API (or you can keep using the CLI).
|
||||
|
||||
---
|
||||
|
||||
## Local dev
|
||||
|
||||
```bash
|
||||
@@ -237,14 +291,23 @@ docker compose down -v
|
||||
|
||||
## Quick test
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/api/comments \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"type":"home_feedback","name":"Test","text":"hello"}'
|
||||
`/api/health` is open; data routes now require a session, so log in first and
|
||||
reuse the cookie jar:
|
||||
|
||||
curl http://127.0.0.1:8000/api/comments
|
||||
```bash
|
||||
curl http://127.0.0.1:8000/api/health # {"ok":true} — no auth needed
|
||||
|
||||
# Sign in, saving the session cookie to a jar
|
||||
curl -c jar.txt -X POST http://127.0.0.1:8000/api/auth/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"username":"alice","password":"<password>"}'
|
||||
|
||||
# Reuse the cookie on protected routes
|
||||
curl -b jar.txt http://127.0.0.1:8000/api/comments
|
||||
```
|
||||
|
||||
Without the cookie, protected routes return `401 {"detail":"Not authenticated"}`.
|
||||
|
||||
Or via the nginx proxy (replace with your hostname):
|
||||
|
||||
```bash
|
||||
|
||||
318
server/app.py
318
server/app.py
@@ -12,14 +12,16 @@ import os
|
||||
import uuid
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import FastAPI, Depends, HTTPException, Query
|
||||
from fastapi import FastAPI, Depends, HTTPException, Query, Request, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import select, delete
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .db import Base, engine, get_db
|
||||
from . import models
|
||||
from . import models, auth
|
||||
|
||||
# Create tables on startup. (For schema changes later, switch to Alembic.)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
@@ -28,18 +30,80 @@ app = FastAPI(title="Work Package Suite API", docs_url="/api/docs", openapi_url=
|
||||
|
||||
# Same-origin in production (NGINX), so CORS is normally unnecessary. For
|
||||
# cross-origin local dev, set CORS_ORIGINS="http://localhost:5500,..."
|
||||
# allow_credentials is required so the browser sends the session cookie.
|
||||
_origins = [o for o in os.getenv("CORS_ORIGINS", "").split(",") if o]
|
||||
if _origins:
|
||||
app.add_middleware(
|
||||
CORSMiddleware, allow_origins=_origins,
|
||||
CORSMiddleware, allow_origins=_origins, allow_credentials=True,
|
||||
allow_methods=["*"], allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# ── Authentication gate ────────────────────────────────────────────────────────
|
||||
# Every /api/ data route requires a valid session cookie. Login, health, and the
|
||||
# docs are exempt (see auth._needs_auth). This is the real security boundary —
|
||||
# the static pages are only client-side guarded for UX. OPTIONS (CORS preflight)
|
||||
# is always allowed so the browser can negotiate before sending credentials.
|
||||
@app.middleware("http")
|
||||
async def auth_gate(request: Request, call_next):
|
||||
if request.method != "OPTIONS" and auth._needs_auth(request.url.path):
|
||||
if not auth.is_request_authenticated(request):
|
||||
return JSONResponse(status_code=401, content={"detail": "Not authenticated"})
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
def gen_id(prefix: str) -> str:
|
||||
return f"{prefix}_{uuid.uuid4().hex[:12]}"
|
||||
|
||||
|
||||
# ── Per-project access control ─────────────────────────────────────────────────
|
||||
# A non-admin user may only touch projects they're a member of (project_members).
|
||||
# Admins bypass all of this. Resources with no project_id (legacy/orphan) are not
|
||||
# gated. List endpoints are scoped to accessible projects; single-resource and
|
||||
# mutating endpoints raise 403 on no access.
|
||||
def accessible_project_ids(db: Session, user: "models.User"):
|
||||
"""Return the set of project ids the user may access, or None for 'all' (admin)."""
|
||||
if user.role == "admin":
|
||||
return None
|
||||
rows = db.scalars(
|
||||
select(models.ProjectMember.project_id).where(models.ProjectMember.user_id == user.id)
|
||||
).all()
|
||||
return set(rows)
|
||||
|
||||
|
||||
def require_project_access(db: Session, user: "models.User", project_id: Optional[str]) -> None:
|
||||
if user.role == "admin" or project_id is None:
|
||||
return
|
||||
ok = db.scalar(
|
||||
select(models.ProjectMember.id).where(
|
||||
(models.ProjectMember.user_id == user.id)
|
||||
& (models.ProjectMember.project_id == project_id)
|
||||
)
|
||||
)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=403, detail="You don't have access to this project")
|
||||
|
||||
|
||||
def scope_to_access(stmt, column, db: Session, user: "models.User"):
|
||||
"""Restrict a SELECT to the user's accessible projects (no-op for admins)."""
|
||||
ids = accessible_project_ids(db, user)
|
||||
if ids is None:
|
||||
return stmt
|
||||
return stmt.where(column.in_(ids))
|
||||
|
||||
|
||||
def grant_project_access(db: Session, user_id: str, project_id: str) -> None:
|
||||
"""Add a (user, project) membership if it isn't already there."""
|
||||
exists = db.scalar(
|
||||
select(models.ProjectMember.id).where(
|
||||
(models.ProjectMember.user_id == user_id)
|
||||
& (models.ProjectMember.project_id == project_id)
|
||||
)
|
||||
)
|
||||
if not exists:
|
||||
db.add(models.ProjectMember(id=gen_id("pm"), user_id=user_id, project_id=project_id))
|
||||
|
||||
|
||||
# ── Request bodies ───────────────────────────────────────────────────────────
|
||||
class ProjectIn(BaseModel):
|
||||
id: Optional[str] = None
|
||||
@@ -100,10 +164,180 @@ def health():
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Authentication ─────────────────────────────────────────────────────────────
|
||||
class LoginIn(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class NewUserIn(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
full_name: str = ""
|
||||
email: str = ""
|
||||
role: str = "user" # 'admin' | 'user'
|
||||
|
||||
|
||||
class PasswordChangeIn(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class AdminPasswordIn(BaseModel):
|
||||
new_password: str
|
||||
|
||||
|
||||
class ActiveIn(BaseModel):
|
||||
is_active: bool
|
||||
|
||||
|
||||
class ProjectAssignIn(BaseModel):
|
||||
project_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
@app.post("/api/auth/login")
|
||||
def login(body: LoginIn, request: Request, response: Response, db: Session = Depends(get_db)):
|
||||
"""Verify credentials and, on success, set the HttpOnly session cookie."""
|
||||
user = auth.find_user(db, body.username)
|
||||
# Always run a hash comparison to avoid leaking which usernames exist via
|
||||
# response timing; verify_password tolerates an empty hash.
|
||||
valid = auth.verify_password(body.password, user.password_hash if user else "")
|
||||
if not user or not valid:
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=403, detail="Account is disabled")
|
||||
user.last_login_at = models.utcnow()
|
||||
db.commit()
|
||||
token = auth.create_token(user)
|
||||
auth.set_session_cookie(response, request, token)
|
||||
return {"user": user.to_dict()}
|
||||
|
||||
|
||||
@app.post("/api/auth/logout")
|
||||
def logout(response: Response):
|
||||
auth.clear_session_cookie(response)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/auth/me")
|
||||
def whoami(user: models.User = Depends(auth.get_current_user)):
|
||||
"""Who is logged in. The frontend guard calls this on every page load."""
|
||||
return {"user": user.to_dict()}
|
||||
|
||||
|
||||
@app.post("/api/auth/password")
|
||||
def change_password(body: PasswordChangeIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
if not auth.verify_password(body.current_password, user.password_hash):
|
||||
raise HTTPException(status_code=400, detail="Current password is incorrect")
|
||||
if len(body.new_password) < 8:
|
||||
raise HTTPException(status_code=400, detail="New password must be at least 8 characters")
|
||||
user.password_hash = auth.hash_password(body.new_password)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── User administration (admin only) ────────────────────────────────────────────
|
||||
@app.get("/api/auth/users")
|
||||
def list_users(_admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||
rows = db.scalars(select(models.User).order_by(models.User.username)).all()
|
||||
return [u.to_dict() for u in rows]
|
||||
|
||||
|
||||
@app.post("/api/auth/users")
|
||||
def create_user(body: NewUserIn, _admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||
if len(body.password) < 8:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
||||
if body.role not in ("admin", "user"):
|
||||
raise HTTPException(status_code=400, detail="role must be 'admin' or 'user'")
|
||||
if auth.find_user(db, body.username):
|
||||
raise HTTPException(status_code=409, detail="A user with that username already exists")
|
||||
u = models.User(
|
||||
id=gen_id("user"),
|
||||
username=body.username.strip(),
|
||||
email=body.email.strip(),
|
||||
full_name=body.full_name.strip(),
|
||||
password_hash=auth.hash_password(body.password),
|
||||
role=body.role,
|
||||
)
|
||||
db.add(u)
|
||||
db.commit()
|
||||
db.refresh(u)
|
||||
return u.to_dict()
|
||||
|
||||
|
||||
@app.post("/api/auth/users/{user_id}/password")
|
||||
def admin_reset_password(user_id: str, body: AdminPasswordIn, _admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||
u = db.get(models.User, user_id)
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if len(body.new_password) < 8:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
||||
u.password_hash = auth.hash_password(body.new_password)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/auth/users/{user_id}/active")
|
||||
def set_user_active(user_id: str, body: ActiveIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||
u = db.get(models.User, user_id)
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if u.id == admin.id and not body.is_active:
|
||||
raise HTTPException(status_code=400, detail="You cannot disable your own account")
|
||||
u.is_active = body.is_active
|
||||
db.commit()
|
||||
return u.to_dict()
|
||||
|
||||
|
||||
@app.delete("/api/auth/users/{user_id}")
|
||||
def delete_user(user_id: str, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||
u = db.get(models.User, user_id)
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if u.id == admin.id:
|
||||
raise HTTPException(status_code=400, detail="You cannot delete your own account")
|
||||
db.delete(u)
|
||||
db.commit()
|
||||
return {"deleted": user_id}
|
||||
|
||||
|
||||
@app.get("/api/auth/users/{user_id}/projects")
|
||||
def get_user_projects(user_id: str, _admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||
"""Which projects a user is assigned to, plus the full project list for the
|
||||
assignment UI. (Admins implicitly access every project regardless.)"""
|
||||
u = db.get(models.User, user_id)
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
assigned = db.scalars(select(models.ProjectMember.project_id).where(models.ProjectMember.user_id == user_id)).all()
|
||||
projects = db.scalars(select(models.Project).order_by(models.Project.name)).all()
|
||||
return {
|
||||
"user": u.to_dict(),
|
||||
"assigned": list(assigned),
|
||||
"projects": [{"id": p.id, "name": p.name, "number": p.number} for p in projects],
|
||||
}
|
||||
|
||||
|
||||
@app.put("/api/auth/users/{user_id}/projects")
|
||||
def set_user_projects(user_id: str, body: ProjectAssignIn, _admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||
"""Replace a user's project assignments with the given set."""
|
||||
u = db.get(models.User, user_id)
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
valid = set(db.scalars(select(models.Project.id).where(models.Project.id.in_(body.project_ids))).all()) if body.project_ids else set()
|
||||
db.execute(delete(models.ProjectMember).where(models.ProjectMember.user_id == user_id))
|
||||
for pid in valid:
|
||||
db.add(models.ProjectMember(id=gen_id("pm"), user_id=user_id, project_id=pid))
|
||||
db.commit()
|
||||
return {"assigned": sorted(valid)}
|
||||
|
||||
|
||||
# ── Projects ─────────────────────────────────────────────────────────────────
|
||||
@app.post("/api/projects")
|
||||
def upsert_project(body: ProjectIn, db: Session = Depends(get_db)):
|
||||
def upsert_project(body: ProjectIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
proj = db.get(models.Project, body.id) if body.id else None
|
||||
is_new = proj is None
|
||||
if not is_new:
|
||||
require_project_access(db, user, proj.id)
|
||||
if proj is None:
|
||||
proj = models.Project(id=body.id or gen_id("proj"))
|
||||
db.add(proj)
|
||||
@@ -116,29 +350,36 @@ def upsert_project(body: ProjectIn, db: Session = Depends(get_db)):
|
||||
proj.created_by = body.created_by or proj.created_by
|
||||
proj.data = body.data
|
||||
db.commit()
|
||||
# A project created by a non-admin auto-grants its creator access.
|
||||
if is_new and user.role != "admin":
|
||||
grant_project_access(db, user.id, proj.id)
|
||||
db.commit()
|
||||
db.refresh(proj)
|
||||
return proj.to_dict()
|
||||
|
||||
|
||||
@app.get("/api/projects")
|
||||
def list_projects(db: Session = Depends(get_db)):
|
||||
rows = db.scalars(select(models.Project).order_by(models.Project.updated_at.desc())).all()
|
||||
def list_projects(user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
stmt = scope_to_access(select(models.Project), models.Project.id, db, user).order_by(models.Project.updated_at.desc())
|
||||
rows = db.scalars(stmt).all()
|
||||
return [p.summary() for p in rows]
|
||||
|
||||
|
||||
@app.get("/api/projects/{project_id}")
|
||||
def get_project(project_id: str, db: Session = Depends(get_db)):
|
||||
def get_project(project_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
proj = db.get(models.Project, project_id)
|
||||
if not proj:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
require_project_access(db, user, proj.id)
|
||||
return proj.to_dict()
|
||||
|
||||
|
||||
@app.delete("/api/projects/{project_id}")
|
||||
def delete_project(project_id: str, db: Session = Depends(get_db)):
|
||||
def delete_project(project_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
proj = db.get(models.Project, project_id)
|
||||
if not proj:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
require_project_access(db, user, proj.id)
|
||||
db.delete(proj)
|
||||
db.commit()
|
||||
return {"deleted": project_id}
|
||||
@@ -146,8 +387,11 @@ def delete_project(project_id: str, db: Session = Depends(get_db)):
|
||||
|
||||
# ── SOPs ─────────────────────────────────────────────────────────────────────
|
||||
@app.post("/api/sops")
|
||||
def upsert_sop(body: SopIn, db: Session = Depends(get_db)):
|
||||
def upsert_sop(body: SopIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
require_project_access(db, user, body.project_id)
|
||||
sop = db.get(models.Sop, body.id) if body.id else None
|
||||
if sop is not None:
|
||||
require_project_access(db, user, sop.project_id)
|
||||
if sop is None:
|
||||
sop = models.Sop(id=body.id or gen_id("sop"))
|
||||
db.add(sop)
|
||||
@@ -163,21 +407,25 @@ def upsert_sop(body: SopIn, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@app.get("/api/sops")
|
||||
def list_sops(project_id: Optional[str] = Query(None), db: Session = Depends(get_db)):
|
||||
def list_sops(project_id: Optional[str] = Query(None), full: bool = Query(False), user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
stmt = select(models.Sop)
|
||||
if project_id:
|
||||
stmt = stmt.where(models.Sop.project_id == project_id)
|
||||
stmt = scope_to_access(stmt, models.Sop.project_id, db, user)
|
||||
rows = db.scalars(stmt.order_by(models.Sop.updated_at.desc())).all()
|
||||
return [s.summary() for s in rows]
|
||||
# full=true includes the data JSON (the whole SOP document) for hydration;
|
||||
# the default summary view stays lean for listing.
|
||||
return [(s.to_dict() if full else s.summary()) for s in rows]
|
||||
|
||||
|
||||
@app.get("/api/sops/latest")
|
||||
def latest_sop(complete: Optional[bool] = None, project_id: Optional[str] = Query(None), db: Session = Depends(get_db)):
|
||||
def latest_sop(complete: Optional[bool] = None, project_id: Optional[str] = Query(None), user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
stmt = select(models.Sop)
|
||||
if complete is not None:
|
||||
stmt = stmt.where(models.Sop.complete == complete)
|
||||
if project_id:
|
||||
stmt = stmt.where(models.Sop.project_id == project_id)
|
||||
stmt = scope_to_access(stmt, models.Sop.project_id, db, user)
|
||||
sop = db.scalars(stmt.order_by(models.Sop.updated_at.desc()).limit(1)).first()
|
||||
if not sop:
|
||||
raise HTTPException(status_code=404, detail="No SOP found")
|
||||
@@ -185,18 +433,20 @@ def latest_sop(complete: Optional[bool] = None, project_id: Optional[str] = Quer
|
||||
|
||||
|
||||
@app.get("/api/sops/{sop_id}")
|
||||
def get_sop(sop_id: str, db: Session = Depends(get_db)):
|
||||
def get_sop(sop_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
sop = db.get(models.Sop, sop_id)
|
||||
if not sop:
|
||||
raise HTTPException(status_code=404, detail="SOP not found")
|
||||
require_project_access(db, user, sop.project_id)
|
||||
return sop.to_dict()
|
||||
|
||||
|
||||
@app.delete("/api/sops/{sop_id}")
|
||||
def delete_sop(sop_id: str, db: Session = Depends(get_db)):
|
||||
def delete_sop(sop_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
sop = db.get(models.Sop, sop_id)
|
||||
if not sop:
|
||||
raise HTTPException(status_code=404, detail="SOP not found")
|
||||
require_project_access(db, user, sop.project_id)
|
||||
db.delete(sop)
|
||||
db.commit()
|
||||
return {"deleted": sop_id}
|
||||
@@ -204,8 +454,11 @@ def delete_sop(sop_id: str, db: Session = Depends(get_db)):
|
||||
|
||||
# ── Work Packages ────────────────────────────────────────────────────────────
|
||||
@app.post("/api/wps")
|
||||
def upsert_wp(body: WpIn, db: Session = Depends(get_db)):
|
||||
def upsert_wp(body: WpIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
require_project_access(db, user, body.project_id)
|
||||
wp = db.get(models.WorkPackage, body.id) if body.id else None
|
||||
if wp is not None:
|
||||
require_project_access(db, user, wp.project_id)
|
||||
if wp is None:
|
||||
wp = models.WorkPackage(id=body.id or gen_id("wp"))
|
||||
db.add(wp)
|
||||
@@ -229,6 +482,8 @@ def list_wps(
|
||||
sop_id: Optional[str] = Query(None),
|
||||
parent_id: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
full: bool = Query(False),
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
stmt = select(models.WorkPackage)
|
||||
@@ -240,12 +495,15 @@ def list_wps(
|
||||
stmt = stmt.where(models.WorkPackage.parent_id == parent_id)
|
||||
if status:
|
||||
stmt = stmt.where(models.WorkPackage.status == status)
|
||||
stmt = scope_to_access(stmt, models.WorkPackage.project_id, db, user)
|
||||
rows = db.scalars(stmt.order_by(models.WorkPackage.updated_at.desc())).all()
|
||||
return [w.summary() for w in rows]
|
||||
# full=true includes the data JSON (full package document) so the creator can
|
||||
# rehydrate everything in one request; default stays lean for listing.
|
||||
return [(w.to_dict() if full else w.summary()) for w in rows]
|
||||
|
||||
|
||||
@app.get("/api/wps/metrics")
|
||||
def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] = Query(None), db: Session = Depends(get_db)):
|
||||
def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] = Query(None), user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
"""Aggregates for the dashboard. Masters (data.split == true) are excluded
|
||||
from counts so a split package's hours aren't double-counted with its
|
||||
instances."""
|
||||
@@ -254,6 +512,7 @@ def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] =
|
||||
stmt = stmt.where(models.WorkPackage.project_id == project_id)
|
||||
if sop_id:
|
||||
stmt = stmt.where(models.WorkPackage.sop_id == sop_id)
|
||||
stmt = scope_to_access(stmt, models.WorkPackage.project_id, db, user)
|
||||
rows = db.scalars(stmt).all()
|
||||
|
||||
by_status: dict[str, int] = {}
|
||||
@@ -287,30 +546,33 @@ def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] =
|
||||
|
||||
|
||||
@app.get("/api/wps/{wp_id}")
|
||||
def get_wp(wp_id: str, db: Session = Depends(get_db)):
|
||||
def get_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
wp = db.get(models.WorkPackage, wp_id)
|
||||
if not wp:
|
||||
raise HTTPException(status_code=404, detail="Work Package not found")
|
||||
require_project_access(db, user, wp.project_id)
|
||||
return wp.to_dict()
|
||||
|
||||
|
||||
@app.delete("/api/wps/{wp_id}")
|
||||
def delete_wp(wp_id: str, db: Session = Depends(get_db)):
|
||||
def delete_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
wp = db.get(models.WorkPackage, wp_id)
|
||||
if not wp:
|
||||
raise HTTPException(status_code=404, detail="Work Package not found")
|
||||
require_project_access(db, user, wp.project_id)
|
||||
db.delete(wp)
|
||||
db.commit()
|
||||
return {"deleted": wp_id}
|
||||
|
||||
|
||||
@app.post("/api/wps/{wp_id}/issue")
|
||||
def issue_wp(wp_id: str, db: Session = Depends(get_db)):
|
||||
def issue_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
"""Release a Work Package to the field. Refuses if any constraint is still
|
||||
open (the AWP release gate)."""
|
||||
wp = db.get(models.WorkPackage, wp_id)
|
||||
if not wp:
|
||||
raise HTTPException(status_code=404, detail="Work Package not found")
|
||||
require_project_access(db, user, wp.project_id)
|
||||
constraints = (wp.data or {}).get("constraints") or []
|
||||
open_names = [c.get("name") for c in constraints if c.get("status") == "open"]
|
||||
if open_names:
|
||||
@@ -323,10 +585,11 @@ def issue_wp(wp_id: str, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@app.post("/api/wps/{wp_id}/status")
|
||||
def set_wp_status(wp_id: str, body: StatusIn, db: Session = Depends(get_db)):
|
||||
def set_wp_status(wp_id: str, body: StatusIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
wp = db.get(models.WorkPackage, wp_id)
|
||||
if not wp:
|
||||
raise HTTPException(status_code=404, detail="Work Package not found")
|
||||
require_project_access(db, user, wp.project_id)
|
||||
wp.status = body.status
|
||||
if body.status == "Issued" and wp.issued_at is None:
|
||||
wp.issued_at = models.utcnow()
|
||||
@@ -385,3 +648,16 @@ def list_comments(
|
||||
stmt = stmt.where(models.Comment.step == step)
|
||||
rows = db.scalars(stmt.order_by(models.Comment.created_at.desc())).all()
|
||||
return [c.to_dict() for c in rows]
|
||||
|
||||
|
||||
# ── Local dev convenience: serve the static site from this app ──────────────────
|
||||
# In production NGINX serves html/ and only proxies /api/ here, so this app never
|
||||
# receives "/" requests, and the api Docker image doesn't even include html/ — so
|
||||
# this mount stays inactive there. Locally (plain uvicorn, no NGINX) it lets you
|
||||
# open the whole suite at http://localhost:8000/ with the API on the SAME origin,
|
||||
# so the session cookie just works (no CORS, no Secure-cookie headache).
|
||||
#
|
||||
# Mounted LAST so the /api/* routes above always match first.
|
||||
_html_dir = os.path.join(os.path.dirname(__file__), "..", "html")
|
||||
if os.path.isdir(_html_dir):
|
||||
app.mount("/", StaticFiles(directory=_html_dir, html=True), name="site")
|
||||
|
||||
186
server/auth.py
Normal file
186
server/auth.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""Authentication for the Work Package Suite.
|
||||
|
||||
A self-contained username/password login. Passwords are stored only as bcrypt
|
||||
hashes; a successful login issues a signed JWT that rides in an HttpOnly cookie
|
||||
(`wp_session`). Because the token is signed and self-validating, there is no
|
||||
server-side session store — every request is checked by verifying the cookie's
|
||||
signature and expiry (see `auth_gate` and `get_current_user`).
|
||||
|
||||
Security model:
|
||||
• The real boundary is `auth_gate` (middleware in app.py): every /api/ data
|
||||
route is refused with 401 unless a valid session cookie is present.
|
||||
• The cookie is HttpOnly (JS can't read it → XSS can't steal the session),
|
||||
SameSite=Lax (blunts CSRF), and Secure whenever the request arrives over
|
||||
HTTPS (detected via X-Forwarded-Proto behind NGINX).
|
||||
• The signing secret comes from AUTH_SECRET_KEY. In production this MUST be
|
||||
set; if it is missing we fall back to a random per-process key (which logs a
|
||||
warning and invalidates every session on restart) so dev still works.
|
||||
|
||||
Roles: 'admin' (may manage users) and 'user'.
|
||||
"""
|
||||
import os
|
||||
import secrets
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import bcrypt
|
||||
import jwt
|
||||
from fastapi import Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .db import get_db
|
||||
from . import models
|
||||
|
||||
log = logging.getLogger("wpsuite.auth")
|
||||
|
||||
COOKIE_NAME = "wp_session"
|
||||
JWT_ALG = "HS256"
|
||||
# How long a login lasts before the user must sign in again.
|
||||
SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "12"))
|
||||
|
||||
# Paths under /api that do NOT require a session (login itself, health, docs).
|
||||
_EXEMPT_PREFIXES = ("/api/auth/",)
|
||||
_EXEMPT_EXACT = {
|
||||
"/api/health",
|
||||
"/api/docs",
|
||||
"/api/openapi.json",
|
||||
"/api/docs/oauth2-redirect",
|
||||
"/api/redoc",
|
||||
}
|
||||
|
||||
|
||||
def _load_secret() -> str:
|
||||
s = os.getenv("AUTH_SECRET_KEY")
|
||||
if s:
|
||||
return s
|
||||
# No secret configured: generate an ephemeral one so the app still runs in
|
||||
# dev. Sessions won't survive a restart, and this is unsafe across multiple
|
||||
# workers — production must set AUTH_SECRET_KEY.
|
||||
log.warning(
|
||||
"AUTH_SECRET_KEY is not set — using a random ephemeral key. "
|
||||
"Logins will reset on restart and break across multiple workers. "
|
||||
"Set AUTH_SECRET_KEY in the environment for production."
|
||||
)
|
||||
return secrets.token_urlsafe(48)
|
||||
|
||||
|
||||
SECRET_KEY = _load_secret()
|
||||
|
||||
|
||||
# ── password hashing ──────────────────────────────────────────────────────────
|
||||
def hash_password(plain: str) -> str:
|
||||
# bcrypt operates on at most 72 bytes; longer inputs are truncated by the
|
||||
# algorithm. Encode explicitly so non-ASCII passwords hash consistently.
|
||||
return bcrypt.hashpw(plain.encode("utf-8")[:72], bcrypt.gensalt()).decode("ascii")
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
if not hashed:
|
||||
return False
|
||||
try:
|
||||
return bcrypt.checkpw(plain.encode("utf-8")[:72], hashed.encode("ascii"))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
# ── tokens ──────────────────────────────────────────────────────────────────
|
||||
def create_token(user: "models.User") -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"sub": user.id,
|
||||
"username": user.username,
|
||||
"role": user.role,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=SESSION_HOURS),
|
||||
}
|
||||
return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALG)
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[dict]:
|
||||
"""Return the token claims if the signature and expiry are valid, else None."""
|
||||
try:
|
||||
return jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
|
||||
except jwt.PyJWTError:
|
||||
return None
|
||||
|
||||
|
||||
# ── cookie helpers ────────────────────────────────────────────────────────────
|
||||
def _is_https(request: Request) -> bool:
|
||||
# Behind NGINX, TLS is terminated at the proxy and forwarded as plain HTTP,
|
||||
# so trust X-Forwarded-Proto (set in nginx-wp-suite.conf) when present.
|
||||
xfp = request.headers.get("x-forwarded-proto", "")
|
||||
if xfp:
|
||||
return xfp.split(",")[0].strip().lower() == "https"
|
||||
return request.url.scheme == "https"
|
||||
|
||||
|
||||
def set_session_cookie(response: Response, request: Request, token: str) -> None:
|
||||
response.set_cookie(
|
||||
key=COOKIE_NAME,
|
||||
value=token,
|
||||
max_age=SESSION_HOURS * 3600,
|
||||
httponly=True,
|
||||
secure=_is_https(request),
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def clear_session_cookie(response: Response) -> None:
|
||||
response.delete_cookie(COOKIE_NAME, path="/")
|
||||
|
||||
|
||||
# ── request gate (used as middleware in app.py) ─────────────────────────────────
|
||||
def _needs_auth(path: str) -> bool:
|
||||
if not path.startswith("/api/"):
|
||||
return False # static assets are served by NGINX, not this app
|
||||
if path in _EXEMPT_EXACT:
|
||||
return False
|
||||
return not any(path.startswith(p) for p in _EXEMPT_PREFIXES)
|
||||
|
||||
|
||||
def is_request_authenticated(request: Request) -> Optional[dict]:
|
||||
"""Validate the session cookie on a raw request. Returns claims or None.
|
||||
Used by the middleware gate, which has no dependency-injection context."""
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
if not token:
|
||||
return None
|
||||
return decode_token(token)
|
||||
|
||||
|
||||
# ── dependencies (used inside route handlers) ───────────────────────────────────
|
||||
def get_current_user(request: Request, db: Session = Depends(get_db)) -> "models.User":
|
||||
"""Resolve the logged-in user from the session cookie, or raise 401.
|
||||
|
||||
Unlike the middleware gate (which only checks the token signature), this also
|
||||
confirms the account still exists and is active — so disabling a user takes
|
||||
effect on their next request."""
|
||||
claims = is_request_authenticated(request)
|
||||
if not claims:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
user = db.get(models.User, claims.get("sub"))
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Account is inactive")
|
||||
return user
|
||||
|
||||
|
||||
def require_admin(user: "models.User" = Depends(get_current_user)) -> "models.User":
|
||||
if user.role != "admin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||
return user
|
||||
|
||||
|
||||
# ── account helpers (shared by routes and the CLI) ──────────────────────────────
|
||||
def find_user(db: Session, username: str) -> Optional["models.User"]:
|
||||
"""Look up by username, case-insensitively (also matches on email)."""
|
||||
uname = (username or "").strip().lower()
|
||||
if not uname:
|
||||
return None
|
||||
return db.scalars(
|
||||
select(models.User).where(
|
||||
(func.lower(models.User.username) == uname)
|
||||
| (func.lower(models.User.email) == uname)
|
||||
)
|
||||
).first()
|
||||
43
server/db.py
43
server/db.py
@@ -1,28 +1,49 @@
|
||||
"""Database engine and session setup.
|
||||
|
||||
The connection string comes from the DATABASE_URL environment variable, e.g.
|
||||
postgresql+psycopg://wpsuite:secret@db-host:5432/wpsuite
|
||||
Connection precedence:
|
||||
1. POSTGRES_USER + POSTGRES_PASSWORD + POSTGRES_DB (preferred) — the URL is
|
||||
built with SQLAlchemy's URL.create(), which encodes the password for you,
|
||||
so passwords with special characters (@ ! # : / …) need NO manual escaping.
|
||||
Host/port default to POSTGRES_HOST=db / POSTGRES_PORT=5432.
|
||||
2. DATABASE_URL — a full SQLAlchemy URL, if you'd rather supply one directly
|
||||
(you must URL-encode any special characters in the password yourself).
|
||||
3. Neither set → a local SQLite file, so the API runs anywhere without Postgres.
|
||||
|
||||
If unset, it falls back to a local SQLite file so the API can be run and tested
|
||||
on any machine without Postgres. The schema is identical either way (SQLAlchemy
|
||||
handles the dialect differences).
|
||||
The schema is identical either way (SQLAlchemy handles dialect differences).
|
||||
"""
|
||||
import os
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import create_engine, URL
|
||||
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
||||
|
||||
# Load a local .env if present (dev convenience). In production the DATABASE_URL
|
||||
# normally comes from the systemd unit's Environment / EnvironmentFile instead.
|
||||
# Load a local .env if present (dev convenience).
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./wpsuite.db")
|
||||
|
||||
# SQLite needs this flag to be used from FastAPI's threadpool; Postgres ignores it.
|
||||
connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
|
||||
def _resolve_url():
|
||||
user = os.getenv("POSTGRES_USER")
|
||||
pw = os.getenv("POSTGRES_PASSWORD")
|
||||
dbname = os.getenv("POSTGRES_DB")
|
||||
if user and pw and dbname:
|
||||
# Build from components — password is encoded automatically.
|
||||
return URL.create(
|
||||
"postgresql+psycopg",
|
||||
username=user, password=pw,
|
||||
host=os.getenv("POSTGRES_HOST", "db"),
|
||||
port=int(os.getenv("POSTGRES_PORT", "5432")),
|
||||
database=dbname,
|
||||
)
|
||||
return os.getenv("DATABASE_URL") or "sqlite:///./wpsuite.db"
|
||||
|
||||
|
||||
DATABASE_URL = _resolve_url()
|
||||
|
||||
# SQLite needs this flag from FastAPI's threadpool; Postgres ignores it.
|
||||
_is_sqlite = isinstance(DATABASE_URL, str) and DATABASE_URL.startswith("sqlite")
|
||||
connect_args = {"check_same_thread": False} if _is_sqlite else {}
|
||||
|
||||
engine = create_engine(DATABASE_URL, connect_args=connect_args, pool_pre_ping=True, future=True)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
||||
|
||||
144
server/manage_users.py
Normal file
144
server/manage_users.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""Command-line user management for the Work Package Suite.
|
||||
|
||||
Use this to create the FIRST admin account (the /api/auth/users endpoint needs an
|
||||
existing admin, so you have to bootstrap one here), and for occasional account
|
||||
maintenance from a shell on the server.
|
||||
|
||||
Run from the PROJECT ROOT (same place you run uvicorn), so the package imports
|
||||
and .env resolve the same way the API does:
|
||||
|
||||
python -m server.manage_users create-admin alice --name "Alice Smith"
|
||||
python -m server.manage_users create bob --role user --name "Bob Jones"
|
||||
python -m server.manage_users list
|
||||
python -m server.manage_users reset-password alice
|
||||
python -m server.manage_users disable bob
|
||||
python -m server.manage_users enable bob
|
||||
|
||||
If --password is omitted you'll be prompted (input is hidden). Passwords must be
|
||||
at least 8 characters.
|
||||
"""
|
||||
import argparse
|
||||
import getpass
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
from .db import SessionLocal, Base, engine
|
||||
from . import models, auth
|
||||
|
||||
|
||||
def _gen_id() -> str:
|
||||
return f"user_{uuid.uuid4().hex[:12]}"
|
||||
|
||||
|
||||
def _prompt_password(provided: str | None) -> str:
|
||||
pw = provided
|
||||
if not pw:
|
||||
pw = getpass.getpass("New password: ")
|
||||
confirm = getpass.getpass("Confirm password: ")
|
||||
if pw != confirm:
|
||||
sys.exit("Passwords do not match.")
|
||||
if len(pw) < 8:
|
||||
sys.exit("Password must be at least 8 characters.")
|
||||
return pw
|
||||
|
||||
|
||||
def cmd_create(args, role: str | None = None) -> None:
|
||||
role = role or args.role
|
||||
if role not in ("admin", "user"):
|
||||
sys.exit("role must be 'admin' or 'user'")
|
||||
pw = _prompt_password(getattr(args, "password", None))
|
||||
with SessionLocal() as db:
|
||||
if auth.find_user(db, args.username):
|
||||
sys.exit(f"A user named '{args.username}' already exists.")
|
||||
u = models.User(
|
||||
id=_gen_id(),
|
||||
username=args.username.strip(),
|
||||
full_name=(args.name or "").strip(),
|
||||
email=(args.email or "").strip(),
|
||||
password_hash=auth.hash_password(pw),
|
||||
role=role,
|
||||
)
|
||||
db.add(u)
|
||||
db.commit()
|
||||
print(f"Created {role}: {u.username} (id={u.id})")
|
||||
|
||||
|
||||
def cmd_list(args) -> None:
|
||||
with SessionLocal() as db:
|
||||
rows = db.query(models.User).order_by(models.User.username).all()
|
||||
if not rows:
|
||||
print("No users yet. Create one with: create-admin <username>")
|
||||
return
|
||||
print(f"{'USERNAME':<24}{'ROLE':<8}{'ACTIVE':<8}{'NAME'}")
|
||||
for u in rows:
|
||||
print(f"{u.username:<24}{u.role:<8}{('yes' if u.is_active else 'no'):<8}{u.full_name}")
|
||||
|
||||
|
||||
def cmd_reset_password(args) -> None:
|
||||
pw = _prompt_password(getattr(args, "password", None))
|
||||
with SessionLocal() as db:
|
||||
u = auth.find_user(db, args.username)
|
||||
if not u:
|
||||
sys.exit(f"No user named '{args.username}'.")
|
||||
u.password_hash = auth.hash_password(pw)
|
||||
db.commit()
|
||||
print(f"Password reset for {u.username}.")
|
||||
|
||||
|
||||
def _set_active(username: str, active: bool) -> None:
|
||||
with SessionLocal() as db:
|
||||
u = auth.find_user(db, username)
|
||||
if not u:
|
||||
sys.exit(f"No user named '{username}'.")
|
||||
u.is_active = active
|
||||
db.commit()
|
||||
print(f"{u.username} is now {'enabled' if active else 'disabled'}.")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# Ensure the users table exists even on a fresh database.
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
p = argparse.ArgumentParser(prog="manage_users", description="Work Package Suite user management")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
def add_create(name, help_):
|
||||
sp = sub.add_parser(name, help=help_)
|
||||
sp.add_argument("username")
|
||||
sp.add_argument("--password", help="set non-interactively (otherwise prompted)")
|
||||
sp.add_argument("--name", default="", help="full name")
|
||||
sp.add_argument("--email", default="")
|
||||
return sp
|
||||
|
||||
add_create("create-admin", "create an admin account")
|
||||
c = add_create("create", "create an account")
|
||||
c.add_argument("--role", choices=["admin", "user"], default="user")
|
||||
|
||||
sub.add_parser("list", help="list all accounts")
|
||||
|
||||
rp = sub.add_parser("reset-password", help="reset a user's password")
|
||||
rp.add_argument("username")
|
||||
rp.add_argument("--password", help="set non-interactively (otherwise prompted)")
|
||||
|
||||
dp = sub.add_parser("disable", help="disable an account (blocks login)")
|
||||
dp.add_argument("username")
|
||||
ep = sub.add_parser("enable", help="re-enable an account")
|
||||
ep.add_argument("username")
|
||||
|
||||
args = p.parse_args()
|
||||
if args.cmd == "create-admin":
|
||||
cmd_create(args, role="admin")
|
||||
elif args.cmd == "create":
|
||||
cmd_create(args)
|
||||
elif args.cmd == "list":
|
||||
cmd_list(args)
|
||||
elif args.cmd == "reset-password":
|
||||
cmd_reset_password(args)
|
||||
elif args.cmd == "disable":
|
||||
_set_active(args.username, False)
|
||||
elif args.cmd == "enable":
|
||||
_set_active(args.username, True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -12,7 +12,7 @@ can upsert without round-tripping a sequence.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from sqlalchemy import String, Boolean, Integer, DateTime, ForeignKey, Text, JSON
|
||||
from sqlalchemy import String, Boolean, Integer, DateTime, ForeignKey, Text, JSON, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from .db import Base
|
||||
|
||||
@@ -111,6 +111,49 @@ class WorkPackage(Base):
|
||||
return {**self.summary(), "data": self.data or {}}
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""A login account. Passwords are never stored in the clear — only a bcrypt
|
||||
hash (see server/auth.py). `username` is what people sign in with; `role` is
|
||||
either 'admin' (can manage users) or 'user'."""
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
username: Mapped[str] = mapped_column(String(120), unique=True, index=True)
|
||||
email: Mapped[str] = mapped_column(String(200), default="")
|
||||
full_name: Mapped[str] = mapped_column(String(200), default="")
|
||||
password_hash: Mapped[str] = mapped_column(String(200), default="")
|
||||
role: Mapped[str] = mapped_column(String(20), default="user") # 'admin' | 'user'
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||
last_login_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Public view of a user — NEVER includes the password hash."""
|
||||
return {
|
||||
"id": self.id, "username": self.username, "email": self.email,
|
||||
"full_name": self.full_name, "role": self.role, "is_active": self.is_active,
|
||||
"created_at": _iso(self.created_at), "last_login_at": _iso(self.last_login_at),
|
||||
}
|
||||
|
||||
|
||||
class ProjectMember(Base):
|
||||
"""Which users may access which projects. A user sees/operates on a project
|
||||
only if a row links them to it (admins bypass this entirely). One row per
|
||||
(user, project) pair."""
|
||||
__tablename__ = "project_members"
|
||||
__table_args__ = (UniqueConstraint("user_id", "project_id", name="uq_project_member"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(40), ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
project_id: Mapped[str] = mapped_column(
|
||||
String(40), ForeignKey("projects.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
|
||||
class Comment(Base):
|
||||
__tablename__ = "comments"
|
||||
|
||||
|
||||
@@ -5,3 +5,5 @@ sqlalchemy>=2.0
|
||||
psycopg[binary]>=3.1
|
||||
pydantic>=2.6
|
||||
python-dotenv>=1.0
|
||||
bcrypt>=4.1 # password hashing
|
||||
PyJWT>=2.8 # signed session tokens
|
||||
|
||||
175
server/seed_demo.py
Normal file
175
server/seed_demo.py
Normal file
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Seed a realistic DEMO project into the Work Package Suite database via the API.
|
||||
|
||||
Creates one project, a complete SOP, and a spread of Work Packages that exercise
|
||||
the features and dashboard: an issued package, a gated (open-constraint) package,
|
||||
a multi-discipline master with its split instances (A/B/C), an overdue package,
|
||||
and an over-threshold draft. Use it to prove the SQL + Python layer end-to-end
|
||||
and to have data to inspect.
|
||||
|
||||
USAGE
|
||||
python3 server/seed_demo.py https://wp-suite.company.local --insecure
|
||||
docker compose exec api python /app/server/seed_demo.py http://localhost:8000
|
||||
python3 server/seed_demo.py https://wp-suite.company.local --clean # remove DEMO-* projects
|
||||
|
||||
IMPORTANT — what shows where:
|
||||
* The DEMO **project** is API/SQL-backed, so it appears in the home-page
|
||||
project picker immediately (proves the projects → SQL path in the UI).
|
||||
* The DEMO **SOP and Work Packages** are written to SQL too, but the current
|
||||
front end still reads SOPs/WPs from the browser (localStorage), so they will
|
||||
NOT render in the WP Creator / Dashboard yet — that's the pending Phase 2
|
||||
wiring. Verify them at the SQL/API layer instead:
|
||||
python3 server/smoketest.py <url> # automated end-to-end check
|
||||
docker compose exec db psql -U wpsuite -d wpsuite \
|
||||
-c "select number,subject,status from work_packages order by number;"
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import ssl
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
BASE = ""
|
||||
CTX = None
|
||||
DEMO_NUMBER = "DEMO-001" # project number prefix used to find/clean demo data
|
||||
|
||||
|
||||
def call(method, path, body=None):
|
||||
url = BASE + path
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, method=method,
|
||||
headers={"Content-Type": "application/json", "Accept": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, context=CTX, timeout=20) as r:
|
||||
raw = r.read().decode(); status = r.status
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read().decode(); status = e.code
|
||||
try:
|
||||
parsed = json.loads(raw) if raw else None
|
||||
except ValueError:
|
||||
parsed = raw
|
||||
return status, parsed
|
||||
|
||||
|
||||
def constraints(open_names=()):
|
||||
base = ["Safety & Permitting", "Quality Control / Inspection", "IFC Drawings & Specs",
|
||||
"Schedule", "Materials (on site, bagged & tagged)", "Work Access & Laydown"]
|
||||
return [{"name": n, "status": ("open" if n in open_names else "cleared"),
|
||||
"comment": ("awaiting delivery" if n in open_names else "")} for n in base]
|
||||
|
||||
|
||||
def main():
|
||||
global BASE, CTX
|
||||
ap = argparse.ArgumentParser(description="Seed a demo project into the Work Package Suite")
|
||||
ap.add_argument("base_url", nargs="?", default="http://localhost:8000",
|
||||
help="Site root, no /api (default: http://localhost:8000)")
|
||||
ap.add_argument("--insecure", action="store_true", help="skip TLS verification")
|
||||
ap.add_argument("--clean", action="store_true", help="delete existing DEMO-* projects and exit")
|
||||
args = ap.parse_args()
|
||||
BASE = args.base_url.rstrip("/")
|
||||
if args.insecure:
|
||||
CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE
|
||||
|
||||
# health gate
|
||||
try:
|
||||
st, _ = call("GET", "/api/health")
|
||||
except urllib.error.URLError as e:
|
||||
print(f"ABORT: cannot reach {BASE}/api/health — {e}"); return 1
|
||||
if st != 200:
|
||||
print(f"ABORT: /api/health returned {st}"); return 1
|
||||
|
||||
# --clean: remove any prior demo projects (cascade removes their SOP + WPs)
|
||||
st, projects = call("GET", "/api/projects")
|
||||
demos = [p for p in (projects or []) if str(p.get("number", "")).startswith("DEMO-")]
|
||||
if args.clean:
|
||||
for p in demos:
|
||||
call("DELETE", f"/api/projects/{p['id']}")
|
||||
print(f"Removed {len(demos)} DEMO project(s).")
|
||||
return 0
|
||||
if demos:
|
||||
print(f"Note: {len(demos)} DEMO project(s) already exist. Run with --clean first to avoid duplicates.\n")
|
||||
|
||||
# 1) Project
|
||||
st, proj = call("POST", "/api/projects", {
|
||||
"name": "DEMO — Micron INC (test data)", "number": DEMO_NUMBER,
|
||||
"client": "Micron Technology, Inc.", "division": "Semiconductor",
|
||||
"site": "Boise, ID — Fab", "created_by": "seed_demo"})
|
||||
pid = proj["id"]
|
||||
print(f"Project: {proj['name']} ({pid})")
|
||||
|
||||
# 2) SOP (complete)
|
||||
st, sop = call("POST", "/api/sops", {
|
||||
"project_id": pid, "name": "DEMO SOP", "number": DEMO_NUMBER, "complete": True,
|
||||
"created_by": "seed_demo",
|
||||
"data": {"governance": {"woFormat": "WP##-[Sector]-[TYPE]",
|
||||
"disciplines": ["Mechanical", "Electrical", "Tech"],
|
||||
"discMode": "choice", "instanceSuffix": "letter",
|
||||
"woSize": "Standard — 3–5 days (≈40–80 hrs)", "sizeHoursMax": "80"}}})
|
||||
sid = sop["id"]
|
||||
print(f"SOP: complete ({sid})")
|
||||
|
||||
# 3) Work packages
|
||||
def wp(number, subject, typ, status, data, parent_id=None):
|
||||
body = {"project_id": pid, "sop_id": sid, "number": number, "subject": subject,
|
||||
"type": typ, "status": status, "created_by": "seed_demo", "data": data}
|
||||
if parent_id:
|
||||
body["parent_id"] = parent_id
|
||||
st, w = call("POST", "/api/wps", body)
|
||||
print(f" WP {number:<16} {status:<12} {subject}")
|
||||
return w
|
||||
|
||||
# a) issued, all clear
|
||||
wp("WP01-1P-CONDUIT", "1P horn/strobe conduit", "Conduit Install", "Issued",
|
||||
{"disciplines": ["Electrical"], "hours": "40", "actualHrs": "",
|
||||
"constraints": constraints(), "due": "2026-06-30"})
|
||||
# b) gated — one open constraint, still Scheduled
|
||||
wp("WP02-1P-WIRE", "1P wire pull", "Wire Pull", "Scheduled",
|
||||
{"disciplines": ["Electrical"], "hours": "60", "actualHrs": "",
|
||||
"constraints": constraints(open_names=["Materials (on site, bagged & tagged)"]), "due": "2026-07-04"})
|
||||
# c) multi-discipline master + split instances (master excluded from metrics)
|
||||
master_id = "wp_demo_master_chiller"
|
||||
instances = [("WP03-CHILLER_Mech", "Mechanical", "A", "Mechanical Install", "In Progress"),
|
||||
("WP03-CHILLER_Elec", "Electrical", "B", "Wire Pull", "Scheduled"),
|
||||
("WP03-CHILLER_Tech", "Tech", "C", "Terminations", "Draft")]
|
||||
child_ids = []
|
||||
for num, disc, label, typ, status in instances:
|
||||
cid = f"wp_demo_{label.lower()}"
|
||||
child_ids.append(cid)
|
||||
body = {"project_id": pid, "sop_id": sid, "parent_id": master_id, "id": cid,
|
||||
"number": num, "subject": "Chiller skid — " + disc, "type": typ, "status": status,
|
||||
"created_by": "seed_demo",
|
||||
"data": {"disciplines": [disc], "instanceOf": master_id, "instanceLabel": label,
|
||||
"parentNumber": "WP03-CHILLER", "hours": "50", "actualHrs": "",
|
||||
"constraints": constraints(), "due": "2026-07-10"}}
|
||||
call("POST", "/api/wps", body)
|
||||
print(f" WP {num:<16} {status:<12} (instance {label})")
|
||||
wp("WP03-CHILLER", "Chiller skid (multi-discipline master)", "Mechanical Install", "Scheduled",
|
||||
{"disciplines": ["Mechanical", "Electrical", "Tech"], "split": True, "children": child_ids,
|
||||
"hours": "150", "constraints": constraints(), "due": "2026-07-10"})
|
||||
call("POST", "/api/wps", {"project_id": pid, "sop_id": sid, "id": master_id,
|
||||
"number": "WP03-CHILLER", "subject": "Chiller skid (multi-discipline master)",
|
||||
"type": "Mechanical Install", "status": "Scheduled", "created_by": "seed_demo",
|
||||
"data": {"disciplines": ["Mechanical", "Electrical", "Tech"], "split": True,
|
||||
"children": child_ids, "hours": "150", "constraints": constraints(),
|
||||
"due": "2026-07-10"}})
|
||||
# d) overdue, in progress
|
||||
wp("WP04-2P-TERM", "2P terminations", "Terminations", "In Progress",
|
||||
{"disciplines": ["Tech"], "hours": "30", "actualHrs": "20",
|
||||
"constraints": constraints(), "due": "2026-06-10"}) # past today (2026-06-16) → overdue
|
||||
# e) over-threshold draft (hours > 80)
|
||||
wp("WP05-3P-PANEL", "3P panel install", "Panel Install", "Draft",
|
||||
{"disciplines": ["Electrical"], "hours": "120", "actualHrs": "",
|
||||
"constraints": constraints(open_names=["Schedule"]), "due": "2026-07-20"})
|
||||
|
||||
# metrics readback
|
||||
st, m = call("GET", f"/api/wps/metrics?project_id={pid}")
|
||||
print(f"\nMetrics (masters excluded): {m}")
|
||||
print(f"\nDone. The DEMO project '{proj['name']}' now appears in the home-page picker.")
|
||||
print("SOP/WPs are in SQL (see header note) — verify with smoketest.py or psql.")
|
||||
print("Remove later with: python3 server/seed_demo.py <url> --clean")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
196
server/smoketest.py
Normal file
196
server/smoketest.py
Normal file
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env python3
|
||||
"""End-to-end smoke test for the Work Package Suite API + PostgreSQL.
|
||||
|
||||
Exercises the real HTTP endpoints the way the front end does, proving that
|
||||
NGINX → FastAPI → PostgreSQL all work and that the Python logic (the AWP
|
||||
release gate, metrics, cascade delete) behaves. Stdlib only — no pip, no jq.
|
||||
|
||||
USAGE
|
||||
# Against the deployed site (through the NGINX proxy):
|
||||
python3 server/smoketest.py https://wp-suite.company.local
|
||||
|
||||
# Self-signed / internal TLS cert? skip verification:
|
||||
python3 server/smoketest.py https://wp-suite.company.local --insecure
|
||||
|
||||
# From inside the api container (hits FastAPI directly):
|
||||
docker compose exec api python /app/server/smoketest.py http://localhost:8000
|
||||
|
||||
# Leave the demo project in the database so you can open it in the UI:
|
||||
python3 server/smoketest.py https://wp-suite.company.local --keep
|
||||
|
||||
The base URL is the SITE root (no /api). Default: http://localhost:8000
|
||||
Exit code 0 = all checks passed, 1 = one or more failed.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import ssl
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
# ── tiny colored reporter ─────────────────────────────────────────────────────
|
||||
_PASS, _FAIL = [], []
|
||||
def _c(s, code): # color if a TTY
|
||||
return f"\033[{code}m{s}\033[0m" if sys.stdout.isatty() else s
|
||||
def ok(msg): _PASS.append(msg); print(" " + _c("PASS", "32") + " " + msg)
|
||||
def bad(msg): _FAIL.append(msg); print(" " + _c("FAIL", "31") + " " + msg)
|
||||
def check(name, cond, detail=""):
|
||||
(ok if cond else bad)(name + (f" ({detail})" if detail and not cond else ""))
|
||||
return cond
|
||||
|
||||
BASE = ""
|
||||
CTX = None
|
||||
|
||||
def call(method, path, body=None):
|
||||
"""Returns (status_code, parsed_body). Never raises on HTTP status."""
|
||||
url = BASE + path
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(
|
||||
url, data=data, method=method,
|
||||
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, context=CTX, timeout=20) as r:
|
||||
raw = r.read().decode(); status = r.status
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read().decode(); status = e.code
|
||||
try:
|
||||
parsed = json.loads(raw) if raw else None
|
||||
except ValueError:
|
||||
parsed = raw
|
||||
return status, parsed
|
||||
|
||||
|
||||
def main():
|
||||
global BASE, CTX
|
||||
ap = argparse.ArgumentParser(description="Work Package Suite API smoke test")
|
||||
ap.add_argument("base_url", nargs="?", default="http://localhost:8000",
|
||||
help="Site root, no /api (default: http://localhost:8000)")
|
||||
ap.add_argument("--insecure", action="store_true", help="skip TLS verification")
|
||||
ap.add_argument("--keep", action="store_true", help="keep the demo project (don't delete)")
|
||||
args = ap.parse_args()
|
||||
BASE = args.base_url.rstrip("/")
|
||||
if args.insecure:
|
||||
CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE
|
||||
|
||||
print(f"\nWork Package Suite — API smoke test\nTarget: {BASE}\n")
|
||||
|
||||
project_id = None
|
||||
try:
|
||||
# 1) Health — API is up and reachable through the proxy.
|
||||
try:
|
||||
st, body = call("GET", "/api/health")
|
||||
except urllib.error.URLError as e:
|
||||
print(_c("\nABORT", "31") + f" cannot reach {BASE}/api/health — {e}\n"
|
||||
" Is the stack up (docker compose ps) and the URL correct?\n")
|
||||
return 1
|
||||
check("health endpoint returns ok", st == 200 and isinstance(body, dict) and body.get("ok") is True,
|
||||
f"status={st} body={body}")
|
||||
|
||||
# 2) Create a project (writes to the projects table).
|
||||
st, proj = call("POST", "/api/projects", {
|
||||
"name": "ZZ Smoke Test Project", "number": "SMOKE-001",
|
||||
"client": "Internal QA", "division": "Controls", "site": "Test Host",
|
||||
"created_by": "smoketest",
|
||||
})
|
||||
project_id = proj.get("id") if isinstance(proj, dict) else None
|
||||
check("create project", st == 200 and bool(project_id), f"status={st}")
|
||||
|
||||
# 3) Read it back + confirm it's in the list (SQL round-trip).
|
||||
st, got = call("GET", f"/api/projects/{project_id}")
|
||||
check("fetch project by id", st == 200 and got.get("number") == "SMOKE-001", f"status={st}")
|
||||
st, lst = call("GET", "/api/projects")
|
||||
check("project appears in list", st == 200 and any(p.get("id") == project_id for p in lst),
|
||||
f"status={st} count={len(lst) if isinstance(lst, list) else '?'}")
|
||||
|
||||
# 4) Create a SOP linked to the project.
|
||||
st, sop = call("POST", "/api/sops", {
|
||||
"project_id": project_id, "name": "ZZ Smoke SOP", "number": "SMOKE-001",
|
||||
"complete": True, "created_by": "smoketest",
|
||||
"data": {"governance": {"woFormat": "WP##-[Sector]-[TYPE]",
|
||||
"disciplines": ["Mechanical", "Electrical", "Tech"]}},
|
||||
})
|
||||
sop_id = sop.get("id") if isinstance(sop, dict) else None
|
||||
check("create SOP linked to project", st == 200 and bool(sop_id) and sop.get("project_id") == project_id,
|
||||
f"status={st}")
|
||||
st, latest = call("GET", f"/api/sops/latest?project_id={project_id}")
|
||||
check("latest SOP for project resolves", st == 200 and latest.get("id") == sop_id, f"status={st}")
|
||||
|
||||
# 5) Create a Work Package with one OPEN constraint (not release-ready).
|
||||
st, wp = call("POST", "/api/wps", {
|
||||
"project_id": project_id, "sop_id": sop_id,
|
||||
"number": "WP01-SMOKE", "subject": "Smoke test package", "type": "Conduit Install",
|
||||
"status": "Scheduled", "created_by": "smoketest",
|
||||
"data": {"disciplines": ["Electrical"], "hours": "40", "actualHrs": "",
|
||||
"constraints": [{"name": "Materials", "status": "open", "comment": "awaiting delivery"},
|
||||
{"name": "Safety & Permitting", "status": "cleared", "comment": ""}]},
|
||||
})
|
||||
wp_id = wp.get("id") if isinstance(wp, dict) else None
|
||||
check("create work package", st == 200 and bool(wp_id), f"status={st}")
|
||||
|
||||
# 6) The AWP release gate: issuing with an open constraint must be REFUSED (409).
|
||||
st, refused = call("POST", f"/api/wps/{wp_id}/issue")
|
||||
check("issue is blocked while a constraint is open (409)", st == 409, f"status={st} body={refused}")
|
||||
|
||||
# 7) Clear the constraint (upsert), then issue must SUCCEED (200, status Issued).
|
||||
call("POST", "/api/wps", {
|
||||
"id": wp_id, "project_id": project_id, "sop_id": sop_id,
|
||||
"number": "WP01-SMOKE", "subject": "Smoke test package", "type": "Conduit Install",
|
||||
"status": "Scheduled",
|
||||
"data": {"disciplines": ["Electrical"], "hours": "40", "actualHrs": "",
|
||||
"constraints": [{"name": "Materials", "status": "cleared", "comment": ""},
|
||||
{"name": "Safety & Permitting", "status": "cleared", "comment": ""}]},
|
||||
})
|
||||
st, issued = call("POST", f"/api/wps/{wp_id}/issue")
|
||||
check("issue succeeds once constraints clear", st == 200 and issued.get("status") == "Issued",
|
||||
f"status={st}")
|
||||
check("issued_at timestamp is set", isinstance(issued, dict) and bool(issued.get("issued_at")))
|
||||
|
||||
# 8) Status transition endpoint.
|
||||
st, prog = call("POST", f"/api/wps/{wp_id}/status", {"status": "In Progress"})
|
||||
check("status transition endpoint", st == 200 and prog.get("status") == "In Progress", f"status={st}")
|
||||
|
||||
# 9) Metrics aggregate for the project (Python aggregation over SQL rows).
|
||||
st, m = call("GET", f"/api/wps/metrics?project_id={project_id}")
|
||||
check("metrics endpoint aggregates", st == 200 and isinstance(m, dict) and m.get("total", 0) >= 1,
|
||||
f"status={st} metrics={m}")
|
||||
|
||||
# 10) Comment / feedback write + read.
|
||||
st, c = call("POST", "/api/feedback", {
|
||||
"type": "wp_review_comment", "name": "smoketest", "wp_id": wp_id,
|
||||
"text": "SMOKE TEST comment — safe to delete", "page": "/smoketest"})
|
||||
check("post comment/feedback", st == 200 and isinstance(c, dict) and bool(c.get("id")), f"status={st}")
|
||||
st, comments = call("GET", f"/api/comments?wp_id={wp_id}")
|
||||
check("comment is queryable", st == 200 and any("SMOKE TEST" in (x.get("text") or "") for x in comments),
|
||||
f"status={st}")
|
||||
|
||||
# 11) WPs filter by project.
|
||||
st, wps = call("GET", f"/api/wps?project_id={project_id}")
|
||||
check("list WPs by project", st == 200 and any(w.get("id") == wp_id for w in wps), f"status={st}")
|
||||
|
||||
finally:
|
||||
# 12) Cleanup — deleting the project cascades to its SOPs and WPs (FK ON DELETE CASCADE).
|
||||
if project_id and not args.keep:
|
||||
st, _ = call("DELETE", f"/api/projects/{project_id}")
|
||||
check("delete project (cascades SOP + WPs)", st == 200, f"status={st}")
|
||||
st, after = call("GET", f"/api/wps?project_id={project_id}")
|
||||
check("WPs removed by cascade", st == 200 and isinstance(after, list) and len(after) == 0,
|
||||
f"status={st} remaining={after}")
|
||||
elif project_id and args.keep:
|
||||
print(f"\n --keep: left demo project {project_id} ('ZZ Smoke Test Project') in the database.")
|
||||
|
||||
# ── summary ────────────────────────────────────────────────────────────────
|
||||
total = len(_PASS) + len(_FAIL)
|
||||
print(f"\n{'-'*52}\n{len(_PASS)}/{total} checks passed.")
|
||||
if _FAIL:
|
||||
print(_c(f"FAILED ({len(_FAIL)}):", "31"))
|
||||
for f in _FAIL:
|
||||
print(" - " + f)
|
||||
print("\nResult: " + _c("FAIL", "31") + "\n")
|
||||
return 1
|
||||
print("\nResult: " + _c("ALL PASS — API, Python logic, and SQL are working.", "32") + "\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user