Acts on the site comments from 8/3 plus the follow-ups. Foundation work first — four of the comments all needed the project team to resolve to real user accounts. Permissions vs project role (new) - User.role is now the PERMISSIONS role: admin | project_admin | project_user. project_admin may delete work packages, change a SOP after it is complete, and delete a project; project_user may not (archiving a WP is still open to them). Enforced by require_project_admin() server-side; the UI only hides dead ends. - New User.project_role holds the person's JOB FUNCTION on the project. It grants nothing — it feeds the SOP team pickers and notification routing. - Admin console shows both columns and explains the difference. Migration rewrites the legacy role 'user' to 'project_user'. - Deleting a project was previously open to any member and unaudited; it now needs project_admin and writes an audit event. ProjectData.remove no longer drops the project from the local cache when the server refuses. SOP project team from user accounts - PM/APM/CM/QM and additional team members are pickers over the project's members, storing the account id next to the display name. A name from an older SOP with no matching account is kept and flagged rather than dropped. - The WP Creator lists the SOP team first in the Owner picker, and a new package defaults to whoever is creating it. Critical constraints - SOP constraints carry a Critical flag; buildConstraints() now copies the whole definition through to the package (it previously reduced them to names, losing description too), and critical rows are marked in the WP form. The email on reopen-after-release is wave 3. Password reset by email - login.html gains Forgot password and a set-a-new-password view, offered only when the server reports email is actually configured. - Single-use signed token (AUTH_RESET_MINUTES, default 60) bound to token_version, sent immediately rather than through the notifications outbox so a reset link is never persisted. Identical response for unknown accounts; per-account send cooldown; a completed reset clears any login lockout. - Session and reset tokens are no longer interchangeable. BIM kill-switch - New admin Features card with bim_enabled, OFF by default. The SOP creator hides the BIM section and the Creator treats every package as install-only while it is off; a SOP that already has BIM keeps its data untouched. Verified with two throwaway-database test scripts: 44 checks on the permissions matrix and token handling, 22 on the reset flow end-to-end against a local SMTP sink (real message captured, link extracted and used). Front-end files parse-checked in headless Chrome. Not yet exercised in a browser against a real login. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
213 lines
8.6 KiB
JavaScript
213 lines
8.6 KiB
JavaScript
/* 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=<token>
|
|
from that email. Posts to /api/auth/reset-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. */
|
|
(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) {
|
|
okBox.classList.remove('show');
|
|
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');
|
|
}
|
|
|
|
// Where to go after signing in: the ?next= param if it's a safe same-site
|
|
// path, otherwise the home page. (Reject absolute/scheme URLs to avoid an
|
|
// open-redirect.)
|
|
function nextTarget() {
|
|
try {
|
|
var next = new URLSearchParams(location.search).get('next') || '';
|
|
if (next && next.charAt(0) === '/' && next.charAt(1) !== '/') return next;
|
|
} catch (e) {}
|
|
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',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
}).then(function (r) {
|
|
return r.json().catch(function () { return null; }).then(function (j) {
|
|
return { status: r.status, ok: r.ok, json: j };
|
|
});
|
|
});
|
|
}
|
|
|
|
function detail(res, fallback) {
|
|
var d = res && res.json && res.json.detail;
|
|
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');
|
|
form.addEventListener('submit', function (e) {
|
|
e.preventDefault();
|
|
clearBanners();
|
|
var username = byId('username').value.trim();
|
|
var password = byId('password').value;
|
|
if (!username || !password) { showError('Enter your username and password.'); return; }
|
|
|
|
submitBtn.disabled = true;
|
|
submitBtn.textContent = 'Signing in…';
|
|
postJson('/api/auth/login', { username: username, password: password })
|
|
.then(function (res) {
|
|
if (res.ok) { location.replace(nextTarget()); return; }
|
|
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.'));
|
|
else showError(detail(res, 'Sign-in failed (HTTP ' + res.status + ').'));
|
|
submitBtn.disabled = false;
|
|
submitBtn.textContent = 'Sign in';
|
|
})
|
|
.catch(function () {
|
|
showError('Could not reach the server. Check your connection and try again.');
|
|
submitBtn.disabled = false;
|
|
submitBtn.textContent = 'Sign in';
|
|
});
|
|
});
|
|
|
|
// ── 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('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('click', function (e) {
|
|
e.preventDefault();
|
|
view('login');
|
|
});
|
|
|
|
var forgotForm = byId('forgot-form');
|
|
var forgotBtn = byId('forgot-submit');
|
|
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('click', function (e) {
|
|
e.preventDefault();
|
|
view('login');
|
|
});
|
|
|
|
var resetForm = byId('reset-form');
|
|
var resetBtn = byId('reset-submit');
|
|
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');
|
|
})();
|