Archive projects, auto-add default members, rebuild the admin console

Three things asked for together, plus the migration they share (a7c31f9e5b02 —
additive, with database defaults for existing rows, so unlike the users.role
rewrite it is safe under a code-only rollback).

ARCHIVE A PROJECT. A finished job leaves every picker, switcher and search, and
freezes read-only, without losing anything. Hiding is free: GET /api/projects
defaults to archived=exclude, so the home picker and the app-bar switcher drop it
without either of them changing. Freezing is require_project_writable(), which
every write that lands on a project now goes through — SOP and WP upserts (both
ends, so a package can be moved neither into nor out of an archived job), deletes,
issue, status, WP archive, and comments on its WPs/SOPs. It answers 409, not 403:
nobody lacks a permission, the project's state is the objection, and the browser
outbox in project-data.js retires 4xx ops instead of retrying them against a job
that will never accept them. Unarchive and delete stay allowed on purpose —
unarchive is the one write an archived project must take, and archive-then-delete
is a normal sequence.

DEFAULT MEMBERS ON NEW PROJECTS. users.auto_add_projects / auto_add_role flag the
people who belong on every job, so an admin says it once instead of remembering it
at each project creation. It runs on the is_new branch of upsert_project, which is
the single road into project creation, so the home page, the sample project and the
demo seeder are all covered and an update never re-runs it. Note the interaction
with the existing creator-grant: that row commits first and add_default_members
never overwrites an existing membership, so the creator grant now carries the
creator's own auto_add_role — otherwise someone flagged "Project Admin on every
job" would land as a plain member on the one job they started themselves.

ADMIN CONSOLE. The user table had outgrown .wrap{max-width:860px}: nine columns in
an 860px card meant every cell wrapped, so one user occupied a ~100px band, the
action buttons stacked, and the table spilled outside its own white card. Now
1240px, with wide tables scrolling inside .tscroll so the page itself never scrolls
sideways, and one spacing/control scale across all twelve cards. Truncation hangs
off a span inside the cell rather than max-width on the td, which table-layout:auto
treats as advisory — the usual reason cell ellipsis works in the stylesheet and not
on the page.

Found in review and fixed here rather than later:

- Stored XSS in the new Projects card, reachable by any signed-in user, landing in
  an admin's session. The uesc(v).replace(/'/g,"\'") idiom this file already used
  in eight places escapes in the wrong order — uesc leaves backslashes alone, so a
  stored name containing \' closes the JS string literal and the rest executes.
  jsq() does backslash, then quote, then HTML, and all thirteen handler bindings go
  through it. The same bug, unescaped entirely, was in the SOP builder's custom
  constraint names (escHandlerArg there). Three of seven test payloads escaped the
  literal under the old idiom — one of them a plain name ending in a backslash, so
  it was breaking buttons for innocent input too.
- _save_comment resolved wp_id and sop_id with if/elif but stored both, so a
  payload naming a WP you may touch and a SOP you may not was authorised on the WP
  alone and still wrote into the other project's thread. Both are checked now.
- Promoting an account to admin left its default-member flag set but invisible,
  ready to take effect again on demotion — cleared, as set_user_auto_add already
  does for the role.

smoketest.py and the console's own smoke test both assert the archive round trip:
out of the default list, present with archived=all, writes refused with 409, and
all of it undone by unarchiving.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-05 13:48:43 -07:00
parent e5977758c0
commit 928ab8c900
14 changed files with 1092 additions and 136 deletions

View File

@@ -1,7 +1,10 @@
# Deploy runbook — WP Suite, 2026-08-04
# Deploy runbook — WP Suite
**For:** IT / whoever administers the Docker host and Portainer
**From:** n.siegfried@prime-controls.com
**Revised:** 2026-08-05 — **this replaces the 2026-08-04 version.** Same procedure,
but the deploy now carries a second database migration and a new admin screen. If
you already have the earlier copy, work from this one instead.
**Expected duration:** 1015 minutes, including the backup
**Expected downtime:** under a minute, while containers are recreated
@@ -23,15 +26,25 @@ Container names are fixed by the compose file and are the same on every host:
## What this deploy changes
Front-end and nginx changes, plus pending database migrations that run
automatically. Two things make it more than a routine restart:
Front-end and nginx changes, a new admin screen, plus pending database migrations
that run automatically. Three things make it more than a routine restart:
1. **The nginx config and the entire `html/` directory are baked into the
container image at build time.** A plain restart deploys nothing — the stack
must be re-pulled and re-built.
2. **A pending migration rewrites existing rows** in the `users.role` column
(values `user``project_user`). That is why step 1 is a backup and not
optional.
(`b41c7ae90d52`, values `user``project_user`). That is why step 1 is a backup
and not optional. If a previous deploy already applied it, it will not run again —
step 0 tells you which of these you are actually about to run.
3. **A second migration adds new columns** (`a7c31f9e5b02`: an archive timestamp on
projects, and two default-membership fields on users). This one is additive and
has database defaults for existing rows, so it does not rewrite anything.
For the people using the app, the visible changes are: projects can now be
**archived** from the Admin Console (they disappear from the pickers and go
read-only, and can be brought back), certain users can be set to join **every new
project automatically**, and the Admin Console has been rebuilt so the user table
fits on screen.
Migrations run themselves when the `wp_api` container starts. There is nothing
to type and **no new environment variables** — do not change the stack's
@@ -53,6 +66,17 @@ docker inspect wp_api --format 'api image: {{.Image}}'
stack is currently on (Portainer → the stack → the Git reference / last-updated
commit). Without these, rollback is guesswork.
The first command prints the migration the database is currently on. Use it to see
which migrations this deploy will actually run:
| `alembic current` shows | What will run | What that means |
|---|---|---|
| `c93f2b1d7e04` or earlier | both migrations | The `users.role` rewrite is included — the backup in step 1 matters most in this case. |
| `d15b8c4ef207` | only `a7c31f9e5b02` | The `users.role` rewrite already happened on an earlier deploy. This one is additive only. |
| `a7c31f9e5b02` | nothing | The database is already up to date; this is a code-only deploy. |
Take the backup either way.
---
## Step 1 — Back up the database
@@ -113,9 +137,17 @@ docker logs wp_api --tail 40
```
You are looking for Alembic `Running upgrade …` lines followed by gunicorn
starting up, and **no** traceback. The API deliberately refuses to start if a
migration fails, so a restarting `wp_api` container means the migration failed —
go to Rollback.
starting up, and **no** traceback. The last one should end at `a7c31f9e5b02`. The
API deliberately refuses to start if a migration fails, so a restarting `wp_api`
container means the migration failed — go to Rollback.
Confirm the database landed on the new revision:
```bash
docker exec wp_api alembic -c server/alembic.ini current
```
Expected: `a7c31f9e5b02 (head)`.
Then verify nginx's own view of its config:
@@ -160,11 +192,22 @@ Open the site and press **Ctrl+Shift+R** (Cmd+Shift+R on macOS) once. The app
uses a service worker; a normal reload can serve the previous version and make a
good deploy look broken.
Sanity check: log in, and confirm the home page offers to select or create a
project.
Sanity checks — all four should take under a minute:
**Deploy complete.** Please report back: the step 0 output, the backup filename,
and the two header lines from step 4.
1. Log in. The home page offers to select or create a project.
2. Open **Admin Console** (the link is on the home page; you need an admin account).
The user table should read as **one line per user** — if rows are three lines tall
and the table spills outside its white card, you are still on the old cached
files: hard-reload again.
3. Two new cards are present and load: **Projects**, and **Default members on new
projects**. Both should list rows, not an error.
4. In the **Projects** card, click **Archive** on a project you don't mind hiding
(a `DEMO-` one if there is one), confirm the prompt, then tick **Show archived**
it should reappear marked `archived`. Click **Unarchive** to put it back. That
round trip proves the new migration and the new endpoint are both live.
**Deploy complete.** Please report back: the step 0 output (including which
migrations ran), the backup filename, and the two header lines from step 4.
---
@@ -194,10 +237,18 @@ docker logs wp_api --tail 100
```
Send me that output. **Do not restore the database and do not roll the API back
without contacting me first** — if the `users.role` migration already committed,
rolling the API back to the previous image without converting those values back
will break logins. That conversion is a one-line command, but it has to match
what actually ran.
without contacting me first.** Which migration got as far as committing decides what
is safe, and they are not the same:
- **`a7c31f9e5b02`** (the new columns) is additive. If only this one ran, rolling
the API back to the previous image is safe on its own — the old code simply
ignores the extra columns. Nothing needs converting.
- **`b41c7ae90d52`** (the `users.role` rewrite) is not. If that one committed,
rolling the API back without converting those values back **will break logins**.
That conversion is a one-line command, but it has to match what actually ran.
The `alembic current` output from step 0, plus the `Running upgrade …` lines in the
log above, are exactly what tells us which case you are in — please include both.
Reach me at n.siegfried@prime-controls.com.

View File

