Productionize WP Suite: auth, security hardening, sync, dashboard, PWA, email
Brings the Work Package Suite from a browser-local prototype to a multi-tenant, SQL-backed deployment hardened for customer IP. Auth & access control - Local username/password login (bcrypt + JWT in an HttpOnly cookie), admin-managed users, per-project membership, and project-scoped API access. - Admin console: change user roles, view the audit trail, manage settings. Security hardening - CSP / HSTS / X-Frame-Options / nosniff headers in nginx; Secure cookie via X-Forwarded-Proto; CSRF Origin check; attribute-safe output escaping. - Login lockout, token_version session revocation, stronger password policy, fail-closed secret loading, encrypted (AES-256) database backups. Persistence & schema - SOPs and Work Packages are now DB-backed and shared across users, written through a durable client sync outbox that queues offline edits. - Alembic migrations applied automatically on container start. New capabilities - Phase 2 dashboard (progress, gating, pagination, archive). - Phase 3 PWA "Field View" with offline caching and auth fallback. - WP owner assignment with OPTIONAL email notifications, OFF by default and toggled from the admin console. SMTP password is read only from the SMTP_PASSWORD env var (never stored); emails carry a WP number + deep link, never customer IP. Also: IBM Carbon restyle, Help section, and DEPLOYMENT.md brought up to date. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
11
.gitignore
vendored
11
.gitignore
vendored
@@ -15,5 +15,16 @@ wpsuite.db
|
||||
# Runtime directories (created by containers)
|
||||
logs/
|
||||
|
||||
# Database backup dumps (large + sensitive) — keep the folder, ignore contents
|
||||
/backups/*
|
||||
!/backups/.gitkeep
|
||||
|
||||
# Local server logs
|
||||
*.log
|
||||
|
||||
# Local scratch / test artifacts (curl cookie jars hold live session tokens)
|
||||
_*.txt
|
||||
cookies.txt
|
||||
|
||||
# Claude Code local workspace (agent memory, session data)
|
||||
.claude/
|
||||
|
||||
154
DEPLOYMENT.md
154
DEPLOYMENT.md
@@ -51,9 +51,25 @@ Create a file named `.env` in the **project root** (same folder as
|
||||
POSTGRES_DB=wpsuite
|
||||
POSTGRES_USER=wpsuite
|
||||
POSTGRES_PASSWORD=<strong-random-password>
|
||||
|
||||
# REQUIRED — signs login session cookies. If unset, `docker compose up` errors
|
||||
# out and the API refuses to start. Generate once and keep it stable:
|
||||
# openssl rand -base64 48
|
||||
AUTH_SECRET_KEY=<strong-random-secret>
|
||||
|
||||
# Encrypts database backups at rest (AES-256). Set this BEFORE the DB holds
|
||||
# customer IP. Keep the passphrase OFF this host — losing it makes dumps
|
||||
# unrecoverable: openssl rand -base64 32
|
||||
BACKUP_ENC_PASSPHRASE=<strong-random-passphrase>
|
||||
|
||||
# OPTIONAL — SMTP password for WP-assignment email notifications. Email is OFF
|
||||
# by default and enabled from the Admin console; the host/port/from-address are
|
||||
# configured there, but the password is only ever read from this variable (never
|
||||
# stored in the DB or shown in the UI). Leave unset until you have SMTP details.
|
||||
# SMTP_PASSWORD=<smtp-app-password>
|
||||
```
|
||||
|
||||
That's it — the API now builds its own connection string from these three
|
||||
The API builds its own DB connection string from the `POSTGRES_*`
|
||||
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
|
||||
@@ -64,7 +80,8 @@ 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.
|
||||
> Set `POSTGRES_DB` / `POSTGRES_USER` / `POSTGRES_PASSWORD` / `AUTH_SECRET_KEY` /
|
||||
> `BACKUP_ENC_PASSPHRASE` (and `SMTP_PASSWORD`, if you enable email) there.
|
||||
|
||||
These are the only credentials in the system, and they never appear in the
|
||||
compose file or in git.
|
||||
@@ -77,6 +94,14 @@ 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.
|
||||
|
||||
> **Serve it over HTTPS, and forward the scheme.** The bundled nginx sets the
|
||||
> security response headers (CSP, HSTS, `X-Frame-Options`, `nosniff`) and passes
|
||||
> `X-Forwarded-Proto: https` to the API, which is what makes the session cookie
|
||||
> `Secure`. If you front the stack with your **own** proxy instead, make sure it
|
||||
> terminates TLS and forwards `X-Forwarded-Proto: https` — otherwise the login
|
||||
> cookie won't get the `Secure` flag. HSTS also assumes the site is only ever
|
||||
> reached over HTTPS.
|
||||
|
||||
## 4. Bring it up
|
||||
|
||||
From the project root:
|
||||
@@ -145,12 +170,10 @@ 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:
|
||||
> **What shows where:** the DEMO **project**, its **SOP**, and its **Work
|
||||
> Packages** are all API/SQL-backed, so they appear in the home-page project
|
||||
> picker and render in the Creator/Dashboard as soon as any user opens the
|
||||
> project. 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;"
|
||||
@@ -160,20 +183,21 @@ python3 server/seed_demo.py https://wp-suite.company.local --clean # remove it
|
||||
|
||||
## What is stored in SQL today
|
||||
|
||||
Be aware of the current persistence split — the API + Postgres are fully
|
||||
deployed, and:
|
||||
The API + Postgres are the system of record. Everything below is server-stored
|
||||
and shared across every user who opens the project:
|
||||
|
||||
| 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. |
|
||||
| **SOPs** | **Yes** — pulled from `/api/sops` on load and written through on every save. |
|
||||
| **Work Packages** | **Yes** — same write-through to `/api/wps` (+ issue / status / archive / metrics), including the owner assignment (`assignee_id`). |
|
||||
|
||||
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.
|
||||
Saves go through a **durable client-side sync outbox**: edits are written to the
|
||||
API immediately, and if the device is offline they queue and retry when it
|
||||
reconnects (4xx rejections are dropped rather than retried forever). The browser
|
||||
cache is only an offline fallback that reconciles through that outbox — so two
|
||||
users on the same project see the same server-stored SOP and Work Packages.
|
||||
|
||||
## Data model (PostgreSQL)
|
||||
|
||||
@@ -181,8 +205,13 @@ and endpoints are already in place.
|
||||
|-------|-------|-------------|
|
||||
| `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) |
|
||||
| `work_packages` | individual IWPs | `project_id` → projects, `sop_id` → sops, `parent_id` (split instances), `number`, `subject`, `type`, `status`, `assignee_id` (owner), `issued_at`, `archived_at`, `data` (full WP JSON) |
|
||||
| `comments` | feedback from any page | `source`, `sop_id`, `wp_id`, `step`, `author`, `text`, `extra` |
|
||||
| `users` | login accounts | `username`, `password_hash` (bcrypt), `role`, `full_name`, `email`, `is_active`, login-lockout + `token_version` fields |
|
||||
| `project_members` | per-project access control | `user_id` → users, `project_id` → projects |
|
||||
| `audit_log` | append-only activity trail | `actor`, `action`, `entity_type`, `entity_id`, `project_id`, `summary`, `detail` |
|
||||
| `notifications` | in-app record + email outbox | `user_id`, `kind`, `wp_id`, `subject`, `status` (pending / sent / failed / skipped) |
|
||||
| `app_settings` | admin-configured settings (e.g. email) | `key`, `value` (JSON) |
|
||||
|
||||
The complete client document is stored verbatim in each row's `data` JSON
|
||||
column; frequently-listed fields are promoted to real columns for filtering.
|
||||
@@ -192,8 +221,13 @@ column; frequently-listed fields are promoted to real columns for filtering.
|
||||
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`.
|
||||
`POST /api/wps/{id}/issue`, `POST /api/wps/{id}/status`, `POST /api/wps/{id}/archive`,
|
||||
`GET /api/wps/metrics` ·
|
||||
Comments `POST /api/comments` (and `/api/feedback`), `GET /api/comments` ·
|
||||
Auth `POST /api/auth/login` / `logout`, `GET /api/auth/me`, admin user management
|
||||
under `/api/auth/users` · Admin-only `GET/PUT /api/settings`,
|
||||
`POST /api/settings/test-email`, `GET /api/notifications`,
|
||||
`GET /api/projects/{id}/members`.
|
||||
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).
|
||||
|
||||
@@ -209,27 +243,77 @@ 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:
|
||||
A **`backup` sidecar** (in `docker-compose.yml`) runs `pg_dump` on a schedule and
|
||||
writes gzipped, timestamped dumps to `./backups/` on the host. It starts with the
|
||||
stack — no cron to set up.
|
||||
|
||||
```bash
|
||||
# Backup (run from project root)
|
||||
docker compose exec -T db pg_dump -U wpsuite wpsuite > backup-$(date +%F).sql
|
||||
- **Cadence / retention:** daily, keeping the newest 14 dumps. Override in `.env`
|
||||
with `BACKUP_INTERVAL_SECONDS` (seconds between dumps) and `BACKUP_KEEP` (how many
|
||||
to keep).
|
||||
- **Encryption at rest:** set `BACKUP_ENC_PASSPHRASE` in `.env` and dumps are
|
||||
written AES-256-encrypted as `*.sql.gz.enc`. **Do this before any customer IP
|
||||
goes in** — without it the dumps (and every offsite copy) are plaintext. Store
|
||||
the passphrase somewhere other than this host; if you lose it the backups can't
|
||||
be restored.
|
||||
- **Ad-hoc backup now:** `docker compose exec backup sh /scripts/db-backup.sh`
|
||||
- **Restore (destructive — overwrites current data):**
|
||||
`docker compose exec backup sh /scripts/db-restore.sh /backups/wpsuite-YYYYMMDD-HHMMSSZ.sql.gz.enc`
|
||||
- **Offsite — do this:** the dumps live in `./backups/` on the host; if the host/volume
|
||||
dies, so do they. Sync that folder offsite from the **host** (e.g. a cron running
|
||||
`rclone`/`aws s3 sync`). The `db`/`backup` containers are on an egress-less
|
||||
`internal` network on purpose, so offsite must be pushed from the host.
|
||||
- **Test restores quarterly:** load the latest dump into a throwaway database and
|
||||
confirm it applies. An untested backup is not a backup.
|
||||
|
||||
# Restore
|
||||
docker compose exec -T db psql -U wpsuite -d wpsuite < backup-YYYY-MM-DD.sql
|
||||
```
|
||||
## Field devices & data at rest
|
||||
|
||||
## Schema migrations (important)
|
||||
The field view (PWA) caches a project's Work Packages/SOP in the browser's
|
||||
localStorage so it works offline — i.e. **customer IP sits on the device**.
|
||||
localStorage is not encrypted and is not a security boundary. Signing out clears
|
||||
the cached project data, but for any tablet/phone that opens customer-IP projects:
|
||||
|
||||
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`):
|
||||
- **Require full-disk encryption** (BitLocker / FileVault / Android FBE / iOS is
|
||||
encrypted by default) and a device passcode.
|
||||
- **Enrol field devices in MDM** so a lost device can be remotely wiped, and keep
|
||||
the browser profile per-user on shared devices.
|
||||
- Users should **sign out** when handing off a shared device (clears the cache).
|
||||
|
||||
- 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.
|
||||
## Email notifications (optional)
|
||||
|
||||
Work-package **owner assignment** works out of the box (in-app only). Optional
|
||||
**email** on assignment is **OFF by default** and is turned on from the **Admin
|
||||
console → Notifications & email** card, where an admin sets the SMTP host / port /
|
||||
TLS / From address and flips the master toggle.
|
||||
|
||||
- The **SMTP password is never stored in the database.** It is read only from the
|
||||
`SMTP_PASSWORD` environment variable (see the `.env` block in step 2 and the
|
||||
`api` service in `docker-compose.yml`). The UI shows only whether it is set.
|
||||
- Email stays effectively off until **all** of: the toggle is on, SMTP host + From
|
||||
are configured, and `SMTP_PASSWORD` is present. Until then, assignments are
|
||||
still recorded in-app (status `skipped`); nothing is sent.
|
||||
- Notification emails carry only a **WP number and a deep link** — never the work
|
||||
package contents — so customer IP stays behind the login.
|
||||
- Use the card's **Send test email** button to confirm SMTP before enabling.
|
||||
|
||||
## Schema migrations (Alembic)
|
||||
|
||||
Schema is managed by **Alembic** (`server/alembic/`). The API container runs
|
||||
`alembic upgrade head` on startup (see the `Dockerfile` CMD), so **deploys apply
|
||||
pending migrations automatically**.
|
||||
|
||||
- The **baseline** migration is idempotent: on a fresh database it creates every
|
||||
table; on a database whose tables already exist (made by the old `create_all`)
|
||||
it adopts the schema as-is — no manual `alembic stamp` needed.
|
||||
- Local dev on SQLite still auto-creates tables for a zero-config run; Postgres is
|
||||
migrations-only.
|
||||
- **To change the schema:** edit `server/models.py`, then generate and review a
|
||||
migration before committing:
|
||||
```bash
|
||||
# from the project root (against your dev SQLite or a staging DB)
|
||||
python -m alembic -c server/alembic.ini revision --autogenerate -m "describe the change"
|
||||
python -m alembic -c server/alembic.ini upgrade head # apply locally to test
|
||||
```
|
||||
The next `docker compose up -d --build api` applies it in production on startup.
|
||||
|
||||
## Local trial without Postgres
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ COPY server/requirements.txt ./server/
|
||||
RUN pip install --no-cache-dir -r server/requirements.txt
|
||||
COPY server/ ./server/
|
||||
EXPOSE 8000
|
||||
# --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"]
|
||||
# Apply any pending DB migrations, THEN start the app. `alembic upgrade head` is
|
||||
# safe on both fresh and existing databases (the baseline migration adopts an
|
||||
# existing schema, so no manual stamp is needed). `exec` hands PID 1 to gunicorn
|
||||
# for correct signal handling; --preload imports the app once before forking.
|
||||
CMD ["sh", "-c", "alembic -c server/alembic.ini upgrade head && exec gunicorn -k uvicorn.workers.UvicornWorker --preload -b 0.0.0.0:8000 --workers 2 server.app:app"]
|
||||
0
backups/.gitkeep
Normal file
0
backups/.gitkeep
Normal file
@@ -27,9 +27,14 @@ services:
|
||||
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}
|
||||
# Signs login session cookies. REQUIRED — compose fails fast if it's unset,
|
||||
# and the API refuses to start in production without it (see server/auth.py).
|
||||
AUTH_SECRET_KEY: ${AUTH_SECRET_KEY:?set AUTH_SECRET_KEY in .env (see server/.env.example)}
|
||||
AUTH_SESSION_HOURS: ${AUTH_SESSION_HOURS:-12}
|
||||
# Optional — SMTP password for WP-assignment emails. Email is off by
|
||||
# default and enabled from the Admin console; this is the only email
|
||||
# secret and it is never stored in the DB. Leave unset until configured.
|
||||
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
@@ -55,6 +60,36 @@ services:
|
||||
networks:
|
||||
- internal
|
||||
|
||||
# Scheduled pg_dump backups. Writes gzipped, timestamped dumps to ./backups on
|
||||
# the host (sync that folder offsite from the host — this container has no
|
||||
# internet egress). See scripts/db-backup.sh and DEPLOYMENT.md § Backups.
|
||||
backup:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: scripts/backup.Dockerfile # postgres client + openssl
|
||||
container_name: wp_db_backup
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
PGHOST: db
|
||||
BACKUP_DIR: /backups
|
||||
BACKUP_KEEP: ${BACKUP_KEEP:-14} # keep the newest N dumps
|
||||
BACKUP_INTERVAL_SECONDS: ${BACKUP_INTERVAL_SECONDS:-86400} # 86400 = daily
|
||||
# Set BACKUP_ENC_PASSPHRASE in .env to encrypt dumps at rest (AES-256).
|
||||
# Required once the DB holds customer IP. Keep the passphrase off this host.
|
||||
BACKUP_ENC_PASSPHRASE: ${BACKUP_ENC_PASSPHRASE:-}
|
||||
volumes:
|
||||
- ./scripts:/scripts:ro
|
||||
- ./backups:/backups
|
||||
entrypoint: ["/bin/sh", "/scripts/backup-cron.sh"]
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- internal
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
nginx_logs:
|
||||
|
||||
@@ -6,18 +6,21 @@
|
||||
<title>Admin Console — Work Package Suite</title>
|
||||
<script src="auth-guard.js"></script>
|
||||
<link rel="icon" href="favicon.ico" sizes="any">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<meta name="theme-color" content="#161616">
|
||||
<link rel="stylesheet" href="theme-light.css">
|
||||
<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; }
|
||||
:root{ --bg:#f4f4f4; --surface:#fff; --border:#e0e0e0; --border-strong:#8d8d8d; --text:#161616;
|
||||
--muted:#525252; --dim:#8d8d8d; --accent:#0f62fe; --green:#198038; --green-bg:#defbe6;
|
||||
--red:#da1e28; --red-bg:#fff1f1; --amber:#8e6a00; --amber-bg:#fdf6dd; --mono:'IBM Plex 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); }
|
||||
body{ margin:0; font-family:'IBM Plex Sans',-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{ background:var(--surface); border:1px solid var(--border); border-radius:0; 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;
|
||||
button{ font:inherit; font-size:13px; font-weight:600; border-radius:0; 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; }
|
||||
@@ -25,10 +28,10 @@
|
||||
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{ padding:10px 14px; border-radius:0; 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);
|
||||
pre.out{ background:#0f1525; color:#d7e0f5; border-radius:0; 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; }
|
||||
@@ -36,30 +39,41 @@
|
||||
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{ background:var(--surface); border:1px solid var(--border); border-radius:0; 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-box input{ width:100%; padding:10px 12px; font-size:14px; border:1px solid var(--border-strong); border-radius:0; 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; }
|
||||
.secwarn{ background:var(--amber-bg); color:var(--amber); border:1px solid var(--amber); border-radius:0; 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); }
|
||||
border-radius:0; 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.admin{ background:#edf5ff; color:#0f62fe; } .tag.user{ background:#e8e8e8; color:#525252; }
|
||||
.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; }
|
||||
select.role-select{ padding:4px 8px; font:inherit; font-size:12px; border:1px solid var(--border-strong); border-radius:0; background:#fff; color:var(--text); cursor:pointer; }
|
||||
select.role-select:hover{ border-color:var(--accent); }
|
||||
select.role-select.is-admin{ color:var(--accent); border-color:var(--accent); font-weight:700; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- SHARED DARK APP BAR -->
|
||||
<header class="wp-appbar">
|
||||
<a href="index.html" class="wp-appbar-brand" title="Back to site">
|
||||
<span class="wp-logo-chip"><img src="prime-controls-logo.jpg" alt="Prime Controls"></span>
|
||||
<span class="wp-appbar-title">Work Package Suite <span class="wp-appbar-sub">| Admin Console</span></span>
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<!-- ADMINS ONLY (shown if the signed-in account isn't an admin) -->
|
||||
<div class="wrap" id="admin-denied" style="display:none">
|
||||
<div class="card">
|
||||
@@ -72,7 +86,7 @@
|
||||
<!-- 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><h1>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>
|
||||
|
||||
@@ -103,6 +117,14 @@
|
||||
<div id="users-create-msg" class="note"></div>
|
||||
</div>
|
||||
|
||||
<!-- NOTIFICATIONS / EMAIL -->
|
||||
<div class="card">
|
||||
<h2>Notifications & email</h2>
|
||||
<div class="sub" style="margin-bottom:10px">Email notifications for work-package assignments. <strong>Off by default</strong> — turn this on only once SMTP is configured. The SMTP <strong>password</strong> is read from the <code>SMTP_PASSWORD</code> environment variable and is never stored here.</div>
|
||||
<div id="settings-box" class="note">Loading…</div>
|
||||
<div id="notif-box" class="note" style="margin-top:14px"></div>
|
||||
</div>
|
||||
|
||||
<!-- ALL FEEDBACK / COMMENTS -->
|
||||
<div class="card">
|
||||
<h2>All feedback & comments</h2>
|
||||
@@ -110,11 +132,29 @@
|
||||
<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;">
|
||||
<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:0;">
|
||||
</div>
|
||||
<div id="comments-admin" class="note" style="margin-top:12px">Click refresh to load.</div>
|
||||
</div>
|
||||
|
||||
<!-- ACTIVITY LOG (AUDIT TRAIL) -->
|
||||
<div class="card">
|
||||
<h2>Activity log</h2>
|
||||
<div class="sub" style="margin-bottom:10px">Who changed what, and when — across projects, SOPs, work packages, and user accounts. Stored server-side in the shared database.</div>
|
||||
<div class="row">
|
||||
<button onclick="loadAudit()">Refresh</button>
|
||||
<select id="audit-type" onchange="renderAudit()">
|
||||
<option value="">All types</option>
|
||||
<option value="wp">Work packages</option>
|
||||
<option value="sop">SOPs</option>
|
||||
<option value="project">Projects</option>
|
||||
<option value="user">User accounts</option>
|
||||
</select>
|
||||
<input id="audit-search" placeholder="Search actor / action / item…" oninput="renderAudit()" style="flex:1;min-width:160px;padding:8px 10px;font:inherit;font-size:13px;border:1px solid var(--border-strong);border-radius:0;">
|
||||
</div>
|
||||
<div id="audit-admin" class="note" style="margin-top:12px">Click refresh to load.</div>
|
||||
</div>
|
||||
|
||||
<!-- USAGE LOGS -->
|
||||
<div class="card">
|
||||
<h2>Usage logs</h2>
|
||||
|
||||
138
html/admin.js
138
html/admin.js
@@ -11,7 +11,10 @@ function reveal(){
|
||||
document.getElementById('admin-main').style.display='';
|
||||
checkHealth();
|
||||
loadUsers();
|
||||
loadSettings();
|
||||
loadNotifications();
|
||||
loadComments();
|
||||
loadAudit();
|
||||
loadUsage();
|
||||
}
|
||||
function showDenied(){
|
||||
@@ -189,11 +192,20 @@ function renderUsers(list, meId){
|
||||
const delBtn = me
|
||||
? ''
|
||||
: '<button class="mini danger" onclick="deleteUser(\''+u.id+'\',\''+uesc(u.username).replace(/'/g,"\\'")+'\')">Delete</button>';
|
||||
// Role can be changed at any time via an inline dropdown. Your own row is
|
||||
// locked (a shown-as-tag) so an admin can't accidentally demote themselves.
|
||||
const escUname = uesc(u.username).replace(/'/g,"\\'");
|
||||
const roleCell = me
|
||||
? '<span class="tag '+(u.role==='admin'?'admin':'user')+'">'+uesc(u.role)+'</span><span class="me-tag">locked</span>'
|
||||
: '<select class="role-select'+(u.role==='admin'?' is-admin':'')+'" title="Change this user’s role" onchange="changeRole(\''+u.id+'\',this.value,\''+escUname+'\')">'+
|
||||
'<option value="user"'+(u.role==='user'?' selected':'')+'>user</option>'+
|
||||
'<option value="admin"'+(u.role==='admin'?' selected':'')+'>admin</option>'+
|
||||
'</select>';
|
||||
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>'+roleCell+'</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">'+
|
||||
@@ -244,6 +256,18 @@ async function toggleActive(id, makeActive){
|
||||
else alert('Failed: '+((json && json.detail)||('HTTP '+status)));
|
||||
}
|
||||
|
||||
// Change a user's role (user ↔ admin) at any time. The server enforces the same
|
||||
// admin-only rule as every other user-management call, and refuses to remove the
|
||||
// last admin. On any failure we reload so the dropdown snaps back to the truth.
|
||||
async function changeRole(id, role, username){
|
||||
const { status, json } = await api('POST','/api/auth/users/'+id+'/role',{role});
|
||||
if(status===200){ loadUsers(); }
|
||||
else {
|
||||
alert('Could not change role for '+username+': '+((json && json.detail)||('HTTP '+status)));
|
||||
loadUsers();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -335,6 +359,118 @@ function renderComments(){
|
||||
'</tr>').join('')+'</tbody></table>';
|
||||
}
|
||||
|
||||
// ── activity log (audit trail) ──────────────────────────────────────────────────
|
||||
let _audit = [];
|
||||
async function loadAudit(){
|
||||
const box = document.getElementById('audit-admin');
|
||||
box.textContent = 'Loading…';
|
||||
const { status, json } = await api('GET','/api/audit?limit=500');
|
||||
if(status!==200 || !Array.isArray(json)){
|
||||
box.innerHTML = '<div class="banner bad">Could not load activity (HTTP '+status+').</div>'; return;
|
||||
}
|
||||
_audit = json;
|
||||
renderAudit();
|
||||
}
|
||||
function renderAudit(){
|
||||
const box = document.getElementById('audit-admin');
|
||||
const type = document.getElementById('audit-type').value;
|
||||
const q = (document.getElementById('audit-search').value||'').toLowerCase();
|
||||
let rows = _audit.filter(e => (!type || e.entity_type===type) &&
|
||||
(!q || ((e.actor||'')+' '+(e.action||'')+' '+(e.summary||'')).toLowerCase().indexOf(q)>=0));
|
||||
if(!rows.length){ box.innerHTML = '<div class="note">No activity'+((type||q)?' matches the filter.':' yet.')+'</div>'; return; }
|
||||
const fmt = s => s ? new Date(s).toLocaleString() : '—';
|
||||
const det = e => {
|
||||
const d = e.detail || {};
|
||||
if(d.from!=null || d.to!=null) return uesc((d.from==null?'—':d.from)+' → '+(d.to==null?'—':d.to));
|
||||
return uesc(Object.keys(d).map(k=>k+': '+d[k]).join(', '));
|
||||
};
|
||||
box.innerHTML = '<table class="users"><thead><tr><th>When</th><th>Who</th><th>Action</th><th>Type</th><th>Item</th><th>Detail</th></tr></thead><tbody>'+
|
||||
rows.map(e => '<tr>'+
|
||||
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(e.at)+'</td>'+
|
||||
'<td><strong>'+uesc(e.actor||'—')+'</strong></td>'+
|
||||
'<td>'+uesc((e.action||'').replace(/_/g,' '))+'</td>'+
|
||||
'<td>'+uesc(e.entity_type||'')+'</td>'+
|
||||
'<td>'+uesc(e.summary||e.entity_id||'')+'</td>'+
|
||||
'<td style="color:var(--muted)">'+det(e)+'</td>'+
|
||||
'</tr>').join('')+'</tbody></table>';
|
||||
}
|
||||
|
||||
// ── notifications / email settings ──────────────────────────────────────────────
|
||||
let _settings = {};
|
||||
async function loadSettings(){
|
||||
const box = document.getElementById('settings-box');
|
||||
const { status, json } = await api('GET','/api/settings');
|
||||
if(status!==200 || !json){ box.innerHTML = '<div class="banner bad">Could not load settings (HTTP '+status+').</div>'; return; }
|
||||
_settings = json; renderSettings();
|
||||
}
|
||||
function renderSettings(){
|
||||
const s = _settings, box = document.getElementById('settings-box');
|
||||
const on = !!s.email_enabled;
|
||||
const pwOk = !!s.smtp_password_set;
|
||||
box.innerHTML =
|
||||
'<label style="display:inline-flex;align-items:center;gap:8px;font-size:14px;font-weight:700;margin-bottom:12px">'+
|
||||
'<input type="checkbox" id="set-enabled"'+(on?' checked':'')+'> Email notifications are <span style="color:'+(on?'var(--green)':'var(--muted)')+'">'+(on?'ON':'OFF')+'</span></label>'+
|
||||
'<div class="urow" style="margin-bottom:8px">'+
|
||||
'<input id="set-host" placeholder="SMTP host (e.g. smtp.company.local)" value="'+uesc(s.smtp_host||'')+'">'+
|
||||
'<input id="set-port" style="flex:0 0 90px;min-width:70px" placeholder="Port" value="'+uesc(s.smtp_port||587)+'">'+
|
||||
'<label style="display:inline-flex;align-items:center;gap:6px;font-size:13px;white-space:nowrap"><input type="checkbox" id="set-tls"'+(s.smtp_use_tls?' checked':'')+'> STARTTLS</label>'+
|
||||
'</div>'+
|
||||
'<div class="urow" style="margin-bottom:8px">'+
|
||||
'<input id="set-from" placeholder="From address (e.g. wp-suite@company.com)" value="'+uesc(s.from_addr||'')+'">'+
|
||||
'<input id="set-fromname" placeholder="From name" value="'+uesc(s.from_name||'')+'">'+
|
||||
'<input id="set-user" placeholder="SMTP username (optional)" value="'+uesc(s.smtp_username||'')+'">'+
|
||||
'</div>'+
|
||||
'<div class="urow" style="margin-bottom:8px">'+
|
||||
'<input id="set-baseurl" placeholder="App base URL for email links (e.g. https://wp.controls.dev)" value="'+uesc(s.app_base_url||'')+'">'+
|
||||
'</div>'+
|
||||
'<div class="note" style="margin-bottom:10px">SMTP password: '+(pwOk?'<span style="color:var(--green);font-weight:600">set via SMTP_PASSWORD env ✓</span>':'<span style="color:var(--amber);font-weight:600">not set — add SMTP_PASSWORD to the environment before enabling</span>')+'</div>'+
|
||||
'<div class="row">'+
|
||||
'<button class="primary" onclick="saveSettings()">Save settings</button>'+
|
||||
'<button onclick="testEmail()">Send test email to me</button>'+
|
||||
'<span id="set-msg" class="note" style="margin:0"></span>'+
|
||||
'</div>';
|
||||
}
|
||||
async function saveSettings(){
|
||||
const v = id => document.getElementById(id);
|
||||
const patch = {
|
||||
email_enabled: v('set-enabled').checked,
|
||||
smtp_host: v('set-host').value.trim(),
|
||||
smtp_port: parseInt(v('set-port').value, 10) || 587,
|
||||
smtp_use_tls: v('set-tls').checked,
|
||||
from_addr: v('set-from').value.trim(),
|
||||
from_name: v('set-fromname').value.trim(),
|
||||
smtp_username: v('set-user').value.trim(),
|
||||
app_base_url: v('set-baseurl').value.trim(),
|
||||
};
|
||||
const msg = v('set-msg'); msg.textContent = 'Saving…'; msg.style.color = 'var(--muted)';
|
||||
const { status, json } = await api('PUT','/api/settings', patch);
|
||||
if(status===200){ _settings = json; renderSettings(); const m = document.getElementById('set-msg'); if(m){ m.textContent = 'Saved.'; m.style.color = 'var(--green)'; } }
|
||||
else { msg.textContent = 'Save failed (HTTP '+status+').'; msg.style.color = 'var(--red)'; }
|
||||
}
|
||||
async function testEmail(){
|
||||
const msg = document.getElementById('set-msg'); msg.textContent = 'Sending test…'; msg.style.color = 'var(--muted)';
|
||||
const { status, json } = await api('POST','/api/settings/test-email', {});
|
||||
if(status===200) { msg.textContent = '✅ Test sent to '+((json&&json.to)||'you')+'.'; msg.style.color = 'var(--green)'; }
|
||||
else { msg.textContent = '❌ '+((json && json.detail) || ('HTTP '+status)); msg.style.color = 'var(--red)'; }
|
||||
}
|
||||
async function loadNotifications(){
|
||||
const box = document.getElementById('notif-box'); if(!box) return;
|
||||
const { status, json } = await api('GET','/api/notifications?all=1&limit=50');
|
||||
if(status!==200 || !Array.isArray(json)){ box.innerHTML = ''; return; }
|
||||
if(!json.length){ box.innerHTML = '<div class="note">No notifications yet.</div>'; return; }
|
||||
const fmt = s => s ? new Date(s).toLocaleString() : '—';
|
||||
const stColor = st => st==='sent'?'var(--green)':st==='failed'?'var(--red)':st==='skipped'?'var(--muted)':'var(--amber)';
|
||||
box.innerHTML = '<div class="sub" style="margin:4px 0 6px;color:var(--muted)">Recent notifications</div>'+
|
||||
'<table class="users"><thead><tr><th>When</th><th>To</th><th>Kind</th><th>Subject</th><th>Status</th></tr></thead><tbody>'+
|
||||
json.map(n => '<tr>'+
|
||||
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(n.created_at)+'</td>'+
|
||||
'<td>'+uesc(n.email||n.user_id)+'</td>'+
|
||||
'<td>'+uesc((n.kind||'').replace(/_/g,' '))+'</td>'+
|
||||
'<td>'+uesc(n.subject||'')+'</td>'+
|
||||
'<td style="color:'+stColor(n.status)+';font-weight:600">'+uesc(n.status)+(n.error?' <span title="'+uesc(n.error)+'">ⓘ</span>':'')+'</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:[]}; } }
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
|
||||
var inIframe = (function () { try { return window.top !== window.self; } catch (e) { return true; } })();
|
||||
|
||||
// Register the PWA service worker (caches the app shell for offline use). Only
|
||||
// from the top window; the API and writes are never cached (see sw.js).
|
||||
if (!inIframe && 'serviceWorker' in navigator) {
|
||||
try { navigator.serviceWorker.register('/sw.js'); } catch (e) {}
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -34,6 +40,19 @@
|
||||
}
|
||||
|
||||
window.wpLogout = function () {
|
||||
try {
|
||||
// Clear the auth cache AND all cached project data (customer IP) from this
|
||||
// device on sign-out — important on shared/field tablets. The outbox
|
||||
// (wp_sync_outbox_v1) is left intact so unsynced writes aren't lost.
|
||||
// (localStorage is not a security boundary; field devices still need
|
||||
// full-disk encryption / MDM — see DEPLOYMENT.md.)
|
||||
localStorage.removeItem('wp_auth_cache');
|
||||
Object.keys(localStorage).forEach(function (k) {
|
||||
if (/^wp_(iwp_v1|suite_sop|suite_state|projects|active_project)/.test(k)) {
|
||||
localStorage.removeItem(k);
|
||||
}
|
||||
});
|
||||
} catch (e) {}
|
||||
fetch('/api/auth/logout', { method: 'POST' })
|
||||
.catch(function () {})
|
||||
.then(function () { window.location.replace('login.html'); });
|
||||
@@ -96,58 +115,99 @@
|
||||
};
|
||||
};
|
||||
|
||||
function isDarkBg(el) {
|
||||
try {
|
||||
var m = (getComputedStyle(el).backgroundColor || '').match(/(\d+),\s*(\d+),\s*(\d+)/);
|
||||
if (!m) return true;
|
||||
return (0.299 * +m[1] + 0.587 * +m[2] + 0.114 * +m[3]) < 140;
|
||||
} catch (e) { return true; }
|
||||
}
|
||||
|
||||
// The user menu (name · Admin · Password · Sign out). Text colors adapt to the
|
||||
// bar it sits in (light links on a dark bar, blue links on a light bar).
|
||||
function buildUserMenu(user, dark) {
|
||||
var wrap = document.createElement('div');
|
||||
wrap.id = 'wp-usermenu';
|
||||
var linkColor = dark ? '#ffffff' : '#0f62fe';
|
||||
wrap.style.cssText = 'display:flex;align-items:center;gap:8px;margin-left:auto;padding-left:14px;white-space:nowrap;' +
|
||||
'font:400 13px/1.2 "IBM Plex Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;' +
|
||||
'color:' + (dark ? '#c6c6c6' : '#525252') + ';';
|
||||
function sep() { var s = document.createElement('span'); s.textContent = '·'; s.style.color = dark ? '#6f6f6f' : '#a8a8a8'; return s; }
|
||||
function link(text, onClick, href) {
|
||||
var a = document.createElement('a'); a.textContent = text; a.href = href || '#';
|
||||
a.style.cssText = 'color:' + linkColor + ';text-decoration:none;font-weight:600;';
|
||||
if (onClick) a.addEventListener('click', function (e) { e.preventDefault(); onClick(); });
|
||||
return a;
|
||||
}
|
||||
var who = document.createElement('span');
|
||||
who.textContent = user.full_name || user.username;
|
||||
who.style.color = dark ? '#ffffff' : '#161616';
|
||||
wrap.appendChild(who);
|
||||
var onAdmin = /(^|\/)admin\.html$/.test(location.pathname);
|
||||
if (user.role === 'admin' && !onAdmin) { wrap.appendChild(sep()); wrap.appendChild(link('Admin', null, 'admin.html')); }
|
||||
wrap.appendChild(sep()); wrap.appendChild(link('Password', function () { window.wpChangePassword(); }));
|
||||
wrap.appendChild(sep()); wrap.appendChild(link('Sign out', function () { window.wpLogout(); }));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function addLogoutPill(user) {
|
||||
if (inIframe) return; // the parent page already shows it
|
||||
if (document.getElementById('wp-logout-pill')) return;
|
||||
if (document.getElementById('wp-usermenu') || document.getElementById('wp-logout-pill')) return;
|
||||
|
||||
// Preferred: drop the menu INTO the top bar so it never floats over the
|
||||
// header's own links (Help, etc.). Works with the dark UI-shell appbar and
|
||||
// the older .header bars alike.
|
||||
var host = document.querySelector('.wp-appbar') || document.querySelector('.header');
|
||||
if (host) {
|
||||
var menu = buildUserMenu(user, isDarkBg(host));
|
||||
// The older .header bars already right-align their own toolbar (via flex:1
|
||||
// or a button's margin-left:auto). A second auto-margin would split the free
|
||||
// space, so only the .wp-appbar (which may have no spacer, e.g. admin) keeps it.
|
||||
if (!host.classList.contains('wp-appbar')) menu.style.marginLeft = '0';
|
||||
host.appendChild(menu);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback for any page with no header bar: a floating pill (as before).
|
||||
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);
|
||||
'display:flex;align-items:center;background:#fff;border:1px solid #e0e0e0;' +
|
||||
'box-shadow:0 1px 4px rgba(0,0,0,.16);border-radius:16px;padding:5px 12px;';
|
||||
pill.appendChild(buildUserMenu(user, false));
|
||||
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) {
|
||||
function proceed(user) {
|
||||
clearTimeout(safety);
|
||||
window.WP_USER = data && data.user;
|
||||
window.WP_USER = 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); });
|
||||
}
|
||||
}
|
||||
|
||||
fetch('/api/auth/me', { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) {
|
||||
if (r.status === 401 || r.status === 403) { try { localStorage.removeItem('wp_auth_cache'); } catch (e) {} goToLogin(); return; }
|
||||
if (!r.ok) { reveal(); clearTimeout(safety); return; } // unexpected; show page rather than trap
|
||||
return r.json().then(function (data) {
|
||||
var user = data && data.user;
|
||||
// Remember the last good auth so the PWA can open offline. The server is
|
||||
// still the real gate; offline writes queue in the outbox until reconnect.
|
||||
try { if (user) localStorage.setItem('wp_auth_cache', JSON.stringify({ user: user, at: Date.now() })); } catch (e) {}
|
||||
proceed(user);
|
||||
});
|
||||
})
|
||||
.catch(function () { goToLogin(); }); // API unreachable → send to login
|
||||
.catch(function () {
|
||||
// Offline / API unreachable: fall back to a recent cached auth if present,
|
||||
// so the app (and the field view) still open without a network.
|
||||
try {
|
||||
var c = JSON.parse(localStorage.getItem('wp_auth_cache') || 'null');
|
||||
if (c && c.user && (Date.now() - (c.at || 0)) < 12 * 3600 * 1000) { proceed(c.user); return; }
|
||||
} catch (e) {}
|
||||
goToLogin();
|
||||
});
|
||||
})();
|
||||
|
||||
86
html/field.html
Normal file
86
html/field.html
Normal file
@@ -0,0 +1,86 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Field View — Work Package Suite</title>
|
||||
<script src="auth-guard.js"></script>
|
||||
<link rel="icon" href="favicon.ico" sizes="any">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<meta name="theme-color" content="#161616">
|
||||
<link rel="stylesheet" href="theme-light.css">
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { -webkit-text-size-adjust: 100%; }
|
||||
.field-wrap { max-width: 760px; margin: 0 auto; padding: 16px 16px 40px; }
|
||||
.fld-ctx { font-size: 13px; color: var(--cds-text-secondary); margin-bottom: 12px; }
|
||||
.fld-ctx b { color: var(--cds-text-primary); }
|
||||
.fld-search { width: 100%; padding: 14px; font-size: 16px; border: 1px solid var(--cds-border-strong); background: #fff; margin-bottom: 14px; }
|
||||
.fld-search:focus { outline: 2px solid var(--cds-focus); outline-offset: -2px; }
|
||||
.wp-card { display: block; width: 100%; text-align: left; background: var(--cds-layer); border: 1px solid var(--cds-border-subtle); border-left: 4px solid var(--cds-border-strong); padding: 14px 16px; margin-bottom: 10px; cursor: pointer; font-family: inherit; }
|
||||
.wp-card:active { background: var(--cds-layer-hover); }
|
||||
.wp-card.ready { border-left-color: var(--cds-support-success); }
|
||||
.wp-card.hold { border-left-color: var(--cds-support-error); }
|
||||
.wp-card .num { font-weight: 600; font-size: 16px; color: var(--cds-text-primary); }
|
||||
.wp-card .subj { color: var(--cds-text-secondary); font-size: 13px; margin-top: 2px; }
|
||||
.wp-card .meta { margin-top: 10px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.pill { display: inline-block; font-size: 12px; font-weight: 600; padding: 3px 10px; border-radius: 14px; }
|
||||
.pill.st { background: var(--cds-layer-accent); color: var(--cds-text-secondary); }
|
||||
.pill.ok { background: #defbe6; color: #0e6027; }
|
||||
.pill.warn { background: #fdf6dd; color: #8a6d00; }
|
||||
.pill.bad { background: #fff1f1; color: #da1e28; }
|
||||
.fld-empty { padding: 32px; text-align: center; color: var(--cds-text-helper); border: 1px dashed var(--cds-border-strong); background: #fff; }
|
||||
.fld-empty a { color: var(--cds-link-primary); }
|
||||
.fld-back { background: none; border: none; color: var(--cds-link-primary); font-size: 15px; padding: 8px 0; cursor: pointer; font-family: inherit; }
|
||||
.fld-h1 { font-size: 20px; font-weight: 600; margin: 4px 0 2px; }
|
||||
.fld-sub { color: var(--cds-text-secondary); font-size: 14px; margin-bottom: 16px; }
|
||||
.fld-sec { background: var(--cds-layer); border: 1px solid var(--cds-border-subtle); padding: 14px 16px; margin-bottom: 14px; }
|
||||
.fld-sec h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--cds-text-helper); margin-bottom: 10px; }
|
||||
.st-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 8px; }
|
||||
.st-btn { padding: 14px 10px; font-size: 15px; font-weight: 600; border: 1px solid var(--cds-border-strong); background: #fff; color: var(--cds-text-secondary); cursor: pointer; font-family: inherit; }
|
||||
.st-btn.on { background: var(--cds-interactive-01); border-color: var(--cds-interactive-01); color: #fff; }
|
||||
.st-btn.hold.on { background: var(--cds-support-error); border-color: var(--cds-support-error); }
|
||||
.cx-row { display: flex; align-items: center; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--cds-border-subtle); }
|
||||
.cx-row:last-child { border-bottom: none; }
|
||||
.cx-name { flex: 1; font-size: 15px; }
|
||||
.cx-state { min-width: 96px; padding: 10px 12px; font-size: 14px; font-weight: 600; border: 1px solid var(--cds-border-strong); background: #fff; cursor: pointer; text-align: center; font-family: inherit; }
|
||||
.cx-state.cleared { background: #defbe6; color: #0e6027; border-color: #a7f0ba; }
|
||||
.cx-state.na { background: var(--cds-layer-accent); color: var(--cds-text-secondary); }
|
||||
.cx-state.open { background: #fff1f1; color: #da1e28; border-color: #ffd7d9; }
|
||||
.fld-note { width: 100%; padding: 12px; font-size: 16px; border: 1px solid var(--cds-border-strong); min-height: 84px; font-family: inherit; resize: vertical; }
|
||||
.fld-photo-row { display: flex; gap: 10px; align-items: center; margin-top: 10px; flex-wrap: wrap; }
|
||||
.fld-btn { padding: 12px 18px; font-size: 15px; font-weight: 600; border: 1px solid var(--cds-border-strong); background: #fff; cursor: pointer; font-family: inherit; }
|
||||
.fld-btn.primary { background: var(--cds-interactive-01); border-color: var(--cds-interactive-01); color: #fff; }
|
||||
.log-item { border: 1px solid var(--cds-border-subtle); padding: 10px 12px; margin-bottom: 8px; font-size: 14px; color: var(--cds-text-primary); white-space: pre-wrap; }
|
||||
.log-item .lm { color: var(--cds-text-helper); font-size: 11px; margin-bottom: 4px; }
|
||||
.log-item img { max-width: 160px; max-height: 120px; margin-top: 6px; display: block; border: 1px solid var(--cds-border-subtle); }
|
||||
.fld-toast { position: fixed; bottom: 76px; left: 50%; transform: translateX(-50%); background: #161616; color: #fff; padding: 12px 20px; font-size: 14px; opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 50; }
|
||||
.fld-toast.show { opacity: 1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="wp-appbar">
|
||||
<a href="index.html" class="wp-appbar-brand" title="Home">
|
||||
<span class="wp-logo-chip"><img src="prime-controls-logo.jpg" alt="Prime Controls"></span>
|
||||
<span class="wp-appbar-title">Field View</span>
|
||||
</a>
|
||||
<div class="wp-appbar-spacer"></div>
|
||||
<a class="wp-appbar-link" href="index.html">Home</a>
|
||||
</header>
|
||||
|
||||
<div class="field-wrap">
|
||||
<div class="fld-ctx" id="fld-ctx"></div>
|
||||
<section id="screen-list">
|
||||
<input class="fld-search" id="fld-search" type="search" placeholder="Search work packages…" oninput="renderList()" aria-label="Search work packages">
|
||||
<div id="wp-list"></div>
|
||||
</section>
|
||||
<section id="screen-detail" style="display:none"></section>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="fld-toast"></div>
|
||||
|
||||
<script src="project-data.js"></script>
|
||||
<script src="help.js"></script>
|
||||
<script src="field.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
164
html/field.js
Normal file
164
html/field.js
Normal file
@@ -0,0 +1,164 @@
|
||||
/* Field view — a touch-optimized screen for updating a Work Package's status,
|
||||
constraints, and a photo/note log from the work face. Reads the same shared
|
||||
data as the desktop creator (via project-data.js) and saves through the sync
|
||||
outbox, so it works offline and syncs when the network returns. */
|
||||
'use strict';
|
||||
|
||||
var PID = '', PROJECT = null, WPS = [], curId = null, pendingPhoto = '', draftNote = '';
|
||||
var STATUSES = ['Draft', 'Scheduled', 'Issued', 'In Progress', 'QC', 'Closed', 'Issue'];
|
||||
var GATED = ['Issued', 'In Progress', 'QC', 'Closed']; // need all constraints cleared to enter
|
||||
|
||||
function esc(s) { return s == null ? '' : String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); }
|
||||
function nsKey(id) { return 'wp_iwp_v1__' + id; }
|
||||
function stLabel(s) { return s === 'Issue' ? 'Issue (Hold)' : s; }
|
||||
function openCount(p) { return ((p && p.constraints) || []).filter(function (c) { return c.status === 'open'; }).length; }
|
||||
function fmtTs(s) { try { return new Date(s).toLocaleString(); } catch (e) { return s || ''; } }
|
||||
function me() { try { return (window.WP_USER && (window.WP_USER.full_name || window.WP_USER.username)) || ''; } catch (e) { return ''; } }
|
||||
function toast(m) { var t = document.getElementById('toast'); if (!t) return; t.textContent = m; t.classList.add('show'); clearTimeout(toast._t); toast._t = setTimeout(function () { t.classList.remove('show'); }, 2000); }
|
||||
|
||||
// ── boot / data ──────────────────────────────────────────────────────────────
|
||||
function boot() {
|
||||
var params = new URLSearchParams(location.search);
|
||||
PID = params.get('project') || (ProjectData.getActiveId && ProjectData.getActiveId()) || '';
|
||||
if (!PID) { showNoProject(); return; }
|
||||
if (ProjectData.getActiveId && ProjectData.getActiveId() !== PID) { try { ProjectData.setActive({ id: PID }); } catch (e) {} }
|
||||
if (ProjectData.get) { ProjectData.get(PID).then(function (p) { PROJECT = p; renderCtx(); }).catch(function () {}); }
|
||||
loadWPs();
|
||||
}
|
||||
function renderCtx() {
|
||||
var el = document.getElementById('fld-ctx'); if (!el) return;
|
||||
if (PROJECT) el.innerHTML = 'Project: <b>' + esc(PROJECT.name || '') + '</b>' + (PROJECT.number ? ' · ' + esc(PROJECT.number) : '');
|
||||
else el.textContent = 'Project: ' + PID;
|
||||
}
|
||||
function readCache() { try { return JSON.parse(localStorage.getItem(nsKey(PID)) || '[]') || []; } catch (e) { return []; } }
|
||||
function writeCache() { try { localStorage.setItem(nsKey(PID), JSON.stringify(WPS)); } catch (e) {} }
|
||||
function activePkgs(list) { return list.filter(function (p) { return !p.split && !p.archived; }); } // real work, not masters/archived
|
||||
|
||||
function loadWPs() {
|
||||
WPS = activePkgs(readCache()); // offline-first: show cached packages immediately
|
||||
renderList();
|
||||
if (ProjectData.pullProject) {
|
||||
ProjectData.pullProject(PID).then(function () {
|
||||
WPS = activePkgs(readCache());
|
||||
if (!curId) renderList(); else renderDetail();
|
||||
}).catch(function () {});
|
||||
}
|
||||
}
|
||||
function showNoProject() {
|
||||
var s = document.getElementById('screen-list');
|
||||
if (s) s.innerHTML = '<div class="fld-empty">No project selected.<br><a href="index.html">Pick a project on the home page</a>, then reopen the field view.</div>';
|
||||
}
|
||||
|
||||
// ── list ───────────────────────────────────────────────────────────────────
|
||||
function renderList() {
|
||||
var box = document.getElementById('wp-list'); if (!box) return;
|
||||
var q = ((document.getElementById('fld-search') || {}).value || '').toLowerCase();
|
||||
var rows = WPS.filter(function (p) { return !q || ((p.number || '') + ' ' + (p.subject || '') + ' ' + (p.type || '')).toLowerCase().indexOf(q) >= 0; });
|
||||
if (!rows.length) { box.innerHTML = '<div class="fld-empty">' + (WPS.length ? 'No packages match your search.' : 'No work packages for this project yet.') + '</div>'; return; }
|
||||
box.innerHTML = rows.map(function (p) {
|
||||
var open = openCount(p);
|
||||
var cls = p.status === 'Issue' ? 'hold' : (open === 0 ? 'ready' : '');
|
||||
var readyPill = p.status === 'Issue' ? '<span class="pill bad">On hold</span>' : (open ? '<span class="pill warn">' + open + ' open</span>' : '<span class="pill ok">Ready</span>');
|
||||
return '<button class="wp-card ' + cls + '" onclick="openWP(\'' + esc(p.id) + '\')">' +
|
||||
'<div class="num">' + esc(p.number || '(no number)') + '</div>' +
|
||||
'<div class="subj">' + esc(p.subject || '') + '</div>' +
|
||||
'<div class="meta"><span class="pill st">' + esc(stLabel(p.status)) + '</span>' + readyPill +
|
||||
(p.type ? '<span class="pill st">' + esc(p.type) + '</span>' : '') + '</div></button>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── detail ─────────────────────────────────────────────────────────────────
|
||||
function curWP() { return WPS.find(function (p) { return p.id === curId; }); }
|
||||
function openWP(id) { curId = id; pendingPhoto = ''; draftNote = ''; renderDetail(); window.scrollTo(0, 0); }
|
||||
function backToList() {
|
||||
curId = null; pendingPhoto = ''; draftNote = '';
|
||||
document.getElementById('screen-detail').style.display = 'none';
|
||||
document.getElementById('screen-list').style.display = '';
|
||||
renderList();
|
||||
}
|
||||
function renderDetail() {
|
||||
var p = curWP(); if (!p) { backToList(); return; }
|
||||
document.getElementById('screen-list').style.display = 'none';
|
||||
var d = document.getElementById('screen-detail'); d.style.display = '';
|
||||
|
||||
var stBtns = STATUSES.map(function (s) {
|
||||
return '<button class="st-btn' + (s === 'Issue' ? ' hold' : '') + (p.status === s ? ' on' : '') + '" onclick="setStatus(\'' + s + '\')">' + esc(stLabel(s)) + '</button>';
|
||||
}).join('');
|
||||
|
||||
var cx = (p.constraints) || [];
|
||||
var cxRows = cx.length ? cx.map(function (c, i) {
|
||||
var st = c.status || 'open';
|
||||
return '<div class="cx-row"><div class="cx-name">' + esc(c.name) + '</div>' +
|
||||
'<button class="cx-state ' + st + '" onclick="cycleConstraint(' + i + ')">' + (st === 'cleared' ? 'Cleared' : st === 'na' ? 'N/A' : 'Open') + '</button></div>';
|
||||
}).join('') : '<div style="color:var(--cds-text-helper);font-size:14px">No constraints on this package.</div>';
|
||||
|
||||
var log = ((p.fieldLog) || []).slice().reverse().map(function (e) {
|
||||
return '<div class="log-item"><div class="lm">' + esc(e.by || '—') + ' · ' + esc(fmtTs(e.ts)) + (e.status ? ' · ' + esc(stLabel(e.status)) : '') + '</div>' +
|
||||
(e.note ? esc(e.note) : '') + (e.photo && /^data:image\//.test(e.photo) ? '<img src="' + esc(e.photo) + '" alt="site photo">' : '') + '</div>';
|
||||
}).join('') || '<div style="color:var(--cds-text-helper);font-size:14px">No field updates yet.</div>';
|
||||
|
||||
d.innerHTML =
|
||||
'<button class="fld-back" onclick="backToList()">‹ All packages</button>' +
|
||||
'<div class="fld-h1">' + esc(p.number || '(no number)') + '</div>' +
|
||||
'<div class="fld-sub">' + esc(p.subject || '') + (p.type ? ' · ' + esc(p.type) : '') + '</div>' +
|
||||
'<div class="fld-sec"><h3>Status</h3><div class="st-grid">' + stBtns + '</div></div>' +
|
||||
'<div class="fld-sec"><h3>Constraints — ' + openCount(p) + ' open</h3>' + cxRows + '</div>' +
|
||||
'<div class="fld-sec"><h3>Add field update</h3>' +
|
||||
'<textarea class="fld-note" id="fld-note" placeholder="What happened on site? (progress, blockers, notes)" oninput="draftNote=this.value">' + esc(draftNote) + '</textarea>' +
|
||||
'<div class="fld-photo-row"><label class="fld-btn">📷 Add photo<input type="file" accept="image/*" capture="environment" style="display:none" onchange="onPhoto(event)"></label>' +
|
||||
'<span id="photo-status" style="font-size:13px;color:var(--cds-text-secondary)">' + (pendingPhoto ? 'Photo attached ✓' : '') + '</span></div>' +
|
||||
'<div style="margin-top:12px"><button class="fld-btn primary" onclick="addUpdate()">Add to log</button></div>' +
|
||||
'</div>' +
|
||||
'<div class="fld-sec"><h3>Field log</h3>' + log + '</div>';
|
||||
}
|
||||
|
||||
// ── mutations (each auto-saves via the outbox; the global sync badge shows state) ──
|
||||
function saveWP(p) {
|
||||
var ix = WPS.findIndex(function (x) { return x.id === p.id; });
|
||||
if (ix >= 0) WPS[ix] = p;
|
||||
writeCache();
|
||||
if (typeof ProjectData !== 'undefined' && ProjectData.pushWP) ProjectData.pushWP(p, PID);
|
||||
}
|
||||
function setStatus(s) {
|
||||
var p = curWP(); if (!p) return;
|
||||
if (GATED.indexOf(s) >= 0 && openCount(p) > 0) { toast('Clear all constraints before moving to ' + stLabel(s)); return; }
|
||||
if (p.status === s) return;
|
||||
p.status = s;
|
||||
if (s === 'Issued' && !p.issuedAt) p.issuedAt = new Date().toISOString();
|
||||
saveWP(p); renderDetail(); toast('Status: ' + stLabel(s));
|
||||
}
|
||||
function cycleConstraint(i) {
|
||||
var p = curWP(); if (!p || !p.constraints || !p.constraints[i]) return;
|
||||
var order = ['open', 'cleared', 'na'];
|
||||
var cur = p.constraints[i].status || 'open';
|
||||
p.constraints[i].status = order[(order.indexOf(cur) + 1) % 3];
|
||||
saveWP(p); renderDetail();
|
||||
}
|
||||
function onPhoto(ev) {
|
||||
var f = ev.target.files && ev.target.files[0]; if (!f) return;
|
||||
var st = document.getElementById('photo-status'); if (st) st.textContent = 'Processing…';
|
||||
var url = URL.createObjectURL(f);
|
||||
var img = new Image();
|
||||
img.onload = function () {
|
||||
var max = 1280, w = img.width, h = img.height, scale = Math.min(1, max / Math.max(w, h));
|
||||
var cv = document.createElement('canvas');
|
||||
cv.width = Math.round(w * scale); cv.height = Math.round(h * scale);
|
||||
cv.getContext('2d').drawImage(img, 0, 0, cv.width, cv.height);
|
||||
try { pendingPhoto = cv.toDataURL('image/jpeg', 0.7); } catch (e) { pendingPhoto = ''; }
|
||||
URL.revokeObjectURL(url);
|
||||
if (st) st.textContent = pendingPhoto ? 'Photo attached ✓' : 'Could not read photo';
|
||||
};
|
||||
img.onerror = function () { URL.revokeObjectURL(url); if (st) st.textContent = 'Could not read photo'; };
|
||||
img.src = url;
|
||||
}
|
||||
function addUpdate() {
|
||||
var p = curWP(); if (!p) return;
|
||||
var note = (draftNote || '').trim();
|
||||
if (!note && !pendingPhoto) { toast('Add a note or photo first'); return; }
|
||||
if (!p.fieldLog) p.fieldLog = [];
|
||||
p.fieldLog.push({ ts: new Date().toISOString(), by: me(), note: note, photo: pendingPhoto || '', status: p.status });
|
||||
pendingPhoto = ''; draftNote = '';
|
||||
saveWP(p); renderDetail(); toast('Update added to log');
|
||||
}
|
||||
|
||||
boot();
|
||||
49
html/help.js
49
html/help.js
@@ -15,64 +15,64 @@
|
||||
// ── 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;
|
||||
margin-left:5px; border-radius:50%; background:#525252; 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;
|
||||
background:#161616; color:#fff; padding:7px 10px; border-radius:0; 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; }
|
||||
border:5px solid transparent; border-top-color:#161616; 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;
|
||||
.ui-help-modal{ background:#fff; color:#161616; max-width:980px; width:100%; height:88vh; max-height:880px;
|
||||
border-radius:0; 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{ display:flex; align-items:center; gap:14px; padding:13px 18px; border-bottom:1px solid #e0e0e0; 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;
|
||||
.ui-help-search input{ width:100%; padding:8px 12px; border:1px solid #8d8d8d; border-radius:0;
|
||||
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-search input:focus{ border-color:#0f62fe; 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:#525252; 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;
|
||||
.ui-help-nav{ width:230px; flex:none; border-right:1px solid #e0e0e0; overflow:auto; padding:10px 8px; background:#fafbfc; }
|
||||
.ui-help-nav a{ display:block; padding:7px 10px; border-radius:0; 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.active{ background:#edf5ff; color:#0353e9; 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-sec h3{ font-size:18px; margin:0 0 10px; color:#161616; scroll-margin-top:10px; }
|
||||
.ui-help-sec h4{ margin:18px 0 6px; font-size:12px; text-transform:uppercase; letter-spacing:.04em; color:#0f62fe; }
|
||||
.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, .ui-help-content td{ border:1px solid #e0e0e0; 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-draft{ background:#eef1f6; color:#525252; } .pill-sched{ background:#edf5ff; color:#0353e9; }
|
||||
.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;
|
||||
.ui-help-callout{ background:#f4f8ff; border-left:3px solid #0f62fe; padding:10px 14px; border-radius: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-noresult{ display:none; color:#525252; 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;
|
||||
border:none; background:#0f62fe; 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; }
|
||||
.ui-help-fab:hover{ background:#0353e9; }
|
||||
@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{ width:auto; display:flex; flex-wrap:wrap; gap:4px; border-right:none; border-bottom:1px solid #e0e0e0; }
|
||||
.ui-help-nav a{ margin:0; font-size:12px; padding:5px 9px; }
|
||||
.ui-help-head{ flex-wrap:wrap; }
|
||||
}`;
|
||||
@@ -94,6 +94,13 @@
|
||||
</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>
|
||||
<h4>Quick start</h4>
|
||||
<ol>
|
||||
<li><strong>Open “SOP Configuration”</strong> and complete the 10 steps for your project (~15 minutes).</li>
|
||||
<li><strong>Finish the SOP</strong> — its home-page card turns green and unlocks the Work Package Creator.</li>
|
||||
<li><strong>Open “Work Package Creation”</strong> to author packages with your SOP defaults pre-populated.</li>
|
||||
<li><strong>Update from the field</strong> using the <strong>Field View</strong>, and <strong>leave feedback</strong> on any page with the Feedback button.</li>
|
||||
</ol>
|
||||
<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: `
|
||||
|
||||
BIN
html/icon-192.png
Normal file
BIN
html/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
html/icon-512.png
Normal file
BIN
html/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
191
html/index.html
191
html/index.html
@@ -6,114 +6,58 @@
|
||||
<title>Work Package Suite — Prime Controls</title>
|
||||
<script src="auth-guard.js"></script>
|
||||
<link rel="icon" href="favicon.ico" sizes="any">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<meta name="theme-color" content="#161616">
|
||||
<link rel="stylesheet" href="theme-light.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--cds-background);
|
||||
color: var(--cds-text-primary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* HEADER */
|
||||
.header {
|
||||
background: var(--cds-layer);
|
||||
padding: 1.5rem 2rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
||||
border-bottom: 1px solid var(--cds-border-subtle);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
text-decoration: none;
|
||||
color: var(--cds-text-primary);
|
||||
background: white;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.logo img {
|
||||
height: 32px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.logo:hover { opacity: 0.9; }
|
||||
|
||||
.header-spacer { flex: 1; }
|
||||
|
||||
.header-nav {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-nav a {
|
||||
color: var(--cds-text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.header-nav a:hover { color: var(--cds-text-primary); }
|
||||
|
||||
/* CONTAINER */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 3rem 2rem;
|
||||
padding: 2.5rem 2rem 3rem;
|
||||
}
|
||||
|
||||
/* HERO */
|
||||
.hero {
|
||||
text-align: center;
|
||||
margin-bottom: 4rem;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 2.625rem;
|
||||
font-size: 2.25rem;
|
||||
font-weight: 300;
|
||||
margin-bottom: 1rem;
|
||||
letter-spacing: -0.01em;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--cds-text-primary);
|
||||
}
|
||||
|
||||
.hero p {
|
||||
font-size: 1.125rem;
|
||||
font-size: 1rem;
|
||||
color: var(--cds-text-secondary);
|
||||
margin-bottom: 2rem;
|
||||
max-width: 700px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
/* CARDS */
|
||||
.cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 3rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--cds-layer);
|
||||
border: 1px solid var(--cds-border-subtle);
|
||||
border-radius: 4px;
|
||||
border-left: 4px solid var(--cds-border-strong);
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
||||
transition: all 0.2s;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
text-decoration: none;
|
||||
color: var(--cds-text-primary);
|
||||
display: flex;
|
||||
@@ -121,9 +65,8 @@
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.4);
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--cds-button-primary);
|
||||
border-left-color: var(--cds-interactive-01);
|
||||
background: var(--cds-layer-hover);
|
||||
}
|
||||
|
||||
.card-badge {
|
||||
@@ -153,10 +96,10 @@
|
||||
|
||||
.card-button {
|
||||
display: inline-block;
|
||||
align-self: flex-start;
|
||||
background: var(--cds-button-primary);
|
||||
color: white;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 3px;
|
||||
padding: 0.7rem 1.25rem;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
@@ -172,16 +115,15 @@
|
||||
|
||||
/* COMPLETE STATE (SOP done) */
|
||||
.card.complete {
|
||||
background: #ecfdf5;
|
||||
border-color: #16a34a;
|
||||
border-left-color: var(--cds-support-success);
|
||||
}
|
||||
.card.complete .card-button { background: #16a34a; }
|
||||
.card.complete .card-button:hover { background: #15803d; }
|
||||
.card.complete .card-button { background: var(--cds-support-success); }
|
||||
.card.complete .card-button:hover { background: #0e6027; }
|
||||
.card-status {
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #16a34a;
|
||||
color: var(--cds-support-success);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.card.disabled {
|
||||
@@ -192,9 +134,8 @@
|
||||
/* SECTION */
|
||||
.section {
|
||||
background: var(--cds-layer);
|
||||
border-radius: 4px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
padding: 1.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid var(--cds-border-subtle);
|
||||
}
|
||||
|
||||
@@ -217,16 +158,6 @@
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.quick-start {
|
||||
background: var(--cds-button-primary);
|
||||
color: white;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.quick-start h2 { color: white; }
|
||||
.quick-start ol { margin-left: 1.5rem; line-height: 2; }
|
||||
.quick-start li { margin-bottom: 0.5rem; }
|
||||
|
||||
/* FOOTER */
|
||||
.footer {
|
||||
background: var(--cds-ui-01);
|
||||
@@ -247,18 +178,16 @@
|
||||
/* COMMENTS SECTION */
|
||||
.comments-section {
|
||||
background: var(--cds-layer);
|
||||
border-radius: 4px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid var(--cds-border-subtle);
|
||||
}
|
||||
|
||||
.comments-toggle {
|
||||
padding: 0.75rem 1.5rem;
|
||||
padding: 0.7rem 1.25rem;
|
||||
background: var(--cds-button-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
@@ -271,8 +200,7 @@
|
||||
display: none;
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--cds-ui-01);
|
||||
border-radius: 3px;
|
||||
background: var(--cds-layer-accent);
|
||||
border: 1px solid var(--cds-border-subtle);
|
||||
}
|
||||
|
||||
@@ -281,15 +209,17 @@
|
||||
.comments-panel input,
|
||||
.comments-panel textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--cds-border-subtle);
|
||||
border-radius: 3px;
|
||||
background: var(--cds-ui-02);
|
||||
padding: 0.7rem;
|
||||
border: 1px solid var(--cds-border-strong);
|
||||
background: var(--cds-field);
|
||||
color: var(--cds-text-primary);
|
||||
font-family: inherit;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.comments-panel input:focus,
|
||||
.comments-panel textarea:focus { outline: 2px solid var(--cds-focus); outline-offset: -2px; }
|
||||
|
||||
.comments-panel textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
@@ -298,12 +228,12 @@
|
||||
.comment-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.comment-buttons button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
@@ -318,11 +248,12 @@
|
||||
.submit-btn:hover { background: var(--cds-hover-primary); }
|
||||
|
||||
.close-btn {
|
||||
background: var(--cds-border-subtle);
|
||||
background: var(--cds-layer-selected);
|
||||
color: var(--cds-text-primary);
|
||||
border: 1px solid var(--cds-border-strong);
|
||||
}
|
||||
|
||||
.close-btn:hover { background: var(--cds-hover-ui); }
|
||||
.close-btn:hover { background: var(--cds-layer-selected-hover); }
|
||||
|
||||
.comments-list {
|
||||
margin-top: 1rem;
|
||||
@@ -334,7 +265,6 @@
|
||||
padding: 0.75rem;
|
||||
background: var(--cds-background);
|
||||
border: 1px solid var(--cds-border-subtle);
|
||||
border-radius: 3px;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -353,22 +283,20 @@
|
||||
.proj-loading { color: var(--cds-text-secondary); font-style: italic; font-size: 13px; }
|
||||
.proj-row { display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center; }
|
||||
.proj-row select { flex: 1; min-width: 240px; padding: 0.6rem 0.7rem; font-size: 14px;
|
||||
border: 1px solid var(--cds-border-strong, #8d8d8d); border-radius: 4px; background: #fff; }
|
||||
border: 1px solid var(--cds-border-strong, #8d8d8d); background: #fff; }
|
||||
.proj-empty { background: var(--cds-ui-01, #fff); border: 1px dashed var(--cds-border-strong, #8d8d8d);
|
||||
border-radius: 6px; padding: 1.25rem; }
|
||||
padding: 1.25rem; }
|
||||
.proj-empty p { margin: 0 0 0.9rem; color: var(--cds-text-secondary); }
|
||||
.proj-actions { display: flex; gap: 0.75rem; flex-wrap: wrap; }
|
||||
.proj-form { margin-top: 1rem; padding: 1rem; border: 1px solid var(--cds-ui-03, #e0e0e0); border-radius: 6px; background: var(--cds-ui-01, #fff); }
|
||||
.proj-form { margin-top: 1rem; padding: 1rem; border: 1px solid var(--cds-ui-03, #e0e0e0); background: var(--cds-ui-01, #fff); }
|
||||
.proj-form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0.75rem; margin-bottom: 0.9rem; }
|
||||
.proj-form-grid label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 12px; font-weight: 600; color: var(--cds-text-secondary); }
|
||||
.proj-form-grid input { padding: 0.55rem 0.65rem; font-size: 14px; border: 1px solid var(--cds-border-strong, #8d8d8d); border-radius: 4px; }
|
||||
.proj-form-grid input { padding: 0.55rem 0.65rem; font-size: 14px; border: 1px solid var(--cds-border-strong, #8d8d8d); }
|
||||
.proj-active { margin-top: 0.85rem; font-size: 13px; color: var(--cds-text-primary); }
|
||||
.link-like { background: none; border: none; color: var(--cds-link-01, #0f62fe); cursor: pointer; font-size: 13px; padding: 0; text-decoration: underline; }
|
||||
|
||||
/* RESPONSIVE */
|
||||
@media (max-width: 768px) {
|
||||
.header-content { flex-direction: column; text-align: center; }
|
||||
.header-spacer { display: none; }
|
||||
.hero h1 { font-size: 1.75rem; }
|
||||
.cards-grid { grid-template-columns: 1fr; }
|
||||
.container { padding: 1.5rem; }
|
||||
@@ -377,19 +305,17 @@
|
||||
</head>
|
||||
<body>
|
||||
<!-- HEADER -->
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<a href="index.html" class="logo">
|
||||
<img src="prime-controls-logo.jpg" alt="Prime Controls">
|
||||
<div>Work Package Suite</div>
|
||||
<header class="wp-appbar">
|
||||
<a href="index.html" class="wp-appbar-brand" title="Work Package Suite home">
|
||||
<span class="wp-logo-chip"><img src="prime-controls-logo.jpg" alt="Prime Controls"></span>
|
||||
<span class="wp-appbar-title">Work Package Suite</span>
|
||||
</a>
|
||||
<div class="header-spacer"></div>
|
||||
<nav class="header-nav">
|
||||
<a href="#overview">Overview</a>
|
||||
<a href="#comments">Feedback</a>
|
||||
<a href="#" onclick="openHelp();return false;">Help</a>
|
||||
<div class="wp-appbar-spacer"></div>
|
||||
<nav class="wp-appbar-actions">
|
||||
<a class="wp-appbar-link" href="#overview">Overview</a>
|
||||
<a class="wp-appbar-link" href="#comments">Feedback</a>
|
||||
<a class="wp-appbar-link" href="#" onclick="openHelp();return false;">Help</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- MAIN CONTENT -->
|
||||
@@ -432,17 +358,13 @@
|
||||
<button class="card-button" id="card-dash-btn">Open Dashboard</button>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
<!-- FIELD VIEW -->
|
||||
<a href="field.html" class="card" id="card-field">
|
||||
<h3>Field View</h3>
|
||||
<p>A phone-friendly view for the work face — update status, clear constraints, and log photos and notes. Installable to a home screen; works offline and syncs when you're back on network.</p>
|
||||
<button class="card-button" id="card-field-btn">Open Field View</button>
|
||||
</a>
|
||||
|
||||
<!-- QUICK START -->
|
||||
<div class="section quick-start">
|
||||
<h2>Getting Started</h2>
|
||||
<ol>
|
||||
<li><strong>Open "SOP Configuration"</strong> and complete the 10 steps for your project (~15 minutes)</li>
|
||||
<li><strong>Finish the SOP</strong> — this card turns green and unlocks the Work Package Creator</li>
|
||||
<li><strong>Open "Work Package Creator"</strong> to author Work Packages with your SOP defaults pre-populated</li>
|
||||
<li><strong>Leave feedback</strong> on any page using the feedback button below</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<!-- COMMENTS SECTION -->
|
||||
@@ -588,6 +510,7 @@
|
||||
setHref('card-sop', 'work-package-suite.html?tab=sop');
|
||||
setHref('card-wp', 'work-package-suite.html?tab=wp');
|
||||
setHref('card-dash', 'work-package-suite.html?view=dashboard');
|
||||
setHref('card-field', 'field.html?src=home');
|
||||
|
||||
cards.style.display = '';
|
||||
heroTitle.textContent = active.name || 'Work Package Suite';
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
max-width: 400px;
|
||||
background: var(--cds-layer);
|
||||
border: 1px solid var(--cds-border-subtle);
|
||||
box-shadow: 0 2px 6px var(--cds-shadow);
|
||||
border-top: 3px solid var(--cds-interactive-01);
|
||||
padding: 2.5rem 2rem;
|
||||
}
|
||||
.brand {
|
||||
@@ -97,7 +97,7 @@
|
||||
<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;">
|
||||
<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;">
|
||||
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>
|
||||
|
||||
|
||||
18
html/manifest.webmanifest
Normal file
18
html/manifest.webmanifest
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "Prime Work Package Suite",
|
||||
"short_name": "WP Suite",
|
||||
"description": "Prime Controls Work Package Suite — SOPs, work packages, and field updates.",
|
||||
"start_url": "/index.html",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#f4f4f4",
|
||||
"theme_color": "#161616",
|
||||
"icons": [
|
||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
|
||||
],
|
||||
"shortcuts": [
|
||||
{ "name": "Field View", "short_name": "Field", "url": "/field.html", "description": "Update work packages from the field" }
|
||||
]
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
var LS_ACTIVE_OBJ = 'wp_active_project_obj';
|
||||
|
||||
function uid() { return 'proj_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); }
|
||||
function esc(v) { return v == null ? '' : String(v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||
function esc(v) { return v == null ? '' : String(v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); }
|
||||
|
||||
function readLocal() { try { return JSON.parse(localStorage.getItem(LS_PROJECTS) || '[]') || []; } catch (e) { return []; } }
|
||||
function writeLocal(list) { try { localStorage.setItem(LS_PROJECTS, JSON.stringify(list)); } catch (e) {} }
|
||||
@@ -107,6 +107,7 @@
|
||||
subject: p.subject || '',
|
||||
type: p.type || '',
|
||||
status: p.status || 'Draft',
|
||||
assignee_id: p.assigneeId || null,
|
||||
created_by: p.createdBy || currentUser(),
|
||||
data: p
|
||||
};
|
||||
@@ -120,6 +121,8 @@
|
||||
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;
|
||||
p.archived = !!row.archived_at;
|
||||
p.assigneeId = row.assignee_id || '';
|
||||
return p;
|
||||
}
|
||||
|
||||
@@ -152,37 +155,200 @@
|
||||
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.
|
||||
// ── Durable write-through outbox ───────────────────────────────────────────
|
||||
// SOP/WP saves must survive a flaky network, a reload, or a crash — otherwise a
|
||||
// silently-failed POST leaves the browser and server divergent. Instead of a
|
||||
// fire-and-forget request, each mutation is appended to a localStorage-backed
|
||||
// queue and flushed to the API with retry + backoff. The API upserts by id and
|
||||
// DELETE is idempotent, so re-sending a queued op is always safe. The app's own
|
||||
// local cache still updates immediately, so rendering never waits on the network.
|
||||
var OUTBOX_KEY = 'wp_sync_outbox_v1';
|
||||
var _flushTimer = null, _backoff = 0, _flushing = false;
|
||||
|
||||
function qRead() { try { return JSON.parse(localStorage.getItem(OUTBOX_KEY) || '[]') || []; } catch (e) { return []; } }
|
||||
function qWrite(list) { try { localStorage.setItem(OUTBOX_KEY, JSON.stringify(list)); } catch (e) {} }
|
||||
|
||||
// Append an op, coalescing by (kind,key) so only the latest write per entity is
|
||||
// queued. A delete supersedes any pending upsert for the same id.
|
||||
function enqueue(op) {
|
||||
var q = qRead();
|
||||
if (op.kind === 'wp-del') {
|
||||
q = q.filter(function (o) { return !(o.key === op.key && (o.kind === 'wp' || o.kind === 'wp-del')); });
|
||||
} else {
|
||||
q = q.filter(function (o) { return !(o.kind === op.kind && o.key === op.key); });
|
||||
}
|
||||
op.opId = op.kind + ':' + op.key + ':' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||||
op.tries = 0;
|
||||
q.push(op);
|
||||
qWrite(q);
|
||||
notifySync();
|
||||
scheduleFlush(0);
|
||||
}
|
||||
|
||||
function opRequest(op) {
|
||||
if (op.kind === 'wp-del') {
|
||||
return fetch(API + '/wps/' + encodeURIComponent(op.key), { method: 'DELETE' });
|
||||
}
|
||||
return fetch(API + (op.kind === 'sop' ? '/sops' : '/wps'), {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(op.body)
|
||||
});
|
||||
}
|
||||
|
||||
function bumpTries(opId, err) {
|
||||
var q = qRead();
|
||||
for (var i = 0; i < q.length; i++) { if (q[i].opId === opId) { q[i].tries = (q[i].tries || 0) + 1; q[i].lastErr = err; break; } }
|
||||
qWrite(q);
|
||||
}
|
||||
// Permanently-failed op (a 4xx client error) — keep it for visibility but stop
|
||||
// retrying, so a rejected write can't loop forever.
|
||||
function markDead(opId, err) {
|
||||
var q = qRead();
|
||||
for (var i = 0; i < q.length; i++) { if (q[i].opId === opId) { q[i].dead = true; q[i].lastErr = err; break; } }
|
||||
qWrite(q);
|
||||
}
|
||||
|
||||
// Attempt every live op; successes are removed, 4xx client errors are marked
|
||||
// dead (won't succeed on retry), transient failures (429/5xx/network) stay queued.
|
||||
function flush() {
|
||||
if (_flushing) return Promise.resolve();
|
||||
var q = qRead().filter(function (o) { return !o.dead; });
|
||||
if (!q.length) { notifySync(); return Promise.resolve(); }
|
||||
_flushing = true; notifySync();
|
||||
var chain = Promise.resolve(), anyFail = false;
|
||||
q.forEach(function (op) {
|
||||
chain = chain.then(function () {
|
||||
return opRequest(op).then(function (r) {
|
||||
var status = r ? r.status : 0;
|
||||
var done = r && (r.ok || (op.kind === 'wp-del' && status === 404)); // 404 on delete = already gone
|
||||
if (done) { qWrite(qRead().filter(function (o) { return o.opId !== op.opId; })); }
|
||||
else if (status >= 400 && status < 500 && status !== 429) { markDead(op.opId, 'HTTP ' + status); }
|
||||
else { anyFail = true; bumpTries(op.opId, 'HTTP ' + status); }
|
||||
}).catch(function (e) { anyFail = true; bumpTries(op.opId, String(e)); });
|
||||
});
|
||||
});
|
||||
return chain.then(function () {
|
||||
_flushing = false;
|
||||
notifySync();
|
||||
if (qRead().filter(function (o) { return !o.dead; }).length) {
|
||||
_backoff = anyFail ? Math.min((_backoff || 5000) * 2, 60000) : 0;
|
||||
scheduleFlush(_backoff || 15000);
|
||||
} else { _backoff = 0; }
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleFlush(delay) {
|
||||
if (_flushTimer) return; // one pending flush at a time
|
||||
_flushTimer = setTimeout(function () { _flushTimer = null; flush(); }, delay || 0);
|
||||
}
|
||||
|
||||
// ── sync status (drives the indicator + any listeners) ──────────────────────
|
||||
function syncCounts() {
|
||||
var q = qRead(), pending = 0, failed = 0;
|
||||
for (var i = 0; i < q.length; i++) {
|
||||
if (q[i].dead || (q[i].tries || 0) >= 3) failed++; else pending++;
|
||||
}
|
||||
return { pending: pending, failed: failed, syncing: _flushing };
|
||||
}
|
||||
ProjectData.syncStatus = syncCounts;
|
||||
function notifySync() {
|
||||
var c = syncCounts();
|
||||
try { document.dispatchEvent(new CustomEvent('wp-sync-changed', { detail: c })); } catch (e) {}
|
||||
renderSyncBadge(c);
|
||||
}
|
||||
|
||||
// Tiny sync indicator (bottom-left). Rendered only in the top-level window so it
|
||||
// isn't duplicated inside the embedded creator iframe; the top window still sees
|
||||
// the iframe's queue changes via the 'storage' event below.
|
||||
var _isTop = (function () { try { return window.top === window.self; } catch (e) { return true; } })();
|
||||
var _badgeHideTimer = null;
|
||||
function renderSyncBadge(c) {
|
||||
if (!_isTop || !document.body) return;
|
||||
var el = document.getElementById('wp-sync-badge');
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.id = 'wp-sync-badge';
|
||||
el.style.cssText = 'position:fixed;right:12px;bottom:12px;z-index:9998;pointer-events:none;display:none;align-items:center;gap:7px;' +
|
||||
'font:500 12px/1.3 "IBM Plex Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;' +
|
||||
'padding:6px 12px;border:1px solid #e0e0e0;background:#fff;color:#525252;box-shadow:0 1px 4px rgba(0,0,0,.12);transition:opacity .2s;';
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
if (_badgeHideTimer) { clearTimeout(_badgeHideTimer); _badgeHideTimer = null; }
|
||||
if (c.failed) {
|
||||
el.textContent = '⚠ ' + c.failed + ' change' + (c.failed === 1 ? '' : 's') + ' not saved — retrying';
|
||||
el.style.color = '#8a6d00'; el.style.borderColor = '#f1c21b'; el.style.background = '#fdf6dd'; el.style.display = 'inline-flex';
|
||||
} else if (c.pending) {
|
||||
el.textContent = '↻ Saving ' + c.pending + ' change' + (c.pending === 1 ? '' : 's') + '…';
|
||||
el.style.color = '#525252'; el.style.borderColor = '#e0e0e0'; el.style.background = '#fff'; el.style.display = 'inline-flex';
|
||||
} else {
|
||||
el.textContent = '✓ All changes saved';
|
||||
el.style.color = '#0e6027'; el.style.borderColor = '#a7f0ba'; el.style.background = '#defbe6'; el.style.display = 'inline-flex';
|
||||
_badgeHideTimer = setTimeout(function () { if (el) el.style.display = 'none'; }, 1800);
|
||||
}
|
||||
}
|
||||
|
||||
// Flush triggers: on reconnect, on cross-frame queue changes, on tab focus, and
|
||||
// a periodic backstop. Anything left from a previous session flushes on load.
|
||||
try {
|
||||
window.addEventListener('online', function () { _backoff = 0; scheduleFlush(0); });
|
||||
window.addEventListener('storage', function (e) { if (e.key === OUTBOX_KEY) { notifySync(); scheduleFlush(0); } });
|
||||
document.addEventListener('visibilitychange', function () { if (!document.hidden) scheduleFlush(0); });
|
||||
setInterval(function () { if (qRead().filter(function (o) { return !o.dead; }).length) scheduleFlush(0); }, 20000);
|
||||
} catch (e) {}
|
||||
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function () { notifySync(); scheduleFlush(0); }); }
|
||||
else { setTimeout(function () { notifySync(); scheduleFlush(0); }, 0); }
|
||||
|
||||
// ── public write API (now durable via the outbox) ───────────────────────────
|
||||
// Write a completed SOP (plus the builder's raw state). 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,
|
||||
enqueue({
|
||||
kind: 'sop', key: 'sop__' + projectId,
|
||||
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; });
|
||||
complete: true, created_by: currentUser(), data: { sop: sop, state: state }
|
||||
}
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
};
|
||||
|
||||
// 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).
|
||||
// Upsert a single Work Package. The local cache stays the source of truth for
|
||||
// immediate rendering; the outbox guarantees the write reaches the server.
|
||||
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; });
|
||||
enqueue({ kind: 'wp', key: p.id, body: pkgToServer(p, projectId) });
|
||||
return Promise.resolve(true);
|
||||
};
|
||||
|
||||
ProjectData.removeWP = function (id) {
|
||||
if (!id) return Promise.resolve();
|
||||
return fetch(API + '/wps/' + encodeURIComponent(id), { method: 'DELETE' })
|
||||
.then(function () {}).catch(function () {});
|
||||
enqueue({ kind: 'wp-del', key: id });
|
||||
return Promise.resolve(true);
|
||||
};
|
||||
|
||||
// Force a flush now and resolve when the queue drains (or a round-trip is done).
|
||||
ProjectData.flushSync = function () { _backoff = 0; return flush(); };
|
||||
|
||||
// Archive / unarchive a Work Package (hide from active lists without deleting).
|
||||
// Direct request (not the outbox) — it's a deliberate, low-frequency action and
|
||||
// the caller updates the view on the returned result.
|
||||
ProjectData.archiveWP = function (id, archived) {
|
||||
if (!id) return Promise.resolve(null);
|
||||
return fetch(API + '/wps/' + encodeURIComponent(id) + '/archive', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ archived: archived !== false })
|
||||
}).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
|
||||
};
|
||||
|
||||
// Fetch this project's ARCHIVED packages (full docs) for the dashboard's
|
||||
// "show archived" view. Returns app-shaped package objects (p.archived === true).
|
||||
ProjectData.listArchived = function (projectId) {
|
||||
if (!projectId) return Promise.resolve([]);
|
||||
return fetch(API + '/wps?full=true&archived=only&project_id=' + encodeURIComponent(projectId), { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : []; })
|
||||
.then(function (rows) { return Array.isArray(rows) ? rows.map(serverToPkg) : []; })
|
||||
.catch(function () { return []; });
|
||||
};
|
||||
|
||||
// One-time discard of pre-multi-project (un-namespaced) SOP/WP data so stale
|
||||
|
||||
63
html/sw.js
Normal file
63
html/sw.js
Normal file
@@ -0,0 +1,63 @@
|
||||
/* Service worker for the Work Package Suite PWA.
|
||||
|
||||
Goal: let the app (and especially the field view) load and run offline. Data
|
||||
durability is already handled by the sync outbox in project-data.js — this
|
||||
worker only caches the static app shell so the pages open without a network.
|
||||
|
||||
Strategy:
|
||||
• /api/* and non-GET → never touched (pass straight to the network; offline
|
||||
reads fall back to the app's localStorage cache, writes queue in the outbox).
|
||||
• same-origin GET → stale-while-revalidate (instant from cache, refreshed
|
||||
in the background when online).
|
||||
*/
|
||||
'use strict';
|
||||
const CACHE = 'wp-suite-shell-v1';
|
||||
const SHELL = [
|
||||
'/', '/index.html', '/work-package-suite.html', '/wp-creation-index.html',
|
||||
'/field.html', '/login.html', '/admin.html',
|
||||
'/theme-light.css', '/work-package-suite-styles.css', '/wp-creation-styles.css',
|
||||
'/auth-guard.js', '/project-data.js', '/feedback-config.js', '/help.js',
|
||||
'/work-package-suite-app.js', '/wp-creation-app.js', '/field.js',
|
||||
'/prime-controls-logo.jpg', '/favicon.ico',
|
||||
'/manifest.webmanifest', '/icon-192.png', '/icon-512.png',
|
||||
];
|
||||
|
||||
self.addEventListener('install', (e) => {
|
||||
// Cache each shell asset individually so one missing file doesn't abort install.
|
||||
e.waitUntil(
|
||||
caches.open(CACHE)
|
||||
.then((c) => Promise.all(SHELL.map((u) => c.add(u).catch(() => {}))))
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (e) => {
|
||||
e.waitUntil(
|
||||
caches.keys()
|
||||
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (e) => {
|
||||
const req = e.request;
|
||||
if (req.method !== 'GET') return; // outbox owns writes
|
||||
const url = new URL(req.url);
|
||||
if (url.origin !== self.location.origin) return; // third-party: default
|
||||
if (url.pathname.startsWith('/api/')) return; // never cache the API
|
||||
|
||||
e.respondWith(
|
||||
caches.match(req).then((cached) => {
|
||||
const network = fetch(req)
|
||||
.then((res) => {
|
||||
if (res && res.ok) {
|
||||
const copy = res.clone();
|
||||
caches.open(CACHE).then((c) => c.put(req, copy));
|
||||
}
|
||||
return res;
|
||||
})
|
||||
.catch(() => cached); // offline → cached copy
|
||||
return cached || network; // cache-first, then refresh
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -157,3 +157,93 @@ input, textarea, select {
|
||||
font-family: inherit;
|
||||
color: var(--cds-text-primary);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
App shell — shared "UI Shell" chrome (Prime Controls, IBM Carbon styling)
|
||||
----------------------------------------------------------------------------
|
||||
One dark top bar across every page so the suite reads as a single product.
|
||||
The Prime Controls logo is a white-background wordmark, so it sits inside a
|
||||
white "chip" on the near-black bar (reads as intentional, not a stray box).
|
||||
Flip --wp-appbar-bg to a light value if a light header is ever preferred.
|
||||
============================================================================ */
|
||||
:root {
|
||||
--wp-appbar-bg: #161616; /* near-black UI Shell bar */
|
||||
--wp-appbar-fg: #ffffff;
|
||||
--wp-appbar-fg-dim: #c6c6c6;
|
||||
--wp-appbar-border: #6f6f6f; /* outline for ghost buttons on the bar */
|
||||
--wp-appbar-hover: #353535;
|
||||
--wp-appbar-height: 48px;
|
||||
}
|
||||
|
||||
.wp-appbar {
|
||||
background: var(--wp-appbar-bg);
|
||||
color: var(--wp-appbar-fg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
height: var(--wp-appbar-height);
|
||||
padding: 0 16px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
.wp-appbar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
text-decoration: none;
|
||||
color: var(--wp-appbar-fg);
|
||||
}
|
||||
.wp-appbar-brand:hover { text-decoration: none; opacity: .92; }
|
||||
.wp-logo-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.wp-logo-chip img { height: 24px; width: auto; display: block; }
|
||||
.wp-appbar-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--wp-appbar-fg);
|
||||
white-space: nowrap;
|
||||
letter-spacing: .01em;
|
||||
}
|
||||
.wp-appbar-title .wp-appbar-sub { font-weight: 400; color: var(--wp-appbar-fg-dim); }
|
||||
.wp-appbar-spacer { flex: 1 1 auto; }
|
||||
.wp-appbar-meta { font-size: 13px; color: var(--wp-appbar-fg-dim); white-space: nowrap; }
|
||||
.wp-appbar-actions { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
/* Buttons and links that live on the dark bar */
|
||||
.wp-appbar-btn {
|
||||
background: transparent;
|
||||
color: var(--wp-appbar-fg);
|
||||
border: 1px solid var(--wp-appbar-border);
|
||||
border-radius: 0;
|
||||
padding: 7px 14px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
line-height: 1.2;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
transition: background .15s, border-color .15s;
|
||||
}
|
||||
.wp-appbar-btn:hover { background: var(--wp-appbar-hover); color: var(--wp-appbar-fg); text-decoration: none; }
|
||||
.wp-appbar-btn.primary { background: var(--cds-interactive-01); border-color: var(--cds-interactive-01); }
|
||||
.wp-appbar-btn.primary:hover { background: var(--cds-hover-primary); border-color: var(--cds-hover-primary); }
|
||||
.wp-appbar-btn:focus-visible { outline: 2px solid var(--wp-appbar-fg); outline-offset: 1px; }
|
||||
.wp-appbar-count { font-size: 13px; color: var(--wp-appbar-fg-dim); padding: 0 2px; white-space: nowrap; }
|
||||
|
||||
/* Plain text links on the dark bar (Overview / Feedback / Help, Admin, etc.) */
|
||||
.wp-appbar-link { color: var(--wp-appbar-fg-dim); text-decoration: none; font-size: 14px; white-space: nowrap; }
|
||||
.wp-appbar-link:hover { color: var(--wp-appbar-fg); text-decoration: none; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.wp-appbar { height: auto; flex-wrap: wrap; gap: 8px; padding: 8px 12px; }
|
||||
.wp-appbar-actions { flex-wrap: wrap; }
|
||||
.wp-appbar-meta { width: 100%; order: 5; }
|
||||
}
|
||||
|
||||
@@ -1095,8 +1095,8 @@ function loadStepComments(){
|
||||
}else{
|
||||
list.innerHTML = stepComments.map(c=>`
|
||||
<div style="padding:0.5rem; background:white; border:1px solid var(--border); border-radius:4px; margin-bottom:0.5rem;">
|
||||
<div style="font-size:11px; color:var(--text-dim); margin-bottom:0.25rem;"><strong>${c.name}</strong> • ${c.timestamp}</div>
|
||||
<div style="font-size:12px; color:var(--text);">${c.text.replace(/</g,'<').replace(/>/g,'>')}</div>
|
||||
<div style="font-size:11px; color:var(--text-dim); margin-bottom:0.25rem;"><strong>${escAttr(c.name)}</strong> • ${escAttr(c.timestamp)}</div>
|
||||
<div style="font-size:12px; color:var(--text);">${escAttr(c.text)}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
:root {
|
||||
--primary: #2563eb;
|
||||
--primary-light: #dbeafe;
|
||||
--success: #16a34a;
|
||||
--warning: #ea580c;
|
||||
--danger: #dc2626;
|
||||
--text: #1f2937;
|
||||
--text-light: #6b7280;
|
||||
--text-dim: #9ca3af;
|
||||
--border: #e5e7eb;
|
||||
--bg: #f9fafb;
|
||||
--primary: #0f62fe;
|
||||
--primary-light: #edf5ff;
|
||||
--success: #198038;
|
||||
--warning: #8e6a00;
|
||||
--warning-bg: #fdf6dd;
|
||||
--danger: #da1e28;
|
||||
--text: #161616;
|
||||
--text-light: #525252;
|
||||
--text-dim: #8d8d8d;
|
||||
--border: #e0e0e0;
|
||||
--border-strong: #8d8d8d;
|
||||
--bg: #f4f4f4;
|
||||
--bg-card: #ffffff;
|
||||
--shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
--shadow-lg: 0 10px 25px rgba(0,0,0,0.1);
|
||||
--appbar: #161616;
|
||||
--shadow: none;
|
||||
--shadow-lg: 0 4px 16px rgba(0,0,0,0.16);
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
|
||||
font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
line-height: 1.5;
|
||||
@@ -28,43 +31,46 @@ body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* HEADER */
|
||||
/* HEADER — dark UI Shell bar */
|
||||
.header {
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
padding: 1.5rem 2rem;
|
||||
background: var(--appbar);
|
||||
color: #fff;
|
||||
padding: 0 16px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: var(--shadow);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Prime logo (white-background wordmark) sits in a white chip on the dark bar */
|
||||
.logo {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
transition: opacity 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logo:hover { opacity: 0.7; }
|
||||
.logo:hover { opacity: 0.92; }
|
||||
.logo img { height: 24px; width: auto; display: block; }
|
||||
|
||||
.logo-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: var(--primary-light);
|
||||
border-radius: 6px;
|
||||
border-radius: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -73,84 +79,90 @@ body {
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0;
|
||||
color: var(--text);
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--text-light);
|
||||
min-height: 20px;
|
||||
font-size: 12px;
|
||||
color: #c6c6c6;
|
||||
min-height: 16px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--bg);
|
||||
color: var(--primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 7px 14px;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
border: 1px solid #6f6f6f;
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.header-button:hover {
|
||||
background: var(--primary-light);
|
||||
border-color: var(--primary);
|
||||
background: #353535;
|
||||
border-color: #6f6f6f;
|
||||
}
|
||||
|
||||
.step-counter {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-light);
|
||||
padding: 0.4rem 0.8rem;
|
||||
background: transparent;
|
||||
border: 1px solid #6f6f6f;
|
||||
color: #c6c6c6;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* MAIN NAVIGATION */
|
||||
/* MAIN NAVIGATION — underline tabs */
|
||||
.main-nav {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 2rem;
|
||||
gap: 0;
|
||||
padding: 0 16px;
|
||||
background: var(--bg-card);
|
||||
border-bottom: 1px solid var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.nav-tab {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--bg);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 13px 18px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
color: var(--text-light);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.nav-tab:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-tab.active {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-color: var(--primary);
|
||||
background: none;
|
||||
color: var(--text);
|
||||
border-bottom-color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tab-icon { font-size: 16px; }
|
||||
@@ -186,35 +198,37 @@ body {
|
||||
}
|
||||
|
||||
.step-item {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
border: 2px solid var(--border);
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-radius: 0;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-light);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
transition: all 0.2s;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.step-item:hover { background: var(--primary-light); border-color: var(--primary); }
|
||||
.step-item.active { background: var(--primary); color: white; border-color: var(--primary); }
|
||||
.step-item:hover { background: var(--bg); border-color: var(--border-strong); color: var(--text); }
|
||||
.step-item.active { background: var(--primary); color: white; border-color: var(--primary); font-weight: 600; }
|
||||
|
||||
/* STEP CONTENT */
|
||||
.step-content {
|
||||
background: var(--bg-card);
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.step { display: none; }
|
||||
|
||||
.step h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.01em;
|
||||
margin-bottom: 0.75rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
@@ -223,7 +237,7 @@ body {
|
||||
color: var(--text-light);
|
||||
background: var(--primary-light);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
border-radius: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
border-left: 4px solid var(--primary);
|
||||
}
|
||||
@@ -253,7 +267,7 @@ body {
|
||||
.field textarea {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
border-radius: 0;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
color: var(--text);
|
||||
@@ -287,7 +301,7 @@ body {
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background: var(--bg);
|
||||
border-radius: 6px;
|
||||
border-radius: 0;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@@ -319,7 +333,7 @@ body {
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg);
|
||||
border-radius: 6px;
|
||||
border-radius: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
@@ -339,31 +353,32 @@ body {
|
||||
|
||||
/* BUTTONS */
|
||||
.add-btn {
|
||||
padding: 0.75rem 1.25rem;
|
||||
padding: 0.7rem 1.25rem;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
border-radius: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.add-btn:hover { background: #1d4ed8; }
|
||||
.add-btn:hover { background: var(--cds-hover-primary, #0353e9); }
|
||||
|
||||
.nav-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--bg);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 0.7rem 1.4rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.nav-btn:hover { border-color: var(--primary); color: var(--primary); }
|
||||
.nav-btn:hover { border-color: var(--primary); color: var(--primary); background: var(--bg); }
|
||||
|
||||
.nav-btn.primary {
|
||||
background: var(--success);
|
||||
@@ -371,7 +386,7 @@ body {
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
.nav-btn.primary:hover { background: #15803d; border-color: #15803d; }
|
||||
.nav-btn.primary:hover { background: #0e6027; border-color: #0e6027; color: white; }
|
||||
|
||||
.nav-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
@@ -382,7 +397,7 @@ body {
|
||||
justify-content: space-between;
|
||||
padding: 1.5rem;
|
||||
background: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
border-radius: 0;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
@@ -398,7 +413,7 @@ body {
|
||||
background: var(--primary-light);
|
||||
color: var(--primary);
|
||||
border: 1px solid var(--primary);
|
||||
border-radius: 6px;
|
||||
border-radius: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
@@ -411,7 +426,7 @@ body {
|
||||
#sequence-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.seq-step {
|
||||
display: flex; align-items: center; gap: 12px; padding: 11px 14px;
|
||||
background: var(--bg-card); border: 1px solid var(--border); border-radius: 6px;
|
||||
background: var(--bg-card); border: 1px solid var(--border); border-radius: 0;
|
||||
box-shadow: var(--shadow); transition: border-color .12s, box-shadow .12s, opacity .12s;
|
||||
}
|
||||
.seq-step:hover { border-color: var(--primary); }
|
||||
@@ -432,7 +447,7 @@ body {
|
||||
width: 28px; height: 28px; cursor: pointer; font-weight: 600; flex-shrink: 0;
|
||||
}
|
||||
.seq-arrow { text-align: center; color: var(--text-dim); font-size: 13px; line-height: .4; margin: -2px 0; }
|
||||
.seq-step.gate { border-color: var(--warning); background: #fff7ed; border-style: dashed; }
|
||||
.seq-step.gate { border-color: var(--warning); background: var(--warning-bg); border-style: dashed; }
|
||||
.seq-step.gate .seq-label { color: var(--warning); font-weight: 500; }
|
||||
.seq-gate-badge {
|
||||
flex-shrink: 0; padding: 3px 9px; border-radius: 20px; background: var(--warning); color: #fff;
|
||||
@@ -448,7 +463,7 @@ body {
|
||||
max-width: calc(100vw - 2rem);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
border-radius: 0;
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 1.25rem;
|
||||
z-index: 1200;
|
||||
@@ -488,7 +503,7 @@ body {
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
border-radius: 0;
|
||||
padding: 2rem;
|
||||
max-width: 600px;
|
||||
max-height: 80vh;
|
||||
@@ -528,7 +543,7 @@ body {
|
||||
|
||||
/* RESPONSIVE */
|
||||
@media (max-width: 768px) {
|
||||
.header { flex-direction: column; text-align: center; gap: 1rem; }
|
||||
.header { height: auto; flex-direction: column; align-items: stretch; text-align: center; gap: 0.75rem; padding: 12px 16px; }
|
||||
.main-nav { flex-wrap: wrap; }
|
||||
.content-area { padding: 1rem; }
|
||||
.step-content { padding: 1rem; }
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
<title>Work Package Suite</title>
|
||||
<script src="auth-guard.js"></script>
|
||||
<link rel="icon" href="favicon.ico" sizes="any">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<meta name="theme-color" content="#161616">
|
||||
<link rel="stylesheet" href="theme-light.css">
|
||||
<link rel="stylesheet" href="work-package-suite-styles.css">
|
||||
</head>
|
||||
@@ -15,9 +17,9 @@
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<a href="index.html" class="logo" title="Back to Home">
|
||||
<img src="prime-controls-logo.jpg" alt="Prime Controls" style="height: 36px; width: auto;">
|
||||
<img src="prime-controls-logo.jpg" alt="Prime Controls" style="height: 24px; width: auto;">
|
||||
</a>
|
||||
<div>
|
||||
<div style="min-width:0;overflow:hidden">
|
||||
<div class="header-title">Work Package Suite</div>
|
||||
<div class="header-subtitle" id="project-display"></div>
|
||||
</div>
|
||||
|
||||
@@ -58,7 +58,11 @@ let currentView='Work Package Form';
|
||||
|
||||
// ── HELPERS ──────────────────────────────────────────────────────────────────
|
||||
function gv(id){ return document.getElementById(id)?.value?.trim() || ''; }
|
||||
function esc(v){ if(v==null) return ''; return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
// Attribute-safe HTML escaper (also escapes " and ' so values are safe inside
|
||||
// href="…" / src="…" attributes, not just element text).
|
||||
function esc(v){ if(v==null) return ''; return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,'''); }
|
||||
// Only allow http(s) URLs into an href; anything else (e.g. javascript:) → '#'.
|
||||
function hrefAttr(u){ return /^https?:\/\//i.test(String(u||'')) ? esc(u) : '#'; }
|
||||
function ns(){ return '<span style="color:var(--text-dim)">—</span>'; }
|
||||
function cell(v){ return v ? esc(v) : ns(); }
|
||||
function pad2(n){ return n<10?'0'+n:''+n; }
|
||||
@@ -197,12 +201,12 @@ function renderSopRefLinks(){
|
||||
const srcs=sopLinkedSources();
|
||||
if(!srcs.length){ box.innerHTML=''; return; }
|
||||
box.innerHTML=`<div class="ref-links-title">Reference folders (from SOP) — navigate to find & copy the specific file link:</div>`+
|
||||
`<div class="ref-links">`+srcs.map(s=>`<a href="${esc(s.link)}" target="_blank" rel="noopener" class="ref-link">📁 ${esc(s.label)} <span class="ref-sys">${esc(s.system||'')}</span></a>`).join('')+`</div>`;
|
||||
`<div class="ref-links">`+srcs.map(s=>`<a href="${hrefAttr(s.link)}" target="_blank" rel="noopener" class="ref-link">📁 ${esc(s.label)} <span class="ref-sys">${esc(s.system||'')}</span></a>`).join('')+`</div>`;
|
||||
}
|
||||
function renderSpecFolderLink(){
|
||||
const el=document.getElementById('spec-folder-link'); if(!el) return;
|
||||
const spec=sopLinkedSources().find(s=>/spec/i.test(s.label));
|
||||
el.innerHTML = spec ? `<a href="${esc(spec.link)}" target="_blank" rel="noopener" class="ref-link-sm">↗ Open spec folder (${esc(spec.system||'SOP')})</a>` : '';
|
||||
el.innerHTML = spec ? `<a href="${hrefAttr(spec.link)}" target="_blank" rel="noopener" class="ref-link-sm">↗ Open spec folder (${esc(spec.system||'SOP')})</a>` : '';
|
||||
}
|
||||
function buildTypePicker(){
|
||||
let types = enabledTypes();
|
||||
@@ -524,7 +528,7 @@ function renderSopFileFolders(){
|
||||
const srcs=sopLinkedSources();
|
||||
box.innerHTML = srcs.length
|
||||
? `<div class="field-hint">1) Open a folder, multi-select files in SharePoint, then use <b>Copy link</b>:</div>`+
|
||||
`<div class="ref-links">`+srcs.map(s=>`<a href="${esc(s.link)}" target="_blank" rel="noopener" class="ref-link">📁 ${esc(s.label)} <span class="ref-sys">${esc(s.system||'')}</span></a>`).join('')+`</div>`
|
||||
`<div class="ref-links">`+srcs.map(s=>`<a href="${hrefAttr(s.link)}" target="_blank" rel="noopener" class="ref-link">📁 ${esc(s.label)} <span class="ref-sys">${esc(s.system||'')}</span></a>`).join('')+`</div>`
|
||||
: `<div class="field-hint">No SOP folders defined — load or import an SOP first.</div>`;
|
||||
}
|
||||
function toggleSopFilePanel(){
|
||||
@@ -619,7 +623,7 @@ function openHoldModal(preselect, fromConstraint){
|
||||
}
|
||||
function holdPhotoChange(ev){
|
||||
const f=ev.target.files&&ev.target.files[0]; if(!f) return;
|
||||
const r=new FileReader(); r.onload=()=>{ holdPhotoData=r.result; document.getElementById('hold-photo-preview').innerHTML=`<img src="${holdPhotoData}" alt="supporting photo">`; }; r.readAsDataURL(f);
|
||||
const r=new FileReader(); r.onload=()=>{ holdPhotoData=r.result; const ok=/^data:image\//.test(holdPhotoData); document.getElementById('hold-photo-preview').innerHTML= ok?`<img src="${esc(holdPhotoData)}" alt="supporting photo">`:''; }; r.readAsDataURL(f);
|
||||
}
|
||||
function submitHold(){
|
||||
const constraint=document.getElementById('hold-constraint').value;
|
||||
@@ -699,7 +703,7 @@ function collectPackage(){
|
||||
parentNumber: prev?prev.parentNumber:undefined, split: prev?prev.split:undefined, children: prev?prev.children:undefined,
|
||||
number:gv('wp_number'), status:getRadio('status')||'Draft', subject:gv('wp_subject'),
|
||||
type:gv('wp_type'), system:gv('wp_system'), location:gv('wp_location'),
|
||||
cost:gv('wp_cost'), wbs:gv('wp_wbs'), assignees:gv('wp_assignees'), distribution:gv('wp_distribution'),
|
||||
cost:gv('wp_cost'), wbs:gv('wp_wbs'), assigneeId:gv('wp_assignee'), assignees:gv('wp_assignees'), distribution:gv('wp_distribution'),
|
||||
due:gv('wp_due'), spec:gv('wp_spec'), desc:gv('wp_desc'),
|
||||
work:steps.join('\n'), workSteps:steps, numberDims:{...numberDims},
|
||||
disciplines:[...pkgDisciplines],
|
||||
@@ -839,7 +843,7 @@ function setFormChrome(on){
|
||||
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(); }
|
||||
if(on){ buildSectionNav(); updateStickyStatus(); makeCollapsible(); initSectionNavAutoHide(); }
|
||||
}
|
||||
// Make each form card collapsible by clicking its heading (idempotent).
|
||||
function makeCollapsible(){
|
||||
@@ -871,6 +875,27 @@ function buildSectionNav(){
|
||||
});
|
||||
nav.innerHTML=chips.join('');
|
||||
}
|
||||
// Keep the section-nav pinned just below the sticky header (so it stays put while
|
||||
// scrolling instead of hiding behind the header), and let it slide out of the way
|
||||
// while reading (scroll down), snapping back the moment you scroll up.
|
||||
function positionSectionNav(){
|
||||
const nav=document.getElementById('section-nav'), hdr=document.querySelector('.header');
|
||||
if(nav && hdr) nav.style.top = hdr.offsetHeight + 'px';
|
||||
}
|
||||
let _snLastY=0, _snBound=false;
|
||||
function initSectionNavAutoHide(){
|
||||
positionSectionNav();
|
||||
if(_snBound) return; _snBound=true;
|
||||
window.addEventListener('resize', positionSectionNav, {passive:true});
|
||||
window.addEventListener('scroll', ()=>{
|
||||
const nav=document.getElementById('section-nav');
|
||||
if(!nav || nav.style.display==='none') return;
|
||||
const y=window.scrollY||document.documentElement.scrollTop||0;
|
||||
if(y>_snLastY+4 && y>140) nav.classList.add('nav-hidden'); // scrolling down
|
||||
else if(y<_snLastY-4) nav.classList.remove('nav-hidden'); // scrolling up
|
||||
_snLastY=y;
|
||||
}, {passive:true});
|
||||
}
|
||||
function updateStickyStatus(){
|
||||
const el=document.getElementById('sticky-status'); if(!el) return;
|
||||
const r=readiness(); const st=getRadio('status');
|
||||
@@ -896,10 +921,43 @@ function renderSavedList(){
|
||||
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>${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>`;
|
||||
<td class="center"><button class="link-btn" onclick="editPackage(${i})">edit</button> <button class="link-btn" onclick="viewPackage(${i})">view</button> <button class="link-btn" onclick="showHistoryRow(${i})">history</button> <button class="row-del" onclick="deletePackage(${i})">✕</button></td></tr>`;
|
||||
}).join('');
|
||||
}
|
||||
function viewPackage(i){ if(savedPackages[i]) renderPackage(savedPackages[i]); }
|
||||
|
||||
// ── WP HISTORY (audit trail) ─────────────────────────────────────────────────
|
||||
function showHistoryRow(i){ const p=savedPackages[i]; if(p) showHistory(p.id, p.number||p.subject); }
|
||||
function showHistoryCurrent(){ showHistory(editingId, (document.getElementById('wp_number')||{}).value); }
|
||||
function closeHistory(){ const m=document.getElementById('wp-history-modal'); if(m) m.remove(); }
|
||||
const HIST_LABEL={created:'Created',updated:'Updated',status_changed:'Status changed',issued:'Issued',deleted:'Deleted'};
|
||||
async function showHistory(wpId, label){
|
||||
if(!wpId){ toast('Save the package first — history is recorded as it changes.'); return; }
|
||||
if(!label){ const _p=savedPackages.find(x=>x.id===wpId)||(typeof dashArchived!=='undefined'&&dashArchived.find(x=>x.id===wpId)); label=_p?(_p.number||_p.subject):''; }
|
||||
closeHistory();
|
||||
const ov=document.createElement('div');
|
||||
ov.id='wp-history-modal'; ov.className='modal-overlay open';
|
||||
ov.innerHTML='<div class="modal" style="max-width:640px"><div class="modal-head"><div class="modal-title">History — '+esc(label||wpId)+'</div>'+
|
||||
'<button class="cmt-x" onclick="closeHistory()" title="Close">✕</button></div>'+
|
||||
'<div class="modal-body" id="wp-history-body"><div class="empty-hint">Loading…</div></div>'+
|
||||
'<div class="modal-foot"><button class="btn btn-primary" onclick="closeHistory()">Close</button></div></div>';
|
||||
ov.addEventListener('click', e=>{ if(e.target===ov) closeHistory(); });
|
||||
document.body.appendChild(ov);
|
||||
let rows=[];
|
||||
try{ const r=await fetch('/api/audit?entity_type=wp&entity_id='+encodeURIComponent(wpId), {headers:{'Accept':'application/json'}}); if(r.ok) rows=await r.json(); }catch(e){}
|
||||
const body=document.getElementById('wp-history-body'); if(!body) return;
|
||||
if(!rows || !rows.length){
|
||||
body.innerHTML='<div class="empty-hint">No history on the server yet. Changes are recorded as the package is saved and its status changes — if this package was just created it may still be syncing.</div>';
|
||||
return;
|
||||
}
|
||||
const fmt=s=>{ try{ return new Date(s).toLocaleString(); }catch(e){ return s||''; } };
|
||||
const det=d=>{ d=d||{}; if(d.from!=null||d.to!=null) return esc((d.from==null?'—':d.from)+' → '+(d.to==null?'—':d.to)); if(d.status) return 'status: '+esc(d.status); return ''; };
|
||||
body.innerHTML='<div class="hist-list">'+rows.map(e=>
|
||||
'<div class="hist-item"><div class="hist-when">'+esc(fmt(e.at))+'</div>'+
|
||||
'<div class="hist-main"><span class="hist-action">'+esc(HIST_LABEL[e.action]||(e.action||'').replace(/_/g,' '))+'</span> '+
|
||||
'<span class="hist-detail">'+det(e.detail)+'</span></div>'+
|
||||
'<div class="hist-actor">by '+esc(e.actor||'—')+'</div></div>').join('')+'</div>';
|
||||
}
|
||||
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); }
|
||||
@@ -909,6 +967,7 @@ function loadPackageIntoForm(p){
|
||||
const set=(id,v)=>{const el=document.getElementById(id); if(el) el.value=v||'';};
|
||||
set('wp_subject',p.subject); set('wp_system',p.system); set('wp_location',p.location);
|
||||
set('wp_assignees',p.assignees); set('wp_distribution',p.distribution);
|
||||
set('wp_assignee',p.assigneeId);
|
||||
set('wp_due',p.due); set('wp_spec',p.spec); set('wp_desc',p.desc); set('wp_hours',p.hours);
|
||||
set('wp_kit_owner',p.kitOwner); set('wp_kit_date',p.kitDate); set('wp_mimo_time',p.mimoTime); set('wp_mimo_loc',p.mimoLoc);
|
||||
set('wp_actual_hrs',p.actualHrs); set('wp_installed_qty',p.installedQty); set('wp_redlines',p.redlines); set('wp_lessons',p.lessons);
|
||||
@@ -981,7 +1040,7 @@ function duplicateWP(){
|
||||
|
||||
function newPackage(){
|
||||
editingId=null;
|
||||
['wp_subject','wp_system','wp_location','wp_wbs','wp_assignees','wp_distribution','wp_due','wp_spec','wp_desc','wp_hours','wp_kit_owner','wp_kit_date','wp_mimo_time','wp_mimo_loc','wp_actual_hrs','wp_installed_qty','wp_redlines','wp_lessons','wp_bimlink','wp_model_area','wp_scan_link'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';});
|
||||
['wp_subject','wp_system','wp_location','wp_wbs','wp_assignee','wp_assignees','wp_distribution','wp_due','wp_spec','wp_desc','wp_hours','wp_kit_owner','wp_kit_date','wp_mimo_time','wp_mimo_loc','wp_actual_hrs','wp_installed_qty','wp_redlines','wp_lessons','wp_bimlink','wp_model_area','wp_scan_link'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';});
|
||||
document.getElementById('wp_type').value=''; document.getElementById('wp_kit_status').value=''; document.getElementById('wp_cost').value='';
|
||||
['wp_lod','wp_clash'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';});
|
||||
pkgKind='iwp'; applyKind();
|
||||
@@ -1025,15 +1084,43 @@ 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();
|
||||
dashPage=0; renderDashboard();
|
||||
}
|
||||
function dashSetStatus(s){ dashFilter.status = dashFilter.status===s ? '' : s; dashPage=0; renderDashboard(); }
|
||||
|
||||
// ── Phase 2: pagination, progress %, and archived view ──────────────────────
|
||||
let dashPage=0, dashShowArchived=false, dashArchived=[];
|
||||
const DASH_PAGE_SIZE=25;
|
||||
// Weighted completion by status (0..1) so progress is smoother than done/not-done.
|
||||
const PROGRESS_W={'Draft':0,'Scheduled':0.25,'Issue':0.4,'Issued':0.5,'In Progress':0.75,'QC':0.9,'Closed':1};
|
||||
function wpProgress(p){ const w=PROGRESS_W[p.status]; return w==null?0:w; }
|
||||
function dashGo(pg){ dashPage=pg; renderDashboard(); }
|
||||
function dashToggleArchived(on){
|
||||
dashShowArchived=!!on; dashPage=0;
|
||||
if(dashShowArchived && !dashArchived.length && typeof ProjectData!=='undefined' && ProjectData.listArchived){
|
||||
ProjectData.listArchived(activeProjectId).then(rows=>{ dashArchived=rows||[]; renderDashboard(); });
|
||||
} else { renderDashboard(); }
|
||||
}
|
||||
function dashArchive(id){
|
||||
const p=savedPackages.find(x=>x.id===id); if(!p) return;
|
||||
if(!confirm('Archive "'+(p.number||p.subject||'this package')+'"? It will be hidden from the active board but kept for the record.')) return;
|
||||
if(typeof ProjectData!=='undefined' && ProjectData.archiveWP) ProjectData.archiveWP(id,true);
|
||||
const ix=savedPackages.findIndex(x=>x.id===id); if(ix>=0){ p.archived=true; dashArchived.unshift(p); savedPackages.splice(ix,1); }
|
||||
saveStore(); renderSavedList(); renderDashboard(); toast('Archived '+(p.number||''));
|
||||
}
|
||||
function dashUnarchive(id){
|
||||
const ix=dashArchived.findIndex(x=>x.id===id); const p=ix>=0?dashArchived[ix]:null; if(!p) return;
|
||||
if(typeof ProjectData!=='undefined' && ProjectData.archiveWP) ProjectData.archiveWP(id,false);
|
||||
p.archived=false; dashArchived.splice(ix,1); if(!savedPackages.some(x=>x.id===id)) savedPackages.push(p);
|
||||
saveStore(); renderSavedList(); renderDashboard(); toast('Restored '+(p.number||''));
|
||||
}
|
||||
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 myUserId(){ try { return (window.WP_USER && window.WP_USER.id) || ''; } catch(e){ return ''; } }
|
||||
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.
|
||||
@@ -1050,11 +1137,12 @@ function showDashboard(){
|
||||
function renderDashboard(){
|
||||
const all=countableWPs();
|
||||
const byStatus={}; STATUS_ORDER.concat(['Issue']).forEach(s=>byStatus[s]=0);
|
||||
let estH=0, actH=0, ready=0, hold=0, overdue=0; const byDisc={};
|
||||
let estH=0, actH=0, ready=0, hold=0, overdue=0, mine=0; const byDisc={}; const meId=myUserId();
|
||||
all.forEach(p=>{
|
||||
byStatus[p.status]=(byStatus[p.status]||0)+1;
|
||||
estH+=parseFloat(p.hours)||0; actH+=parseFloat(p.actualHrs)||0;
|
||||
if(p.status==='Issue') hold++;
|
||||
if(meId && p.assigneeId===meId) mine++;
|
||||
if(wpOpenConstraints(p).length===0 && p.status!=='Closed' && p.status!=='Issue') ready++;
|
||||
if(isOverdue(p)) overdue++;
|
||||
(p.disciplines&&p.disciplines.length?p.disciplines:['(none)']).forEach(d=>byDisc[d]=(byDisc[d]||0)+1);
|
||||
@@ -1067,6 +1155,7 @@ function renderDashboard(){
|
||||
};
|
||||
let h=`<div class="dash-metrics">
|
||||
${card('Total WPs', all.length, '', 'all')}
|
||||
${meId ? card('My WPs', mine, mine?'dm-blue':'', 'mine') : ''}
|
||||
${card('Release-ready', ready, ready?'dm-green':'', 'ready')}
|
||||
${card('On hold', hold, hold?'dm-red':'', 'onhold')}
|
||||
${card('Overdue', overdue, overdue?'dm-red':'', 'overdue')}
|
||||
@@ -1082,6 +1171,17 @@ function renderDashboard(){
|
||||
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>`;
|
||||
|
||||
// progress by phase (discipline), weighted by status; archived excluded
|
||||
const overallPct = all.length ? Math.round(all.reduce((s,p)=>s+wpProgress(p),0)/all.length*100) : 0;
|
||||
const phaseGroups={};
|
||||
all.forEach(p=>{ (p.disciplines&&p.disciplines.length?p.disciplines:['(none)']).forEach(d=>{ (phaseGroups[d]=phaseGroups[d]||[]).push(p); }); });
|
||||
let prog=`<div class="dash-panel"><div class="dash-panel-title">Progress by phase</div>`;
|
||||
prog+=`<div class="prog-row"><div class="prog-name"><strong>Overall</strong></div><div class="prog-bar"><div class="prog-fill" style="width:${overallPct}%"></div></div><div class="prog-pct">${overallPct}%</div></div>`;
|
||||
Object.keys(phaseGroups).sort().forEach(d=>{ const g=phaseGroups[d]; const pct=g.length?Math.round(g.reduce((s,p)=>s+wpProgress(p),0)/g.length*100):0; const done=g.filter(p=>p.status==='Closed').length;
|
||||
prog+=`<div class="prog-row"><div class="prog-name">${esc(d)}</div><div class="prog-bar"><div class="prog-fill" style="width:${pct}%"></div></div><div class="prog-pct">${pct}% <span class="prog-sub">${done}/${g.length}</span></div></div>`; });
|
||||
prog+=`<div class="field-hint" style="margin-top:8px">Weighted by status (Draft 0 · Scheduled 25 · Issued 50 · In Progress 75 · QC 90 · Closed 100%). Archived packages excluded.</div></div>`;
|
||||
h+=prog;
|
||||
|
||||
// gating panel — what's blocking release
|
||||
const gated=all.filter(p=>wpOpenConstraints(p).length>0);
|
||||
h+=`<div class="dash-panel"><div class="dash-panel-title">⛔ Gating constraints (${gated.length} package${gated.length===1?'':'s'} blocked)</div>`;
|
||||
@@ -1096,39 +1196,60 @@ function renderDashboard(){
|
||||
const discList=Object.keys(byDisc);
|
||||
const discOpts=['<option value="">All disciplines</option>'].concat(discList.map(d=>`<option ${dashFilter.discipline===d?'selected':''}>${esc(d)}</option>`)).join('');
|
||||
h+=`<div class="dash-filters">
|
||||
<input type="search" placeholder="Search WP # / subject…" value="${(dashFilter.q||'').replace(/"/g,'"')}" oninput="dashFilter.q=this.value;renderDashboard()">
|
||||
<select onchange="dashFilter.status=this.value;renderDashboard()">${statusOpts}</select>
|
||||
<select onchange="dashFilter.discipline=this.value;renderDashboard()">${discOpts}</select>
|
||||
<input type="search" placeholder="Search WP # / subject / type…" value="${(dashFilter.q||'').replace(/"/g,'"')}" oninput="dashFilter.q=this.value;dashPage=0;renderDashboard()">
|
||||
<select onchange="dashFilter.status=this.value;dashPage=0;renderDashboard()">${statusOpts}</select>
|
||||
<select onchange="dashFilter.discipline=this.value;dashPage=0;renderDashboard()">${discOpts}</select>
|
||||
<label class="dash-arch-toggle"><input type="checkbox" ${dashShowArchived?'checked':''} onchange="dashToggleArchived(this.checked)"> Show archived${dashShowArchived?' ('+dashArchived.length+')':''}</label>
|
||||
</div>`;
|
||||
|
||||
// main board (includes masters, marked)
|
||||
// main board (includes masters, marked; archived only when toggled on)
|
||||
const q=(dashFilter.q||'').toLowerCase();
|
||||
const rows=WPData.list().filter(p=>{
|
||||
const boardSource = dashShowArchived ? WPData.list().concat(dashArchived) : WPData.list();
|
||||
const rows=boardSource.filter(p=>{
|
||||
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(q && !((p.number||'')+' '+(p.subject||'')+' '+(p.type||'')).toLowerCase().includes(q)) return false;
|
||||
if(dashFilter.flag==='mine' && p.assigneeId!==myUserId()) 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>
|
||||
const totalRows=rows.length;
|
||||
const pages=Math.max(1, Math.ceil(totalRows/DASH_PAGE_SIZE));
|
||||
if(dashPage>=pages) dashPage=pages-1;
|
||||
if(dashPage<0) dashPage=0;
|
||||
const pageRows=rows.slice(dashPage*DASH_PAGE_SIZE, dashPage*DASH_PAGE_SIZE+DASH_PAGE_SIZE);
|
||||
h+=`<div class="dash-panel"><div class="dash-panel-title">Work Packages (${totalRows})</div>
|
||||
<table class="dash-table"><thead><tr><th>WP #</th><th>Subject</th><th>Type</th><th>Discipline</th><th>Status</th><th>Gates</th><th>Due</th><th>Hrs</th><th></th></tr></thead><tbody>`;
|
||||
if(!rows.length) h+=`<tr><td colspan="9" class="field-hint" style="padding:14px">No work packages match.</td></tr>`;
|
||||
rows.forEach(p=>{
|
||||
if(!totalRows) h+=`<tr><td colspan="9" class="field-hint" style="padding:14px">No work packages match.</td></tr>`;
|
||||
pageRows.forEach(p=>{
|
||||
const ix=savedPackages.findIndex(x=>x.id===p.id);
|
||||
const open=wpOpenConstraints(p).length;
|
||||
const gates= p.split?'<span class="badge badge-O">master</span>':(open?`<span class="badge badge-O">${open} open</span>`:`<span class="badge badge-Y">clear</span>`);
|
||||
const due= p.due?`<span style="${isOverdue(p)?'color:var(--red);font-weight:700':''}">${esc(p.due)}</span>`:ns();
|
||||
const pid=esc(p.id);
|
||||
let actions;
|
||||
if(p.archived){
|
||||
actions=`<button class="link-btn" onclick="showHistory('${pid}')">history</button> <button class="link-btn" onclick="dashUnarchive('${pid}')">restore</button>`;
|
||||
} else {
|
||||
const canIssue = !p.split && open===0 && p.status!=='Closed' && p.status!=='Issued' && p.status!=='Issue';
|
||||
const issueBtn = canIssue?`<button class="link-btn" onclick="dashIssue('${p.id}')">issue</button>`:'';
|
||||
h+=`<tr><td class="row-label">${esc(p.number||'—')}${p.instanceOf?` <span class="badge badge-Y">${esc(p.instanceLabel||'')}</span>`:''}</td>
|
||||
const issueBtn = canIssue?`<button class="link-btn" onclick="dashIssue('${pid}')">issue</button> `:'';
|
||||
actions=`${issueBtn}<button class="link-btn" onclick="dashView(${ix})">view</button> <button class="link-btn" onclick="dashEdit(${ix})">edit</button> <button class="link-btn" onclick="dashArchive('${pid}')">archive</button>`;
|
||||
}
|
||||
h+=`<tr${p.archived?' style="opacity:.6"':''}><td class="row-label">${esc(p.number||'—')}${p.instanceOf?` <span class="badge badge-Y">${esc(p.instanceLabel||'')}</span>`:''}${p.archived?' <span class="badge badge-NA">archived</span>':''}</td>
|
||||
<td>${esc(p.subject||'')}</td><td>${esc(p.type||'')}</td>
|
||||
<td style="font-size:11px">${esc((p.disciplines||[]).join(', '))||ns()}</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>`;
|
||||
<td class="center" style="white-space:nowrap">${actions}</td></tr>`;
|
||||
});
|
||||
h+=`</tbody></table></div>`;
|
||||
h+=`</tbody></table>`;
|
||||
if(pages>1){
|
||||
h+=`<div class="dash-pager"><span>Page ${dashPage+1} of ${pages} · ${totalRows} packages</span>
|
||||
<span class="dash-pager-btns"><button class="btn btn-ghost" ${dashPage===0?'disabled':''} onclick="dashGo(${dashPage-1})">‹ Prev</button>
|
||||
<button class="btn btn-ghost" ${dashPage>=pages-1?'disabled':''} onclick="dashGo(${dashPage+1})">Next ›</button></span></div>`;
|
||||
}
|
||||
h+=`</div>`;
|
||||
document.getElementById('dash-body').innerHTML=h;
|
||||
}
|
||||
function dashIssue(id){
|
||||
@@ -1225,10 +1346,27 @@ function bootSOP(){
|
||||
if(activeProjectId){ SOP=null; renderCtxBar(); newPackage(); }
|
||||
else { loadSampleSOP(); }
|
||||
}
|
||||
// Populate the Owner picker with this project's members (+ admins). The list is
|
||||
// only used to pick an assignee; the server re-validates on save.
|
||||
async function loadMembers(){
|
||||
const sel=document.getElementById('wp_assignee');
|
||||
if(!sel || !activeProjectId) return;
|
||||
try {
|
||||
const r=await fetch('/api/projects/'+encodeURIComponent(activeProjectId)+'/members',{credentials:'same-origin'});
|
||||
if(!r.ok) return;
|
||||
const list=await r.json();
|
||||
const cur=sel.value;
|
||||
sel.innerHTML='<option value="">— Unassigned —</option>'+
|
||||
list.map(u=>`<option value="${esc(u.id)}">${esc(u.full_name||u.username)}</option>`).join('');
|
||||
if(cur) sel.value=cur;
|
||||
} catch(e){}
|
||||
}
|
||||
|
||||
function bootData(){
|
||||
loadStore(); // reads the localStorage cache (hydrated from the server below)
|
||||
bootSOP();
|
||||
setRadio('status','Draft');
|
||||
loadMembers();
|
||||
renderSavedList();
|
||||
cmtInit();
|
||||
// Deep-link: open straight to the dashboard when requested (?view=dashboard or #dashboard).
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
<title>Work Package (IWP) — Prime Controls</title>
|
||||
<script src="auth-guard.js"></script>
|
||||
<link rel="icon" href="favicon.ico" sizes="any">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<meta name="theme-color" content="#161616">
|
||||
<link rel="stylesheet" href="theme-light.css">
|
||||
<link rel="stylesheet" href="wp-creation-styles.css">
|
||||
</head>
|
||||
@@ -28,6 +30,7 @@
|
||||
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="showDashboard()">📊 Dashboard</button>
|
||||
<button class="btn btn-ghost embed-first" style="padding:7px 16px" onclick="newPackage()">+ New</button>
|
||||
<button class="btn btn-ghost" style="padding:7px 16px" onclick="duplicateWP()">⧉ Duplicate</button>
|
||||
<button class="btn btn-ghost" style="padding:7px 16px" onclick="showHistoryCurrent()" title="Change history for this work package">🕘 History</button>
|
||||
<button class="btn btn-ghost embed-hide" id="comments-btn" style="padding:7px 16px" onclick="toggleComments()">💬 Comments <span class="cbadge-total" id="cbadge-total" style="display:none">0</span></button>
|
||||
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="showAnalytics()">▤ Usage Data</button>
|
||||
</div>
|
||||
@@ -84,6 +87,7 @@
|
||||
<div class="field"><label>Acumatica Task</label><input type="text" id="wp_wbs" placeholder="Acumatica task no."></div>
|
||||
</div>
|
||||
<div class="field-grid">
|
||||
<div class="field"><label>Owner <span class="help-tip" data-tip="The accountable owner (a user account on this project). Assigning notifies them by email if email notifications are enabled in the admin console.">i</span></label><select id="wp_assignee"><option value="">— Unassigned —</option></select></div>
|
||||
<div class="field"><label>Assignees</label><input type="text" id="wp_assignees" placeholder="name (company), name (company)"></div>
|
||||
<div class="field"><label>Distribution</label><input type="text" id="wp_distribution" placeholder="notify list"></div>
|
||||
<div class="field"><label>Due Date</label><input type="date" id="wp_due"></div>
|
||||
|
||||
@@ -7,25 +7,25 @@
|
||||
body.embedded .embed-first { margin-left: auto; }
|
||||
|
||||
:root {
|
||||
--bg: #f4f5f7;
|
||||
--bg: #f4f4f4;
|
||||
--surface: #ffffff;
|
||||
--surface2: #f7f8fa;
|
||||
--border: #e3e6ec;
|
||||
--border-strong: #d0d5de;
|
||||
--text: #1a2230;
|
||||
--text-muted: #5a6675;
|
||||
--text-dim: #9aa3b2;
|
||||
--accent: #2563d6;
|
||||
--accent-dim: #e8f0fe;
|
||||
--accent-green: #15924f;
|
||||
--accent-green-dim: #e4f6ec;
|
||||
--accent-amber: #b87100;
|
||||
--accent-amber-dim: #fdf2e0;
|
||||
--red: #cf3b3b;
|
||||
--red-dim: #fbeaea;
|
||||
--radius: 5px;
|
||||
--shadow: 0 1px 2px rgba(20,30,50,.04), 0 1px 3px rgba(20,30,50,.06);
|
||||
--shadow-lg: 0 4px 16px rgba(20,30,50,.08);
|
||||
--surface2: #f4f4f4;
|
||||
--border: #e0e0e0;
|
||||
--border-strong: #8d8d8d;
|
||||
--text: #161616;
|
||||
--text-muted: #525252;
|
||||
--text-dim: #8d8d8d;
|
||||
--accent: #0f62fe;
|
||||
--accent-dim: #edf5ff;
|
||||
--accent-green: #198038;
|
||||
--accent-green-dim: #defbe6;
|
||||
--accent-amber: #8e6a00;
|
||||
--accent-amber-dim: #fdf6dd;
|
||||
--red: #da1e28;
|
||||
--red-dim: #fff1f1;
|
||||
--radius: 0;
|
||||
--shadow: none;
|
||||
--shadow-lg: 0 4px 16px rgba(20,30,50,.12);
|
||||
--mono: 'IBM Plex Mono', ui-monospace, 'Cascadia Mono', 'Segoe UI Mono', Consolas, monospace;
|
||||
--sans: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
@@ -213,7 +213,7 @@
|
||||
|
||||
.notice {
|
||||
background: var(--accent-dim); border: 1px solid #b9d2fb; border-radius: var(--radius);
|
||||
padding: 10px 14px; font-size: 12px; color: #1a4fad; margin-bottom: 18px; font-family: var(--mono);
|
||||
padding: 10px 14px; font-size: 12px; color: #0043ce; margin-bottom: 18px; font-family: var(--mono);
|
||||
}
|
||||
|
||||
/* ── DELIVERABLES ── */
|
||||
@@ -245,9 +245,9 @@
|
||||
.btn-ghost { background: var(--surface); border-color: var(--border-strong); color: var(--text-muted); }
|
||||
.btn-ghost:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: var(--shadow); }
|
||||
.btn-primary:hover { background: #1d52b8; }
|
||||
.btn-primary:hover { background: #0353e9; }
|
||||
.btn-generate { background: var(--accent-green); border-color: var(--accent-green); color: #fff; font-weight: 700; box-shadow: var(--shadow); }
|
||||
.btn-generate:hover { background: #117a42; }
|
||||
.btn-generate:hover { background: #0e6027; }
|
||||
|
||||
/* ── OUTPUT ── */
|
||||
#output-section { display: none; }
|
||||
@@ -419,7 +419,7 @@
|
||||
.ov-select.ov-unset { color:var(--red) !important; border-color:var(--red); }
|
||||
.use-btn { display:inline-block; margin-left:8px; padding:4px 14px; font-family:var(--sans); font-size:11px; font-weight:700;
|
||||
color:#fff; background:var(--accent-green); border:none; border-radius:var(--radius); cursor:pointer; letter-spacing:.03em; }
|
||||
.use-btn:hover { background:#0f7a40; }
|
||||
.use-btn:hover { background:#0e6027; }
|
||||
.sum-chips { display:flex; flex-wrap:wrap; gap:7px; }
|
||||
.sum-chip { background:var(--accent-dim); color:var(--accent); border:1px solid #b9d2fb; border-radius:3px;
|
||||
padding:3px 10px; font-family:var(--mono); font-size:10px; }
|
||||
@@ -495,7 +495,7 @@
|
||||
|
||||
.modal-overlay { position:fixed; inset:0; background:rgba(20,28,40,.55); display:none; align-items:center; justify-content:center; z-index:9000; padding:20px; }
|
||||
.modal-overlay.open { display:flex; }
|
||||
.modal { background:var(--surface); border-radius:12px; width:100%; max-width:520px; box-shadow:0 20px 60px rgba(0,0,0,.3); overflow:hidden; max-height:90vh; display:flex; flex-direction:column; }
|
||||
.modal { background:var(--surface); border-radius:0; width:100%; max-width:520px; box-shadow:0 20px 60px rgba(0,0,0,.3); overflow:hidden; max-height:90vh; display:flex; flex-direction:column; }
|
||||
.modal-head { display:flex; align-items:center; justify-content:space-between; padding:16px 20px; border-bottom:1px solid var(--border); }
|
||||
.modal-title { font-weight:700; font-size:15px; color:var(--text); }
|
||||
.modal-body { padding:18px 20px; overflow-y:auto; }
|
||||
@@ -571,9 +571,11 @@
|
||||
|
||||
/* 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); }
|
||||
padding:8px 12px; background:rgba(255,255,255,.94); backdrop-filter:blur(4px);
|
||||
border-bottom:1px solid var(--border); box-shadow:0 1px 4px rgba(20,30,50,.06);
|
||||
transition:transform .22s ease; }
|
||||
.section-nav-bar:empty{ display:none; }
|
||||
.section-nav-bar.nav-hidden{ transform:translateY(-160%); }
|
||||
.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); }
|
||||
@@ -606,11 +608,12 @@
|
||||
|
||||
/* Dashboard */
|
||||
.dash-metrics { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:12px; margin-bottom:16px; }
|
||||
.dash-metric { background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:14px 16px; text-align:center; }
|
||||
.dash-metric { background:var(--surface); border:1px solid var(--border); border-radius:0; padding:14px 16px; text-align:center; }
|
||||
.dash-metric .dm-val { font-size:26px; font-weight:800; line-height:1; }
|
||||
.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.dm-blue .dm-val { color:var(--accent, #0f62fe); }
|
||||
.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); }
|
||||
@@ -620,7 +623,7 @@
|
||||
.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; }
|
||||
.dash-chip.chip-red { background:var(--red-dim); color:var(--red); border-color:var(--red); }
|
||||
.dash-panel { background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:14px 16px; margin-bottom:16px; }
|
||||
.dash-panel { background:var(--surface); border:1px solid var(--border); border-radius:0; padding:14px 16px; margin-bottom:16px; }
|
||||
.dash-panel-title { font-weight:700; font-size:13px; margin-bottom:10px; }
|
||||
.dash-table { width:100%; border-collapse:collapse; font-size:12.5px; }
|
||||
.dash-table th { text-align:left; background:var(--surface2); border-bottom:1px solid var(--border); padding:6px 8px; font-size:11px; text-transform:uppercase; color:var(--text-muted); }
|
||||
@@ -629,3 +632,27 @@
|
||||
.dash-filters input, .dash-filters select { padding:7px 10px; border:1px solid var(--border-strong); border-radius:6px; font-size:13px; }
|
||||
.dash-filters input[type=search] { flex:1; min-width:200px; }
|
||||
@media (max-width:640px){ .dash-breakdown { grid-template-columns:1fr; } }
|
||||
|
||||
/* ── WP history (audit trail) modal ──────────────────────────────────────── */
|
||||
.hist-list { display:flex; flex-direction:column; }
|
||||
.hist-item { display:grid; grid-template-columns:170px 1fr auto; gap:12px; align-items:baseline;
|
||||
padding:9px 2px; border-bottom:1px solid var(--border); }
|
||||
.hist-item:last-child { border-bottom:none; }
|
||||
.hist-when { font-family:var(--mono); font-size:11px; color:var(--text-muted); white-space:nowrap; }
|
||||
.hist-action { font-weight:600; color:var(--text); }
|
||||
.hist-detail { color:var(--accent); font-size:13px; }
|
||||
.hist-actor { font-size:12px; color:var(--text-muted); white-space:nowrap; }
|
||||
@media (max-width:560px){ .hist-item { grid-template-columns:1fr; gap:2px; } }
|
||||
|
||||
/* ── Dashboard progress bars + pager + archived toggle (Phase 2) ──────────── */
|
||||
.prog-row { display:grid; grid-template-columns:150px 1fr 96px; gap:10px; align-items:center; margin-bottom:7px; }
|
||||
.prog-name { font-size:12.5px; color:var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.prog-bar { height:10px; background:var(--surface2); border:1px solid var(--border); overflow:hidden; }
|
||||
.prog-fill { height:100%; background:var(--accent); transition:width .3s ease; }
|
||||
.prog-pct { font-size:12px; font-weight:600; color:var(--text); text-align:right; white-space:nowrap; }
|
||||
.prog-sub { font-weight:400; color:var(--text-muted); font-size:11px; }
|
||||
.dash-arch-toggle { display:inline-flex; align-items:center; gap:6px; font-size:13px; color:var(--text-muted); white-space:nowrap; cursor:pointer; }
|
||||
.dash-pager { display:flex; align-items:center; gap:12px; margin-top:12px; font-size:12.5px; color:var(--text-muted); }
|
||||
.dash-pager-btns { margin-left:auto; display:flex; gap:8px; }
|
||||
.dash-pager .btn { padding:5px 12px; }
|
||||
@media (max-width:560px){ .prog-row { grid-template-columns:110px 1fr 74px; } }
|
||||
|
||||
@@ -34,6 +34,13 @@ server {
|
||||
root /var/www/wp-suite; # <-- web root
|
||||
index index.html;
|
||||
|
||||
# ── Security response headers (defense-in-depth) ─────────────────────────
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "no-referrer" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; form-action 'self'" always;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,17 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# ── Security response headers (defense-in-depth) ─────────────────────────
|
||||
# CSP keeps 'unsafe-inline' for now because the app uses inline handlers/styles
|
||||
# heavily; even so, connect-src/img-src/object-src/base-uri/frame-ancestors
|
||||
# sharply limit what injected script could load or exfiltrate. Tighten toward
|
||||
# nonce-based scripts once inline handlers are refactored.
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "no-referrer" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; form-action 'self'" always;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
@@ -19,7 +30,10 @@ server {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# This container is only ever reached via the TLS-terminating external
|
||||
# proxy, so the real client scheme is HTTPS. Hard-set it (a local $scheme
|
||||
# here is always "http") so the API marks the session cookie Secure.
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
client_max_body_size 5m;
|
||||
}
|
||||
}
|
||||
|
||||
16
scripts/backup-cron.sh
Normal file
16
scripts/backup-cron.sh
Normal file
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
# Entry point for the `backup` sidecar container. Runs db-backup.sh on a fixed
|
||||
# interval (default: daily). Kept deliberately simple — a sleep loop instead of a
|
||||
# cron daemon — so it works in a bare postgres:16-alpine image.
|
||||
set -eu
|
||||
|
||||
INTERVAL="${BACKUP_INTERVAL_SECONDS:-86400}" # 86400 = once a day
|
||||
echo "[backup] sidecar started; interval=${INTERVAL}s, keep=${BACKUP_KEEP:-14}, dir=${BACKUP_DIR:-/backups}"
|
||||
|
||||
# Take one backup shortly after start so a freshly-deployed stack has an
|
||||
# immediate restore point instead of waiting a whole interval.
|
||||
sleep 20
|
||||
while true; do
|
||||
sh /scripts/db-backup.sh || echo "[backup] run failed; will retry next interval" >&2
|
||||
sleep "$INTERVAL"
|
||||
done
|
||||
5
scripts/backup.Dockerfile
Normal file
5
scripts/backup.Dockerfile
Normal file
@@ -0,0 +1,5 @@
|
||||
# Backup sidecar image: Postgres client tools (pg_dump/psql) + openssl for
|
||||
# at-rest encryption of dumps. The scripts themselves are bind-mounted at runtime
|
||||
# (see the `backup` service in docker-compose.yml), so they're not COPYed here.
|
||||
FROM postgres:16-alpine
|
||||
RUN apk add --no-cache openssl
|
||||
58
scripts/db-backup.sh
Normal file
58
scripts/db-backup.sh
Normal file
@@ -0,0 +1,58 @@
|
||||
#!/bin/sh
|
||||
# One database backup: pg_dump -> gzip [-> openssl AES-256] -> timestamped file in
|
||||
# $BACKUP_DIR, then prune to the newest $BACKUP_KEEP files.
|
||||
#
|
||||
# Encryption: if BACKUP_ENC_PASSPHRASE is set, the dump is encrypted at rest with
|
||||
# AES-256 (openssl, PBKDF2) and written as *.sql.gz.enc. STRONGLY recommended once
|
||||
# the database holds customer IP — otherwise the dump (and every offsite copy) is
|
||||
# plaintext. Keep the passphrase OUT of the backups directory (and off the host if
|
||||
# possible); losing it means the backups are unrecoverable.
|
||||
#
|
||||
# Runs inside a container that has pg_dump + openssl (see scripts/backup.Dockerfile).
|
||||
set -eu
|
||||
|
||||
BACKUP_DIR="${BACKUP_DIR:-/backups}"
|
||||
KEEP="${BACKUP_KEEP:-14}"
|
||||
PGHOST="${PGHOST:-db}"
|
||||
PGPORT="${PGPORT:-5432}"
|
||||
DB="${POSTGRES_DB:?POSTGRES_DB is required}"
|
||||
DB_USER="${POSTGRES_USER:?POSTGRES_USER is required}"
|
||||
export PGPASSWORD="${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}"
|
||||
ENC="${BACKUP_ENC_PASSPHRASE:-}"
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
ts="$(date -u +%Y%m%d-%H%M%SZ)"
|
||||
if [ -n "$ENC" ]; then
|
||||
out="$BACKUP_DIR/wpsuite-$ts.sql.gz.enc"
|
||||
else
|
||||
out="$BACKUP_DIR/wpsuite-$ts.sql.gz"
|
||||
echo "[db-backup] WARNING: BACKUP_ENC_PASSPHRASE not set — this dump is UNENCRYPTED. Set it to protect data at rest." >&2
|
||||
fi
|
||||
tmp="$out.partial"
|
||||
|
||||
echo "[db-backup] $(date -u) dumping ${DB}@${PGHOST} -> ${out}"
|
||||
if [ -n "$ENC" ]; then
|
||||
if pg_dump -h "$PGHOST" -p "$PGPORT" -U "$DB_USER" -d "$DB" --clean --if-exists \
|
||||
| gzip -c \
|
||||
| openssl enc -aes-256-cbc -pbkdf2 -salt -pass env:BACKUP_ENC_PASSPHRASE > "$tmp"; then
|
||||
mv "$tmp" "$out"
|
||||
else
|
||||
echo "[db-backup] FAILED — pg_dump/encrypt error" >&2; rm -f "$tmp"; exit 1
|
||||
fi
|
||||
else
|
||||
if pg_dump -h "$PGHOST" -p "$PGPORT" -U "$DB_USER" -d "$DB" --clean --if-exists | gzip -c > "$tmp"; then
|
||||
mv "$tmp" "$out"
|
||||
else
|
||||
echo "[db-backup] FAILED — pg_dump error" >&2; rm -f "$tmp"; exit 1
|
||||
fi
|
||||
fi
|
||||
echo "[db-backup] wrote $(du -h "$out" | cut -f1) ${out}"
|
||||
|
||||
# Retention: keep the newest $KEEP dumps (plaintext or encrypted), delete the rest.
|
||||
count="$(ls -1t "$BACKUP_DIR"/wpsuite-*.sql.gz* 2>/dev/null | grep -v '\.partial$' | wc -l | tr -d ' ')"
|
||||
if [ "$count" -gt "$KEEP" ]; then
|
||||
ls -1t "$BACKUP_DIR"/wpsuite-*.sql.gz* 2>/dev/null | grep -v '\.partial$' | tail -n +"$((KEEP + 1))" | while IFS= read -r f; do
|
||||
echo "[db-backup] pruning $f"
|
||||
rm -f "$f"
|
||||
done
|
||||
fi
|
||||
33
scripts/db-restore.sh
Normal file
33
scripts/db-restore.sh
Normal file
@@ -0,0 +1,33 @@
|
||||
#!/bin/sh
|
||||
# Restore a pg_dump backup (plaintext *.sql.gz or encrypted *.sql.gz.enc).
|
||||
#
|
||||
# DESTRUCTIVE: dumps are taken with --clean --if-exists, so restoring drops and
|
||||
# recreates objects before loading. Take a fresh backup first if in doubt.
|
||||
#
|
||||
# Usage (from the project root):
|
||||
# docker compose exec backup sh /scripts/db-restore.sh /backups/wpsuite-YYYYMMDD-HHMMSSZ.sql.gz.enc
|
||||
# For an encrypted (.enc) file, BACKUP_ENC_PASSPHRASE must be set (it is, in the
|
||||
# backup container's environment).
|
||||
set -eu
|
||||
|
||||
FILE="${1:?usage: db-restore.sh <path-to-.sql.gz[.enc]>}"
|
||||
PGHOST="${PGHOST:-db}"
|
||||
PGPORT="${PGPORT:-5432}"
|
||||
DB="${POSTGRES_DB:?POSTGRES_DB is required}"
|
||||
DB_USER="${POSTGRES_USER:?POSTGRES_USER is required}"
|
||||
export PGPASSWORD="${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}"
|
||||
|
||||
[ -f "$FILE" ] || { echo "[db-restore] no such file: $FILE" >&2; exit 1; }
|
||||
|
||||
echo "[db-restore] restoring ${FILE} -> ${DB}@${PGHOST} (this OVERWRITES current data)"
|
||||
case "$FILE" in
|
||||
*.enc)
|
||||
: "${BACKUP_ENC_PASSPHRASE:?BACKUP_ENC_PASSPHRASE is required to decrypt ${FILE}}"
|
||||
openssl enc -d -aes-256-cbc -pbkdf2 -pass env:BACKUP_ENC_PASSPHRASE -in "$FILE" \
|
||||
| gunzip -c | psql -h "$PGHOST" -p "$PGPORT" -U "$DB_USER" -d "$DB" -v ON_ERROR_STOP=1
|
||||
;;
|
||||
*)
|
||||
gunzip -c "$FILE" | psql -h "$PGHOST" -p "$PGPORT" -U "$DB_USER" -d "$DB" -v ON_ERROR_STOP=1
|
||||
;;
|
||||
esac
|
||||
echo "[db-restore] done."
|
||||
@@ -20,3 +20,13 @@ AUTH_SECRET_KEY=CHANGE_ME_run_the_command_above
|
||||
|
||||
# How long a login lasts before re-authentication (hours). Default 12.
|
||||
# AUTH_SESSION_HOURS=12
|
||||
|
||||
# ── Email notifications (optional) ─────────────────────────────────────────────
|
||||
# WP-assignment emails are OFF by default and are turned on from the Admin
|
||||
# console (Notifications & email card), where the SMTP host/port/from-address
|
||||
# live. The one secret that must NOT be stored in the database — the SMTP
|
||||
# password — is read from this environment variable instead. Leave it unset
|
||||
# until you have the SMTP details; the toggle stays effectively off (queued
|
||||
# notifications are marked "skipped", nothing is sent) until both the toggle is
|
||||
# on and SMTP is configured.
|
||||
# SMTP_PASSWORD=your-smtp-app-password
|
||||
|
||||
43
server/alembic.ini
Normal file
43
server/alembic.ini
Normal file
@@ -0,0 +1,43 @@
|
||||
# Alembic configuration for the Work Package Suite.
|
||||
# The database URL is NOT hard-coded here — env.py pulls it from the same place
|
||||
# the app does (server/db.py: POSTGRES_* / DATABASE_URL / SQLite fallback), so
|
||||
# migrations always target the same database as the running app.
|
||||
[alembic]
|
||||
script_location = %(here)s/alembic
|
||||
prepend_sys_path = .
|
||||
# Use OS-native path separators on Windows dev machines.
|
||||
path_separator = os
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
63
server/alembic/env.py
Normal file
63
server/alembic/env.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Alembic environment for the Work Package Suite.
|
||||
|
||||
We reuse the application's own database configuration (server/db.py) so a
|
||||
migration always targets the same database the app would connect to — Postgres
|
||||
in production (from POSTGRES_* / DATABASE_URL) or the SQLite dev file otherwise.
|
||||
No connection string is stored in alembic.ini.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
|
||||
# Make the `server` package importable no matter where alembic is invoked from
|
||||
# (repo root, /app in the container, etc.). env.py lives at server/alembic/env.py,
|
||||
# so the repo root is two directories up.
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_REPO = os.path.dirname(os.path.dirname(_HERE))
|
||||
if _REPO not in sys.path:
|
||||
sys.path.insert(0, _REPO)
|
||||
|
||||
from server.db import Base, DATABASE_URL, engine # noqa: E402
|
||||
from server import models # noqa: E402,F401 (imported for its side effect: registers all tables on Base.metadata)
|
||||
|
||||
config = context.config
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# The app resolves its URL from the environment; feed the same value to Alembic.
|
||||
config.set_main_option("sqlalchemy.url", str(DATABASE_URL))
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Emit SQL to stdout (`alembic upgrade --sql`) without a live connection."""
|
||||
context.configure(
|
||||
url=str(DATABASE_URL),
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
compare_type=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations against a live connection, reusing the app's engine."""
|
||||
with engine.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
23
server/alembic/script.py.mako
Normal file
23
server/alembic/script.py.mako
Normal file
@@ -0,0 +1,23 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
0
server/alembic/versions/.gitkeep
Normal file
0
server/alembic/versions/.gitkeep
Normal file
@@ -0,0 +1,30 @@
|
||||
"""user login lockout fields
|
||||
|
||||
Revision ID: 18373f14809e
|
||||
Revises: 47bbe76aa749
|
||||
Create Date: 2026-07-15 14:50:58.423834
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '18373f14809e'
|
||||
down_revision = '47bbe76aa749'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
# server_default backfills existing rows to 0 (the column is NOT NULL).
|
||||
op.add_column('users', sa.Column('failed_attempts', sa.Integer(), nullable=False, server_default='0'))
|
||||
op.add_column('users', sa.Column('locked_until', sa.DateTime(timezone=True), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('users', 'locked_until')
|
||||
op.drop_column('users', 'failed_attempts')
|
||||
# ### end Alembic commands ###
|
||||
29
server/alembic/versions/47bbe76aa749_wp_archived_at.py
Normal file
29
server/alembic/versions/47bbe76aa749_wp_archived_at.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""wp archived_at
|
||||
|
||||
Revision ID: 47bbe76aa749
|
||||
Revises: 4e094197c9aa
|
||||
Create Date: 2026-07-15 12:00:14.356398
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '47bbe76aa749'
|
||||
down_revision = '4e094197c9aa'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('work_packages', sa.Column('archived_at', sa.DateTime(timezone=True), nullable=True))
|
||||
op.create_index(op.f('ix_work_packages_archived_at'), 'work_packages', ['archived_at'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_work_packages_archived_at'), table_name='work_packages')
|
||||
op.drop_column('work_packages', 'archived_at')
|
||||
# ### end Alembic commands ###
|
||||
48
server/alembic/versions/4e094197c9aa_audit_log.py
Normal file
48
server/alembic/versions/4e094197c9aa_audit_log.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""audit log
|
||||
|
||||
Revision ID: 4e094197c9aa
|
||||
Revises: c6af106a04da
|
||||
Create Date: 2026-07-15 10:12:52.859694
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '4e094197c9aa'
|
||||
down_revision = 'c6af106a04da'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('audit_log',
|
||||
sa.Column('id', sa.String(length=40), nullable=False),
|
||||
sa.Column('at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('actor', sa.String(length=200), nullable=False),
|
||||
sa.Column('action', sa.String(length=60), nullable=False),
|
||||
sa.Column('entity_type', sa.String(length=40), nullable=False),
|
||||
sa.Column('entity_id', sa.String(length=40), nullable=False),
|
||||
sa.Column('project_id', sa.String(length=40), nullable=True),
|
||||
sa.Column('summary', sa.String(length=400), nullable=False),
|
||||
sa.Column('detail', sa.JSON(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_audit_log_action'), 'audit_log', ['action'], unique=False)
|
||||
op.create_index(op.f('ix_audit_log_at'), 'audit_log', ['at'], unique=False)
|
||||
op.create_index(op.f('ix_audit_log_entity_id'), 'audit_log', ['entity_id'], unique=False)
|
||||
op.create_index(op.f('ix_audit_log_entity_type'), 'audit_log', ['entity_type'], unique=False)
|
||||
op.create_index(op.f('ix_audit_log_project_id'), 'audit_log', ['project_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_audit_log_project_id'), table_name='audit_log')
|
||||
op.drop_index(op.f('ix_audit_log_entity_type'), table_name='audit_log')
|
||||
op.drop_index(op.f('ix_audit_log_entity_id'), table_name='audit_log')
|
||||
op.drop_index(op.f('ix_audit_log_at'), table_name='audit_log')
|
||||
op.drop_index(op.f('ix_audit_log_action'), table_name='audit_log')
|
||||
op.drop_table('audit_log')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,63 @@
|
||||
"""assignment + settings + notifications
|
||||
|
||||
Revision ID: 57dec34f11cb
|
||||
Revises: ad8e6cc5de0f
|
||||
Create Date: 2026-07-15 16:43:09.230419
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '57dec34f11cb'
|
||||
down_revision = 'ad8e6cc5de0f'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('app_settings',
|
||||
sa.Column('key', sa.String(length=80), nullable=False),
|
||||
sa.Column('value', sa.JSON(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint('key')
|
||||
)
|
||||
op.create_table('notifications',
|
||||
sa.Column('id', sa.String(length=40), nullable=False),
|
||||
sa.Column('user_id', sa.String(length=40), nullable=False),
|
||||
sa.Column('email', sa.String(length=200), nullable=False),
|
||||
sa.Column('kind', sa.String(length=40), nullable=False),
|
||||
sa.Column('wp_id', sa.String(length=40), nullable=True),
|
||||
sa.Column('project_id', sa.String(length=40), nullable=True),
|
||||
sa.Column('subject', sa.String(length=300), nullable=False),
|
||||
sa.Column('body', sa.Text(), nullable=False),
|
||||
sa.Column('link', sa.String(length=500), nullable=False),
|
||||
sa.Column('status', sa.String(length=20), nullable=False),
|
||||
sa.Column('error', sa.String(length=400), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('sent_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_notifications_created_at'), 'notifications', ['created_at'], unique=False)
|
||||
op.create_index(op.f('ix_notifications_kind'), 'notifications', ['kind'], unique=False)
|
||||
op.create_index(op.f('ix_notifications_project_id'), 'notifications', ['project_id'], unique=False)
|
||||
op.create_index(op.f('ix_notifications_status'), 'notifications', ['status'], unique=False)
|
||||
op.create_index(op.f('ix_notifications_user_id'), 'notifications', ['user_id'], unique=False)
|
||||
op.add_column('work_packages', sa.Column('assignee_id', sa.String(length=40), nullable=True))
|
||||
op.create_index(op.f('ix_work_packages_assignee_id'), 'work_packages', ['assignee_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_work_packages_assignee_id'), table_name='work_packages')
|
||||
op.drop_column('work_packages', 'assignee_id')
|
||||
op.drop_index(op.f('ix_notifications_user_id'), table_name='notifications')
|
||||
op.drop_index(op.f('ix_notifications_status'), table_name='notifications')
|
||||
op.drop_index(op.f('ix_notifications_project_id'), table_name='notifications')
|
||||
op.drop_index(op.f('ix_notifications_kind'), table_name='notifications')
|
||||
op.drop_index(op.f('ix_notifications_created_at'), table_name='notifications')
|
||||
op.drop_table('notifications')
|
||||
op.drop_table('app_settings')
|
||||
# ### end Alembic commands ###
|
||||
28
server/alembic/versions/ad8e6cc5de0f_user_token_version.py
Normal file
28
server/alembic/versions/ad8e6cc5de0f_user_token_version.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""user token_version
|
||||
|
||||
Revision ID: ad8e6cc5de0f
|
||||
Revises: 18373f14809e
|
||||
Create Date: 2026-07-15 16:03:57.736556
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'ad8e6cc5de0f'
|
||||
down_revision = '18373f14809e'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
# server_default backfills existing rows to 0 (the column is NOT NULL).
|
||||
op.add_column('users', sa.Column('token_version', sa.Integer(), nullable=False, server_default='0'))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('users', 'token_version')
|
||||
# ### end Alembic commands ###
|
||||
145
server/alembic/versions/c6af106a04da_baseline_schema.py
Normal file
145
server/alembic/versions/c6af106a04da_baseline_schema.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""baseline schema
|
||||
|
||||
Revision ID: c6af106a04da
|
||||
Revises:
|
||||
Create Date: 2026-07-15 08:21:07.450350
|
||||
|
||||
This is the initial baseline. It creates the current schema on a fresh database,
|
||||
and safely ADOPTS an existing database (one whose tables were created by the old
|
||||
`Base.metadata.create_all()` before Alembic was introduced): if the schema is
|
||||
already present it records this revision without recreating anything. That means
|
||||
`alembic upgrade head` is safe to run on both new and existing deployments — no
|
||||
manual `alembic stamp` step required.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'c6af106a04da'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if sa.inspect(bind).has_table("projects"):
|
||||
# Existing pre-Alembic database — adopt it as the baseline as-is.
|
||||
return
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('comments',
|
||||
sa.Column('id', sa.String(length=40), nullable=False),
|
||||
sa.Column('source', sa.String(length=40), nullable=False),
|
||||
sa.Column('sop_id', sa.String(length=40), nullable=True),
|
||||
sa.Column('wp_id', sa.String(length=40), nullable=True),
|
||||
sa.Column('step', sa.Integer(), nullable=True),
|
||||
sa.Column('author', sa.String(length=200), nullable=False),
|
||||
sa.Column('text', sa.Text(), nullable=False),
|
||||
sa.Column('page', sa.String(length=200), nullable=False),
|
||||
sa.Column('extra', sa.JSON(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_comments_sop_id'), 'comments', ['sop_id'], unique=False)
|
||||
op.create_index(op.f('ix_comments_source'), 'comments', ['source'], unique=False)
|
||||
op.create_index(op.f('ix_comments_wp_id'), 'comments', ['wp_id'], unique=False)
|
||||
op.create_table('projects',
|
||||
sa.Column('id', sa.String(length=40), nullable=False),
|
||||
sa.Column('name', sa.String(length=300), nullable=False),
|
||||
sa.Column('number', sa.String(length=100), nullable=False),
|
||||
sa.Column('client', sa.String(length=300), nullable=False),
|
||||
sa.Column('division', sa.String(length=200), nullable=False),
|
||||
sa.Column('site', sa.String(length=300), nullable=False),
|
||||
sa.Column('sample', sa.Boolean(), nullable=False),
|
||||
sa.Column('data', sa.JSON(), nullable=False),
|
||||
sa.Column('created_by', sa.String(length=200), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_projects_number'), 'projects', ['number'], unique=False)
|
||||
op.create_table('users',
|
||||
sa.Column('id', sa.String(length=40), nullable=False),
|
||||
sa.Column('username', sa.String(length=120), nullable=False),
|
||||
sa.Column('email', sa.String(length=200), nullable=False),
|
||||
sa.Column('full_name', sa.String(length=200), nullable=False),
|
||||
sa.Column('password_hash', sa.String(length=200), nullable=False),
|
||||
sa.Column('role', sa.String(length=20), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('last_login_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
|
||||
op.create_table('project_members',
|
||||
sa.Column('id', sa.String(length=40), nullable=False),
|
||||
sa.Column('user_id', sa.String(length=40), nullable=False),
|
||||
sa.Column('project_id', sa.String(length=40), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'project_id', name='uq_project_member')
|
||||
)
|
||||
op.create_index(op.f('ix_project_members_project_id'), 'project_members', ['project_id'], unique=False)
|
||||
op.create_index(op.f('ix_project_members_user_id'), 'project_members', ['user_id'], unique=False)
|
||||
op.create_table('sops',
|
||||
sa.Column('id', sa.String(length=40), nullable=False),
|
||||
sa.Column('project_id', sa.String(length=40), nullable=True),
|
||||
sa.Column('name', sa.String(length=300), nullable=False),
|
||||
sa.Column('number', sa.String(length=100), nullable=False),
|
||||
sa.Column('complete', sa.Boolean(), nullable=False),
|
||||
sa.Column('data', sa.JSON(), nullable=False),
|
||||
sa.Column('created_by', sa.String(length=200), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_sops_project_id'), 'sops', ['project_id'], unique=False)
|
||||
op.create_table('work_packages',
|
||||
sa.Column('id', sa.String(length=40), nullable=False),
|
||||
sa.Column('project_id', sa.String(length=40), nullable=True),
|
||||
sa.Column('sop_id', sa.String(length=40), nullable=True),
|
||||
sa.Column('parent_id', sa.String(length=40), nullable=True),
|
||||
sa.Column('number', sa.String(length=120), nullable=False),
|
||||
sa.Column('subject', sa.String(length=400), nullable=False),
|
||||
sa.Column('type', sa.String(length=120), nullable=False),
|
||||
sa.Column('status', sa.String(length=40), nullable=False),
|
||||
sa.Column('issued_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('data', sa.JSON(), nullable=False),
|
||||
sa.Column('created_by', sa.String(length=200), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['sop_id'], ['sops.id'], ondelete='SET NULL'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_work_packages_parent_id'), 'work_packages', ['parent_id'], unique=False)
|
||||
op.create_index(op.f('ix_work_packages_project_id'), 'work_packages', ['project_id'], unique=False)
|
||||
op.create_index(op.f('ix_work_packages_sop_id'), 'work_packages', ['sop_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_work_packages_sop_id'), table_name='work_packages')
|
||||
op.drop_index(op.f('ix_work_packages_project_id'), table_name='work_packages')
|
||||
op.drop_index(op.f('ix_work_packages_parent_id'), table_name='work_packages')
|
||||
op.drop_table('work_packages')
|
||||
op.drop_index(op.f('ix_sops_project_id'), table_name='sops')
|
||||
op.drop_table('sops')
|
||||
op.drop_index(op.f('ix_project_members_user_id'), table_name='project_members')
|
||||
op.drop_index(op.f('ix_project_members_project_id'), table_name='project_members')
|
||||
op.drop_table('project_members')
|
||||
op.drop_index(op.f('ix_users_username'), table_name='users')
|
||||
op.drop_table('users')
|
||||
op.drop_index(op.f('ix_projects_number'), table_name='projects')
|
||||
op.drop_table('projects')
|
||||
op.drop_index(op.f('ix_comments_wp_id'), table_name='comments')
|
||||
op.drop_index(op.f('ix_comments_source'), table_name='comments')
|
||||
op.drop_index(op.f('ix_comments_sop_id'), table_name='comments')
|
||||
op.drop_table('comments')
|
||||
# ### end Alembic commands ###
|
||||
469
server/app.py
469
server/app.py
@@ -9,24 +9,40 @@ Run (prod): gunicorn -k uvicorn.workers.UvicornWorker -b 127.0.0.1:8000 serve
|
||||
Interactive docs: http://<host>/api/docs
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from datetime import timedelta, timezone
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import FastAPI, Depends, HTTPException, Query, Request, Response
|
||||
from fastapi import FastAPI, Depends, HTTPException, Query, Request, Response, BackgroundTasks
|
||||
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 import select, delete, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .db import Base, engine, get_db
|
||||
from . import models, auth
|
||||
from . import models, auth, notify
|
||||
|
||||
# Create tables on startup. (For schema changes later, switch to Alembic.)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
# Schema management:
|
||||
# • Local dev (SQLite) auto-creates tables for a zero-config run.
|
||||
# • Production (Postgres) owns its schema through Alembic migrations, which run
|
||||
# at container start (`alembic upgrade head`, see Dockerfile / DEPLOYMENT.md).
|
||||
# We must NOT create_all there, or it would race/collide with the migration.
|
||||
if engine.dialect.name == "sqlite":
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
app = FastAPI(title="Work Package Suite API", docs_url="/api/docs", openapi_url="/api/openapi.json")
|
||||
# Interactive docs are handy in dev but hand an attacker the full API map in prod,
|
||||
# so enable them only on the SQLite dev fallback (production runs on Postgres).
|
||||
_docs_enabled = engine.dialect.name == "sqlite"
|
||||
app = FastAPI(
|
||||
title="Work Package Suite API",
|
||||
docs_url="/api/docs" if _docs_enabled else None,
|
||||
redoc_url=None,
|
||||
openapi_url="/api/openapi.json" if _docs_enabled else None,
|
||||
)
|
||||
|
||||
# Same-origin in production (NGINX), so CORS is normally unnecessary. For
|
||||
# cross-origin local dev, set CORS_ORIGINS="http://localhost:5500,..."
|
||||
@@ -35,7 +51,7 @@ _origins = [o for o in os.getenv("CORS_ORIGINS", "").split(",") if o]
|
||||
if _origins:
|
||||
app.add_middleware(
|
||||
CORSMiddleware, allow_origins=_origins, allow_credentials=True,
|
||||
allow_methods=["*"], allow_headers=["*"],
|
||||
allow_methods=["*"], allow_headers=["*"], expose_headers=["X-Total-Count"],
|
||||
)
|
||||
|
||||
|
||||
@@ -44,11 +60,33 @@ if _origins:
|
||||
# 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.
|
||||
_UNSAFE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
||||
|
||||
|
||||
def _csrf_ok(request: Request) -> bool:
|
||||
"""CSRF defense-in-depth behind SameSite=Lax: when the browser sends an Origin
|
||||
on a state-changing request, it must be same-origin (or an allowed CORS origin).
|
||||
Non-browser clients (no Origin header) are unaffected."""
|
||||
origin = request.headers.get("origin")
|
||||
if not origin:
|
||||
return True
|
||||
if _origins and origin in _origins:
|
||||
return True
|
||||
try:
|
||||
return urlparse(origin).netloc == request.headers.get("host", "")
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def auth_gate(request: Request, call_next):
|
||||
if request.method != "OPTIONS" and auth._needs_auth(request.url.path):
|
||||
path = request.url.path
|
||||
method = request.method
|
||||
if method != "OPTIONS" and auth._needs_auth(path):
|
||||
if not auth.is_request_authenticated(request):
|
||||
return JSONResponse(status_code=401, content={"detail": "Not authenticated"})
|
||||
if method in _UNSAFE_METHODS and path.startswith("/api/") and not _csrf_ok(request):
|
||||
return JSONResponse(status_code=403, content={"detail": "Cross-origin request rejected"})
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
@@ -56,6 +94,16 @@ def gen_id(prefix: str) -> str:
|
||||
return f"{prefix}_{uuid.uuid4().hex[:12]}"
|
||||
|
||||
|
||||
# Clients may supply their own resource ids (offline-first). Constrain them to a
|
||||
# safe charset so an id can never carry HTML/JS that a UI might place in markup.
|
||||
_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,40}$")
|
||||
|
||||
|
||||
def check_id(v: Optional[str]) -> None:
|
||||
if v and not _ID_RE.match(v):
|
||||
raise HTTPException(status_code=400, detail="Invalid id format")
|
||||
|
||||
|
||||
# ── 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
|
||||
@@ -72,8 +120,12 @@ def accessible_project_ids(db: Session, user: "models.User"):
|
||||
|
||||
|
||||
def require_project_access(db: Session, user: "models.User", project_id: Optional[str]) -> None:
|
||||
if user.role == "admin" or project_id is None:
|
||||
if user.role == "admin":
|
||||
return
|
||||
if not project_id:
|
||||
# Non-admins may not read/mutate resources with no project assignment
|
||||
# (orphan/legacy rows); only admins can touch project-less data.
|
||||
raise HTTPException(status_code=403, detail="This resource is not assigned to a project you can access")
|
||||
ok = db.scalar(
|
||||
select(models.ProjectMember.id).where(
|
||||
(models.ProjectMember.user_id == user.id)
|
||||
@@ -104,6 +156,54 @@ def grant_project_access(db: Session, user_id: str, project_id: str) -> None:
|
||||
db.add(models.ProjectMember(id=gen_id("pm"), user_id=user_id, project_id=project_id))
|
||||
|
||||
|
||||
# ── Audit trail ────────────────────────────────────────────────────────────────
|
||||
def log_event(db: Session, actor, action: str, entity_type: str, entity_id: str,
|
||||
project_id: Optional[str] = None, summary: str = "", detail: Optional[dict] = None) -> None:
|
||||
"""Append an audit-trail row in the CURRENT transaction so it commits
|
||||
atomically with the change it describes. `actor` may be a User or a username."""
|
||||
who = actor.username if isinstance(actor, models.User) else (actor or "")
|
||||
db.add(models.AuditLog(
|
||||
id=gen_id("ev"), actor=who, action=action, entity_type=entity_type,
|
||||
entity_id=entity_id or "", project_id=project_id, summary=(summary or "")[:400], detail=detail or {},
|
||||
))
|
||||
|
||||
|
||||
# ── Assignment ─────────────────────────────────────────────────────────────────
|
||||
def require_assignable(db: Session, user_id: str, project_id: Optional[str]) -> None:
|
||||
"""A WP can only be assigned to an active user who can access its project."""
|
||||
u = db.get(models.User, user_id)
|
||||
if not u or not u.is_active:
|
||||
raise HTTPException(status_code=400, detail="Assignee is not a valid user")
|
||||
if u.role == "admin":
|
||||
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=400, detail="Assignee is not a member of this project")
|
||||
|
||||
|
||||
def wp_link(db: Session, wp: "models.WorkPackage") -> str:
|
||||
base = (notify.get_settings(db).get("app_base_url") or "").rstrip("/")
|
||||
path = f"/work-package-suite.html?tab=wp&project={wp.project_id or ''}"
|
||||
return (base + path) if base else path
|
||||
|
||||
|
||||
def assign_body(assignee: "models.User", wp: "models.WorkPackage", actor: "models.User", link: str) -> str:
|
||||
# Deliberately minimal — a WP number + a link, NOT the package contents (keeps
|
||||
# customer IP inside the app, behind login).
|
||||
who = actor.full_name or actor.username
|
||||
name = assignee.full_name or assignee.username
|
||||
return (
|
||||
f"Hi {name},\n\n"
|
||||
f"{who} assigned you a work package: {wp.number or '(no number)'}.\n\n"
|
||||
f"Open the Work Package Suite to view and action it:\n{link}\n\n"
|
||||
f"— This is an automated message from the Work Package Suite."
|
||||
)
|
||||
|
||||
|
||||
# ── Request bodies ───────────────────────────────────────────────────────────
|
||||
class ProjectIn(BaseModel):
|
||||
id: Optional[str] = None
|
||||
@@ -136,14 +236,35 @@ class WpIn(BaseModel):
|
||||
subject: str = ""
|
||||
type: str = ""
|
||||
status: str = "Draft"
|
||||
assignee_id: Optional[str] = None
|
||||
created_by: str = ""
|
||||
data: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SettingsIn(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
email_enabled: Optional[bool] = None
|
||||
smtp_host: Optional[str] = None
|
||||
smtp_port: Optional[int] = None
|
||||
smtp_use_tls: Optional[bool] = None
|
||||
smtp_username: Optional[str] = None
|
||||
from_addr: Optional[str] = None
|
||||
from_name: Optional[str] = None
|
||||
app_base_url: Optional[str] = None
|
||||
|
||||
|
||||
class TestEmailIn(BaseModel):
|
||||
to: Optional[str] = None
|
||||
|
||||
|
||||
class StatusIn(BaseModel):
|
||||
status: str
|
||||
|
||||
|
||||
class ArchiveIn(BaseModel):
|
||||
archived: bool = True
|
||||
|
||||
|
||||
class CommentIn(BaseModel):
|
||||
# Tolerate any extra keys the feedback payload includes (timestamp, app, …).
|
||||
model_config = ConfigDict(extra="allow")
|
||||
@@ -191,22 +312,49 @@ class ActiveIn(BaseModel):
|
||||
is_active: bool
|
||||
|
||||
|
||||
class RoleIn(BaseModel):
|
||||
role: str # 'admin' | 'user'
|
||||
|
||||
|
||||
class ProjectAssignIn(BaseModel):
|
||||
project_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
LOGIN_MAX_ATTEMPTS = int(os.getenv("AUTH_MAX_ATTEMPTS", "5"))
|
||||
LOGIN_LOCKOUT_MINUTES = int(os.getenv("AUTH_LOCKOUT_MINUTES", "15"))
|
||||
|
||||
|
||||
@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."""
|
||||
"""Verify credentials and, on success, set the HttpOnly session cookie.
|
||||
Throttles online password guessing: after LOGIN_MAX_ATTEMPTS consecutive
|
||||
failures an account is locked for LOGIN_LOCKOUT_MINUTES."""
|
||||
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.
|
||||
now = models.utcnow()
|
||||
# Always run the hash comparison first — even for missing or locked accounts —
|
||||
# so response timing doesn't leak which usernames exist. verify_password
|
||||
# tolerates an empty hash.
|
||||
valid = auth.verify_password(body.password, user.password_hash if user else "")
|
||||
locked = user.locked_until if user else None
|
||||
if locked is not None and locked.tzinfo is None:
|
||||
locked = locked.replace(tzinfo=timezone.utc) # SQLite returns naive datetimes; normalize to UTC
|
||||
if locked is not None and locked > now:
|
||||
raise HTTPException(status_code=429, detail="Too many failed attempts. Try again later.")
|
||||
if not user or not valid:
|
||||
if user:
|
||||
user.failed_attempts = (user.failed_attempts or 0) + 1
|
||||
if user.failed_attempts >= LOGIN_MAX_ATTEMPTS:
|
||||
user.locked_until = now + timedelta(minutes=LOGIN_LOCKOUT_MINUTES)
|
||||
user.failed_attempts = 0
|
||||
log_event(db, user.username, "login_locked", "user", user.id, summary=user.username,
|
||||
detail={"minutes": LOGIN_LOCKOUT_MINUTES})
|
||||
db.commit()
|
||||
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()
|
||||
user.failed_attempts = 0
|
||||
user.locked_until = None
|
||||
user.last_login_at = now
|
||||
db.commit()
|
||||
token = auth.create_token(user)
|
||||
auth.set_session_cookie(response, request, token)
|
||||
@@ -226,13 +374,18 @@ def whoami(user: models.User = Depends(auth.get_current_user)):
|
||||
|
||||
|
||||
@app.post("/api/auth/password")
|
||||
def change_password(body: PasswordChangeIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
def change_password(body: PasswordChangeIn, request: Request, response: Response, 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")
|
||||
problem = auth.password_problem(body.new_password, user.username, user.email)
|
||||
if problem:
|
||||
raise HTTPException(status_code=400, detail=problem)
|
||||
user.password_hash = auth.hash_password(body.new_password)
|
||||
user.token_version = (user.token_version or 0) + 1 # invalidate all OTHER existing sessions
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
# Keep this session logged in by re-issuing a cookie carrying the new version.
|
||||
auth.set_session_cookie(response, request, auth.create_token(user))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -245,8 +398,9 @@ def list_users(_admin: models.User = Depends(auth.require_admin), db: Session =
|
||||
|
||||
@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")
|
||||
problem = auth.password_problem(body.password, body.username, body.email)
|
||||
if problem:
|
||||
raise HTTPException(status_code=400, detail=problem)
|
||||
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):
|
||||
@@ -260,6 +414,7 @@ def create_user(body: NewUserIn, _admin: models.User = Depends(auth.require_admi
|
||||
role=body.role,
|
||||
)
|
||||
db.add(u)
|
||||
log_event(db, _admin, "user_created", "user", u.id, summary=u.username, detail={"role": u.role})
|
||||
db.commit()
|
||||
db.refresh(u)
|
||||
return u.to_dict()
|
||||
@@ -270,9 +425,11 @@ def admin_reset_password(user_id: str, body: AdminPasswordIn, _admin: models.Use
|
||||
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")
|
||||
problem = auth.password_problem(body.new_password, u.username, u.email)
|
||||
if problem:
|
||||
raise HTTPException(status_code=400, detail=problem)
|
||||
u.password_hash = auth.hash_password(body.new_password)
|
||||
u.token_version = (u.token_version or 0) + 1 # revoke the user's existing sessions
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
@@ -285,10 +442,43 @@ def set_user_active(user_id: str, body: ActiveIn, admin: models.User = Depends(a
|
||||
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
|
||||
log_event(db, admin, "user_enabled" if body.is_active else "user_disabled", "user", u.id,
|
||||
summary=u.username, detail={"is_active": bool(body.is_active)})
|
||||
db.commit()
|
||||
return u.to_dict()
|
||||
|
||||
|
||||
@app.post("/api/auth/users/{user_id}/role")
|
||||
def set_user_role(user_id: str, body: RoleIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||
"""Change a user's role (admin ↔ user). Admins can do this at any time.
|
||||
Guards: you can't change your own role (avoids self-lockout), and the last
|
||||
remaining admin can't be demoted (keeps the app manageable)."""
|
||||
if body.role not in ("admin", "user"):
|
||||
raise HTTPException(status_code=400, detail="role must be 'admin' or 'user'")
|
||||
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 change your own role")
|
||||
if u.role == "admin" and body.role != "admin":
|
||||
other_admins = db.scalars(
|
||||
select(models.User.id).where(
|
||||
(models.User.role == "admin")
|
||||
& (models.User.id != u.id)
|
||||
& (models.User.is_active.is_(True))
|
||||
)
|
||||
).all()
|
||||
if not other_admins:
|
||||
raise HTTPException(status_code=400, detail="Can't remove the last admin account")
|
||||
old_role = u.role
|
||||
u.role = body.role
|
||||
log_event(db, admin, "role_changed", "user", u.id, summary=u.username,
|
||||
detail={"from": old_role, "to": body.role})
|
||||
db.commit()
|
||||
db.refresh(u)
|
||||
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)
|
||||
@@ -296,6 +486,7 @@ def delete_user(user_id: str, admin: models.User = Depends(auth.require_admin),
|
||||
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")
|
||||
log_event(db, admin, "user_deleted", "user", u.id, summary=u.username, detail={"role": u.role})
|
||||
db.delete(u)
|
||||
db.commit()
|
||||
return {"deleted": user_id}
|
||||
@@ -334,6 +525,7 @@ def set_user_projects(user_id: str, body: ProjectAssignIn, _admin: models.User =
|
||||
# ── Projects ─────────────────────────────────────────────────────────────────
|
||||
@app.post("/api/projects")
|
||||
def upsert_project(body: ProjectIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
check_id(body.id)
|
||||
proj = db.get(models.Project, body.id) if body.id else None
|
||||
is_new = proj is None
|
||||
if not is_new:
|
||||
@@ -349,6 +541,8 @@ def upsert_project(body: ProjectIn, user: models.User = Depends(auth.get_current
|
||||
proj.sample = body.sample
|
||||
proj.created_by = body.created_by or proj.created_by
|
||||
proj.data = body.data
|
||||
log_event(db, user, "created" if is_new else "updated", "project", proj.id,
|
||||
project_id=proj.id, summary=(proj.name or proj.number or proj.id))
|
||||
db.commit()
|
||||
# A project created by a non-admin auto-grants its creator access.
|
||||
if is_new and user.role != "admin":
|
||||
@@ -388,10 +582,12 @@ def delete_project(project_id: str, user: models.User = Depends(auth.get_current
|
||||
# ── SOPs ─────────────────────────────────────────────────────────────────────
|
||||
@app.post("/api/sops")
|
||||
def upsert_sop(body: SopIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
check_id(body.id)
|
||||
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)
|
||||
is_new = sop is None
|
||||
if sop is None:
|
||||
sop = models.Sop(id=body.id or gen_id("sop"))
|
||||
db.add(sop)
|
||||
@@ -401,6 +597,9 @@ def upsert_sop(body: SopIn, user: models.User = Depends(auth.get_current_user),
|
||||
sop.complete = body.complete
|
||||
sop.created_by = body.created_by or sop.created_by
|
||||
sop.data = body.data
|
||||
log_event(db, user, "completed" if body.complete else ("created" if is_new else "updated"),
|
||||
"sop", sop.id, project_id=sop.project_id, summary=(sop.name or sop.number or sop.id),
|
||||
detail={"complete": bool(body.complete)})
|
||||
db.commit()
|
||||
db.refresh(sop)
|
||||
return sop.to_dict()
|
||||
@@ -447,6 +646,8 @@ def delete_sop(sop_id: str, user: models.User = Depends(auth.get_current_user),
|
||||
if not sop:
|
||||
raise HTTPException(status_code=404, detail="SOP not found")
|
||||
require_project_access(db, user, sop.project_id)
|
||||
log_event(db, user, "deleted", "sop", sop.id, project_id=sop.project_id,
|
||||
summary=(sop.name or sop.number or sop.id))
|
||||
db.delete(sop)
|
||||
db.commit()
|
||||
return {"deleted": sop_id}
|
||||
@@ -454,11 +655,17 @@ def delete_sop(sop_id: str, user: models.User = Depends(auth.get_current_user),
|
||||
|
||||
# ── Work Packages ────────────────────────────────────────────────────────────
|
||||
@app.post("/api/wps")
|
||||
def upsert_wp(body: WpIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
def upsert_wp(body: WpIn, background_tasks: BackgroundTasks, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
check_id(body.id)
|
||||
check_id(body.parent_id)
|
||||
check_id(body.assignee_id)
|
||||
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)
|
||||
is_new = wp is None
|
||||
old_status = None if is_new else wp.status
|
||||
old_assignee = None if is_new else wp.assignee_id
|
||||
if wp is None:
|
||||
wp = models.WorkPackage(id=body.id or gen_id("wp"))
|
||||
db.add(wp)
|
||||
@@ -469,19 +676,52 @@ def upsert_wp(body: WpIn, user: models.User = Depends(auth.get_current_user), db
|
||||
wp.subject = body.subject
|
||||
wp.type = body.type
|
||||
wp.status = body.status
|
||||
new_assignee = body.assignee_id or None
|
||||
if new_assignee:
|
||||
require_assignable(db, new_assignee, body.project_id)
|
||||
wp.assignee_id = new_assignee
|
||||
wp.created_by = body.created_by or wp.created_by
|
||||
wp.data = body.data
|
||||
if is_new:
|
||||
_act, _detail = "created", {"status": wp.status}
|
||||
elif old_status != wp.status:
|
||||
_act, _detail = "status_changed", {"from": old_status, "to": wp.status}
|
||||
else:
|
||||
_act, _detail = "updated", {"status": wp.status}
|
||||
log_event(db, user, _act, "wp", wp.id, project_id=wp.project_id,
|
||||
summary=(wp.number or wp.subject or wp.id), detail=_detail)
|
||||
# Notify a newly-assigned owner (skip self-assignment).
|
||||
notif = None
|
||||
if new_assignee and new_assignee != old_assignee and new_assignee != user.id:
|
||||
assignee = db.get(models.User, new_assignee)
|
||||
if assignee:
|
||||
link = wp_link(db, wp)
|
||||
notif = notify.enqueue(
|
||||
db, user=assignee, kind="wp_assigned",
|
||||
subject=f"You were assigned {wp.number or 'a work package'}",
|
||||
body=assign_body(assignee, wp, user, link),
|
||||
link=link, wp_id=wp.id, project_id=wp.project_id,
|
||||
)
|
||||
log_event(db, user, "assigned", "wp", wp.id, project_id=wp.project_id,
|
||||
summary=(wp.number or wp.subject or wp.id), detail={"to": assignee.username})
|
||||
db.commit()
|
||||
db.refresh(wp)
|
||||
if notif is not None:
|
||||
background_tasks.add_task(notify.deliver, notif.id)
|
||||
return wp.to_dict()
|
||||
|
||||
|
||||
@app.get("/api/wps")
|
||||
def list_wps(
|
||||
response: Response,
|
||||
project_id: Optional[str] = Query(None),
|
||||
sop_id: Optional[str] = Query(None),
|
||||
parent_id: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
q: Optional[str] = Query(None, description="search number / subject / type"),
|
||||
archived: str = Query("exclude", description="exclude (default) | only | all"),
|
||||
limit: Optional[int] = Query(None, ge=1, le=1000),
|
||||
offset: int = Query(0, ge=0),
|
||||
full: bool = Query(False),
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
@@ -495,8 +735,25 @@ def list_wps(
|
||||
stmt = stmt.where(models.WorkPackage.parent_id == parent_id)
|
||||
if status:
|
||||
stmt = stmt.where(models.WorkPackage.status == status)
|
||||
if archived == "only":
|
||||
stmt = stmt.where(models.WorkPackage.archived_at.is_not(None))
|
||||
elif archived != "all":
|
||||
stmt = stmt.where(models.WorkPackage.archived_at.is_(None)) # default: hide archived
|
||||
if q and q.strip():
|
||||
like = f"%{q.strip()}%"
|
||||
stmt = stmt.where(
|
||||
models.WorkPackage.number.ilike(like)
|
||||
| models.WorkPackage.subject.ilike(like)
|
||||
| models.WorkPackage.type.ilike(like)
|
||||
)
|
||||
stmt = scope_to_access(stmt, models.WorkPackage.project_id, db, user)
|
||||
rows = db.scalars(stmt.order_by(models.WorkPackage.updated_at.desc())).all()
|
||||
# Report the pre-pagination total so the client can build a pager.
|
||||
total = db.scalar(select(func.count()).select_from(stmt.subquery()))
|
||||
response.headers["X-Total-Count"] = str(total or 0)
|
||||
stmt = stmt.order_by(models.WorkPackage.updated_at.desc()).offset(offset)
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(limit)
|
||||
rows = db.scalars(stmt).all()
|
||||
# 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]
|
||||
@@ -507,7 +764,7 @@ def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] =
|
||||
"""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."""
|
||||
stmt = select(models.WorkPackage)
|
||||
stmt = select(models.WorkPackage).where(models.WorkPackage.archived_at.is_(None))
|
||||
if project_id:
|
||||
stmt = stmt.where(models.WorkPackage.project_id == project_id)
|
||||
if sop_id:
|
||||
@@ -560,6 +817,8 @@ def delete_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db
|
||||
if not wp:
|
||||
raise HTTPException(status_code=404, detail="Work Package not found")
|
||||
require_project_access(db, user, wp.project_id)
|
||||
log_event(db, user, "deleted", "wp", wp.id, project_id=wp.project_id,
|
||||
summary=(wp.number or wp.subject or wp.id))
|
||||
db.delete(wp)
|
||||
db.commit()
|
||||
return {"deleted": wp_id}
|
||||
@@ -579,6 +838,8 @@ def issue_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db:
|
||||
raise HTTPException(status_code=409, detail={"message": "Open constraints block issuance", "open": open_names})
|
||||
wp.status = "Issued"
|
||||
wp.issued_at = models.utcnow()
|
||||
log_event(db, user, "issued", "wp", wp.id, project_id=wp.project_id,
|
||||
summary=(wp.number or wp.subject or wp.id), detail={"to": "Issued"})
|
||||
db.commit()
|
||||
db.refresh(wp)
|
||||
return wp.to_dict()
|
||||
@@ -590,16 +851,147 @@ def set_wp_status(wp_id: str, body: StatusIn, user: models.User = Depends(auth.g
|
||||
if not wp:
|
||||
raise HTTPException(status_code=404, detail="Work Package not found")
|
||||
require_project_access(db, user, wp.project_id)
|
||||
old_status = wp.status
|
||||
wp.status = body.status
|
||||
if body.status == "Issued" and wp.issued_at is None:
|
||||
wp.issued_at = models.utcnow()
|
||||
if old_status != body.status:
|
||||
log_event(db, user, "status_changed", "wp", wp.id, project_id=wp.project_id,
|
||||
summary=(wp.number or wp.subject or wp.id), detail={"from": old_status, "to": body.status})
|
||||
db.commit()
|
||||
db.refresh(wp)
|
||||
return wp.to_dict()
|
||||
|
||||
|
||||
@app.post("/api/wps/{wp_id}/archive")
|
||||
def archive_wp(wp_id: str, body: ArchiveIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
"""Archive (or unarchive) a Work Package — hides it from the default lists and
|
||||
the dashboard without deleting it. Kept for the record on long-running jobs."""
|
||||
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)
|
||||
was_archived = wp.archived_at is not None
|
||||
if body.archived and not was_archived:
|
||||
wp.archived_at = models.utcnow()
|
||||
log_event(db, user, "archived", "wp", wp.id, project_id=wp.project_id,
|
||||
summary=(wp.number or wp.subject or wp.id))
|
||||
elif not body.archived and was_archived:
|
||||
wp.archived_at = None
|
||||
log_event(db, user, "unarchived", "wp", wp.id, project_id=wp.project_id,
|
||||
summary=(wp.number or wp.subject or wp.id))
|
||||
db.commit()
|
||||
db.refresh(wp)
|
||||
return wp.to_dict()
|
||||
|
||||
|
||||
# ── Audit trail (history) ──────────────────────────────────────────────────────
|
||||
@app.get("/api/audit")
|
||||
def list_audit(
|
||||
entity_type: Optional[str] = Query(None),
|
||||
entity_id: Optional[str] = Query(None),
|
||||
project_id: Optional[str] = Query(None),
|
||||
action: Optional[str] = Query(None),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""History / audit trail. Pass entity_type+entity_id for one item's history,
|
||||
or project_id for a project activity feed. Scoped to the caller's project
|
||||
access; admins additionally see project-less events (user management)."""
|
||||
stmt = select(models.AuditLog)
|
||||
if entity_type:
|
||||
stmt = stmt.where(models.AuditLog.entity_type == entity_type)
|
||||
if entity_id:
|
||||
stmt = stmt.where(models.AuditLog.entity_id == entity_id)
|
||||
if action:
|
||||
stmt = stmt.where(models.AuditLog.action == action)
|
||||
if project_id:
|
||||
require_project_access(db, user, project_id)
|
||||
stmt = stmt.where(models.AuditLog.project_id == project_id)
|
||||
# Non-admins only ever see events tied to a project they can access.
|
||||
ids = accessible_project_ids(db, user)
|
||||
if ids is not None:
|
||||
stmt = stmt.where(models.AuditLog.project_id.in_(ids))
|
||||
rows = db.scalars(stmt.order_by(models.AuditLog.at.desc()).limit(limit).offset(offset)).all()
|
||||
return [e.to_dict() for e in rows]
|
||||
|
||||
|
||||
# ── Settings (admin) ────────────────────────────────────────────────────────────
|
||||
@app.get("/api/settings")
|
||||
def get_app_settings(_admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||
return notify.public_settings(db)
|
||||
|
||||
|
||||
@app.put("/api/settings")
|
||||
def put_app_settings(body: SettingsIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||
patch = {k: v for k, v in body.model_dump().items() if v is not None}
|
||||
saved = notify.save_settings(db, patch)
|
||||
log_event(db, admin, "settings_updated", "settings", "notifications",
|
||||
summary="notifications", detail={"email_enabled": bool(saved.get("email_enabled"))})
|
||||
db.commit()
|
||||
return notify.public_settings(db)
|
||||
|
||||
|
||||
@app.post("/api/settings/test-email")
|
||||
def send_test_email(body: TestEmailIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
|
||||
s = notify.get_settings(db)
|
||||
if not notify.smtp_ready(s):
|
||||
raise HTTPException(status_code=400, detail="Set the SMTP host and From address first.")
|
||||
to = (body.to or admin.email or "").strip()
|
||||
if not to:
|
||||
raise HTTPException(status_code=400, detail="No recipient — add an email to your account or pass 'to'.")
|
||||
try:
|
||||
notify.send_email(s, to, "Work Package Suite — test email",
|
||||
"This is a test from the Work Package Suite. If you got this, SMTP is working.")
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise HTTPException(status_code=400, detail=f"Send failed: {e}")
|
||||
return {"ok": True, "to": to}
|
||||
|
||||
|
||||
# ── Notifications + project members ─────────────────────────────────────────────
|
||||
@app.get("/api/notifications")
|
||||
def list_notifications(all: bool = Query(False), limit: int = Query(100, ge=1, le=500),
|
||||
user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
stmt = select(models.Notification)
|
||||
if not (all and user.role == "admin"):
|
||||
stmt = stmt.where(models.Notification.user_id == user.id)
|
||||
rows = db.scalars(stmt.order_by(models.Notification.created_at.desc()).limit(limit)).all()
|
||||
return [n.to_dict() for n in rows]
|
||||
|
||||
|
||||
@app.get("/api/projects/{project_id}/members")
|
||||
def project_members(project_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
"""Users who can be assigned WPs on this project — its members plus admins."""
|
||||
require_project_access(db, user, project_id)
|
||||
member_ids = set(db.scalars(select(models.ProjectMember.user_id).where(models.ProjectMember.project_id == project_id)).all())
|
||||
members = db.scalars(select(models.User).where(models.User.id.in_(member_ids))).all() if member_ids else []
|
||||
admins = db.scalars(select(models.User).where(models.User.role == "admin")).all()
|
||||
out, seen = [], set()
|
||||
for u in list(members) + list(admins):
|
||||
if u.id in seen or not u.is_active:
|
||||
continue
|
||||
seen.add(u.id)
|
||||
out.append({"id": u.id, "username": u.username, "full_name": u.full_name, "email": u.email})
|
||||
out.sort(key=lambda x: (x["full_name"] or x["username"] or "").lower())
|
||||
return out
|
||||
|
||||
|
||||
# ── Comments / feedback ──────────────────────────────────────────────────────
|
||||
def _save_comment(body: CommentIn, db: Session) -> dict:
|
||||
def _save_comment(body: CommentIn, db: Session, user: "models.User") -> dict:
|
||||
# A comment tied to a WP/SOP requires access to that resource's project, so
|
||||
# a user can't write into another project's review thread.
|
||||
if body.wp_id:
|
||||
wp = db.get(models.WorkPackage, body.wp_id)
|
||||
if not wp:
|
||||
raise HTTPException(status_code=404, detail="Work Package not found")
|
||||
require_project_access(db, user, wp.project_id)
|
||||
elif body.sop_id:
|
||||
sop = db.get(models.Sop, body.sop_id)
|
||||
if not sop:
|
||||
raise HTTPException(status_code=404, detail="SOP not found")
|
||||
require_project_access(db, user, sop.project_id)
|
||||
extra = body.model_extra or {}
|
||||
c = models.Comment(
|
||||
id=gen_id("c"),
|
||||
@@ -607,7 +999,9 @@ def _save_comment(body: CommentIn, db: Session) -> dict:
|
||||
sop_id=body.sop_id,
|
||||
wp_id=body.wp_id,
|
||||
step=body.step,
|
||||
author=(body.author or body.name or "Anonymous"),
|
||||
# Attribution comes from the authenticated session, NEVER the client
|
||||
# payload — otherwise comments could be forged as another user.
|
||||
author=(user.full_name or user.username),
|
||||
text=body.text or "",
|
||||
page=body.page or "",
|
||||
extra=extra,
|
||||
@@ -619,14 +1013,14 @@ def _save_comment(body: CommentIn, db: Session) -> dict:
|
||||
|
||||
|
||||
@app.post("/api/comments")
|
||||
def create_comment(body: CommentIn, db: Session = Depends(get_db)):
|
||||
return _save_comment(body, db)
|
||||
def create_comment(body: CommentIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
return _save_comment(body, db, user)
|
||||
|
||||
|
||||
# Alias so the existing client (which posts to /api/feedback) keeps working.
|
||||
@app.post("/api/feedback")
|
||||
def create_feedback(body: CommentIn, db: Session = Depends(get_db)):
|
||||
return _save_comment(body, db)
|
||||
def create_feedback(body: CommentIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
return _save_comment(body, db, user)
|
||||
|
||||
|
||||
@app.get("/api/comments")
|
||||
@@ -635,6 +1029,7 @@ def list_comments(
|
||||
sop_id: Optional[str] = Query(None),
|
||||
wp_id: Optional[str] = Query(None),
|
||||
step: Optional[int] = Query(None),
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
stmt = select(models.Comment)
|
||||
@@ -646,6 +1041,22 @@ def list_comments(
|
||||
stmt = stmt.where(models.Comment.wp_id == wp_id)
|
||||
if step is not None:
|
||||
stmt = stmt.where(models.Comment.step == step)
|
||||
# Non-admins see general app feedback plus comments on WPs/SOPs in their own
|
||||
# projects only — never another project's review threads.
|
||||
ids = accessible_project_ids(db, user)
|
||||
if ids is not None:
|
||||
acc_wp = select(models.WorkPackage.id).where(models.WorkPackage.project_id.in_(ids))
|
||||
acc_sop = select(models.Sop.id).where(models.Sop.project_id.in_(ids))
|
||||
# The "general feedback" branch is ONLY for comments not tied to any
|
||||
# WP/SOP — otherwise a project-scoped comment tagged source=home_feedback
|
||||
# by the client would leak across projects. Project-tied comments are
|
||||
# visible strictly by project membership.
|
||||
stmt = stmt.where(
|
||||
((models.Comment.source == "home_feedback")
|
||||
& models.Comment.wp_id.is_(None) & models.Comment.sop_id.is_(None))
|
||||
| (models.Comment.wp_id.in_(acc_wp))
|
||||
| (models.Comment.sop_id.in_(acc_sop))
|
||||
)
|
||||
rows = db.scalars(stmt.order_by(models.Comment.created_at.desc())).all()
|
||||
return [c.to_dict() for c in rows]
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ from fastapi import Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .db import get_db
|
||||
from .db import get_db, DATABASE_URL
|
||||
from . import models
|
||||
|
||||
log = logging.getLogger("wpsuite.auth")
|
||||
@@ -40,6 +40,29 @@ JWT_ALG = "HS256"
|
||||
# How long a login lasts before the user must sign in again.
|
||||
SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "12"))
|
||||
|
||||
# Password policy (shared by the API and the CLI).
|
||||
MIN_PASSWORD_LEN = int(os.getenv("AUTH_MIN_PASSWORD_LEN", "12"))
|
||||
_COMMON_PASSWORDS = {
|
||||
"password", "password1", "password123", "passw0rd", "12345678", "123456789",
|
||||
"1234567890", "qwerty123", "letmein123", "changeme", "admin123", "welcome123",
|
||||
"iloveyou1", "abc12345", "qwertyuiop",
|
||||
}
|
||||
|
||||
|
||||
def password_problem(pw: str, username: str = "", email: str = "") -> Optional[str]:
|
||||
"""Return a human-readable reason the password is unacceptable, or None if OK.
|
||||
Shared by the API endpoints and the CLI so the policy is enforced everywhere."""
|
||||
if len(pw) < MIN_PASSWORD_LEN:
|
||||
return f"Password must be at least {MIN_PASSWORD_LEN} characters."
|
||||
low = pw.lower()
|
||||
if username and low == username.strip().lower():
|
||||
return "Password must not be the same as the username."
|
||||
if email and low == email.strip().lower():
|
||||
return "Password must not be the same as the email."
|
||||
if low in _COMMON_PASSWORDS:
|
||||
return "That password is too common — choose something less guessable."
|
||||
return None
|
||||
|
||||
# Paths under /api that do NOT require a session (login itself, health, docs).
|
||||
_EXEMPT_PREFIXES = ("/api/auth/",)
|
||||
_EXEMPT_EXACT = {
|
||||
@@ -55,13 +78,24 @@ 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.
|
||||
# No key configured. In production (a real database is configured via
|
||||
# POSTGRES_* / DATABASE_URL) this is FATAL — refuse to start rather than sign
|
||||
# sessions with a throwaway key that silently rotates on every restart. In
|
||||
# local dev (SQLite, no DB env) fall back to an ephemeral key so the app still
|
||||
# runs zero-config.
|
||||
# "Prod" = a real (non-SQLite) database is in use — matches exactly the
|
||||
# condition db.py uses to pick Postgres, so we don't wrongly block a
|
||||
# zero-config SQLite dev run just because a stray POSTGRES_USER is exported.
|
||||
is_prod = not str(DATABASE_URL).startswith("sqlite")
|
||||
if is_prod:
|
||||
raise RuntimeError(
|
||||
"AUTH_SECRET_KEY is not set. Refusing to start in production with an "
|
||||
"ephemeral signing key — set a strong fixed AUTH_SECRET_KEY "
|
||||
"(see server/.env.example / DEPLOYMENT.md)."
|
||||
)
|
||||
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."
|
||||
"AUTH_SECRET_KEY is not set — using a random ephemeral key for local dev. "
|
||||
"Logins reset on restart. Set AUTH_SECRET_KEY for anything non-dev."
|
||||
)
|
||||
return secrets.token_urlsafe(48)
|
||||
|
||||
@@ -92,6 +126,7 @@ def create_token(user: "models.User") -> str:
|
||||
"sub": user.id,
|
||||
"username": user.username,
|
||||
"role": user.role,
|
||||
"ver": user.token_version or 0,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=SESSION_HOURS),
|
||||
}
|
||||
@@ -163,6 +198,10 @@ def get_current_user(request: Request, db: Session = Depends(get_db)) -> "models
|
||||
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")
|
||||
# Session revocation: a mismatch means the token was invalidated (e.g. the
|
||||
# password was changed after this token was issued).
|
||||
if (claims.get("ver", 0) or 0) != (user.token_version or 0):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Session expired")
|
||||
return user
|
||||
|
||||
|
||||
|
||||
@@ -30,15 +30,16 @@ def _gen_id() -> str:
|
||||
return f"user_{uuid.uuid4().hex[:12]}"
|
||||
|
||||
|
||||
def _prompt_password(provided: str | None) -> str:
|
||||
def _prompt_password(provided: str | None, username: str = "") -> 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.")
|
||||
problem = auth.password_problem(pw, username)
|
||||
if problem:
|
||||
sys.exit(problem)
|
||||
return pw
|
||||
|
||||
|
||||
@@ -46,7 +47,7 @@ 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))
|
||||
pw = _prompt_password(getattr(args, "password", None), args.username)
|
||||
with SessionLocal() as db:
|
||||
if auth.find_user(db, args.username):
|
||||
sys.exit(f"A user named '{args.username}' already exists.")
|
||||
@@ -75,7 +76,7 @@ def cmd_list(args) -> None:
|
||||
|
||||
|
||||
def cmd_reset_password(args) -> None:
|
||||
pw = _prompt_password(getattr(args, "password", None))
|
||||
pw = _prompt_password(getattr(args, "password", None), args.username)
|
||||
with SessionLocal() as db:
|
||||
u = auth.find_user(db, args.username)
|
||||
if not u:
|
||||
|
||||
@@ -92,7 +92,13 @@ class WorkPackage(Base):
|
||||
subject: Mapped[str] = mapped_column(String(400), default="")
|
||||
type: Mapped[str] = mapped_column(String(120), default="")
|
||||
status: Mapped[str] = mapped_column(String(40), default="Draft")
|
||||
# The accountable owner (a user id), for "My Work Packages" + assignment
|
||||
# notifications. Free-text `data.assignees`/`distribution` still hold the wider list.
|
||||
assignee_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
|
||||
issued_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
# Archived packages are hidden from the default lists/dashboard but kept for
|
||||
# the record (years-long projects accumulate hundreds of closed WPs).
|
||||
archived_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
data: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
created_by: Mapped[str] = mapped_column(String(200), default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
@@ -102,7 +108,9 @@ class WorkPackage(Base):
|
||||
return {
|
||||
"id": self.id, "project_id": self.project_id, "sop_id": self.sop_id,
|
||||
"parent_id": self.parent_id, "number": self.number, "subject": self.subject,
|
||||
"type": self.type, "status": self.status, "issued_at": _iso(self.issued_at),
|
||||
"type": self.type, "status": self.status, "assignee_id": self.assignee_id,
|
||||
"issued_at": _iso(self.issued_at),
|
||||
"archived_at": _iso(self.archived_at), "archived": self.archived_at is not None,
|
||||
"created_by": self.created_by,
|
||||
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
|
||||
}
|
||||
@@ -127,6 +135,12 @@ class User(Base):
|
||||
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)
|
||||
# Online-guessing throttle (see login()): consecutive failures + a lockout window.
|
||||
failed_attempts: Mapped[int] = mapped_column(Integer, default=0)
|
||||
locked_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
# Bumped to invalidate all existing sessions for this user (e.g. on a password
|
||||
# change). The value is embedded in the JWT and re-checked on every request.
|
||||
token_version: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Public view of a user — NEVER includes the password hash."""
|
||||
@@ -176,5 +190,74 @@ class Comment(Base):
|
||||
}
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
"""Append-only history: who changed what, when. Rows are written inside the
|
||||
same transaction as the change they describe (see server/app.py: log_event),
|
||||
so the trail can't drift from the data. `detail` holds a compact JSON summary
|
||||
of the change, e.g. {"from": "Scheduled", "to": "Issued"}.
|
||||
|
||||
Not a ForeignKey to any entity on purpose — the log must survive the deletion
|
||||
of the thing it describes (you still want "who deleted WP01, and when")."""
|
||||
__tablename__ = "audit_log"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
|
||||
actor: Mapped[str] = mapped_column(String(200), default="") # username who made the change
|
||||
action: Mapped[str] = mapped_column(String(60), default="", index=True) # created | updated | status_changed | issued | role_changed | ...
|
||||
entity_type: Mapped[str] = mapped_column(String(40), default="", index=True) # wp | sop | project | user
|
||||
entity_id: Mapped[str] = mapped_column(String(40), default="", index=True)
|
||||
project_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
|
||||
summary: Mapped[str] = mapped_column(String(400), default="") # human one-liner (e.g. the WP number/subject)
|
||||
detail: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id, "at": _iso(self.at), "actor": self.actor, "action": self.action,
|
||||
"entity_type": self.entity_type, "entity_id": self.entity_id,
|
||||
"project_id": self.project_id, "summary": self.summary, "detail": self.detail or {},
|
||||
}
|
||||
|
||||
|
||||
class AppSetting(Base):
|
||||
"""Admin-editable application settings (feature flags, SMTP config, …) stored
|
||||
as key -> JSON value. Read/written via /api/settings (admin only). Secrets like
|
||||
the SMTP password are NOT stored here — they come from the environment."""
|
||||
__tablename__ = "app_settings"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(80), primary_key=True)
|
||||
value: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||
|
||||
|
||||
class Notification(Base):
|
||||
"""Outbox for user notifications (an in-app record + an optional email). A row
|
||||
is written when something notable happens (e.g. a WP assignment); the email
|
||||
sender processes it only when email notifications are enabled AND SMTP is set —
|
||||
otherwise it's recorded as 'skipped'. See server/notify.py."""
|
||||
__tablename__ = "notifications"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String(40), index=True) # recipient
|
||||
email: Mapped[str] = mapped_column(String(200), default="")
|
||||
kind: Mapped[str] = mapped_column(String(40), default="", index=True) # wp_assigned | …
|
||||
wp_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True)
|
||||
project_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
|
||||
subject: Mapped[str] = mapped_column(String(300), default="")
|
||||
body: Mapped[str] = mapped_column(Text, default="")
|
||||
link: Mapped[str] = mapped_column(String(500), default="")
|
||||
status: Mapped[str] = mapped_column(String(20), default="pending", index=True) # pending|sent|failed|skipped
|
||||
error: Mapped[str] = mapped_column(String(400), default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
|
||||
sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id, "user_id": self.user_id, "email": self.email, "kind": self.kind,
|
||||
"wp_id": self.wp_id, "project_id": self.project_id, "subject": self.subject,
|
||||
"status": self.status, "error": self.error,
|
||||
"created_at": _iso(self.created_at), "sent_at": _iso(self.sent_at),
|
||||
}
|
||||
|
||||
|
||||
def _iso(dt: Optional[datetime]) -> Optional[str]:
|
||||
return dt.isoformat() if dt else None
|
||||
|
||||
134
server/notify.py
Normal file
134
server/notify.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Notifications: admin-configurable email + an outbox.
|
||||
|
||||
Email notifications are OFF by default and controlled from the admin console (a
|
||||
toggle stored in `app_settings`). Even when enabled, mail is only sent if SMTP is
|
||||
configured. The SMTP PASSWORD is read from the `SMTP_PASSWORD` environment variable
|
||||
and is NEVER stored in the database or shown in the UI.
|
||||
|
||||
Every notable event (e.g. a WP assignment) writes a `notifications` row — an in-app
|
||||
record — and, when email is on + SMTP is set, the row is delivered by email in a
|
||||
background task. Notification bodies deliberately avoid customer IP: they carry a WP
|
||||
number and a deep link, not the work-package contents.
|
||||
"""
|
||||
import os
|
||||
import smtplib
|
||||
import uuid
|
||||
import logging
|
||||
from email.message import EmailMessage
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from . import models
|
||||
|
||||
log = logging.getLogger("wpsuite.notify")
|
||||
|
||||
SETTINGS_KEY = "notifications"
|
||||
DEFAULTS = {
|
||||
"email_enabled": False, # master toggle — OFF until SMTP is sorted
|
||||
"smtp_host": "",
|
||||
"smtp_port": 587,
|
||||
"smtp_use_tls": True,
|
||||
"smtp_username": "",
|
||||
"from_addr": "",
|
||||
"from_name": "Work Package Suite",
|
||||
"app_base_url": "", # e.g. https://wp.controls.dev — used to build email links
|
||||
}
|
||||
|
||||
|
||||
def get_settings(db: Session) -> dict:
|
||||
row = db.get(models.AppSetting, SETTINGS_KEY)
|
||||
s = dict(DEFAULTS)
|
||||
if row and row.value:
|
||||
s.update({k: row.value[k] for k in row.value if k in DEFAULTS})
|
||||
return s
|
||||
|
||||
|
||||
def save_settings(db: Session, patch: dict) -> dict:
|
||||
cur = get_settings(db)
|
||||
for k in DEFAULTS:
|
||||
if k in patch and patch[k] is not None:
|
||||
cur[k] = patch[k]
|
||||
row = db.get(models.AppSetting, SETTINGS_KEY)
|
||||
if row:
|
||||
row.value = cur
|
||||
else:
|
||||
db.add(models.AppSetting(key=SETTINGS_KEY, value=cur))
|
||||
db.commit()
|
||||
return cur
|
||||
|
||||
|
||||
def public_settings(db: Session) -> dict:
|
||||
"""Settings safe to return to the admin UI — no secrets."""
|
||||
s = get_settings(db)
|
||||
s["smtp_password_set"] = bool(os.getenv("SMTP_PASSWORD"))
|
||||
return s
|
||||
|
||||
|
||||
def smtp_ready(s: dict) -> bool:
|
||||
return bool(s.get("smtp_host") and s.get("from_addr"))
|
||||
|
||||
|
||||
def send_email(s: dict, to_addr: str, subject: str, body: str) -> None:
|
||||
"""Send one email via SMTP. Raises on any failure (caller records it)."""
|
||||
if not to_addr:
|
||||
raise ValueError("no recipient email")
|
||||
msg = EmailMessage()
|
||||
from_name = s.get("from_name") or ""
|
||||
msg["From"] = f"{from_name} <{s['from_addr']}>" if from_name else s["from_addr"]
|
||||
msg["To"] = to_addr
|
||||
msg["Subject"] = subject
|
||||
msg.set_content(body)
|
||||
host = s["smtp_host"]
|
||||
port = int(s.get("smtp_port") or 587)
|
||||
user = s.get("smtp_username") or ""
|
||||
pw = os.getenv("SMTP_PASSWORD", "")
|
||||
with smtplib.SMTP(host, port, timeout=15) as srv:
|
||||
if s.get("smtp_use_tls", True):
|
||||
srv.starttls()
|
||||
if user:
|
||||
srv.login(user, pw)
|
||||
srv.send_message(msg)
|
||||
|
||||
|
||||
def enqueue(db: Session, *, user: "models.User", kind: str, subject: str, body: str,
|
||||
link: str = "", wp_id: Optional[str] = None, project_id: Optional[str] = None) -> "models.Notification":
|
||||
"""Record a notification. Marked 'pending' only if email is enabled + SMTP ready +
|
||||
the recipient has an email; otherwise 'skipped' (still an in-app record). Does NOT
|
||||
commit — the caller commits with its own transaction. Returns the row."""
|
||||
s = get_settings(db)
|
||||
deliverable = bool(s.get("email_enabled")) and smtp_ready(s) and bool(user.email)
|
||||
n = models.Notification(
|
||||
id="ntf_" + uuid.uuid4().hex[:12],
|
||||
user_id=user.id, email=user.email or "", kind=kind,
|
||||
wp_id=wp_id, project_id=project_id, subject=subject[:300], body=body,
|
||||
link=link[:500], status="pending" if deliverable else "skipped",
|
||||
)
|
||||
db.add(n)
|
||||
return n
|
||||
|
||||
|
||||
def deliver(notif_id: str) -> None:
|
||||
"""Background task: send one pending notification, on its own DB session."""
|
||||
from .db import SessionLocal
|
||||
db = SessionLocal()
|
||||
try:
|
||||
n = db.get(models.Notification, notif_id)
|
||||
if not n or n.status != "pending":
|
||||
return
|
||||
s = get_settings(db)
|
||||
if not (s.get("email_enabled") and smtp_ready(s) and n.email):
|
||||
n.status = "skipped"
|
||||
db.commit()
|
||||
return
|
||||
try:
|
||||
send_email(s, n.email, n.subject, n.body)
|
||||
n.status = "sent"
|
||||
n.sent_at = models.utcnow()
|
||||
except Exception as e: # noqa: BLE001 — record any SMTP failure, don't crash the worker
|
||||
n.status = "failed"
|
||||
n.error = str(e)[:400]
|
||||
log.warning("notification %s failed to send: %s", notif_id, e)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
@@ -1,9 +1,16 @@
|
||||
fastapi>=0.110
|
||||
uvicorn[standard]>=0.29
|
||||
gunicorn>=21.2
|
||||
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
|
||||
# Pinned to exact versions for reproducible builds — no silent dependency drift
|
||||
# on every `docker compose up --build`. To update: bump a version here on purpose,
|
||||
# run `pip-audit` against the result, and test. For supply-chain integrity, the
|
||||
# next step is a hashed lockfile (`pip-compile --generate-hashes` → install with
|
||||
# `pip install --require-hashes`).
|
||||
fastapi==0.138.1
|
||||
uvicorn[standard]==0.49.0
|
||||
gunicorn==26.0.0
|
||||
sqlalchemy==2.0.51
|
||||
alembic==1.18.5 # database migrations
|
||||
psycopg[binary]==3.3.4
|
||||
pydantic==2.13.4
|
||||
python-dotenv==1.2.2
|
||||
bcrypt==5.0.0 # password hashing
|
||||
PyJWT==2.13.0 # signed session tokens
|
||||
starlette==1.3.1 # pinned transitive (cookie / CORS handling — security-relevant)
|
||||
|
||||
Reference in New Issue
Block a user