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>
276 lines
15 KiB
JavaScript
276 lines
15 KiB
JavaScript
/* Auth guard for the Work Package Suite.
|
|
Included in the <head> of every protected page (before other scripts). It
|
|
confirms there is a valid session by calling /api/auth/me; if not, it sends
|
|
the user to the login page. The real protection is server-side (the API
|
|
refuses data requests without a session) — this guard is for UX so people
|
|
land on the login screen instead of an empty app.
|
|
|
|
It also exposes:
|
|
window.WP_USER the logged-in user object (set once verified)
|
|
window.wpLogout() clears the session and returns to the login page
|
|
and dispatches a 'wp-auth-ready' event on document once WP_USER is set. */
|
|
(function () {
|
|
'use strict';
|
|
|
|
var inIframe = (function () { try { return window.top !== window.self; } catch (e) { return true; } })();
|
|
|
|
// Register the PWA service worker (caches the app shell for offline use). Only
|
|
// from the top window; the API and writes are never cached (see sw.js).
|
|
if (!inIframe && 'serviceWorker' in navigator) {
|
|
try { navigator.serviceWorker.register('/sw.js'); } catch (e) {}
|
|
}
|
|
|
|
// Hide the page until we know the user is allowed, to avoid a flash of the app
|
|
// before a redirect. A safety timer reveals it even if the check hangs.
|
|
var root = document.documentElement;
|
|
var style = document.createElement('style');
|
|
style.textContent = '.wp-auth-pending body{visibility:hidden!important}';
|
|
(document.head || root).appendChild(style);
|
|
root.className += ' wp-auth-pending';
|
|
function reveal() { root.className = root.className.replace(/\bwp-auth-pending\b/, ''); }
|
|
var safety = setTimeout(reveal, 4000);
|
|
|
|
function goToLogin() {
|
|
clearTimeout(safety);
|
|
var next = encodeURIComponent(location.pathname + location.search);
|
|
var url = 'login.html?next=' + next;
|
|
// If we're inside the WP-creator iframe, redirect the whole window.
|
|
var w = inIframe ? window.top : window;
|
|
try { w.location.replace(url); } catch (e) { window.location.replace(url); }
|
|
}
|
|
|
|
window.wpLogout = function () {
|
|
try {
|
|
// Clear the auth cache AND all cached project data (customer IP) from this
|
|
// device on sign-out — important on shared/field tablets. The outbox
|
|
// (wp_sync_outbox_v1) is left intact so unsynced writes aren't lost.
|
|
// (localStorage is not a security boundary; field devices still need
|
|
// full-disk encryption / MDM — see DEPLOYMENT.md.)
|
|
localStorage.removeItem('wp_auth_cache');
|
|
Object.keys(localStorage).forEach(function (k) {
|
|
if (/^wp_(iwp_v1|suite_sop|suite_state|projects|active_project)/.test(k)) {
|
|
localStorage.removeItem(k);
|
|
}
|
|
});
|
|
} catch (e) {}
|
|
fetch('/api/auth/logout', { method: 'POST' })
|
|
.catch(function () {})
|
|
.then(function () { window.location.replace('login.html'); });
|
|
};
|
|
|
|
// Change-password dialog (uses POST /api/auth/password, which requires the
|
|
// current password). Available from the top-right pill on any page.
|
|
window.wpChangePassword = function () {
|
|
if (document.getElementById('wp-pw-modal')) return;
|
|
var ov = document.createElement('div');
|
|
ov.id = 'wp-pw-modal';
|
|
ov.style.cssText = 'position:fixed;inset:0;background:rgba(20,30,50,.5);display:flex;align-items:center;' +
|
|
'justify-content:center;z-index:10002;padding:20px;font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;';
|
|
var inp = 'width:100%;padding:9px 10px;margin-bottom:12px;border:1px solid #8d8d8d;border-radius:4px;font-size:14px;';
|
|
var lbl = 'display:block;font-size:12px;color:#525252;margin-bottom:4px;';
|
|
ov.innerHTML =
|
|
'<div style="background:#fff;color:#161616;border-radius:10px;max-width:380px;width:100%;box-shadow:0 12px 40px rgba(20,30,50,.3);overflow:hidden;">' +
|
|
'<div style="padding:14px 18px;border-bottom:1px solid #e0e0e0;font-weight:700;">Change password</div>' +
|
|
'<div style="padding:16px 18px;">' +
|
|
'<div id="wp-pw-msg" style="display:none;font-size:12.5px;padding:8px 10px;border-radius:6px;margin-bottom:12px;"></div>' +
|
|
'<label style="' + lbl + '">Current password</label>' +
|
|
'<input id="wp-pw-cur" type="password" autocomplete="current-password" style="' + inp + '">' +
|
|
'<label style="' + lbl + '">New password (at least 12 characters)</label>' +
|
|
'<input id="wp-pw-new" type="password" autocomplete="new-password" style="' + inp + '">' +
|
|
'<label style="' + lbl + '">Confirm new password</label>' +
|
|
'<input id="wp-pw-new2" type="password" autocomplete="new-password" style="' + inp + 'margin-bottom:0;">' +
|
|
'</div>' +
|
|
'<div style="padding:12px 18px;border-top:1px solid #e0e0e0;display:flex;gap:8px;justify-content:flex-end;">' +
|
|
'<button type="button" id="wp-pw-cancel" style="padding:8px 14px;border:1px solid #8d8d8d;background:#fff;border-radius:6px;cursor:pointer;font-weight:600;">Cancel</button>' +
|
|
'<button type="button" id="wp-pw-save" style="padding:8px 14px;border:none;background:#0f62fe;color:#fff;border-radius:6px;cursor:pointer;font-weight:600;">Update password</button>' +
|
|
'</div>' +
|
|
'</div>';
|
|
function close() { var m = document.getElementById('wp-pw-modal'); if (m) m.remove(); }
|
|
function msg(text, ok) {
|
|
var el = document.getElementById('wp-pw-msg');
|
|
el.style.display = 'block'; el.textContent = text;
|
|
el.style.background = ok ? '#defbe6' : '#fff1f1'; el.style.color = ok ? '#0e6027' : '#da1e28';
|
|
}
|
|
ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
|
|
document.body.appendChild(ov);
|
|
document.getElementById('wp-pw-cancel').onclick = close;
|
|
document.getElementById('wp-pw-cur').focus();
|
|
document.getElementById('wp-pw-save').onclick = function () {
|
|
var cur = document.getElementById('wp-pw-cur').value;
|
|
var n1 = document.getElementById('wp-pw-new').value;
|
|
var n2 = document.getElementById('wp-pw-new2').value;
|
|
if (!cur || !n1) { msg('Please fill in every field.', false); return; }
|
|
if (n1.length < 12) { msg('New password must be at least 12 characters.', false); return; }
|
|
if (n1 !== n2) { msg('New passwords do not match.', false); return; }
|
|
fetch('/api/auth/password', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ current_password: cur, new_password: n1 })
|
|
})
|
|
.then(function (r) { return r.json().catch(function () { return null; }).then(function (j) { return { ok: r.ok, status: r.status, j: j }; }); })
|
|
.then(function (res) {
|
|
if (res.ok) { msg('Password updated.', true); setTimeout(close, 1200); }
|
|
else { msg((res.j && res.j.detail) || ('Could not update (HTTP ' + res.status + ').'), false); }
|
|
})
|
|
.catch(function () { msg('Could not reach the server.', false); });
|
|
};
|
|
};
|
|
|
|
// ── permissions helpers ────────────────────────────────────────────────────
|
|
// The server enforces all of this; these are for hiding controls the signed-in
|
|
// user can't use, so nobody clicks a button just to get a 403.
|
|
// 'user' is the legacy value for what is now 'project_user'.
|
|
window.wpRole = function () {
|
|
var r = (window.WP_USER && window.WP_USER.role) || '';
|
|
return r === 'user' ? 'project_user' : r;
|
|
};
|
|
window.wpIsAdmin = function () { return window.wpRole() === 'admin'; };
|
|
// A Project Super User is a Project Admin with user administration on top, so it
|
|
// counts here too (server: auth.is_project_admin).
|
|
window.wpIsProjectAdmin = function () {
|
|
var r = window.wpRole();
|
|
return r === 'admin' || r === 'project_super_user' || r === 'project_admin';
|
|
};
|
|
// Deleting a work package, deleting a project, and editing a completed SOP are
|
|
// all Project Admin actions (see server require_project_admin).
|
|
window.wpCanDeleteWP = window.wpIsProjectAdmin;
|
|
window.wpCanEditCompletedSOP = window.wpIsProjectAdmin;
|
|
// Whether this account can administer USER accounts. The account role is only half
|
|
// the answer — the role can also be held on a single project — so anything that
|
|
// needs the real verdict asks GET /api/auth/user-scope (users.js does). This is the
|
|
// cheap hint used to decide whether to bother offering a control.
|
|
window.wpMayManageUsers = function () {
|
|
var r = window.wpRole();
|
|
return r === 'admin' || r === 'project_super_user';
|
|
};
|
|
|
|
// ── app feature flags ──────────────────────────────────────────────────────
|
|
// Cached per page load. Pages that must know before rendering should await
|
|
// wpFlags(); anything already rendered can re-check on the 'wp-flags-ready' event.
|
|
window.WP_FLAGS = null;
|
|
var _flagsPromise = null;
|
|
window.wpFlags = function () {
|
|
if (window.WP_FLAGS) return Promise.resolve(window.WP_FLAGS);
|
|
if (_flagsPromise) return _flagsPromise;
|
|
_flagsPromise = fetch('/api/app-flags', { headers: { 'Accept': 'application/json' } })
|
|
.then(function (r) { return r.ok ? r.json() : {}; })
|
|
.catch(function () { return {}; }) // offline: fall through to defaults
|
|
.then(function (f) {
|
|
window.WP_FLAGS = f || {};
|
|
try { document.dispatchEvent(new CustomEvent('wp-flags-ready', { detail: window.WP_FLAGS })); } catch (e) {}
|
|
return window.WP_FLAGS;
|
|
});
|
|
return _flagsPromise;
|
|
};
|
|
// BIM/VDC is off unless an admin has switched it on, so an unreachable API or a
|
|
// stale cache errs toward hiding the unfinished tooling rather than showing it.
|
|
window.wpBimEnabled = function () { return !!(window.WP_FLAGS && window.WP_FLAGS.bim_enabled); };
|
|
|
|
function isDarkBg(el) {
|
|
try {
|
|
var m = (getComputedStyle(el).backgroundColor || '').match(/(\d+),\s*(\d+),\s*(\d+)/);
|
|
if (!m) return true;
|
|
return (0.299 * +m[1] + 0.587 * +m[2] + 0.114 * +m[3]) < 140;
|
|
} catch (e) { return true; }
|
|
}
|
|
|
|
// The user menu (name · Admin · Password · Sign out). Text colors adapt to the
|
|
// bar it sits in (light links on a dark bar, blue links on a light bar).
|
|
function buildUserMenu(user, dark) {
|
|
var wrap = document.createElement('div');
|
|
wrap.id = 'wp-usermenu';
|
|
var linkColor = dark ? '#ffffff' : '#0f62fe';
|
|
wrap.style.cssText = 'display:flex;align-items:center;gap:8px;margin-left:auto;padding-left:14px;white-space:nowrap;' +
|
|
'font:400 13px/1.2 "IBM Plex Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;' +
|
|
'color:' + (dark ? '#c6c6c6' : '#525252') + ';';
|
|
function sep() { var s = document.createElement('span'); s.textContent = '·'; s.style.color = dark ? '#6f6f6f' : '#a8a8a8'; return s; }
|
|
function link(text, onClick, href) {
|
|
var a = document.createElement('a'); a.textContent = text; a.href = href || '#';
|
|
a.style.cssText = 'color:' + linkColor + ';text-decoration:none;font-weight:600;';
|
|
if (onClick) a.addEventListener('click', function (e) { e.preventDefault(); onClick(); });
|
|
return a;
|
|
}
|
|
var who = document.createElement('span');
|
|
who.textContent = user.full_name || user.username;
|
|
who.style.color = dark ? '#ffffff' : '#161616';
|
|
wrap.appendChild(who);
|
|
var onAdmin = /(^|\/)admin\.html$/.test(location.pathname);
|
|
if (window.wpIsAdmin() && !onAdmin) { wrap.appendChild(sep()); wrap.appendChild(link('Admin', null, 'admin.html')); }
|
|
// The directory is readable by everyone — it's how you find who is on your job —
|
|
// so it is offered to everyone, not just the people who can edit accounts.
|
|
if (!/(^|\/)users\.html$/.test(location.pathname)) {
|
|
wrap.appendChild(sep()); wrap.appendChild(link('Users', null, 'users.html'));
|
|
}
|
|
// Always offered; wp-format.js may still be parsing when the menu is built, so
|
|
// the check happens at click time rather than once, up front.
|
|
wrap.appendChild(sep());
|
|
wrap.appendChild(link('Language & time', function () {
|
|
if (typeof window.wpPreferences === 'function') window.wpPreferences();
|
|
}));
|
|
wrap.appendChild(sep()); wrap.appendChild(link('Password', function () { window.wpChangePassword(); }));
|
|
wrap.appendChild(sep()); wrap.appendChild(link('Sign out', function () { window.wpLogout(); }));
|
|
return wrap;
|
|
}
|
|
|
|
function addLogoutPill(user) {
|
|
if (inIframe) return; // the parent page already shows it
|
|
if (document.getElementById('wp-usermenu') || document.getElementById('wp-logout-pill')) return;
|
|
|
|
// Preferred: drop the menu INTO the top bar so it never floats over the
|
|
// header's own links (Help, etc.). Works with the dark UI-shell appbar and
|
|
// the older .header bars alike.
|
|
var host = document.querySelector('.wp-appbar') || document.querySelector('.header');
|
|
if (host) {
|
|
var menu = buildUserMenu(user, isDarkBg(host));
|
|
// The older .header bars already right-align their own toolbar (via flex:1
|
|
// or a button's margin-left:auto). A second auto-margin would split the free
|
|
// space, so only the .wp-appbar (which may have no spacer, e.g. admin) keeps it.
|
|
if (!host.classList.contains('wp-appbar')) menu.style.marginLeft = '0';
|
|
host.appendChild(menu);
|
|
return;
|
|
}
|
|
|
|
// Fallback for any page with no header bar: a floating pill (as before).
|
|
var pill = document.createElement('div');
|
|
pill.id = 'wp-logout-pill';
|
|
pill.style.cssText = 'position:fixed;top:12px;right:12px;z-index:10001;' +
|
|
'display:flex;align-items:center;background:#fff;border:1px solid #e0e0e0;' +
|
|
'box-shadow:0 1px 4px rgba(0,0,0,.16);border-radius:16px;padding:5px 12px;';
|
|
pill.appendChild(buildUserMenu(user, false));
|
|
document.body.appendChild(pill);
|
|
}
|
|
|
|
function proceed(user) {
|
|
clearTimeout(safety);
|
|
window.WP_USER = user;
|
|
reveal();
|
|
if (window.WP_USER) {
|
|
window.wpFlags(); // start the feature-flag fetch; pages await it as needed
|
|
try { document.dispatchEvent(new CustomEvent('wp-auth-ready', { detail: window.WP_USER })); } catch (e) {}
|
|
if (document.body) addLogoutPill(window.WP_USER);
|
|
else document.addEventListener('DOMContentLoaded', function () { addLogoutPill(window.WP_USER); });
|
|
}
|
|
}
|
|
|
|
fetch('/api/auth/me', { headers: { 'Accept': 'application/json' } })
|
|
.then(function (r) {
|
|
if (r.status === 401 || r.status === 403) { try { localStorage.removeItem('wp_auth_cache'); } catch (e) {} goToLogin(); return; }
|
|
if (!r.ok) { reveal(); clearTimeout(safety); return; } // unexpected; show page rather than trap
|
|
return r.json().then(function (data) {
|
|
var user = data && data.user;
|
|
// Remember the last good auth so the PWA can open offline. The server is
|
|
// still the real gate; offline writes queue in the outbox until reconnect.
|
|
try { if (user) localStorage.setItem('wp_auth_cache', JSON.stringify({ user: user, at: Date.now() })); } catch (e) {}
|
|
proceed(user);
|
|
});
|
|
})
|
|
.catch(function () {
|
|
// Offline / API unreachable: fall back to a recent cached auth if present,
|
|
// so the app (and the field view) still open without a network.
|
|
try {
|
|
var c = JSON.parse(localStorage.getItem('wp_auth_cache') || 'null');
|
|
if (c && c.user && (Date.now() - (c.at || 0)) < 12 * 3600 * 1000) { proceed(c.user); return; }
|
|
} catch (e) {}
|
|
goToLogin();
|
|
});
|
|
})();
|