@@ -209,11 +209,11 @@ users on the same project see the same server-stored SOP and Work Packages.
| Table | Holds | Key columns |
|-------|-------|-------------|
| `projects` | top-level construction projects | `name`, `number`, `client`, `division`, `site`, `sample`, `data` |
| `projects` | top-level construction projects | `name`, `number`, `client`, `division`, `site`, `sample`, `archived_at`, `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`, `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 |
| `users` | login accounts | `username`, `password_hash` (bcrypt), `role`, `full_name`, `email`, `is_active`, `auto_add_projects` + `auto_add_role` (default membership on new projects), 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) |
@@ -224,18 +224,23 @@ column; frequently-listed fields are promoted to real columns for filtering.
### Endpoints (summary)
Projects `GET/POST /api/projects`, `GET/DELETE /api/projects/{id}` ·
Projects `GET/POST /api/projects`, `GET/DELETE /api/projects/{id}`,
`POST /api/projects/{id}/archive` ·
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`, `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`,
under `/api/auth/users` (including `POST /api/auth/users/{id}/auto-add`) ·
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).
List/latest/metrics accept a `project_id` (and `sop_id`) filter. `GET /api/projects`
and `GET /api/wps` both take `archived=exclude|only|all` and **default to
`exclude`** — anything that needs to see archived rows (the admin console, the demo
cleanup) must ask for them. Full reference and request shapes: `/api/docs` and
[`server/README.md`](server/README.md).
---
@@ -416,8 +421,9 @@ not get a second bar.
- Switching project reloads the current page with `?project=<id>`; every page
already resolves its project from that parameter.
- Search calls `GET /api/search?q=`, which is **scoped to the caller's projects**
(`scope_to_access`) and hides archived work packages. LIKE wildcards in the query
are escaped, so searching `100%` matches a literal `100%`. Two-character minimum.
(`scope_to_access`) and hides archived work packages, archived projects, and
anything belonging to an archived project. LIKE wildcards in the query are escaped,
so searching `100%` matches a literal `100%`. Two-character minimum.
- Ctrl/Cmd-K focuses the field from anywhere.
## Schema migrations (Alembic)
@@ -470,6 +476,53 @@ the project's members plus app admins, each with their effective role on that
project. A project with nobody assigned shows only the admins, which is why
assigning people is the first step on a new job.
### Default members on new projects
Memberships are also created automatically. **Admin console → Default members on
new projects** flags accounts (`users.auto_add_projects`) that belong on every job —
the PM who runs them all, the QC lead — with the role they should hold there
(`users.auto_add_role`, sharing `project_members.role`'s value space, `''` =
inherit the account's own).
- It applies **only to projects created after the flag is set**. Nothing is
back-filled onto existing jobs; use **Project access** for those.
- App admins are skipped (they already reach every project) and the flag is cleared
if an account is promoted to admin. Inactive accounts are skipped.
- Runs in `add_default_members()` on the `is_new` branch of `upsert_project`, so it
covers every route into project creation — the home page, the sample project, the
demo seeder. An update never re-runs it.
- If the creator is themselves a flagged member, the membership created for them as
creator carries their `auto_add_role`, so they aren't silently downgraded on the
one job they started.
- Audit-logged once per project as `project_access_granted` with
`detail.reason = "auto_add_projects"`.
## Archiving a project
A finished job is archived rather than deleted: `projects.archived_at`, set from
**Admin console → Projects** (or `POST /api/projects/{id}/archive`, which needs
Project Admin **on that project**, same bar as deleting it).
An archived project is **hidden and frozen**:
- It leaves the home picker, the app-bar switcher and global search, because
`GET /api/projects` defaults to `archived=exclude`.
- It is still readable by id, so a deep link renders it — with a read-only banner
from `wp-chrome.js` — and the admin console still lists it under
`?archived=all`.
- Every write that lands on it is refused with **409** by
`require_project_writable()`: saving a project, SOP or work package, deleting
either, issuing, status changes, WP archiving, and comments on its WPs/SOPs.
Moving a work package *into* or *out of* an archived project is refused too.
409 rather than 403 is deliberate — nobody lacks a permission, the project's state
is the objection, and the browser outbox (`html/project-data.js`) retires 4xx ops
instead of retrying them forever.
- Unarchiving and **deleting** stay allowed: unarchive is the one write an archived
project must accept, and archive-then-delete is a normal sequence.
Nothing is removed, and unarchiving restores all of it. `server/smoketest.py`
asserts the whole round trip.
## Asset freshness (why the app can't run half-updated)
A page must never run against a stylesheet or script from a previous deploy. Three

View File

@@ -14,58 +14,198 @@
<link rel="stylesheet" href="theme-light.css">
<link rel="stylesheet" href="wp-chrome.css">
<style>
/* ══ TOKENS ══════════════════════════════════════════════════════════════
The console is the only page in the suite that is mostly dense tables, so
it carries its own sheet. The palette, the square corners and the type are
Carbon's — the same ones theme-light.css sets — so it still reads as one
product with the rest of the suite. Two scales do all the spacing and all
the control sizing; nothing in here should invent its own. */
: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; }
--muted:#525252; --dim:#8d8d8d; --accent:#0f62fe; --accent-hover:#0353e9; --accent-soft:#edf5ff;
--green:#198038; --green-bg:#defbe6;
--red:#da1e28; --red-bg:#fff1f1; --amber:#8e6a00; --amber-bg:#fdf6dd;
--head-bg:#f4f4f4; --zebra:#fafafa; --row-hover:#eef0f2;
--mono:'IBM Plex Mono','Cascadia Mono',Consolas,monospace;
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:20px; --s6:28px;
--ctl:32px; /* every button / input / select that sits in a form row */
--ctl-sm:26px; } /* every control that sits inside a table cell */
*{ box-sizing:border-box; }
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: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:0; padding:8px 14px; cursor:pointer;
/* ══ PAGE ════════════════════════════════════════════════════════════════
1240px, not 860: the user table is nine columns wide and at 860 it spilled
straight out of its own white card. Wide enough for that table, still a
readable measure for the prose, which is capped separately. */
.wrap{ max-width:1240px; margin:0 auto; padding:var(--s6) var(--s5) 80px; }
h1{ font-size:20px; line-height:1.2; margin:0 0 2px; }
.sub{ color:var(--muted); font-size:13px; line-height:1.5; margin:0 0 var(--s3); max-width:96ch; }
a.home{ color:var(--accent); font-size:13px; text-decoration:none; white-space:nowrap; }
a.home:hover{ text-decoration:underline; }
/* ══ CARDS ═══════════════════════════════════════════════════════════════ */
.card{ background:var(--surface); border:1px solid var(--border); border-radius:0;
padding:var(--s4) var(--s5) var(--s5); margin-bottom:var(--s4); }
/* One card header everywhere: small uppercase accent label on a hairline.
admin.js also emits h2 for sub-sections inside a card (Localization
defaults, Step views, Actions) with an inline margin-top — the same
treatment reads correctly as a divider there, so both get it. */
.card h2{ font-size:12px; font-weight:600; letter-spacing:.08em; text-transform:uppercase;
color:var(--accent); margin:0 0 var(--s3); padding-bottom:var(--s2); border-bottom:1px solid var(--border); }
.wrap code{ font-family:var(--mono); font-size:.92em; background:var(--bg); padding:1px 4px; }
/* ══ CONTROLS ════════════════════════════════════════════════════════════
Every button, input and select in a form row is exactly --ctl tall, so a
toolbar is one clean band instead of a ragged one. */
button{ font:inherit; font-size:13px; font-weight:600; line-height:1; white-space:nowrap;
height:var(--ctl); padding:0 var(--s3); border-radius:0; cursor:pointer;
border:1px solid var(--border-strong); background:#fff; color:var(--text); }
button:hover{ border-color:var(--accent); color:var(--accent); }
button:focus-visible{ outline:2px solid var(--accent); outline-offset:-3px; }
button:disabled, button:disabled:hover{ color:var(--dim); border-color:var(--border); background:#fff; cursor:default; }
button.primary{ background:var(--accent); border-color:var(--accent); color:#fff; }
button.primary:hover{ background:#1e54bb; color:#fff; }
button.primary:hover{ background:var(--accent-hover); border-color:var(--accent-hover); color:#fff; }
button.danger{ border-color:var(--red); color:var(--red); }
button.danger:hover{ background:var(--red-bg); }
.row{ display:flex; gap:10px; flex-wrap:wrap; align-items:center; }
.banner{ padding:10px 14px; border-radius: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: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; }
button.danger:hover{ background:var(--red-bg); border-color:var(--red); color:var(--red); }
.row{ display:flex; gap:var(--s2); flex-wrap:wrap; align-items:center; }
/* The filter / search / button strip at the top of a card. */
.toolbar{ display:flex; gap:var(--s2); flex-wrap:wrap; align-items:center; margin:0 0 var(--s3); }
.toolbar + .banner{ margin-top:0; }
.urow{ display:flex; gap:var(--s2); flex-wrap:wrap; align-items:center; }
/* Checkboxes are excluded: they are drawn by the platform and want none of a
text field's height, padding or border. */
.toolbar input:not([type=checkbox]), .toolbar select,
.urow input:not([type=checkbox]), .urow select{
height:var(--ctl); padding:0 var(--s2); font:inherit; font-size:13px; line-height:normal;
border:1px solid var(--border-strong); border-radius:0; background:#fff; color:var(--text); }
.toolbar select, .urow select{ cursor:pointer; padding-right:var(--s1); }
.toolbar input:focus-visible, .toolbar select:focus-visible,
.urow input:focus-visible, .urow select:focus-visible{ outline:2px solid var(--accent); outline-offset:-2px; }
.toolbar > input{ flex:1 1 240px; min-width:150px; }
.urow input:not([type=checkbox]){ flex:1 1 140px; min-width:0; }
/* Inline checkbox + label, sized to sit on the same line as the buttons. */
.chk{ display:inline-flex; align-items:center; gap:var(--s2); height:var(--ctl); padding:0 var(--s1);
font-size:13px; color:var(--muted); white-space:nowrap; cursor:pointer; }
.chk input{ width:16px; height:16px; margin:0; accent-color:var(--accent); cursor:pointer; }
/* ══ FEEDBACK: banners, notes, console output, key/value ═════════════════ */
.banner{ margin:var(--s3) 0 0; padding:9px var(--s3); border-radius:0; font-size:13px; font-weight:600;
line-height:1.4; border:1px solid var(--border); border-left:3px solid var(--border-strong);
background:var(--surface); color:var(--text); }
.banner.ok{ background:var(--green-bg); color:var(--green); border-color:#a7f0ba; border-left-color:var(--green); }
.banner.bad{ background:var(--red-bg); color:var(--red); border-color:#ffd7d9; border-left-color:var(--red); }
/* These three start life as empty divs that admin.js fills on demand, so they
only earn their gap once they are actually saying something. */
#users-banner:not(:empty), #projects-banner:not(:empty), #defmem-banner:not(:empty){ margin-bottom:var(--s3); }
/* --muted, not --dim: #8d8d8d on white is 3.3:1, under the 4.5:1 floor at 12px,
and #features-box / #settings-box are themselves .note — their primary toggle
labels inherit this colour. */
.note{ font-size:12px; line-height:1.55; color:var(--muted); margin-top:var(--s2); }
.note strong, .note em{ color:var(--text); }
pre.out{ background:#0f1525; color:#d7e0f5; border-radius:0; padding:var(--s3) var(--s4); font-family:var(--mono);
font-size:12px; line-height:1.55; white-space:pre-wrap; max-height:340px; overflow:auto; margin:var(--s3) 0 0; }
pre.out .p{ color:#56d364; font-weight:700; } pre.out .f{ color:#ff7b72; font-weight:700; }
table.kv{ border-collapse:collapse; font-size:13px; margin-top:8px; }
table.kv th{ text-align:left; padding:5px 18px 5px 0; color:var(--muted); font-weight:600; }
table.kv td{ padding:5px 0; font-variant-numeric:tabular-nums; font-weight:700; }
.note{ font-size:12px; color:var(--dim); margin-top:10px; }
.gate-overlay{ position:fixed; inset:0; background:var(--bg); display:flex; align-items:center; justify-content:center; padding:20px; }
.gate-box{ background:var(--surface); border:1px solid var(--border); border-radius: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: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: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: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:#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; }
table.kv{ border-collapse:collapse; font-size:13px; margin-top:var(--s2); }
table.kv th{ text-align:left; padding:var(--s1) var(--s5) var(--s1) 0; color:var(--muted); font-weight:600; white-space:nowrap; }
table.kv td{ padding:var(--s1) 0; font-variant-numeric:tabular-nums; font-weight:700; color:var(--text); }
/* ══ DATA TABLES ═════════════════════════════════════════════════════════
table.users is the name admin.js already emits; table.grid is the same
object under the shared name. One rule set serves both, so existing markup
picks up the dense styling without being rewritten. border-collapse is
separate rather than collapse because a collapsed border does not travel
with a sticky header. */
table.grid, table.users{ width:100%; border-collapse:separate; border-spacing:0;
font-size:13px; color:var(--text); background:var(--surface); }
table.grid th, table.users th{ position:sticky; top:0; z-index:2; background:var(--head-bg);
text-align:left; padding:var(--s2) var(--s3); white-space:nowrap;
font-size:11px; font-weight:600; letter-spacing:.04em; text-transform:uppercase; color:var(--muted);
box-shadow:inset 0 -1px 0 var(--border); }
/* Cells never wrap: a wrapped cell turns one user into a 100px tall band and
the table stops reading as rows. Anything genuinely long truncates (.ell)
or is exempted by name further down. */
table.grid td, table.users td{ padding:var(--s1) var(--s3); border-bottom:1px solid var(--border);
vertical-align:middle; white-space:nowrap; }
table.grid tbody tr:last-child td, table.users tbody tr:last-child td{ border-bottom:none; }
table.grid tbody tr:nth-child(even) td, table.users tbody tr:nth-child(even) td{ background:var(--zebra); }
/* A neutral hover, not --accent-soft: that is .tag.admin's fill, and an "all
projects" pill sitting on its own colour disappears the moment you hover it. */
table.grid tbody tr:hover td, table.users tbody tr:hover td{ background:var(--row-hover); }
/* Truncation has to hang off a block INSIDE the cell. max-width on a <td> is
advisory under table-layout:auto — the cell just grows to fit and the ellipsis
never appears, which is the usual reason this trick looks like it works in the
stylesheet and doesn't on the page. admin.js emits <td class="ell"><span>. */
.ell{ max-width:240px; }
.ell > span{ display:block; max-width:240px; overflow:hidden; text-overflow:ellipsis;
white-space:nowrap; }
/* Every action cell admin.js renders is a .cellactions, and it must not wrap:
unwrapped, the three buttons stack and the row grows fourfold — which is what
the console looked like before this pass. */
.cellactions{ display:flex; flex-wrap:nowrap; align-items:center; gap:var(--s1); white-space:nowrap; }
/* Controls that live in a cell are one step smaller, which is what keeps a
row at ~34px instead of ~100px. .chk is form-row sized by default, so it needs
saying again here or the default-members rows stand 6px taller than the rest. */
button.mini{ height:var(--ctl-sm); padding:0 var(--s2); font-size:12px; }
table.grid td .chk, table.users td .chk{ height:var(--ctl-sm); }
select.role-select{ height:var(--ctl-sm); max-width:170px; padding:0 var(--s1) 0 var(--s2);
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; }
select.role-select.is-admin{ color:var(--accent); border-color:var(--accent); font-weight:600; }
.tag{ display:inline-block; padding:1px 8px; border-radius:11px; font-size:11px; font-weight:600;
line-height:1.55; white-space:nowrap; vertical-align:middle; }
.tag.admin{ background:var(--accent-soft); color:var(--accent); }
.tag.user{ background:#e8e8e8; color:var(--muted); }
.tag.on{ background:var(--green-bg); color:var(--green); }
.tag.off{ background:var(--red-bg); color:var(--red); }
.tag.archived{ background:var(--amber-bg); color:var(--amber); }
.me-tag{ font-size:11px; color:var(--dim); margin-left:6px; white-space:nowrap; }
/* A wide table scrolls inside its own box so the page never scrolls sideways,
and the capped height is what gives the sticky header something to do. Every
container admin.js paints a table into is one, so a sticky header always has
a scrollport of its own rather than sliding up behind the app bar. */
.tscroll, #users-table, #comments-admin, #audit-admin, #notif-box, #usage-admin,
#projects-table, #defmem-table{
overflow:auto; max-height:min(70vh,640px); overscroll-behavior:contain; }
/* If admin.js wraps its table in its own .tscroll, the outer box steps aside
so one table never ends up with two scrollbars. */
#users-table:has(.tscroll), #comments-admin:has(.tscroll), #audit-admin:has(.tscroll),
#notif-box:has(.tscroll), #usage-admin:has(.tscroll), #projects-table:has(.tscroll),
#defmem-table:has(.tscroll){
overflow:visible; max-height:none; }
/* Column exceptions, addressed by card because admin.js emits these tables
without per-cell classes. Email is the one user cell long enough to stretch
a row, so it truncates; comment text and audit detail are the two columns
you are actually here to read, so they wrap inside a sane width instead. */
#users-table table.users td:nth-child(3){ max-width:230px; overflow:hidden; text-overflow:ellipsis; }
#comments-admin table.users td:nth-child(5){ white-space:normal; min-width:260px; max-width:640px; }
#audit-admin table.users td:nth-child(6){ white-space:normal; max-width:420px; }
/* ══ GATES & WARNINGS ════════════════════════════════════════════════════ */
.gate-overlay{ position:fixed; inset:0; background:var(--bg); display:flex; align-items:center; justify-content:center; padding:var(--s5); z-index:9999; }
.gate-box{ background:var(--surface); border:1px solid var(--border); border-radius:0; padding:var(--s6); max-width:380px; width:100%; box-shadow:0 8px 30px rgba(20,30,50,.12); }
.gate-box h2{ margin:0 0 var(--s1); padding:0; border:0; font-size:17px; text-transform:none; letter-spacing:0; color:var(--text); }
.gate-box p{ color:var(--muted); font-size:13px; margin:0 0 var(--s4); }
.gate-box input{ width:100%; height:var(--ctl); padding:0 var(--s3); font:inherit; font-size:14px; border:1px solid var(--border-strong); border-radius:0; margin-bottom:var(--s3); }
.gate-msg{ color:var(--red); font-size:12px; min-height:16px; margin-bottom:var(--s2); }
.secwarn{ background:var(--amber-bg); color:var(--amber); border:1px solid var(--amber); border-radius:0; padding:9px 13px; font-size:12px; margin-bottom:var(--s4); }
/* The denial notice is a sentence, not a table — don't stretch it to 1240px. */
#admin-denied .card{ max-width:560px; }
/* ══ NARROW SCREENS ══════════════════════════════════════════════════════
The page itself must never scroll sideways; the wide tables scroll inside
their own box instead, and there they get the full page height to do it. */
@media (max-width:900px){
.wrap{ padding:var(--s4) var(--s3) 60px; }
.card{ padding:var(--s3) var(--s4) var(--s4); }
.toolbar > input{ flex:1 1 100%; }
.tscroll, #users-table, #comments-admin, #audit-admin, #notif-box, #usage-admin,
#projects-table, #defmem-table{ max-height:none; }
}
@media (max-width:620px){
.urow input, .urow select, .urow button{ flex:1 1 100%; }
}
</style>
</head>
<body>
@@ -82,34 +222,34 @@
<div class="wrap" id="admin-denied" style="display:none">
<div class="card">
<h2>Admins only</h2>
<p class="sub" style="margin:0 0 12px">Your account doesnt have admin access. Sign in with an admin account, or ask an administrator to grant you the admin role.</p>
<p class="sub">Your account doesnt have admin access. Sign in with an admin account, or ask an administrator to grant you the admin role.</p>
<div class="row"><a class="home" href="index.html">← Back to site</a> <button onclick="wpLogout()">Sign out</button></div>
</div>
</div>
<!-- CONSOLE -->
<div class="wrap" id="admin-main" style="display:none">
<div class="row" style="justify-content:space-between">
<div><h1>Admin Console</h1><div class="sub">Stack diagnostics &amp; tests · talks to <code>/api</code> on this host</div></div>
<div class="row" style="justify-content:space-between; margin-bottom:var(--s5)">
<div><h1>Admin Console</h1><div class="sub" style="margin:0">Stack diagnostics &amp; tests · talks to <code>/api</code> on this host</div></div>
<div class="row"><a class="home" href="index.html">← Site</a></div>
</div>
<!-- CONNECTIVITY -->
<div class="card">
<h2>API connectivity</h2>
<div class="row"><button class="primary" onclick="checkHealth()">Check /api/health</button></div>
<div class="toolbar"><button class="primary" onclick="checkHealth()">Check /api/health</button></div>
<div class="banner" id="health-banner"></div>
</div>
<!-- USER ADMINISTRATION -->
<div class="card">
<h2>User administration</h2>
<div class="sub" style="margin-bottom:10px">Login accounts for the portal. Requires an <strong>admin</strong> role on your own account.</div>
<div class="row"><button onclick="loadUsers()">Refresh users</button></div>
<div class="sub">Login accounts for the portal. Requires an <strong>admin</strong> role on your own account.</div>
<div class="toolbar"><button onclick="loadUsers()">Refresh users</button></div>
<div id="users-banner"></div>
<div id="users-table" style="margin-top:12px"></div>
<div id="users-table"></div>
<h2 style="margin-top:22px">Add a user</h2>
<h2 style="margin-top:var(--s6)">Add a user</h2>
<div class="urow">
<input id="nu-username" placeholder="Username *" autocomplete="off">
<input id="nu-fullname" placeholder="Full name" autocomplete="off">
@@ -126,17 +266,44 @@
<div id="users-create-msg" class="note"></div>
</div>
<!-- PROJECTS (ARCHIVE / UNARCHIVE) -->
<div class="card">
<h2>Projects</h2>
<div class="sub">Archiving a project hides it from every picker, switcher and search, and freezes it
read-only — nothing is deleted and every work package, SOP and comment is kept exactly as it is.
Unarchive here to bring it back; the project returns unchanged.</div>
<div class="toolbar">
<button onclick="loadProjects()">Refresh projects</button>
<label class="chk"><input type="checkbox" id="proj-show-archived" onchange="renderProjects()"> Show archived</label>
<input id="proj-search" placeholder="Search name / number / client…" oninput="renderProjects()">
</div>
<div id="projects-banner"></div>
<div id="projects-table"><div class="note">Click “Refresh projects” to load.</div></div>
</div>
<!-- DEFAULT MEMBERS ON NEW PROJECTS -->
<div class="card">
<h2>Default members on new projects</h2>
<div class="sub">Everyone flagged here is added automatically to every project created from now on,
with the role chosen here. It does not touch projects that already exist — for those, use
<strong>Project access</strong> in the user table above. Administrators are listed with nothing
to set: they already reach every project.</div>
<div class="toolbar"><button onclick="loadDefaultMembers()">Refresh</button></div>
<div id="defmem-banner"></div>
<div id="defmem-table"><div class="note">Click “Refresh” to load.</div></div>
</div>
<!-- FEATURE FLAGS -->
<div class="card">
<h2>Features</h2>
<div class="sub" style="margin-bottom:10px">Switches that change what the suite offers on every project.</div>
<div class="sub">Switches that change what the suite offers on every project.</div>
<div id="features-box" class="note">Loading…</div>
</div>
<!-- NOTIFICATIONS / EMAIL -->
<div class="card">
<h2>Notifications &amp; email</h2>
<div class="sub" style="margin-bottom:10px">Email notifications for work-package assignments, and self-service password resets. <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 class="sub">Email notifications for work-package assignments, and self-service password resets. <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>
@@ -144,20 +311,20 @@
<!-- ALL FEEDBACK / COMMENTS -->
<div class="card">
<h2>All feedback &amp; comments</h2>
<div class="sub" style="margin-bottom:10px">Every comment submitted across the suite — who wrote it, what they said, and where they were (page &amp; step) when they commented.</div>
<div class="row">
<div class="sub">Every comment submitted across the suite — who wrote it, what they said, and where they were (page &amp; step) when they commented.</div>
<div class="toolbar">
<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:0;">
<input id="cmt-search" placeholder="Search text / author…" oninput="renderComments()">
</div>
<div id="comments-admin" class="note" style="margin-top:12px">Click refresh to load.</div>
<div id="comments-admin" class="note">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">
<div class="sub">Who changed what, and when — across projects, SOPs, work packages, and user accounts. Stored server-side in the shared database.</div>
<div class="toolbar">
<button onclick="loadAudit()">Refresh</button>
<select id="audit-type" onchange="renderAudit()">
<option value="">All types</option>
@@ -166,42 +333,42 @@
<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;">
<input id="audit-search" placeholder="Search actor / action / item…" oninput="renderAudit()">
</div>
<div id="audit-admin" class="note" style="margin-top:12px">Click refresh to load.</div>
<div id="audit-admin" class="note">Click refresh to load.</div>
</div>
<!-- USAGE LOGS -->
<div class="card">
<h2>Usage logs</h2>
<div class="sub" style="margin-bottom:10px">Engagement recorded by the suite — sessions, step views, and actions. Note: stored locally per browser, so this reflects activity on <strong>this</strong> machine.</div>
<div class="row">
<div class="sub">Engagement recorded by the suite — sessions, step views, and actions. Note: stored locally per browser, so this reflects activity on <strong>this</strong> machine.</div>
<div class="toolbar">
<button onclick="loadUsage()">Refresh</button>
<button onclick="downloadUsage()">Download JSON</button>
</div>
<div id="usage-admin" class="note" style="margin-top:12px">Click refresh to load.</div>
<div id="usage-admin" class="note">Click refresh to load.</div>
</div>
<!-- DB SNAPSHOT -->
<div class="card">
<h2>Database snapshot</h2>
<div class="row"><button onclick="snapshot()">Refresh counts</button></div>
<div class="toolbar"><button onclick="snapshot()">Refresh counts</button></div>
<div id="snapshot-out" class="note">Click refresh to read row counts from SQL via the API.</div>
</div>
<!-- SMOKE TEST -->
<div class="card">
<h2>End-to-end smoke test</h2>
<div class="sub" style="margin-bottom:8px">Creates a throwaway project, exercises the issue gate / status / metrics / comments, then deletes it (cascade). Mirrors <code>server/smoketest.py</code>.</div>
<div class="row"><button class="primary" onclick="runSmokeTest()">Run smoke test</button></div>
<div class="sub">Creates a throwaway project, exercises the issue gate / status / metrics / comments, then deletes it (cascade). Mirrors <code>server/smoketest.py</code>.</div>
<div class="toolbar"><button class="primary" onclick="runSmokeTest()">Run smoke test</button></div>
<pre class="out" id="smoke-out">Ready.</pre>
</div>
<!-- DEMO DATA -->
<div class="card">
<h2>Demo data</h2>
<div class="sub" style="margin-bottom:8px">Seed a realistic <code>DEMO</code> project (SOP + a spread of Work Packages) into SQL, or remove all <code>DEMO-</code>/<code>SMOKE-</code> projects.</div>
<div class="row">
<div class="sub">Seed a realistic <code>DEMO</code> project (SOP + a spread of Work Packages) into SQL, or remove all <code>DEMO-</code>/<code>SMOKE-</code> projects.</div>
<div class="toolbar">
<button class="primary" onclick="seedDemo()">Seed demo project</button>
<button class="danger" onclick="cleanDemo()">Clean DEMO / SMOKE projects</button>
</div>

View File

@@ -12,6 +12,8 @@ function reveal(){
fillProjectRoleOptions();
checkHealth();
loadUsers();
loadProjects();
loadDefaultMembers();
loadSettings();
loadNotifications();
loadComments();
@@ -53,15 +55,18 @@ async function checkHealth(){
// ── db snapshot ───────────────────────────────────────────────────────────────
async function snapshot(){
const out = document.getElementById('snapshot-out'); out.textContent='Loading…';
// archived=all: /api/projects now hides archived projects by default, and a row
// count that silently drops them is not a snapshot of the database.
const [p,s,w,c] = await Promise.all([
api('GET','/api/projects'), api('GET','/api/sops'),
api('GET','/api/projects?archived=all'), api('GET','/api/sops'),
api('GET','/api/wps'), api('GET','/api/comments')]);
if(p.status!==200){
out.innerHTML = `<div class="banner bad">API not reachable (HTTP ${p.status}). Fix /api/ routing first.</div>`; return;
}
const n = r => Array.isArray(r.json) ? r.json.length : ('err '+r.status);
const archived = Array.isArray(p.json) ? p.json.filter(x => x && x.archived).length : 0;
out.innerHTML = `<table class="kv">
<tr><th>Projects</th><td>${n(p)}</td></tr>
<tr><th>Projects</th><td>${n(p)}${archived ? ` <span class="note">(${archived} archived)</span>` : ''}</td></tr>
<tr><th>SOPs</th><td>${n(s)}</td></tr>
<tr><th>Work Packages</th><td>${n(w)}</td></tr>
<tr><th>Comments</th><td>${n(c)}</td></tr></table>`;
@@ -95,6 +100,14 @@ async function runSmokeTest(){
r = await api('GET','/api/wps/metrics?project_id='+pid); chk('metrics aggregate', r.status===200 && r.json && r.json.total>=1, JSON.stringify(r.json));
r = await api('POST','/api/feedback',{type:'wp_review_comment',name:'admin-console',wp_id:wid,text:'SMOKE TEST comment — safe to delete'}); chk('post comment', r.status===200 && !!(r.json && r.json.id));
r = await api('GET','/api/wps?project_id='+pid); chk('list WPs by project', r.status===200 && r.json.some(w=>w.id===wid));
// Archive round-trip: out of the default list, still there with archived=all,
// frozen against writes, and all three undone by unarchiving.
r = await api('POST','/api/projects/'+pid+'/archive',{archived:true}); chk('archive project', r.status===200 && r.json.archived===true, 'status '+r.status);
r = await api('GET','/api/projects'); chk('archived project leaves the default list', r.status===200 && !r.json.some(p=>p.id===pid));
r = await api('GET','/api/projects?archived=all'); chk('archived project visible with archived=all', r.status===200 && r.json.some(p=>p.id===pid));
r = await api('POST','/api/wps',{id:wid,project_id:pid,sop_id:sid,number:'WP01-SMOKE',subject:'edited while archived',type:'Conduit Install',status:'Scheduled',data:{disciplines:['Electrical'],hours:'40'}});
chk('write to an archived project refused (409)', r.status===409, 'status '+r.status);
r = await api('POST','/api/projects/'+pid+'/archive',{archived:false}); chk('unarchive project', r.status===200 && r.json.archived===false, 'status '+r.status);
} catch(e){ chk('unexpected error', false, String(e)); }
finally {
if(pid){ const r=await api('DELETE','/api/projects/'+pid); chk('cleanup — delete project (cascades SOP+WPs)', r.status===200, 'status '+r.status); }
@@ -141,7 +154,9 @@ async function seedDemo(){
async function cleanDemo(){
if(!confirm('Delete ALL projects whose number starts with DEMO- or SMOKE- (and their SOPs/WPs via cascade)?')) return;
const o=document.getElementById('demo-out'); o.innerHTML='';
const r = await api('GET','/api/projects');
// archived=all, or an archived DEMO-/SMOKE- project becomes unreachable from
// this button — the default list hides it and nothing else here can delete it.
const r = await api('GET','/api/projects?archived=all');
if(r.status!==200){ demoLog('❌ API unreachable (HTTP '+r.status+').'); return; }
const targets=(r.json||[]).filter(p=>/^(DEMO-|SMOKE-)/.test(String(p.number||'')));
if(!targets.length){ demoLog('Nothing to remove.'); return; }
@@ -153,6 +168,22 @@ async function cleanDemo(){
// ── user administration ────────────────────────────────────────────────────────
function uesc(v){ return v==null ? '' : String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
// A value bound into an inline handler — onclick="fn('…')" — is escaped TWICE: once
// for the JS string literal it lands in, and again for the HTML attribute carrying
// it. The order is the whole point. Escape the backslashes FIRST, then the quotes,
// then hand the result to uesc: uesc leaves \ and ' alone, so the JS escaping
// survives, and the browser decodes the entities before the JS parser runs.
//
// Doing it the other way round — uesc(v).replace(/'/g,"\\'") — silently fails on a
// value containing a backslash: the \ we add is itself escaped by the stored one,
// the quote closes the literal, and everything after it runs as code. Project names
// and full names are free text that any signed-in user can write, so that is a real
// path from a project_user to whatever an admin's session can do. Use jsq() for
// EVERY value that lands inside an inline handler.
function jsq(v){
return uesc(String(v==null ? '' : v).replace(/\\/g,'\\\\').replace(/'/g,"\\'"));
}
async function currentUserId(){
if(window.WP_USER && window.WP_USER.id) return window.WP_USER.id;
const { status, json } = await api('GET','/api/auth/me');
@@ -206,7 +237,7 @@ function fillProjectRoleOptions(){
let _userProjectCounts = {}; // user id -> number of assigned projects
function projAccessCell(u){
const uname = uesc(u.username).replace(/'/g, "\\'");
const uname = jsq(u.username);
if(normRole(u.role) === 'admin'){
return '<span class="tag admin" title="Admins can access every project">all projects</span>';
}
@@ -214,7 +245,7 @@ function projAccessCell(u){
const label = (n === undefined) ? 'Projects…'
: (n === 0 ? 'No projects yet' : n + ' project' + (n === 1 ? '' : 's'));
return '<button class="mini' + (n === 0 ? ' danger' : '') +
'" onclick="manageProjects(\'' + u.id + '\',\'' + uname + '\')"' +
'" onclick="manageProjects(\'' + jsq(u.id) + '\',\'' + uname + '\')"' +
' title="Choose which projects this user can access, and their role on each">' +
label + '</button>';
}
@@ -245,19 +276,20 @@ function renderUsers(list, meId){
const active = u.is_active;
const disableBtn = me
? '<button class="mini" disabled title="You cant disable yourself">—</button>'
: '<button class="mini" onclick="toggleActive(\''+u.id+'\','+(!active)+')">'+(active?'Disable':'Enable')+'</button>';
: '<button class="mini" onclick="toggleActive(\''+jsq(u.id)+'\','+(!active)+')">'+(active?'Disable':'Enable')+'</button>';
const delBtn = me
? ''
: '<button class="mini danger" onclick="deleteUser(\''+u.id+'\',\''+uesc(u.username).replace(/'/g,"\\'")+'\')">Delete</button>';
: '<button class="mini danger" onclick="deleteUser(\''+jsq(u.id)+'\',\''+jsq(u.username)+'\')">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 escUname = jsq(u.username);
const escUid = jsq(u.id);
// PERMISSIONS role — what the account may do. Your own row is locked (shown as
// a tag) so an admin can't accidentally demote themselves.
const role = normRole(u.role);
const roleCell = me
? '<span class="tag '+(role==='admin'?'admin':'user')+'">'+uesc(PERM_LABELS[role]||role)+'</span><span class="me-tag">locked</span>'
: '<select class="role-select'+(role==='admin'?' is-admin':'')+'" title="Change what this account may do" onchange="changeRole(\''+u.id+'\',this.value,\''+escUname+'\')">'+
: '<select class="role-select'+(role==='admin'?' is-admin':'')+'" title="Change what this account may do" onchange="changeRole(\''+escUid+'\',this.value,\''+escUname+'\')">'+
PERM_ROLES.map(function(r){
return '<option value="'+r+'"'+(role===r?' selected':'')+'>'+uesc(PERM_LABELS[r])+'</option>';
}).join('')+
@@ -265,7 +297,7 @@ function renderUsers(list, meId){
// PROJECT role — the person's job function. Descriptive only; grants nothing.
const pr = u.project_role || '';
const projRoleCell =
'<select class="role-select" title="Job function on the project" onchange="changeProjectRole(\''+u.id+'\',this.value,\''+escUname+'\')">'+
'<select class="role-select" title="Job function on the project" onchange="changeProjectRole(\''+escUid+'\',this.value,\''+escUname+'\')">'+
'<option value=""'+(pr?'':' selected')+'>— none —</option>'+
PROJECT_ROLES.map(function(r){
return '<option value="'+uesc(r)+'"'+(pr===r?' selected':'')+'>'+uesc(r)+'</option>';
@@ -276,25 +308,29 @@ function renderUsers(list, meId){
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>'+
// The address is truncated with the full value on the title: a long one used
// to wrap mid-word and push the whole row onto three lines.
'<td class="ell" title="'+uesc(u.email||'')+'"><span>'+uesc(u.email||'')+'</span></td>'+
'<td>'+roleCell+'</td>'+
'<td>'+projRoleCell+'</td>'+
'<td>'+projAccessCell(u)+'</td>'+
'<td><div class="cellactions">'+projAccessCell(u)+'</div></td>'+
'<td><span class="tag '+(active?'on':'off')+'">'+(active?'active':'disabled')+'</span></td>'+
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(u.last_login_at)+'</td>'+
'<td style="white-space:nowrap"><div class="row" style="gap:6px">'+
'<button class="mini" onclick="resetPw(\''+u.id+'\',\''+uesc(u.username).replace(/'/g,"\\'")+'\')">Reset password</button>'+
'<td><div class="cellactions">'+
'<button class="mini" onclick="resetPw(\''+escUid+'\',\''+escUname+'\')">Reset password</button>'+
disableBtn+delBtn+
'</div></td>'+
'</tr>';
}).join('');
wrap.innerHTML='<table class="users"><thead><tr>'+
// Nine columns outrun even the widened card, so the table scrolls inside .tscroll
// rather than forcing every cell to wrap. The explanatory note stays outside it.
wrap.innerHTML='<div class="tscroll"><table class="users grid"><thead><tr>'+
'<th>Username</th><th>Name</th><th>Email</th>'+
'<th title="What this account may do in the app">Permissions</th>'+
'<th title="Job function on the project — descriptive only">Project role</th>'+
'<th title="Which projects this user can access, and their role on each">Project access</th>'+
'<th>Status</th><th>Last login</th><th>Actions</th>'+
'</tr></thead><tbody>'+rows+'</tbody></table>'+
'</tr></thead><tbody>'+rows+'</tbody></table></div>'+
'<div class="note" style="margin-top:10px"><strong>Permissions</strong> — '+
'<em>Administrator</em>: manages users, settings and every project. '+
'<em>Project Admin</em>: on their assigned projects, may delete work packages, '+
@@ -383,6 +419,10 @@ function openProjectModal(userId, username, projects, assigned, userObj, roles){
roles = roles || {};
// Each project row: access tick + the role ON THAT project. "Same as account"
// inherits the account's Permissions, so the common case needs no thought.
// Live jobs first — an archived one is still listed (an existing assignment has to
// stay removable) but it is finished work, so it doesn't belong at the top of a
// list you're using to staff someone.
projects = projects.slice().sort((a,b) => (a.archived?1:0) - (b.archived?1:0));
const items = projects.length ? projects.map(p => {
const on = assigned.has(p.id);
const cur = roles[p.id] || '';
@@ -397,7 +437,8 @@ function openProjectModal(userId, username, projects, assigned, userObj, roles){
'<input type="checkbox" value="'+uesc(p.id)+'"'+(on?' checked':'')+(isAdmin?' disabled':'')+
' onchange="projRowToggled(this)">'+
'<span style="overflow:hidden;text-overflow:ellipsis;"><strong>'+uesc(p.name||'(unnamed)')+'</strong>'+
(p.number?' <span style="color:var(--muted)">'+uesc(p.number)+'</span>':'')+'</span>'+
(p.number?' <span style="color:var(--muted)">'+uesc(p.number)+'</span>':'')+
(p.archived?' <span class="tag archived" title="Archived — read-only until an admin unarchives it">archived</span>':'')+'</span>'+
'</label>'+ sel +
'</div>';
}).join('') : '<div class="note">No projects exist yet.</div>';
@@ -435,6 +476,223 @@ function openProjectModal(userId, username, projects, assigned, userObj, roles){
};
}
// ── projects: archive / unarchive ───────────────────────────────────────────────
// Archiving is the answer to "this job is over but I can't throw the data away".
// An archived project disappears from every picker, switcher and search in the
// suite and is frozen read-only; nothing is deleted. That makes this card the ONLY
// place an archived project is still visible, so it asks for archived=all and does
// the hiding itself — otherwise an admin could never find one to unarchive.
let _adminProjects = [];
async function loadProjects(){
const banner=document.getElementById('projects-banner');
const wrap=document.getElementById('projects-table');
if(!banner || !wrap) return;
banner.className='banner'; banner.textContent='Loading…'; banner.style.display='';
const { status, json } = await api('GET','/api/projects?archived=all');
if(status===403){
banner.className='banner bad';
banner.textContent='❌ Your account is not an admin, so you cant archive or delete projects here.';
wrap.innerHTML=''; return;
}
if(status===401){
banner.className='banner bad'; banner.textContent='❌ Not signed in. Reload and log in again.'; wrap.innerHTML=''; return;
}
if(status!==200 || !Array.isArray(json)){
banner.className='banner bad'; banner.textContent='❌ Could not load projects (HTTP '+status+').'; wrap.innerHTML=''; return;
}
banner.style.display='none';
_adminProjects = json;
renderProjects();
}
function renderProjects(){
const wrap=document.getElementById('projects-table');
if(!wrap) return;
const showArchived = !!(document.getElementById('proj-show-archived')||{}).checked;
const q = (((document.getElementById('proj-search')||{}).value)||'').trim().toLowerCase();
const total = _adminProjects.length;
if(!total){ wrap.innerHTML='<div class="note">No projects yet.</div>'; return; }
const list = _adminProjects.filter(p => {
if(!showArchived && p.archived) return false;
if(!q) return true;
return ((p.name||'')+' '+(p.number||'')+' '+(p.client||'')+' '+(p.site||'')).toLowerCase().indexOf(q) >= 0;
});
const count = '<div class="note">'+list.length+' of '+total+' project'+(total===1?'':'s')+
(showArchived ? '' : ' <span title="Tick “Show archived” to include them">· archived hidden</span>')+'</div>';
if(!list.length){
wrap.innerHTML = count + '<div class="note">Nothing matches'+
(showArchived ? '' : ' — archived projects are hidden. Tick “Show archived” to include them')+'.</div>';
return;
}
const fmt = s => s ? wpFormatDateTime(s) : '—';
const rows = list.map(p => {
// Project names are free text written by whoever created the job — jsq(), not
// uesc(), is what makes them safe to bind into the handlers below.
const pid = jsq(p.id);
const pname = jsq(p.name||'(unnamed)');
const arch = !!p.archived;
return '<tr>'+
'<td><strong>'+uesc(p.name||'(unnamed)')+'</strong>'+
(p.number ? ' <span class="note">'+uesc(p.number)+'</span>' : '')+'</td>'+
'<td class="ell" title="'+uesc(p.client||'')+'"><span>'+uesc(p.client||'—')+'</span></td>'+
'<td class="ell" title="'+uesc(p.site||'')+'"><span>'+uesc(p.site||'—')+'</span></td>'+
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(p.created_at)+'</td>'+
'<td>'+(arch
? '<span class="tag archived" title="Hidden everywhere and read-only until unarchived">archived</span>'
: '<span class="tag on">active</span>')+'</td>'+
'<td><div class="cellactions">'+
'<button class="mini" onclick="archiveProject(\''+pid+'\',\''+pname+'\','+(arch?'false':'true')+')">'+
(arch?'Unarchive':'Archive')+'</button>'+
'<button class="mini danger" onclick="deleteProjectAdmin(\''+pid+'\',\''+pname+'\')">Delete</button>'+
'</div></td>'+
'</tr>';
}).join('');
wrap.innerHTML = count +
'<div class="tscroll"><table class="grid"><thead><tr>'+
'<th>Project</th><th>Client</th><th>Site</th><th>Created</th><th>Status</th><th>Actions</th>'+
'</tr></thead><tbody>'+rows+'</tbody></table></div>'+
'<div class="note"><strong>Delete</strong> is not archive: it removes the project, its SOP and every '+
'work package on it for good. Archive first if there is any doubt.</div>';
}
// Both directions are explained in full before anything happens: archiving makes a
// project vanish for everyone else in the company, and there is no undo prompt on
// the other side of that.
async function archiveProject(id, name, archived){
const ask = archived
? 'Archive “'+name+'”?\n\n'+
'• It disappears from every project picker, switcher and search across the suite.\n'+
'• It becomes read-only — nobody can add or change its SOP or work packages.\n'+
'• Nothing is deleted. Unarchive here at any time to bring it back.'
: 'Unarchive “'+name+'”?\n\n'+
'It becomes visible in the pickers again and can be edited as normal.';
if(!confirm(ask)) return;
const { status, json } = await api('POST','/api/projects/'+id+'/archive',{archived:!!archived});
if(status===200) loadProjects();
else alert('Could not '+(archived?'archive':'unarchive')+' '+name+': '+((json && json.detail)||('HTTP '+status)));
}
// Named deleteProjectAdmin, not deleteProject: every function in this file is a
// global shared with the other scripts the page loads, and "deleteProject" is broad
// enough to collide with one of them later. The -Admin suffix also says which of the
// two project deletions this is — the console's, not a project member's.
async function deleteProjectAdmin(id, name){
if(!confirm('DELETE “'+name+'” permanently?\n\n'+
'Its SOP, EVERY work package on it and every access assignment are deleted with it '+
'(database cascade). This cannot be undone.\n\n'+
'If you only want it out of the way, cancel and use Archive instead.')) return;
const { status, json } = await api('DELETE','/api/projects/'+id);
if(status===200) loadProjects();
else alert('Could not delete '+name+': '+((json && json.detail)||('HTTP '+status)));
}
// ── default members on new projects ─────────────────────────────────────────────
// A rule about the FUTURE: flagged users are auto-added to every project created
// from now on. It is not a bulk assignment — existing projects are untouched, which
// is what the note under the table is there to say.
let _defMemUsers = [];
async function loadDefaultMembers(){
const banner=document.getElementById('defmem-banner');
const wrap=document.getElementById('defmem-table');
if(!banner || !wrap) return;
banner.className='banner'; banner.textContent='Loading…'; banner.style.display='';
const { status, json } = await api('GET','/api/auth/users');
if(status===403){
banner.className='banner bad';
banner.textContent='❌ Your account is not an admin, so you cant change who is added to new projects.';
wrap.innerHTML=''; return;
}
if(status===401){
banner.className='banner bad'; banner.textContent='❌ Not signed in. Reload and log in again.'; wrap.innerHTML=''; return;
}
if(status!==200 || !Array.isArray(json)){
banner.className='banner bad'; banner.textContent='❌ Could not load users (HTTP '+status+').'; wrap.innerHTML=''; return;
}
banner.style.display='none';
_defMemUsers = json;
renderDefaultMembers();
}
// Same idea as projRowToggled() — the role only matters while the tick is on — but
// here the select sits in a sibling <td>, so the lookup is scoped to the row.
function defMemToggled(cb){
const row = cb.closest('tr');
const sel = row && row.querySelector('select');
if(sel) sel.disabled = !cb.checked;
}
function renderDefaultMembers(){
const wrap=document.getElementById('defmem-table');
if(!wrap) return;
if(!_defMemUsers.length){ wrap.innerHTML='<div class="note">No users yet.</div>'; return; }
const rows = _defMemUsers.map(u => {
const uid = jsq(u.id);
const uname = jsq(u.username);
const role = normRole(u.role);
const who = '<td><strong>'+uesc(u.username)+'</strong>'+
(u.full_name ? ' <span class="note">'+uesc(u.full_name)+'</span>' : '')+'</td>'+
'<td class="ell" title="'+uesc(u.email||'')+'"><span>'+uesc(u.email||'—')+'</span></td>';
// Admins reach every project already, so there is nothing to add them to —
// the same thing projAccessCell() says in the user table.
if(role === 'admin'){
return '<tr>'+who+
'<td><span class="tag admin">'+uesc(PERM_LABELS.admin)+'</span></td>'+
'<td colspan="2"><span class="tag admin" title="Admins can access every project">all projects</span>'+
' <span class="note">Administrators already reach every project.</span></td>'+
'</tr>';
}
const on = !!u.auto_add_projects;
const cur = u.auto_add_role || '';
return '<tr>'+who+
'<td><span class="tag user">'+uesc(PERM_LABELS[role]||role)+'</span></td>'+
'<td><label class="chk">'+
'<input type="checkbox" id="defmem-cb-'+uesc(u.id)+'"'+(on?' checked':'')+
' title="Add this user to every project created from now on"'+
' onchange="defMemToggled(this);setAutoAdd(\''+uid+'\',\''+uname+'\')"> Add automatically'+
'</label></td>'+
'<td><select class="role-select" id="defmem-role-'+uesc(u.id)+'"'+(on?'':' disabled')+
' title="The role this user gets on those projects"'+
' onchange="setAutoAdd(\''+uid+'\',\''+uname+'\')">'+
'<option value=""'+(cur===''?' selected':'')+'>Same as account ('+uesc(PERM_LABELS[role]||role)+')</option>'+
'<option value="project_admin"'+(cur==='project_admin'?' selected':'')+'>Project Admin here</option>'+
'<option value="project_user"'+(cur==='project_user'?' selected':'')+'>Project User here</option>'+
'</select></td>'+
'</tr>';
}).join('');
wrap.innerHTML =
'<div class="tscroll"><table class="grid"><thead><tr>'+
'<th>User</th><th>Email</th>'+
'<th title="What this account may do in the app">Account permissions</th>'+
'<th title="Add this user to every project created from now on">Add to new projects</th>'+
'<th title="Their role on those projects">Role on those projects</th>'+
'</tr></thead><tbody>'+rows+'</tbody></table></div>'+
'<div class="note">This only affects projects created <strong>from now on</strong> — existing projects '+
'are untouched. Use <strong>Project access</strong> in the user table above to add someone to a project '+
'that already exists.</div>';
}
// Saves on every tick and every dropdown change — there is no Save button, so a
// failure must not leave a control showing something the server never accepted.
// On success we swap in the row the server returned (it clears the role whenever
// the flag is off); on failure we reload so the controls snap back to the truth.
async function setAutoAdd(id, username){
const cb = document.getElementById('defmem-cb-'+id);
if(!cb) return;
const sel = document.getElementById('defmem-role-'+id);
const auto_add = !!cb.checked;
const { status, json } = await api('POST','/api/auth/users/'+id+'/auto-add',
{ auto_add, role: auto_add ? ((sel && sel.value) || '') : '' });
if(status===200 && json && json.id){
_defMemUsers = _defMemUsers.map(u => u.id===json.id ? json : u);
renderDefaultMembers();
} else {
alert('Could not change the new-project default for '+username+': '+((json && json.detail)||('HTTP '+status)));
loadDefaultMembers();
}
}
// ── all feedback / comments ─────────────────────────────────────────────────────
let _comments = [];
async function loadComments(){

View File

@@ -292,6 +292,10 @@
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; }
/* Shown once when the project someone had open turns out to have been archived
rather than deleted — otherwise the picker just silently resets on them. */
.proj-archived-note { background: #fdf6dd; border: 1px solid #f1c21b; color: #8e6a00;
padding: 0.7rem 0.9rem; margin-bottom: 0.9rem; font-size: 13px; line-height: 1.5; }
.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); }
@@ -414,12 +418,34 @@
_projects = list || [];
// Reconcile the active project against the list; clear if it's gone.
const active = ProjectData.getActive();
if(active && !_projects.some(p => p.id === active.id)) ProjectData.setActive(null);
const dropped = (active && !_projects.some(p => p.id === active.id)) ? active : null;
if(dropped) ProjectData.setActive(null);
renderProjectPicker();
applyActiveProject();
// "No longer in the list" used to mean one thing — deleted. Now it also
// means archived, and resetting someone to "Select a project" with no word
// about it sends them hunting for a job that is merely finished.
if(dropped) explainDroppedProject(dropped);
});
}
function explainDroppedProject(p){
// The project is still readable when it's archived; a delete (or access being
// taken away) fails here, and that case genuinely has nothing to say.
ProjectData.get(p.id).then(full => {
if(!full || !full.archived) return;
const box = document.getElementById('project-picker');
if(!box || document.getElementById('proj-archived-note')) return;
const note = document.createElement('div');
note.id = 'proj-archived-note';
note.className = 'proj-archived-note';
note.innerHTML = `<strong>${esc(p.name || 'The project you had open')}</strong> has been
archived — it is read-only and no longer listed here. An administrator can unarchive it
from the Admin Console.`;
box.insertBefore(note, box.firstChild);
}).catch(() => {});
}
function createFormHtml(){
return `<div class="proj-form" id="proj-form" style="display:none">
<div class="proj-form-grid">

View File

@@ -73,6 +73,10 @@
});
},
// Archiving a project lives in the admin console (html/admin.js), which doesn't
// load this file — deliberately not mirrored here, so there's only one
// implementation of it rather than two that can disagree.
// ── active project context ────────────────────────────────────────────────
getActiveId: function () { try { return localStorage.getItem(LS_ACTIVE) || ''; } catch (e) { return ''; } },
getActive: function () { try { return JSON.parse(localStorage.getItem(LS_ACTIVE_OBJ) || 'null'); } catch (e) { return null; } },
@@ -208,7 +212,8 @@
qWrite(q);
}
// Permanently-failed op (a 4xx client error) — keep it for visibility but stop
// retrying, so a rejected write can't loop forever.
// retrying, so a rejected write can't loop forever. `err` is the server's own
// explanation when it sent one; it is what the sync badge shows the user.
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; } }
@@ -229,7 +234,16 @@
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 if (status >= 400 && status < 500 && status !== 429) {
// Refused once, refused forever — so the only useful thing left is the
// reason. A 409 here is the archived-project gate, whose detail tells
// the user the project is read-only and how to get it unarchived; a
// bare "HTTP 409" would leave them staring at a change that vanished.
return r.json().catch(function () { return null; }).then(function (j) {
var why = (j && typeof j.detail === 'string' && j.detail) || ('HTTP ' + status);
markDead(op.opId, why);
});
}
else { anyFail = true; bumpTries(op.opId, 'HTTP ' + status); }
}).catch(function (e) { anyFail = true; bumpTries(op.opId, String(e)); });
});
@@ -250,12 +264,17 @@
}
// ── sync status (drives the indicator + any listeners) ──────────────────────
// `failed` stays the total not-getting-through count (what listeners already
// read); `dead` splits out the ops the server has permanently refused, with the
// first reason it gave, because those two states need different words.
function syncCounts() {
var q = qRead(), pending = 0, failed = 0;
var q = qRead(), pending = 0, failed = 0, dead = 0, reason = '';
for (var i = 0; i < q.length; i++) {
if (q[i].dead || (q[i].tries || 0) >= 3) failed++; else pending++;
if (q[i].dead) { dead++; if (!reason && q[i].lastErr) reason = String(q[i].lastErr); }
else if ((q[i].tries || 0) >= 3) failed++;
else pending++;
}
return { pending: pending, failed: failed, syncing: _flushing };
return { pending: pending, failed: failed + dead, dead: dead, reason: reason, syncing: _flushing };
}
ProjectData.syncStatus = syncCounts;
function notifySync() {
@@ -281,7 +300,17 @@
document.body.appendChild(el);
}
if (_badgeHideTimer) { clearTimeout(_badgeHideTimer); _badgeHideTimer = null; }
if (c.failed) {
// A dead op is a refusal, not a hiccup — "retrying" would be a lie, and the
// reason is the only thing that tells the user what to do (e.g. the project is
// archived). Stack it under the headline; the badge never hides in this state.
el.style.flexDirection = c.dead ? 'column' : 'row';
el.style.alignItems = c.dead ? 'flex-start' : 'center';
el.style.maxWidth = c.dead ? 'min(340px, calc(100vw - 32px))' : 'none';
if (c.dead) {
el.innerHTML = '<span>✕ ' + c.dead + ' change' + (c.dead === 1 ? '' : 's') + ' rejected — not saved</span>' +
(c.reason ? '<span style="font-weight:400">' + esc(c.reason) + '</span>' : '');
el.style.color = '#a2191f'; el.style.borderColor = '#ffd7d9'; el.style.background = '#fff1f1'; el.style.display = 'inline-flex';
} else 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) {

View File

@@ -964,7 +964,7 @@ function renderCustomConstraints(){
<strong>${escAttr(c.name)}</strong>
<span style="display:flex; align-items:center; gap:0.75rem;">
${criticalToggle(c.name, true, !!c.critical)}
<button onclick="removeCustomConstraint('${c.name.replace(/'/g,"\\'")}')" title="Remove" style="background:var(--danger); color:#fff; border:none; border-radius:4px; width:28px; height:28px; cursor:pointer; font-weight:600;">✕</button>
<button onclick="removeCustomConstraint('${escHandlerArg(c.name)}')" title="Remove" style="background:var(--danger); color:#fff; border:none; border-radius:4px; width:28px; height:28px; cursor:pointer; font-weight:600;">✕</button>
</span>
</div>`).join('') : `<div style="font-size:12px; color:var(--text-dim);">No custom constraints added yet.</div>`;
}
@@ -1111,6 +1111,13 @@ const DEFAULT_SOURCES = [
// SharePoint "Copy Link" URLs contain & (and labels/notes may contain & " < >),
// so attribute values must be escaped or a re-render corrupts the field.
function escAttr(v){ return String(v==null?'':v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
// A value bound into an inline handler — onclick="fn('…')" — needs BOTH escapes, in
// this order: backslash, then quote (for the JS string literal), then escAttr (for
// the attribute carrying it). Quote-escaping alone breaks on a value containing a
// backslash — the backslash escapes the backslash, the quote closes the literal, and
// the rest runs as code. Constraint names travel with the SOP to everyone on the
// project, so they are not this browser's own input.
function escHandlerArg(v){ return escAttr(String(v==null?'':v).replace(/\\/g,'\\\\').replace(/'/g,"\\'")); }
function renderSources(){
const container = document.getElementById('sources-list');
if(!state.sources.length) state.sources = DEFAULT_SOURCES.map(s=>({label:s.label, system:'', notes:'', link:'', ph:s.ph, preset:true}));

View File

@@ -35,6 +35,32 @@
--wpc-accent: #78a9ff;
}
/* ── archived-project banner ──────────────────────────────────────────────── */
/* Inserted by wp-chrome.js as the top bar's next sibling, so it sits directly
under the bar in normal flow and can never overlap it or eat its height (the
bar is sticky; this strip scrolls away under it). `flex: 0 0 auto` is for the
suite page, whose shell is a flex column — without it the strip would squash.
Amber tokens are the suite's warning set, same as the sync badge. */
.wpc-archived {
flex: 0 0 auto;
display: flex;
align-items: flex-start;
gap: 8px;
padding: 9px 16px;
background: #fdf6dd;
color: #8e6a00;
border-bottom: 1px solid #f1c21b;
border-radius: 0;
font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 13.5px;
line-height: 1.35;
}
.wpc-archived-ico { flex: 0 0 auto; font-size: 14px; }
.wpc-archived-text { min-width: 0; } /* wraps instead of forcing a scrollbar */
@media (max-width: 620px) {
.wpc-archived { padding: 8px 12px; font-size: 13px; }
}
/* ── project switcher ─────────────────────────────────────────────────────── */
.wpc-proj { position: relative; flex: 0 0 auto; }
.wpc-proj-btn {

View File

@@ -167,6 +167,58 @@
.catch(function () {});
}
// ── archived-project banner ────────────────────────────────────────────────
// An archived project is still readable and still deep-linkable (?project=<id>),
// but every write now 409s. With nothing on the page to say so, that reads as a
// silent failure — so the bar, the one thing every page has, carries the warning.
//
// The state comes from GET /api/projects/<id>, never from "it's missing from the
// switcher": absence also means "you have no access to it", which is a different
// message. Fails closed and silent — an error means no banner, not a broken page.
function activeProjectId() {
try {
var q = new URLSearchParams(location.search).get('project');
if (q) return q;
return (window.ProjectData && ProjectData.getActiveId && ProjectData.getActiveId()) || '';
} catch (e) { return ''; }
}
function buildArchivedBanner() {
var bar = el('div', 'wpc-archived');
bar.setAttribute('role', 'status');
bar.innerHTML =
'<span class="wpc-archived-ico" aria-hidden="true">⚠</span>' +
'<span class="wpc-archived-text"><strong>Archived project — read-only.</strong> ' +
'Unarchive it from the Admin Console to make changes.</span>';
return bar;
}
function checkArchived(host) {
var id = activeProjectId();
if (!id || !host || !host.parentNode) return;
var pinned = false;
try { pinned = !!new URLSearchParams(location.search).get('project'); } catch (e) {}
fetch('/api/projects/' + encodeURIComponent(id), { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (p) {
if (!p || !p.archived) return;
// The home page reconciles the stored active project against the (now
// archive-filtered) list while this request is in flight, and drops it. If
// that happened, the id we asked about is nobody's context any more —
// banner-ing it would contradict the picker one line below. A ?project=
// deep link is pinned to this page and can't be cleared out from under us.
if (!pinned && window.ProjectData && ProjectData.getActiveId &&
ProjectData.getActiveId() !== id) return;
// Also on the document element, so the two big apps can gate their own UI
// from CSS or a boot check without a second round trip. The server stays
// the real gate; this is only there so the UI can agree with it.
try { document.documentElement.setAttribute('data-wp-archived', '1'); } catch (e) {}
if (document.querySelector('.wpc-archived')) return;
host.parentNode.insertBefore(buildArchivedBanner(), host.nextSibling);
})
.catch(function () {});
}
// ── global search ──────────────────────────────────────────────────────────
function buildSearch() {
var wrap = el('div', 'wpc-search');
@@ -325,6 +377,7 @@
if (m.before) m.host.insertBefore(chrome, m.before);
else m.host.appendChild(chrome);
loadProjects(switcher);
checkArchived(m.host);
window.wpChromeRefresh = function () { switcher.wpcRefresh(); };
}

View File

@@ -0,0 +1,48 @@
"""project archive + default members on new projects
Two changes that ship together:
• projects.archived_at — an archived project disappears from every picker,
switcher and search and is frozen read-only. Mirrors work_packages.archived_at
(47bbe76aa749): NULL means "live", which is what every existing row gets, so
nothing changes for current data.
• users.auto_add_projects / users.auto_add_role — a PM or QA lead who belongs on
every job is added to each new project automatically. auto_add_role shares
project_members.role's value space ('' = inherit the account's own role).
Revision ID: a7c31f9e5b02
Revises: d15b8c4ef207
Create Date: 2026-08-05 10:12:47.503914
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a7c31f9e5b02'
down_revision = 'd15b8c4ef207'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('projects', sa.Column('archived_at', sa.DateTime(timezone=True), nullable=True))
op.create_index(op.f('ix_projects_archived_at'), 'projects', ['archived_at'], unique=False)
# These two are NOT NULL and land on a table that already has rows, so
# server_default is what backfills them (nobody is auto-added until an admin
# turns it on). The defaults are deliberately LEFT IN PLACE afterwards, as every
# other migration here does (18373f14809e, b41c7ae90d52, c93f2b1d7e04,
# d15b8c4ef207): dropping one needs ALTER COLUMN, which SQLite only fakes via a
# batch_alter_table table rebuild, and dev runs on SQLite (wpsuite.db). The ORM
# supplies both values on every INSERT, so the leftover default is only ever
# read by hand-written SQL — and there it is the answer we'd want anyway.
op.add_column('users', sa.Column('auto_add_projects', sa.Boolean(),
nullable=False, server_default=sa.false()))
op.add_column('users', sa.Column('auto_add_role', sa.String(length=20),
nullable=False, server_default=''))
def downgrade() -> None:
op.drop_column('users', 'auto_add_role')
op.drop_column('users', 'auto_add_projects')
op.drop_index(op.f('ix_projects_archived_at'), table_name='projects')
op.drop_column('projects', 'archived_at')

View File

@@ -170,6 +170,29 @@ def require_project_admin(db: Session, user: "models.User", project_id: Optional
)
def require_project_writable(db: Session, user_or_none, project_id: Optional[str],
what: str = "This change") -> None:
"""An archived project is frozen: everything on it stays readable, nothing on it
may be written. Every mutating path that lands on a project goes through here —
saving a SOP or a package, issuing, status changes, deletes, comments.
It is 409 and not 403 on purpose. Nobody lacks a permission here: the state of
the project is the objection, and even an admin gets refused until they unarchive
it — hence the caller identity is accepted for symmetry with the other guards but
deliberately unused. 409 also matters offline: the browser outbox in
html/project-data.js retires any 4xx op instead of retrying it forever, so an edit
queued before the archive dies quietly rather than looping against a frozen job."""
if not project_id:
return
proj = db.get(models.Project, project_id)
if proj is not None and proj.archived_at is not None:
raise HTTPException(
status_code=409,
detail=(f"{what} — this project is archived (read-only). An administrator "
f"can unarchive it from the admin console."),
)
def scope_to_access(stmt, column, db: Session, user: "models.User"):
"""Restrict a SELECT to the user's accessible projects (no-op for admins)."""
ids = accessible_project_ids(db, user)
@@ -178,16 +201,50 @@ def scope_to_access(stmt, column, db: Session, user: "models.User"):
return stmt.where(column.in_(ids))
def grant_project_access(db: Session, user_id: str, project_id: str) -> None:
"""Add a (user, project) membership if it isn't already there."""
def grant_project_access(db: Session, user_id: str, project_id: str, role: str = "") -> bool:
"""Add a (user, project) membership if it isn't already there, carrying an
optional per-project role override ('' = inherit the account's own). Returns True
only when a row was actually added, so a caller can report what it did. Does NOT
commit — the caller owns the transaction."""
exists = db.scalar(
select(models.ProjectMember.id).where(
(models.ProjectMember.user_id == user_id)
& (models.ProjectMember.project_id == project_id)
)
)
if not exists:
db.add(models.ProjectMember(id=gen_id("pm"), user_id=user_id, project_id=project_id))
if exists:
return False
db.add(models.ProjectMember(id=gen_id("pm"), user_id=user_id, project_id=project_id, role=role))
return True
def add_default_members(db: Session, project_id: str, actor) -> list[str]:
"""Put the standing default members onto a brand-new project.
Some people belong on every job the moment it exists — the PM who runs them all,
the QC lead — and flagging their account (users.auto_add_projects) is how an admin
says that once instead of remembering it at every project creation. App admins are
skipped because they already reach every project, an inactive account is not
revived by a new job, and an existing membership is never duplicated or
overwritten. Returns the usernames added; does NOT commit, matching
grant_project_access."""
rows = db.scalars(
select(models.User).where(
(models.User.auto_add_projects.is_(True)) & (models.User.is_active.is_(True))
).order_by(models.User.username)
).all()
added = []
for u in rows:
if auth.is_admin(u):
continue
if grant_project_access(db, u.id, project_id, (u.auto_add_role or "").strip()):
added.append(u.username)
if added:
# One line for the batch, not one per person — this is a single automatic act.
log_event(db, actor, "project_access_granted", "project", project_id,
project_id=project_id, summary=f"{len(added)} default member(s) added",
detail={"users": added, "reason": "auto_add_projects"})
return added
# ── Audit trail ────────────────────────────────────────────────────────────────
@@ -381,6 +438,13 @@ class ProjectAssignIn(BaseModel):
roles: dict[str, str] = Field(default_factory=dict)
class AutoAddIn(BaseModel):
auto_add: bool
# Role to give this user on the projects they're auto-added to; '' inherits the
# account's own role, same value space as ProjectMember.role.
role: str = ""
LOGIN_MAX_ATTEMPTS = int(os.getenv("AUTH_MAX_ATTEMPTS", "5"))
LOGIN_LOCKOUT_MINUTES = int(os.getenv("AUTH_LOCKOUT_MINUTES", "15"))
@@ -690,8 +754,16 @@ def set_user_role(user_id: str, body: RoleIn, admin: models.User = Depends(auth.
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})
detail = {"from": old_role, "to": body.role}
# Promoting someone to admin retires their default-member flag: an admin already
# reaches every project, so the flag would do nothing except sit there invisibly
# (the console shows admins no controls) and come back to life the day they are
# demoted. Same reasoning as clearing the role in set_user_auto_add.
if body.role == auth.ROLE_ADMIN and (u.auto_add_projects or u.auto_add_role):
u.auto_add_projects = False
u.auto_add_role = ""
detail["auto_add_cleared"] = True
log_event(db, admin, "role_changed", "user", u.id, summary=u.username, detail=detail)
db.commit()
db.refresh(u)
return u.to_dict()
@@ -714,6 +786,33 @@ def set_user_project_role(user_id: str, body: ProjectRoleIn, admin: models.User
return u.to_dict()
@app.post("/api/auth/users/{user_id}/auto-add")
def set_user_auto_add(user_id: str, body: AutoAddIn, admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
"""Flag a user as a default member of every project created from here on, with
an optional role on those projects. It only touches NEW projects — existing
assignments stay under the admin's hand (see set_user_projects), because
back-filling everyone onto historical jobs is never what this flag means."""
allowed = ("", auth.ROLE_PROJECT_ADMIN, auth.ROLE_PROJECT_USER)
role = (body.role or "").strip()
if role not in allowed:
raise HTTPException(
status_code=400,
detail=f"role must be '' (inherit) or one of {auth.ROLE_PROJECT_ADMIN}, {auth.ROLE_PROJECT_USER}",
)
u = db.get(models.User, user_id)
if not u:
raise HTTPException(status_code=404, detail="User not found")
u.auto_add_projects = bool(body.auto_add)
# A role left behind on a switched-off flag is a trap: it would quietly take
# effect the day someone switches the flag back on.
u.auto_add_role = role if u.auto_add_projects else ""
log_event(db, admin, "auto_add_changed", "user", u.id, summary=u.username,
detail={"auto_add": bool(u.auto_add_projects), "role": u.auto_add_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)
@@ -741,7 +840,11 @@ def get_user_projects(user_id: str, _admin: models.User = Depends(auth.require_a
"assigned": [r.project_id for r in rows],
# Per-project role overrides, keyed by project id ('' = inherit the account's).
"roles": {r.project_id: (r.role or "") for r in rows},
"projects": [{"id": p.id, "name": p.name, "number": p.number} for p in projects],
# Archived projects stay on this list on purpose — an existing assignment has
# to remain visible and removable — but they're flagged so the dialog can say
# so, rather than offering a finished job as though it were live work.
"projects": [{"id": p.id, "name": p.name, "number": p.number,
"archived": p.archived_at is not None} for p in projects],
}
@@ -775,6 +878,7 @@ def upsert_project(body: ProjectIn, user: models.User = Depends(auth.get_current
is_new = proj is None
if not is_new:
require_project_access(db, user, proj.id)
require_project_writable(db, user, proj.id, "Editing a project")
if proj is None:
proj = models.Project(id=body.id or gen_id("proj"))
db.add(proj)
@@ -789,18 +893,36 @@ def upsert_project(body: ProjectIn, user: models.User = Depends(auth.get_current
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.
# A project created by a non-admin auto-grants its creator access. If that
# creator is ALSO a standing default member, this is the row that sticks —
# add_default_members below never overwrites an existing membership — so it has
# to carry the role they'd have been given, or someone whose flag says
# "Project Admin on every job" would silently land as a plain member on the one
# job they started themselves.
if is_new and not auth.is_admin(user):
grant_project_access(db, user.id, proj.id)
creator_role = (user.auto_add_role or "").strip() if user.auto_add_projects else ""
grant_project_access(db, user.id, proj.id, creator_role)
db.commit()
# …and anyone flagged as a default member joins at creation time too.
if is_new:
add_default_members(db, proj.id, user)
db.commit()
db.refresh(proj)
return proj.to_dict()
@app.get("/api/projects")
def list_projects(user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
stmt = scope_to_access(select(models.Project), models.Project.id, db, user).order_by(models.Project.updated_at.desc())
rows = db.scalars(stmt).all()
def list_projects(
archived: str = Query("exclude", description="exclude (default) | only | all"),
user: models.User = Depends(auth.get_current_user),
db: Session = Depends(get_db),
):
stmt = scope_to_access(select(models.Project), models.Project.id, db, user)
if archived == "only":
stmt = stmt.where(models.Project.archived_at.is_not(None))
elif archived != "all":
stmt = stmt.where(models.Project.archived_at.is_(None)) # default: hide archived
rows = db.scalars(stmt.order_by(models.Project.updated_at.desc())).all()
return [p.summary() for p in rows]
@@ -827,14 +949,46 @@ def delete_project(project_id: str, user: models.User = Depends(auth.get_current
return {"deleted": project_id}
@app.post("/api/projects/{project_id}/archive")
def archive_project(project_id: str, body: ArchiveIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
"""Archive (or unarchive) a whole project — it drops out of every picker,
switcher and search, and freezes read-only, without losing a thing. This is how
a finished job gets out of everyone's way while staying on the record; delete is
still there for a job that should never have existed."""
proj = db.get(models.Project, project_id)
if not proj:
raise HTTPException(status_code=404, detail="Project not found")
# Takes the whole job out of circulation for everyone on it, so it sits at the
# same bar as deleting it. Note the deliberate absence of require_project_writable
# — unarchiving is the one write an archived project must still accept.
require_project_admin(db, user, proj.id, "Archiving a project")
was_archived = proj.archived_at is not None
if body.archived and not was_archived:
proj.archived_at = models.utcnow()
log_event(db, user, "archived", "project", proj.id, project_id=proj.id,
summary=(proj.name or proj.number or proj.id))
elif not body.archived and was_archived:
proj.archived_at = None
log_event(db, user, "unarchived", "project", proj.id, project_id=proj.id,
summary=(proj.name or proj.number or proj.id))
db.commit()
db.refresh(proj)
return proj.to_dict()
# ── 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)
# Both ends are checked: the project the SOP is being written INTO here, and the
# one it currently sits on below — so an archived job can be neither edited nor
# used as a source to move a SOP out of.
require_project_writable(db, user, body.project_id, "Saving a SOP")
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)
require_project_writable(db, user, sop.project_id, "Saving a SOP")
# The SOP is the project's baseline: once it's been completed, changing it
# is a Project Admin action. Authoring and revising a draft is open to any
# project member, including marking it complete the first time.
@@ -899,6 +1053,7 @@ 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_admin(db, user, sop.project_id, "Deleting a SOP")
require_project_writable(db, user, sop.project_id, "Deleting a SOP")
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)
@@ -1090,9 +1245,12 @@ def upsert_wp(body: WpIn, background_tasks: BackgroundTasks, user: models.User =
check_id(body.parent_id)
check_id(body.assignee_id)
require_project_access(db, user, body.project_id)
# Destination and origin both have to be live — see upsert_sop.
require_project_writable(db, user, body.project_id, "Saving a work package")
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)
require_project_writable(db, user, wp.project_id, "Saving a work package")
is_new = wp is None
old_status = None if is_new else wp.status
old_assignee = None if is_new else wp.assignee_id
@@ -1276,6 +1434,7 @@ def delete_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db
# Deleting a work package is irreversible — Project Admin only. A project_user
# who wants one out of the way can archive it instead (reversible).
require_project_admin(db, user, wp.project_id, "Deleting a work package")
require_project_writable(db, user, wp.project_id, "Deleting a work package")
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)
@@ -1291,6 +1450,7 @@ def issue_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)
require_project_writable(db, user, wp.project_id, "Issuing a work package")
enforce_release_gates(db, wp.id, wp.data, "Issued", wp.status)
wp.status = "Issued"
wp.issued_at = models.utcnow()
@@ -1307,6 +1467,7 @@ 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)
require_project_writable(db, user, wp.project_id, "Changing a work package's status")
old_status = wp.status
# Same gates as /issue — this route must not be a way around them.
enforce_release_gates(db, wp.id, wp.data, body.status, old_status)
@@ -1329,6 +1490,9 @@ def archive_wp(wp_id: str, body: ArchiveIn, user: models.User = Depends(auth.get
if not wp:
raise HTTPException(status_code=404, detail="Work Package not found")
require_project_access(db, user, wp.project_id)
# An archived project is frozen whole: its packages keep the archive state they
# had, and tidying inside it waits until the job is unarchived.
require_project_writable(db, user, wp.project_id, "Archiving a work package")
was_archived = wp.archived_at is not None
if body.archived and not was_archived:
wp.archived_at = models.utcnow()
@@ -1399,12 +1563,23 @@ def global_search(
pat = _like_term(term)
esc = "\\"
# An archived project is meant to be gone from view, and search is the one place
# it would otherwise come back — as the project itself, or as one of its packages
# or SOPs. So the archived ids are read once here and filtered out of all three
# result sets. Rows with no project at all (orphan/legacy, admin-only) survive
# explicitly: SQL's NOT IN drops NULLs, and they aren't on an archived job.
archived_pids = set(db.scalars(
select(models.Project.id).where(models.Project.archived_at.is_not(None))
).all())
proj_stmt = select(models.Project).where(
func.lower(models.Project.name).like(pat, escape=esc)
| func.lower(models.Project.number).like(pat, escape=esc)
| func.lower(models.Project.client).like(pat, escape=esc)
| func.lower(models.Project.site).like(pat, escape=esc)
)
if archived_pids:
proj_stmt = proj_stmt.where(models.Project.id.not_in(archived_pids))
proj_stmt = scope_to_access(proj_stmt, models.Project.id, db, user)
projects = db.scalars(proj_stmt.order_by(models.Project.updated_at.desc()).limit(limit)).all()
@@ -1417,6 +1592,11 @@ def global_search(
| func.lower(models.WorkPackage.status).like(pat, escape=esc)
)
)
if archived_pids:
wp_stmt = wp_stmt.where(
models.WorkPackage.project_id.is_(None)
| models.WorkPackage.project_id.not_in(archived_pids)
)
wp_stmt = scope_to_access(wp_stmt, models.WorkPackage.project_id, db, user)
wps = db.scalars(wp_stmt.order_by(models.WorkPackage.updated_at.desc()).limit(limit)).all()
@@ -1424,6 +1604,10 @@ def global_search(
func.lower(models.Sop.name).like(pat, escape=esc)
| func.lower(models.Sop.number).like(pat, escape=esc)
)
if archived_pids:
sop_stmt = sop_stmt.where(
models.Sop.project_id.is_(None) | models.Sop.project_id.not_in(archived_pids)
)
sop_stmt = scope_to_access(sop_stmt, models.Sop.project_id, db, user)
sops = db.scalars(sop_stmt.order_by(models.Sop.updated_at.desc()).limit(limit)).all()
@@ -1535,17 +1719,28 @@ def project_members(project_id: str, user: models.User = Depends(auth.get_curren
# ── Comments / feedback ──────────────────────────────────────────────────────
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.
# a user can't write into another project's review thread. A review thread on an
# archived project is frozen with the rest of it; general app feedback isn't tied
# to a project at all and keeps working regardless.
#
# Both ids are checked independently — NOT if/elif. The row stores whichever ids
# the payload carried, so a body naming a WP you may touch AND a SOP you may not
# would, under an elif, be authorised on the WP alone and still land in the other
# project's SOP thread.
check_id(body.wp_id)
check_id(body.sop_id)
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:
require_project_writable(db, user, wp.project_id, "Commenting on a work package")
if 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)
require_project_writable(db, user, sop.project_id, "Commenting on a SOP")
extra = body.model_extra or {}
c = models.Comment(
id=gen_id("c"),

View File

@@ -33,6 +33,11 @@ class Project(Base):
division: Mapped[str] = mapped_column(String(200), default="")
site: Mapped[str] = mapped_column(String(300), default="")
sample: Mapped[bool] = mapped_column(Boolean, default=False)
# Archived projects are hidden from every picker, switcher and search but kept
# for the record — a finished job still has to be readable years later. Unlike
# an archived work package they are also FROZEN read-only: the API refuses any
# write to the project or to anything under it until an admin unarchives it.
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)
@@ -42,7 +47,9 @@ class Project(Base):
return {
"id": self.id, "name": self.name, "number": self.number,
"client": self.client, "division": self.division, "site": self.site,
"sample": self.sample, "created_by": self.created_by,
"sample": self.sample,
"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),
}
@@ -140,6 +147,13 @@ class User(Base):
role: Mapped[str] = mapped_column(String(20), default="project_user") # permissions role
# Job function on the project — free text, offered from a suggested list.
project_role: Mapped[str] = mapped_column(String(120), default="")
# A PM or QA lead who belongs on every job shouldn't have to be ticked into each
# new project by hand, so flagged accounts get a ProjectMember row the moment a
# project is created. `auto_add_role` is the role they land with and shares
# ProjectMember.role's value space: '' = inherit the account's own role,
# otherwise 'project_admin' | 'project_user'.
auto_add_projects: Mapped[bool] = mapped_column(Boolean, default=False)
auto_add_role: Mapped[str] = mapped_column(String(20), default="")
# Display preferences. Empty means "fall back to the app default, then to the
# browser". A stored value follows the person between devices, which matters on
# shared field tablets where the browser locale isn't theirs.
@@ -162,6 +176,8 @@ class User(Base):
"id": self.id, "username": self.username, "email": self.email,
"full_name": self.full_name, "role": self.role,
"project_role": self.project_role or "", "is_active": self.is_active,
"auto_add_projects": bool(self.auto_add_projects),
"auto_add_role": self.auto_add_role or "",
"locale": self.locale or "", "timezone": self.timezone or "",
"created_at": _iso(self.created_at), "last_login_at": _iso(self.last_login_at),
}

View File

@@ -79,8 +79,11 @@ def main():
if st != 200:
print(f"ABORT: /api/health returned {st}"); return 1
# --clean: remove any prior demo projects (cascade removes their SOP + WPs)
st, projects = call("GET", "/api/projects")
# --clean: remove any prior demo projects (cascade removes their SOP + WPs).
# archived=all because /api/projects hides archived projects by default — an
# archived DEMO project is still a DEMO project, and --clean has to find it.
# (Deleting one is still allowed; only writes to its contents are frozen.)
st, projects = call("GET", "/api/projects?archived=all")
demos = [p for p in (projects or []) if str(p.get("number", "")).startswith("DEMO-")]
if args.clean:
for p in demos:

View File

@@ -168,8 +168,32 @@ def main():
st, wps = call("GET", f"/api/wps?project_id={project_id}")
check("list WPs by project", st == 200 and any(w.get("id") == wp_id for w in wps), f"status={st}")
# 12) Archiving a project: it leaves the default list, stays reachable with
# archived=all, and freezes read-only — then unarchiving restores all three.
# The freeze is the whole point of the feature, so it is asserted, not assumed.
st, arch = call("POST", f"/api/projects/{project_id}/archive", {"archived": True})
check("archive project", st == 200 and arch.get("archived") is True, f"status={st}")
st, lst = call("GET", "/api/projects")
check("archived project drops out of the default list",
st == 200 and not any(p.get("id") == project_id for p in lst), f"status={st}")
st, lst = call("GET", "/api/projects?archived=all")
check("archived project is still there with archived=all",
st == 200 and any(p.get("id") == project_id for p in lst), f"status={st}")
st, refused = call("POST", "/api/wps", {
"id": wp_id, "project_id": project_id, "sop_id": sop_id,
"number": "WP01-SMOKE", "subject": "edited while archived", "type": "Conduit Install",
"status": "Scheduled", "data": {"disciplines": ["Electrical"], "hours": "40"}})
check("writing to an archived project is refused (409)", st == 409, f"status={st} body={refused}")
st, unarch = call("POST", f"/api/projects/{project_id}/archive", {"archived": False})
check("unarchive project", st == 200 and unarch.get("archived") is False, f"status={st}")
st, _ = call("POST", "/api/wps", {
"id": wp_id, "project_id": project_id, "sop_id": sop_id,
"number": "WP01-SMOKE", "subject": "Smoke test package", "type": "Conduit Install",
"status": "In Progress", "data": {"disciplines": ["Electrical"], "hours": "40"}})
check("writing succeeds again once unarchived", st == 200, f"status={st}")
finally:
# 12) Cleanup — deleting the project cascades to its SOPs and WPs (FK ON DELETE CASCADE).
# 13) Cleanup — deleting the project cascades to its SOPs and WPs (FK ON DELETE CASCADE).
if project_id and not args.keep:
st, _ = call("DELETE", f"/api/projects/{project_id}")
check("delete project (cascades SOP + WPs)", st == 200, f"status={st}")