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>
389 lines
18 KiB
JavaScript
389 lines
18 KiB
JavaScript
/* Shared app chrome for the Work Package Suite: a project switcher beside the
|
|
Prime logo and a global search centered in the top bar.
|
|
|
|
One script for every page because there are two generations of top bar — the
|
|
dark UI-shell `.wp-appbar` (home, admin, field) and the older light `.header`
|
|
(SOP suite, WP creator). We find whichever exists, insert the same markup, and
|
|
flip a colour set based on how dark the host bar is.
|
|
|
|
Search hits GET /api/search, which scopes results to the projects the signed-in
|
|
user may access — so this is a convenience, never a way to see another job.
|
|
|
|
Skipped inside an iframe: the WP creator is embedded in the suite page, and a
|
|
second bar inside the frame would be nonsense. */
|
|
(function () {
|
|
'use strict';
|
|
|
|
var inIframe = (function () { try { return window.top !== window.self; } catch (e) { return true; } })();
|
|
if (inIframe) return;
|
|
|
|
var SEARCH_MIN = 2; // characters before we ask the server
|
|
var DEBOUNCE_MS = 180;
|
|
|
|
function el(tag, cls, html) {
|
|
var n = document.createElement(tag);
|
|
if (cls) n.className = cls;
|
|
if (html != null) n.innerHTML = html;
|
|
return n;
|
|
}
|
|
function esc(v) {
|
|
return String(v == null ? '' : v)
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
}
|
|
function isDark(node) {
|
|
try {
|
|
var m = (getComputedStyle(node).backgroundColor || '').match(/(\d+),\s*(\d+),\s*(\d+)/);
|
|
if (!m) return false;
|
|
return (0.299 * +m[1] + 0.587 * +m[2] + 0.114 * +m[3]) < 140;
|
|
} catch (e) { return false; }
|
|
}
|
|
|
|
// ── where to put the chrome ────────────────────────────────────────────────
|
|
// Returns {host, insertBefore} or null. The insertion point matters: on the
|
|
// dark bar we sit before the spacer (so search takes the middle); on the light
|
|
// bars we sit between the left block and the right-hand buttons.
|
|
function findMount() {
|
|
var appbar = document.querySelector('.wp-appbar');
|
|
if (appbar) {
|
|
return { host: appbar, before: appbar.querySelector('.wp-appbar-spacer') };
|
|
}
|
|
var header = document.querySelector('.header');
|
|
if (header) {
|
|
// The suite page wraps its own left/right groups; the creator's bar is a
|
|
// flat row of buttons whose first button carries margin-left:auto.
|
|
var right = header.querySelector('.header-right');
|
|
if (right) return { host: header, before: right };
|
|
var firstBtn = header.querySelector('.btn, button');
|
|
return { host: header, before: firstBtn };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ── project switcher ───────────────────────────────────────────────────────
|
|
var projects = [];
|
|
|
|
function activeProject() {
|
|
try { return (window.ProjectData && ProjectData.getActive()) || null; } catch (e) { return null; }
|
|
}
|
|
|
|
function projectLabel(p) {
|
|
if (!p) return 'Select a project';
|
|
var n = p.name || '(unnamed)';
|
|
return p.number ? (p.number + ' — ' + n) : n;
|
|
}
|
|
|
|
// Switching project reloads the current page with ?project=<id>. Every page
|
|
// already resolves its project from that param (falling back to the stored
|
|
// active id), so a reload is both the simplest and the safest route — no page
|
|
// has to re-hydrate half its state in place.
|
|
function switchProject(p) {
|
|
try { if (window.ProjectData) ProjectData.setActive(p); } catch (e) {}
|
|
var url = new URL(location.href);
|
|
url.searchParams.set('project', p.id);
|
|
url.hash = '';
|
|
location.assign(url.toString());
|
|
}
|
|
|
|
function buildProjectSwitcher() {
|
|
var wrap = el('div', 'wpc-proj');
|
|
var btn = el('button', 'wpc-proj-btn');
|
|
btn.type = 'button';
|
|
btn.setAttribute('aria-haspopup', 'listbox');
|
|
btn.setAttribute('aria-expanded', 'false');
|
|
btn.title = 'Switch project';
|
|
var cur = activeProject();
|
|
btn.innerHTML =
|
|
'<span class="wpc-proj-labels">' +
|
|
'<span class="wpc-proj-kicker">Project</span>' +
|
|
'<span class="wpc-proj-name">' + esc(projectLabel(cur)) + '</span>' +
|
|
'</span><span class="wpc-caret">▾</span>';
|
|
var pop = el('div', 'wpc-pop');
|
|
pop.hidden = true;
|
|
wrap.appendChild(btn);
|
|
wrap.appendChild(pop);
|
|
|
|
function render() {
|
|
var curId = (activeProject() || {}).id || '';
|
|
var rows = projects.map(function (p) {
|
|
return '<button type="button" class="wpc-item' + (p.id === curId ? ' is-current' : '') +
|
|
'" data-pid="' + esc(p.id) + '">' +
|
|
'<span class="wpc-item-title">' + esc(p.name || '(unnamed)') + '</span>' +
|
|
'<span class="wpc-item-sub">' + esc([p.number, p.client, p.site].filter(Boolean).join(' · ') ||
|
|
'no number') + (p.sample ? ' · sample' : '') + '</span>' +
|
|
'</button>';
|
|
}).join('');
|
|
pop.innerHTML =
|
|
'<div class="wpc-pop-head">Switch project</div>' +
|
|
(rows || '<div class="wpc-empty">No projects you can access yet.</div>') +
|
|
'<div class="wpc-pop-foot"><a class="wpc-foot-btn" href="index.html">All projects / new project</a></div>';
|
|
Array.prototype.forEach.call(pop.querySelectorAll('.wpc-item'), function (item) {
|
|
item.addEventListener('click', function () {
|
|
var p = projects.filter(function (x) { return x.id === item.getAttribute('data-pid'); })[0];
|
|
if (p) switchProject(p);
|
|
});
|
|
});
|
|
}
|
|
|
|
function open() {
|
|
render();
|
|
pop.hidden = false;
|
|
btn.setAttribute('aria-expanded', 'true');
|
|
}
|
|
function close() {
|
|
pop.hidden = true;
|
|
btn.setAttribute('aria-expanded', 'false');
|
|
}
|
|
btn.addEventListener('click', function (e) {
|
|
e.stopPropagation();
|
|
if (pop.hidden) open(); else close();
|
|
});
|
|
document.addEventListener('click', function (e) { if (!wrap.contains(e.target)) close(); });
|
|
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
|
|
|
|
// Refresh the label once the project list (and any active project) is known.
|
|
wrap.wpcRefresh = function () {
|
|
var c = activeProject();
|
|
var nameEl = btn.querySelector('.wpc-proj-name');
|
|
if (nameEl) nameEl.textContent = projectLabel(c);
|
|
if (!pop.hidden) render();
|
|
};
|
|
return wrap;
|
|
}
|
|
|
|
function loadProjects(switcher) {
|
|
// ProjectData.list() already hits the API and falls back to its local cache
|
|
// when offline, so there's no second request to make here.
|
|
var p;
|
|
try {
|
|
p = (window.ProjectData && ProjectData.list) ? ProjectData.list() : null;
|
|
} catch (e) { p = null; }
|
|
if (!p) {
|
|
p = fetch('/api/projects', { headers: { Accept: 'application/json' } })
|
|
.then(function (r) { return r.ok ? r.json() : []; });
|
|
}
|
|
Promise.resolve(p)
|
|
.then(function (list) { projects = Array.isArray(list) ? list : []; switcher.wpcRefresh(); })
|
|
.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');
|
|
var box = el('div', 'wpc-search-box');
|
|
box.innerHTML =
|
|
'<span class="wpc-search-ico" aria-hidden="true">⌕</span>' +
|
|
'<input class="wpc-search-input" type="search" autocomplete="off" spellcheck="false" ' +
|
|
'placeholder="Search work packages, projects, SOPs…" aria-label="Search">' +
|
|
'<button class="wpc-clear" type="button" title="Clear" hidden>✕</button>' +
|
|
'<span class="wpc-kbd">Ctrl K</span>';
|
|
var pop = el('div', 'wpc-pop');
|
|
pop.hidden = true;
|
|
wrap.appendChild(box);
|
|
wrap.appendChild(pop);
|
|
|
|
var input = box.querySelector('.wpc-search-input');
|
|
var clear = box.querySelector('.wpc-clear');
|
|
var timer = null, seq = 0, items = [], activeIx = -1;
|
|
|
|
function close() { pop.hidden = true; activeIx = -1; }
|
|
|
|
function highlight() {
|
|
Array.prototype.forEach.call(pop.querySelectorAll('.wpc-item'), function (n, i) {
|
|
n.classList.toggle('is-active', i === activeIx);
|
|
if (i === activeIx && n.scrollIntoView) n.scrollIntoView({ block: 'nearest' });
|
|
});
|
|
}
|
|
|
|
// A work package lives inside the suite's Creator tab, so open the suite on
|
|
// that project with the package requested; a SOP opens the SOP tab.
|
|
function hrefFor(hit) {
|
|
if (hit.kind === 'project') return 'work-package-suite.html?project=' + encodeURIComponent(hit.id);
|
|
if (hit.kind === 'wp') {
|
|
return 'work-package-suite.html?tab=wp&project=' + encodeURIComponent(hit.project_id || '') +
|
|
'&wp=' + encodeURIComponent(hit.id);
|
|
}
|
|
return 'work-package-suite.html?tab=sop&project=' + encodeURIComponent(hit.project_id || '');
|
|
}
|
|
|
|
function go(hit) {
|
|
if (!hit) return;
|
|
if (hit.kind === 'project') {
|
|
var p = projects.filter(function (x) { return x.id === hit.id; })[0];
|
|
if (p) { switchProject(p); return; }
|
|
}
|
|
// Set the active project only from a full record — writing a stub would
|
|
// clobber the cached project (name, number, client) other pages read. The
|
|
// ?project= param in the URL is what actually switches context.
|
|
var full = projects.filter(function (x) { return x.id === hit.project_id; })[0];
|
|
if (full) { try { if (window.ProjectData) ProjectData.setActive(full); } catch (e) {} }
|
|
location.assign(hrefFor(hit));
|
|
}
|
|
|
|
function renderResults(data) {
|
|
items = [];
|
|
var html = '';
|
|
function group(title, rows) {
|
|
if (!rows.length) return;
|
|
html += '<div class="wpc-pop-head">' + esc(title) + '</div>' + rows.join('');
|
|
}
|
|
group('Work packages', (data.wps || []).map(function (w) {
|
|
items.push({ kind: 'wp', id: w.id, project_id: w.project_id, project_name: w.project_name });
|
|
return '<button type="button" class="wpc-item" data-ix="' + (items.length - 1) + '">' +
|
|
'<span class="wpc-item-title"><span class="wpc-item-mono">' + esc(w.number || '(unnumbered)') + '</span> ' +
|
|
esc(w.subject || '') + '</span>' +
|
|
'<span class="wpc-item-sub">' + esc([w.status, w.type, w.project_name].filter(Boolean).join(' · ')) + '</span>' +
|
|
'</button>';
|
|
}));
|
|
group('Projects', (data.projects || []).map(function (p) {
|
|
items.push({ kind: 'project', id: p.id });
|
|
return '<button type="button" class="wpc-item" data-ix="' + (items.length - 1) + '">' +
|
|
'<span class="wpc-item-title">' + esc(p.name || '(unnamed)') + '</span>' +
|
|
'<span class="wpc-item-sub">' + esc([p.number, p.client].filter(Boolean).join(' · ') || 'project') + '</span>' +
|
|
'</button>';
|
|
}));
|
|
group('SOPs', (data.sops || []).map(function (s) {
|
|
items.push({ kind: 'sop', id: s.id, project_id: s.project_id, project_name: s.project_name });
|
|
return '<button type="button" class="wpc-item" data-ix="' + (items.length - 1) + '">' +
|
|
'<span class="wpc-item-title">' + esc(s.name || 'SOP') + '</span>' +
|
|
'<span class="wpc-item-sub">' + esc([s.complete ? 'complete' : 'draft', s.project_name].filter(Boolean).join(' · ')) + '</span>' +
|
|
'</button>';
|
|
}));
|
|
if (!items.length) {
|
|
html = '<div class="wpc-empty">Nothing matches “' + esc(data.query || '') + '” in the projects you can access.</div>';
|
|
}
|
|
pop.innerHTML = html;
|
|
pop.hidden = false;
|
|
activeIx = items.length ? 0 : -1;
|
|
highlight();
|
|
Array.prototype.forEach.call(pop.querySelectorAll('.wpc-item'), function (n) {
|
|
n.addEventListener('click', function () { go(items[+n.getAttribute('data-ix')]); });
|
|
n.addEventListener('mouseenter', function () { activeIx = +n.getAttribute('data-ix'); highlight(); });
|
|
});
|
|
}
|
|
|
|
function run(q) {
|
|
var mine = ++seq;
|
|
fetch('/api/search?q=' + encodeURIComponent(q), { headers: { Accept: 'application/json' } })
|
|
.then(function (r) { return r.ok ? r.json() : null; })
|
|
.then(function (data) {
|
|
if (mine !== seq) return; // a newer keystroke already won
|
|
if (!data) { close(); return; }
|
|
renderResults(data);
|
|
})
|
|
.catch(function () {
|
|
if (mine !== seq) return;
|
|
pop.innerHTML = '<div class="wpc-empty">Search is unavailable offline.</div>';
|
|
pop.hidden = false;
|
|
});
|
|
}
|
|
|
|
input.addEventListener('input', function () {
|
|
var q = input.value.trim();
|
|
clear.hidden = !q;
|
|
clearTimeout(timer);
|
|
if (q.length < SEARCH_MIN) { close(); return; }
|
|
timer = setTimeout(function () { run(q); }, DEBOUNCE_MS);
|
|
});
|
|
input.addEventListener('keydown', function (e) {
|
|
if (e.key === 'Escape') { close(); input.blur(); return; }
|
|
if (pop.hidden || !items.length) return;
|
|
if (e.key === 'ArrowDown') { e.preventDefault(); activeIx = (activeIx + 1) % items.length; highlight(); }
|
|
else if (e.key === 'ArrowUp') { e.preventDefault(); activeIx = (activeIx - 1 + items.length) % items.length; highlight(); }
|
|
else if (e.key === 'Enter') { e.preventDefault(); go(items[activeIx]); }
|
|
});
|
|
input.addEventListener('focus', function () {
|
|
if (input.value.trim().length >= SEARCH_MIN && items.length) pop.hidden = false;
|
|
});
|
|
clear.addEventListener('click', function () {
|
|
input.value = ''; clear.hidden = true; close(); input.focus();
|
|
});
|
|
document.addEventListener('click', function (e) { if (!wrap.contains(e.target)) close(); });
|
|
|
|
// Ctrl/Cmd-K from anywhere focuses search (matches the tools people already
|
|
// use). Ignored while typing in another field so it can't steal a shortcut.
|
|
document.addEventListener('keydown', function (e) {
|
|
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
|
|
e.preventDefault();
|
|
input.focus();
|
|
input.select();
|
|
}
|
|
});
|
|
return wrap;
|
|
}
|
|
|
|
// ── mount ──────────────────────────────────────────────────────────────────
|
|
function mount() {
|
|
if (document.querySelector('.wp-chrome')) return;
|
|
var m = findMount();
|
|
if (!m) return;
|
|
var chrome = el('div', 'wp-chrome');
|
|
if (isDark(m.host)) chrome.setAttribute('data-bar', 'dark');
|
|
var switcher = buildProjectSwitcher();
|
|
chrome.appendChild(switcher);
|
|
chrome.appendChild(buildSearch());
|
|
if (m.before) m.host.insertBefore(chrome, m.before);
|
|
else m.host.appendChild(chrome);
|
|
loadProjects(switcher);
|
|
checkArchived(m.host);
|
|
window.wpChromeRefresh = function () { switcher.wpcRefresh(); };
|
|
}
|
|
|
|
// Wait for the auth guard: an unauthenticated page is about to redirect, and
|
|
// /api/search would 401 anyway.
|
|
if (window.WP_USER) mount();
|
|
else document.addEventListener('wp-auth-ready', mount);
|
|
})();
|