diff --git a/docs/waves/backlog.md b/docs/waves/backlog.md index 7b65ec9..c932fe5 100644 --- a/docs/waves/backlog.md +++ b/docs/waves/backlog.md @@ -572,3 +572,22 @@ deliberately deferred. - **Suggested wave or follow-up:** next housekeeping pass. If kept, its docstring needs rewriting — it currently explains itself in terms of password resets, which no longer exist. + +### BL-027 — Okta exists on this estate; OIDC is a live alternative to the LDAPS bind + +- **Found during:** `T10.6` (D13), repointing "Forgot password?" at + `https://primecontrols.okta.com/` +- **Where:** authentication as a whole — `server/ldap_auth.py`, `server/app.py` `login()` +- **What:** D13 chose an LDAPS simple bind, decided before it was known that the company + runs an Okta tenant. Okta presumably federates to `prime.local` (which is why the + Windows password is still the one that binds), but its existence means an OIDC + authorization-code flow is available in principle. That would be strictly better on + three counts the LDAPS design cannot match: this app would never see a password at all, + MFA would come for free, and the domain-lockout hazard that forced + `AUTH_MAX_ATTEMPTS` down to 2 would disappear entirely, because failed attempts would + land on Okta rather than on a bind this endpoint makes. +- **Why not now:** D13 was decided and reaffirmed, T10.1–T10.4 are built and verified + against the live domain, and swapping the mechanism mid-wave is exactly the reordering + `CLAUDE.md` forbids. Recording it is not the same as reopening it. +- **Suggested wave or follow-up:** its own item and its own decision, with Nick and + whoever administers the Okta tenant. Not a widening of D13. diff --git a/docs/waves/wave-10.md b/docs/waves/wave-10.md index 83afc8c..d6b4e2b 100644 --- a/docs/waves/wave-10.md +++ b/docs/waves/wave-10.md @@ -234,8 +234,21 @@ to read the group name off the login page. two of them now point at endpoints that no longer exist. `auth-guard.js` has a change-password dialog, and the user-admin UI has a "reset password" action per row. -**Do:** Remove the `#view-forgot` and `#view-reset` sections, the `#forgot-link`, and the -reset-token handling in `login.js`. Remove the change-password dialog from `auth-guard.js` +**Decided Aug 21: "Forgot password?" is KEPT and repointed at +`https://primecontrols.okta.com/`.** An earlier draft of this task removed the link, and a +plain sentence saying "contact IT" was proposed instead. Okta is the better answer — it is a +real self-service path, and with no app password and no break-glass it is the only recovery +route that exists. Note this is the first sign of an Okta tenancy on this estate; see the +new backlog entry. + +Mechanics that matter: it is a plain ``, not a form post, so the `form-action 'self'` +in the CSP does not apply and no `navigate-to` directive is set — off-origin link navigation +is allowed as-is. `target="_blank"` needs `rel="noopener noreferrer"`, and there must be NO +click handler on `#forgot-link`: the old one called `preventDefault()` to swap views, and +leaving it would silently swallow the navigation. + +**Do:** Remove the `#view-forgot` and `#view-reset` sections and the reset-token handling in +`login.js`, and repoint `#forgot-link` as above. Remove the change-password dialog from `auth-guard.js` (`backlog.md:180` refers to it) and the per-row password reset from the users UI. Keep the sign-in form, and relabel the password field's hint to say it is the Windows/domain password — people need to know which password to type. @@ -244,7 +257,10 @@ Keep the role-granting controls exactly as they are. That is criterion 4. **Done when:** -- [ ] `grep -rn "forgot\|reset-password\|new-password" html/` returns nothing but unrelated matches +- [ ] `grep -rn "forgot\|reset-password\|new-password" html/` returns nothing but prose +- [ ] "Forgot password?" opens `https://primecontrols.okta.com/` in a new tab +- [ ] `#forgot-link` has NO click handler (a `preventDefault()` would swallow the navigation) +- [ ] a 503 from the login endpoint says sign-in is unavailable, not that the password is wrong - [ ] the sign-in form still submits, and a failure still announces through `role="alert"` (`login.html` already does this correctly — do not regress it) - [ ] the password field says which password to enter - [ ] no dead `` or handler remains for a removed view diff --git a/html/auth-guard.js b/html/auth-guard.js index 6a991ab..8564a69 100644 --- a/html/auth-guard.js +++ b/html/auth-guard.js @@ -60,63 +60,6 @@ .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 var(--cds-border-strong);border-radius:4px;font-size:14px;'; - var lbl = 'display:block;font-size:12px;color:var(--cds-text-secondary);margin-bottom:4px;'; - ov.innerHTML = - '
' + - '
Change password
' + - '
' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '
' + - '
' + - '' + - '' + - '
' + - '
'; - 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 ? 'var(--wp-status-success-bg)' : 'var(--wp-status-error-bg)'; el.style.color = ok ? 'var(--wp-status-success-text)' : 'var(--cds-support-error)'; - } - 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. diff --git a/html/login.html b/html/login.html index 5a91149..5b3878a 100644 --- a/html/login.html +++ b/html/login.html @@ -112,47 +112,16 @@ +
Use your Windows password — the same one you use to sign in to your computer.
-

