User accounts lived in the Admin Console, which is admins-only. Project admins
need to create the accounts on their own jobs without an app admin on the phone,
so accounts move to a new User Directory page and a new role carries the right.
server/auth.py, server/app.py
New permissions role `project_super_user`, between admin and project_admin:
everything a project admin may do, plus user administration SCOPED to the
projects they hold the role on. Four limits make it safe to hand out, all
enforced server-side:
* Scope comes from projects, not the job title. It resolves per membership
(managed_project_ids), so an ordinary account can hold it on one job via
ProjectMember.role, and a super user demoted on one job administers
nobody there. No projects, no authority.
* Account-level changes (password, disable, rename, permissions, delete)
require EXCLUSIVE scope: refused when the target is also on a project the
caller does not administer, because those changes are global. The
directory renders such rows read-only with the reason.
* No admin or super-user targets, and neither role can be granted by a
super user -- that is the line that stops it becoming app-wide control.
* PUT .../projects rebuilds only the caller's own slice; memberships on
projects they do not administer are left untouched. A payload that simply
omits them must not cut someone off a job the caller cannot see.
Creating requires naming at least one of your own projects: an account with
none would be one the creator instantly cannot manage.
/api/auth/users is now scoped rather than admin-only, and carries a per-row
`manageable` verdict plus the reason. Non-managers get a contact card only --
a project user has no business reading colleagues' login history. New
/api/auth/user-scope tells the page what it may offer. Administrative
password resets are now audited; they were the one account change that left
no trace. Settings, feature flags and the auto-add rule stay admin-only.
While here: one definition of "is a user manager", derived from the managed
set. An account-role-only version disagreed with the scoped one and locked
per-project super users out of routes they were entitled to.
html/users.html, html/users.js
The directory: three renderings from one page -- admin (everything), super
user (controls per row, read-only where scope is shared), everyone else (a
read-only directory of the people on their own projects).
html/console.css, html/console-util.js
Extracted from admin.html/admin.js so both console pages share them. A
divergent jsq() is an XSS and a divergent role list offers permissions the
server refuses, so neither may exist twice.
html/wp-sidenav.{js,css}
Global nav drawer, role-gated, carrying ?project= across links. Mounted on
the field view (which had no way to anywhere) plus both console pages.
No migration: users.role is already String(20) and the new value fits.
Verified: 93 scope/gate tests, 29 live HTTP tests through the real dependency
stack, 33 static JS checks. Not verified in a browser -- no JS engine on this
machine -- so users.html and field.html want one manual load.
server/smoketest.py still fails with 401s. Pre-existing: it has no login code,
so auth_gate refuses it. Confirmed unchanged by stashing this work.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
109 lines
4.8 KiB
JavaScript
109 lines
4.8 KiB
JavaScript
/* Service worker for the Work Package Suite PWA.
|
|
|
|
Goal: let the app (and especially the field view) load and run offline. Data
|
|
durability is already handled by the sync outbox in project-data.js — this
|
|
worker only caches the static app shell so the pages open without a network.
|
|
|
|
Strategy:
|
|
• /api/* and non-GET → never touched (pass straight to the network; offline
|
|
reads fall back to the app's localStorage cache, writes queue in the outbox).
|
|
• HTML / CSS / JS → network-first, cache as fallback. These reference each
|
|
other, so a page must never run against a stale sibling.
|
|
• images / icons / manifest → stale-while-revalidate (instant from cache).
|
|
*/
|
|
'use strict';
|
|
// Bumped when the shell file list changes, so clients fetch the new assets
|
|
// instead of serving a half-old shell from the previous cache.
|
|
const CACHE = 'wp-suite-shell-v6';
|
|
const SHELL = [
|
|
'/', '/index.html', '/work-package-suite.html', '/wp-creation-index.html',
|
|
'/field.html', '/login.html', '/admin.html', '/users.html',
|
|
'/theme-light.css', '/work-package-suite-styles.css', '/wp-creation-styles.css',
|
|
'/wp-chrome.css', '/console.css', '/wp-sidenav.css',
|
|
'/auth-guard.js', '/project-data.js', '/feedback-config.js', '/help.js',
|
|
'/work-package-suite-app.js', '/wp-creation-app.js', '/field.js',
|
|
'/wp-chrome.js', '/wp-sidenav.js', '/wp-format.js', '/login.js',
|
|
'/console-util.js', '/admin.js', '/users.js',
|
|
'/prime-controls-logo.jpg', '/favicon.ico',
|
|
'/manifest.webmanifest', '/icon-192.png', '/icon-512.png',
|
|
];
|
|
|
|
self.addEventListener('install', (e) => {
|
|
// Cache each shell asset individually so one missing file doesn't abort install.
|
|
e.waitUntil(
|
|
caches.open(CACHE)
|
|
// cache:'reload' bypasses the browser HTTP cache. Without it the precache
|
|
// can be filled from stale HTTP entries, freezing a mismatched shell.
|
|
.then((c) => Promise.all(SHELL.map(
|
|
(u) => c.add(new Request(u, { cache: 'reload' })).catch(() => {}))))
|
|
.then(() => self.skipWaiting())
|
|
);
|
|
});
|
|
|
|
self.addEventListener('activate', (e) => {
|
|
e.waitUntil(
|
|
caches.keys()
|
|
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
|
|
.then(() => self.clients.claim())
|
|
);
|
|
});
|
|
|
|
// Code (HTML / CSS / JS) is fetched NETWORK-FIRST, falling back to the cache when
|
|
// offline. Everything else (images, icons, the manifest) stays cache-first, which is
|
|
// where offline speed actually comes from.
|
|
//
|
|
// Why not cache-first for code: these files reference each other, and the cache
|
|
// stores them as independent entries. Cache-first served whichever copy of each file
|
|
// happened to be stored, so a browser could run new HTML against old CSS — which is
|
|
// exactly how the embedded creator once collapsed to a 300x150 iframe. A page must
|
|
// only ever run against the stylesheet and scripts it shipped with.
|
|
const CODE_RE = /\.(html|css|js)$|\/$/i;
|
|
|
|
self.addEventListener('fetch', (e) => {
|
|
const req = e.request;
|
|
if (req.method !== 'GET') return; // outbox owns writes
|
|
const url = new URL(req.url);
|
|
if (url.origin !== self.location.origin) return; // third-party: default
|
|
if (url.pathname.startsWith('/api/')) return; // never cache the API
|
|
|
|
const isCode = CODE_RE.test(url.pathname);
|
|
|
|
// Cache key WITHOUT the query string. Links inside the app carry ?project=…&tab=…,
|
|
// and the embedded creator used to carry a cache-busting timestamp, so keying on the
|
|
// full URL both missed every offline navigation and grew the cache without bound.
|
|
const key = new Request(url.origin + url.pathname, { credentials: 'same-origin' });
|
|
const fromCache = () => caches.match(key).then((c) => c || caches.match(req));
|
|
|
|
const store = (res) => {
|
|
if (res && res.ok && res.type !== 'opaque') {
|
|
const copy = res.clone();
|
|
caches.open(CACHE).then((c) => c.put(key, copy)).catch(() => {});
|
|
}
|
|
return res;
|
|
};
|
|
|
|
if (isCode) {
|
|
e.respondWith(
|
|
// cache:'no-cache' forces revalidation with the server. Plain fetch() inherits
|
|
// the request's default cache mode, which consults the browser HTTP cache — so
|
|
// "network-first" alone still let a page run against a stale sibling file.
|
|
fetch(req, { cache: 'no-cache' })
|
|
.then((res) => {
|
|
// A 502/404 must not replace a page the cache could still serve.
|
|
if (!res || !res.ok) return fromCache().then((c) => c || res);
|
|
return store(res);
|
|
})
|
|
.catch(() => fromCache()) // offline → last good copy
|
|
.then((res) => res || Response.error()) // never resolve to undefined
|
|
);
|
|
return;
|
|
}
|
|
|
|
e.respondWith(
|
|
caches.match(key).then((cached) => {
|
|
const network = fetch(req).then(store).catch(() => cached);
|
|
return cached || network.then((res) => res || Response.error());
|
|
})
|
|
);
|
|
});
|