Files
Project-SDE-WP-Suite/html/auth-guard.js
Matt Mabrey 73da684b99 T10.4: remove the local password path entirely
Real deletion (D15's 'full replacement'), not a toggle. Okta is now the only
credential this app accepts anywhere.

Backend:
- server/models.py: drop User.password_hash.
- server/alembic/versions/1d60a608bb51_...: matching migration (op.drop_column,
  same plain-drop precedent as project_role/locked_until/etc.; downgrade re-adds
  it with server_default='').
- server/auth.py: remove hash_password/verify_password/password_problem/
  MIN_PASSWORD_LEN/_COMMON_PASSWORDS, create_reset_token/decode_reset_token/
  RESET_MINUTES, the bcrypt import. Roles/tokens/cookies/get_current_user
  untouched.
- server/app.py: remove login(), the whole self-service reset-password block
  (forgot-password/reset-available/reset-password), and change_password()
  (POST /api/auth/password). Rework create_user() to drop the password field
  (with a docstring note: the username must exactly match the eventual Okta
  identity claim, or a later sign-in provisions a second account instead of
  matching this one). Remove admin_reset_password() outright - nothing left to
  reset. Fixes a bug this task's own predecessor left behind: okta_callback()'s
  JIT provisioning (T10.3) was still setting password_hash="", which would have
  raised TypeError the moment the column was actually dropped.

Admin bootstrap (D16): server/manage_users.py moves from creating accounts
(create/create-admin/reset-password, all password-based) to a single 'promote
<username> --role <role>' command that changes the role on a row Okta's JIT
provisioning already created - the documented path for naming the first admin.
list/disable/enable unchanged.

Frontend: html/users.js drops the password field and validation from
createUser(), removes resetPw() and its button (nothing left to reset).
html/users.html drops the #nu-password input, adds a tooltip on username
explaining the exact-match-to-Okta requirement. html/auth-guard.js removes the
wpChangePassword dialog; html/wp-sidenav.js removes the 'Password' menu item
that opened it.

Tests: tests/browser_check.py and tests/launcher_check.py stop hashing a
password to seed fixture rows (and the --keep-server hint now prints a
ready-to-use cookie-setting snippet instead of a dead username/password).
tests/pipeline_check.py and tests/token_check.py drop an unused PW import.
tests/console_dialogs_check.py: the admin password-reset dialog it drove no
longer exists, so that scenario is removed - the prompt-with-validate() UI
pattern it exercised is still covered via creator_dialogs_check.py's
wp-creation-app.js call sites, noted in this file's docstring so the coverage
move isn't silent. tests/url_state_check.py: the "next= survives a real sign-in
via login" scenario is explicitly marked SKIPPED (not deleted, not faked) -
that promise is specific to the login FORM this task removed and can't be
honestly re-proven until T10.5 rebuilds it as an Okta redirect; a minted-token
cookie now stands in as setup only, so scenarios 3-6 in that file still get a
signed-in page to run against.

server/smoketest.py and server/seed_demo.py: switched from POST /api/auth/login
to minting a session the same way okta_callback() does (auth.create_token(),
seeded into the cookie jar) rather than waiting on T10.7. This is a real
operational change, documented in both files' own AUTHENTICATION sections: they
now need to run where AUTH_SECRET_KEY and the database match the target
server's (inside the api container, or local dev) - they can no longer sign in
to an arbitrary remote URL from an unrelated workstation, because Okta requires
a real browser and these are stdlib scripts. The account must already exist;
neither script creates or promotes one.

server/requirements.txt: bcrypt dropped, nothing imports it anymore.

Verified: full Alembic chain (baseline through this migration) upgrades and
downgrades cleanly against a throwaway SQLite DB. okta_callback() JIT
provisioning re-tested against the post-migration schema (would have thrown
before the password_hash="" fix above). create_user() verified via a live HTTP
call with no password field. manage_users.py promote verified end to end
(seed a JIT-shaped row at project_user, promote to admin, list). smoketest.py
and seed_demo.py both run to completion against a live uvicorn instance using
the new minted-session path - 25/25 checks, including logout actually
invalidating the session (proving the cookie-jar seeding didn't just fake the
sign-in, it preserved the real expiry mechanics).

wave-10.md T10.4 / D15 / D16
2026-09-03 10:58:28 -07:00

160 lines
8.1 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';
// Register the PWA service worker (caches the app shell for offline use). The
// API and writes are never cached (see sw.js). This used to be skipped inside
// an iframe so the embedded creator did not register a second time; B7/T7.1
// dissolved that frame and there is no longer a document in the app that is
// not the top one.
if ('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;
// Was `inIframe ? window.top : window`, so an expired session inside the
// embedded creator replaced the whole window rather than painting a login
// page into a frame. No frame, no branch (B7/T7.1).
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'); });
};
// window.wpChangePassword used to open a change-password dialog here. Removed in
// T10.4 (D15/D16): there is no local password to change anymore — identity is
// Okta's job. The "Password" item that called this is gone from wp-sidenav.js
// too.
// ── 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); };
// The flat user menu that used to sit in this bar is gone (T2.2). It duplicated
// Admin, Users and Sign out from the navigation drawer, and being one unbreakable
// 412px run with an inline white-space:nowrap, it was what clipped the bar at 390px
// and cut "Sign out" in half — F2. wp-sidenav.js now carries all of it, including
// the item that was only here: Language & time. (Password was the other one; T10.4
// removed it along with the rest of local auth — D15/D16.)
//
// Nothing replaces it. Every signed-in page mounts the drawer, so there is no page
// left that would need a floating fallback 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) {}
}
}
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();
});
})();