Forgot password?

- - - - - - -

Authorized use only · BTG / Pilot

diff --git a/html/login.js b/html/login.js index f95e8f3..fe02769 100644 --- a/html/login.js +++ b/html/login.js @@ -1,26 +1,20 @@ /* Login page logic for the Work Package Suite. - Three views on one page: - • sign in posts to /api/auth/login. On success the server sets an - HttpOnly session cookie (not readable here — that's the - point) and we redirect to ?next= or the home page. - • forgot password posts to /api/auth/forgot-password, which emails a - single-use link. Only offered when the server reports - email is actually configured (/api/auth/reset-available); - otherwise we say to ask an admin. - • set a new password shown when the page is opened as login.html?reset= - from that email. Posts to /api/auth/reset-password. + One view. Sign in posts to /api/auth/login, the server authenticates by binding + to the domain over LDAPS (D13), and on success sets an HttpOnly session cookie — + not readable from here, which is the point — after which we redirect to ?next= + or the home page. The password entered is the person's WINDOWS password. - The reset token stays in the URL only until it's used; on success we strip it - from the address bar so it isn't left in history or copied out of the bar. */ + There is no forgot-password flow and no reset view: the suite holds no password + to reset. "Forgot password?" is a plain external link to Okta in login.html, so + there is deliberately no click handler for it here — one that called + preventDefault() would swallow the navigation. */ (function () { 'use strict'; var errorBox = document.getElementById('error'); var okBox = document.getElementById('ok'); - function show(el) { if (el) el.style.display = ''; } - function hide(el) { if (el) el.style.display = 'none'; } function byId(id) { return document.getElementById(id); } function showError(msg) { @@ -28,11 +22,6 @@ errorBox.textContent = msg; errorBox.classList.add('show'); } - function showOk(msg) { - errorBox.classList.remove('show'); - okBox.textContent = msg; - okBox.classList.add('show'); - } function clearBanners() { errorBox.classList.remove('show'); okBox.classList.remove('show'); @@ -49,10 +38,6 @@ return 'index.html'; } - function resetToken() { - try { return new URLSearchParams(location.search).get('reset') || ''; } catch (e) { return ''; } - } - function postJson(url, payload) { return fetch(url, { method: 'POST', @@ -70,18 +55,11 @@ return (typeof d === 'string' && d) ? d : fallback; } - function view(which) { - clearBanners(); - ['login', 'forgot', 'reset'].forEach(function (v) { - (which === v ? show : hide)(byId('view-' + v)); - }); - } - // ── sign in ──────────────────────────────────────────────────────────────── var form = byId('login-form'); var submitBtn = byId('submit'); - // Guarded because a cached older login.html may not have the reset views; an - // unguarded addEventListener on null would break sign-in itself. + // Guarded: an unguarded addEventListener on null would break sign-in itself if a + // cached older login.html were served. if (!form || !submitBtn) return; form.addEventListener('submit', function (e) { e.preventDefault(); @@ -98,6 +76,10 @@ if (res.status === 401) showError('Invalid username or password.'); else if (res.status === 403) showError(detail(res, 'Your account is disabled.')); else if (res.status === 429) showError(detail(res, 'Too many failed attempts. Try again later.')); + // 503 means the directory is unreachable or misconfigured — OUR fault, not a + // wrong password. Saying so stops people hunting for a password they no + // longer have while a deploy is broken. + else if (res.status === 503) showError(detail(res, 'Sign-in is temporarily unavailable. Contact IT.')); else showError(detail(res, 'Sign-in failed (HTTP ' + res.status + ').')); submitBtn.disabled = false; submitBtn.textContent = 'Sign in'; @@ -109,107 +91,4 @@ }); }); - // ── forgot password ──────────────────────────────────────────────────────── - var resetAvailable = null; // null = not checked yet - - function checkResetAvailable() { - if (resetAvailable !== null) return Promise.resolve(resetAvailable); - return fetch('/api/auth/reset-available') - .then(function (r) { return r.ok ? r.json() : null; }) - .then(function (j) { resetAvailable = !!(j && j.enabled); return resetAvailable; }) - .catch(function () { resetAvailable = false; return false; }); - } - - (byId('forgot-link') || {addEventListener: function(){}}).addEventListener('click', function (e) { - e.preventDefault(); - view('forgot'); - // Prefill from the sign-in box so nobody types their username twice. - var u = byId('username').value.trim(); - if (u) byId('forgot-username').value = u; - checkResetAvailable().then(function (enabled) { - // With email off there's nothing to submit — say so and hide the form. - (enabled ? hide : show)(byId('forgot-unavailable')); - (enabled ? show : hide)(byId('forgot-form')); - if (enabled) byId('forgot-username').focus(); - }); - }); - - (byId('back-to-login') || {addEventListener: function(){}}).addEventListener('click', function (e) { - e.preventDefault(); - view('login'); - }); - - var forgotForm = byId('forgot-form') || document.createElement('form'); - var forgotBtn = byId('forgot-submit') || document.createElement('button'); - forgotForm.addEventListener('submit', function (e) { - e.preventDefault(); - clearBanners(); - var who = byId('forgot-username').value.trim(); - if (!who) { showError('Enter your username or email.'); return; } - forgotBtn.disabled = true; - forgotBtn.textContent = 'Sending…'; - postJson('/api/auth/forgot-password', { username: who }) - .then(function (res) { - if (res.status === 503) { - showError(detail(res, "Password reset by email isn't available. Ask an administrator.")); - } else if (res.ok) { - // Deliberately the same message whether or not the account exists. - showOk('If that account exists, a reset link is on its way. The link expires in an hour.'); - hide(forgotForm); - } else { - showError(detail(res, 'Could not send the reset email (HTTP ' + res.status + ').')); - } - forgotBtn.disabled = false; - forgotBtn.textContent = 'Email me a reset link'; - }) - .catch(function () { - showError('Could not reach the server. Check your connection and try again.'); - forgotBtn.disabled = false; - forgotBtn.textContent = 'Email me a reset link'; - }); - }); - - // ── set a new password (from the emailed link) ────────────────────────────── - (byId('reset-to-login') || {addEventListener: function(){}}).addEventListener('click', function (e) { - e.preventDefault(); - view('login'); - }); - - var resetForm = byId('reset-form') || document.createElement('form'); - var resetBtn = byId('reset-submit') || document.createElement('button'); - resetForm.addEventListener('submit', function (e) { - e.preventDefault(); - clearBanners(); - var token = resetToken(); - var pw = byId('new-password').value; - var pw2 = byId('new-password2').value; - if (!token) { showError('This reset link is incomplete. Request a new one.'); return; } - if (pw !== pw2) { showError('The two passwords do not match.'); return; } - if (pw.length < 12) { showError('Password must be at least 12 characters.'); return; } - - resetBtn.disabled = true; - resetBtn.textContent = 'Saving…'; - postJson('/api/auth/reset-password', { token: token, new_password: pw }) - .then(function (res) { - if (res.ok) { - // Take the token out of the URL before anything else — it's spent. - try { history.replaceState(null, '', 'login.html'); } catch (err) {} - view('login'); - showOk('Password updated. Sign in with your new password.'); - byId('username').focus(); - return; - } - showError(detail(res, 'Could not set your password (HTTP ' + res.status + ').')); - resetBtn.disabled = false; - resetBtn.textContent = 'Set password & sign in'; - }) - .catch(function () { - showError('Could not reach the server. Check your connection and try again.'); - resetBtn.disabled = false; - resetBtn.textContent = 'Set password & sign in'; - }); - }); - - // Arriving from the reset email opens straight into the new-password view. - if (resetToken()) view('reset'); })(); diff --git a/html/users.html b/html/users.html index 1e9fa2b..066da61 100644 --- a/html/users.html +++ b/html/users.html @@ -26,9 +26,8 @@ room for "Assistant Project Manager" without pushing Actions off screen. */ #users-table table td:nth-child(3){ max-width:230px; overflow:hidden; text-overflow:ellipsis; } #users-banner:not(:empty), #scope-banner:not(:empty){ margin-bottom:var(--s3); } - /* The create form is a lot of fields; give the password one room to breathe and + /* The create form is a lot of fields; let them wrap and let the project picker take a full row of its own. */ - #nu-password{ flex:1 1 200px; } #nu-projects{ margin-top:var(--s2); } #nu-projects .pickrow{ padding:var(--s1) var(--s1); } /* A manager with one project doesn't need a scrolling picker; a manager with @@ -88,7 +87,6 @@ -
diff --git a/html/users.js b/html/users.js index 2a32c77..ab5bea3 100644 --- a/html/users.js +++ b/html/users.js @@ -183,11 +183,12 @@ function managerRow(u){ : projRoleReadonly(u, can, why); const actions = []; - if(can && !me) actions.push(''); if(can && !me) actions.push(''); if(can && !me) actions.push(''); - if(me) actions.push(''); + // D13: no self-service action left on your own row — the domain owns the password + // and role changes are never self-applied. + if(me) actions.push('you'); if(!can && !me) actions.push('read-only'); return ''+ @@ -254,19 +255,6 @@ function projAccessCell(u){ // ── row actions ─────────────────────────────────────────────────────────────── // Each one reloads on failure so a control can never sit there showing a value the // server refused. -async function resetPw(id, username){ - // The min-12 rule was stated in the prompt label and enforced only by the - // server round-trip; the kit's validate() answers AT the input instead. - const pw = await wpPromptDialog({title:'Reset password', - message:'Set a new password for "'+username+'". Their existing sessions are signed out.', - label:'New password (min 12 characters)', - validate:v => (v && v.length >= 12) ? '' : 'At least 12 characters.'}); - if(pw === null) return; - const { status, json } = await api('POST','/api/auth/users/'+id+'/password',{new_password:pw}); - if(status === 200) toast('Password reset for '+username+'. Their existing sessions are signed out.'); - else wpAlertDialog({title:'Reset failed', message:'Could not reset the password: '+apiError(status, json)}); -} - async function toggleActive(id, makeActive){ const { status, json } = await api('POST','/api/auth/users/'+id+'/active',{is_active:makeActive}); if(status === 200) loadUsers(); @@ -345,24 +333,22 @@ async function createUser(){ const msg = document.getElementById('users-create-msg'); const val = id => (document.getElementById(id)||{}).value || ''; const username = val('nu-username').trim(); - const password = val('nu-password'); const project_ids = [...document.querySelectorAll('#nu-project-list input[type=checkbox]:checked')] .map(c => c.value); const say = (color, text) => { msg.style.color = color; msg.textContent = text; }; if(!username){ say('var(--red)','Username is required.'); return; } - if(password.length < 12){ say('var(--red)','Password must be at least 12 characters.'); return; } if(_scope.scope !== 'all' && !project_ids.length){ say('var(--red)','Pick at least one project — you administer users per project.'); return; } say('var(--muted)','Creating…'); const { status, json } = await api('POST','/api/auth/users',{ - username, password, project_ids, + username, project_ids, full_name: val('nu-fullname').trim(), email: val('nu-email').trim(), role: val('nu-role'), project_role: val('nu-project-role'), }); if(status === 200){ say('var(--green)','✓ Created '+username+'.'); - ['nu-username','nu-fullname','nu-email','nu-password'].forEach(id => document.getElementById(id).value = ''); + ['nu-username','nu-fullname','nu-email'].forEach(id => document.getElementById(id).value = ''); loadUsers(); } else { say('var(--red)','✕ '+apiError(status, json, 'Could not create the account')); diff --git a/html/wp-sidenav.js b/html/wp-sidenav.js index 3ca490e..aa38868 100644 --- a/html/wp-sidenav.js +++ b/html/wp-sidenav.js @@ -53,7 +53,6 @@ { section: 'Account' }, { action: 'wpPreferences', icon: '◷', label: 'Language & time', sub: 'Dates, numbers and time zone' }, - { action: 'wpChangePassword', icon: '⚿', label: 'Password', sub: 'Change your password' }, ]; function esc(v) {