Files
UmbraTrans/web/assets/app.js
umbra audio 3419485aa7 Fix a syntax error I introduced, which put a password in a URL
The commit immediately before this one - the password-toggle fix - deleted a closing
brace and did not replace it. app.js did not parse. The browser reported it in a console
nobody was watching, and every script-driven behaviour on the sign-in page stopped.

The consequence was not a cosmetic one. The login form's submit handler, which is the
only thing calling preventDefault(), never ran, so the browser performed a native form
submission. Neither form declared a method, and a form with no method attribute submits
as GET. Both password fields went into the query string:

    GET /?email=umbra%40bdmurphy.me&password=...

The server logged it in plain text, three times, before it was noticed. It would also
have sat in browser history and in any proxy access log between here and the public
internet.

Two fixes, the second of which is the one that matters:

- The toggle fix is reapplied correctly to the last known-good version of app.js, with
  nothing else in the file touched - the diff is four lines against thirteen and two
  against one.
- **Every form on every page now declares method=post.** The script still intercepts
  and submits via fetch; this decides what happens when the script does not run, which is
  precisely the case that failed. Five more forms carried the same latent defect,
  including password reset and email verification.

test_form_safety.py checks both, and was confirmed to fail when app.js is broken the
same way. It uses esprima rather than a hand-written brace counter, which gave two false
readings during this work - one of them 'balanced' for a file that did not parse at all.
A check that can be wrong about a syntax error is worse than no check.

The account password should be treated as exposed and changed. So should any password
entered into that form while the script was broken.
2026-10-03 20:06:05 -07:00

814 lines
29 KiB
JavaScript

/*
* UmbraTrans sign-in and workspace page behaviour.
*
* This file is shared by `index.html` and `workspace.html`. Every DOM lookup
* is guarded so the two pages can share it: each setup function returns
* immediately when the markup it drives is not present.
*
* Sign-in posts the account *name* (never an email address) to
* `POST /api/auth/login`, then routes by role. A session that must replace its
* temporary password is held on the page and navigation is locked until the
* change succeeds.
*/
(function (global) {
'use strict';
var api = global.UmbraApi;
if (!api) {
return;
}
var query = api.query;
var queryAll = api.queryAll;
/* -------------------------------------------------------- first-run setup */
var setupEntry = query('#setup-entry');
var setupLink = query('#setup-link');
/*
* Offer the setup page only when the service says it is outstanding.
*
* Asked rather than assumed, and a failed probe hides the link: advertising a
* setup page that is certain to refuse is worse than not offering it.
*/
function offerSetupIfRequired() {
api.request('/api/setup/status', { method: 'GET' }).then(
function (data) {
if (data && data.setup_required) {
api.show(setupEntry, true);
api.show(setupLink, true);
}
},
function () {
api.show(setupEntry, false);
api.show(setupLink, false);
}
);
}
/* ------------------------------------------------------------- sign-in */
function setMessage(node, text, tone) {
if (!node) {
return;
}
node.textContent = text;
node.className = 'form-message' + (tone ? ' form-message--' + tone : '');
}
function destinationFor(role) {
return role === 'platform_admin' || role === 'organization_admin' ? 'uadmin/' : 'workspace.html';
}
function showPasswordToggle(button, input) {
if (!button || !input) {
return;
}
/*
* The button names the action a click will take. This was inverted: the value
* below was called `reveal` but held `input.type === 'password'` - true when the
* password is HIDDEN - and then chose the label, so a masked field offered
* "Hide" and clicking it revealed the password and relabelled itself "Show".
*
* Renamed to `visible`, which is what the value means. A correct-looking name is
* not what prevents this; the name agreeing with the value is.
*/
var visible = input.type === 'text';
button.setAttribute('aria-pressed', visible ? 'true' : 'false');
button.textContent = visible ? 'Hide' : 'Show';
button.setAttribute('aria-label', (visible ? 'Hide' : 'Show') + ' ' + (input.getAttribute('data-field-label') || 'password'));
}
function wirePasswordToggle(buttonId, inputId) {
var button = query('#' + buttonId);
var input = query('#' + inputId);
if (!button || !input) {
return;
}
showPasswordToggle(button, input);
button.addEventListener('click', function () {
input.type = input.type === 'text' ? 'password' : 'text';
showPasswordToggle(button, input);
});
}
function setupSignIn() {
var loginForm = query('#login-form');
var passwordForm = query('#password-form');
var loginView = query('#login-view');
var passwordView = query('#password-view');
if (!loginView || !passwordView) {
return;
}
var loginMessage = query('#login-message');
var passwordMessage = query('#password-message');
var loginSubmit = query('#login-submit');
var passwordSubmit = query('#password-submit');
var usernameInput = query('#login-email');
var passwordInput = query('#login-password');
var verifyPanel = query('#verify-panel');
var verifyPanelText = query('#verify-panel-text');
var verifyPanelMessage = query('#verify-panel-message');
var resendButton = query('#resend-verification');
var currentInput = query('#current-password');
var newInput = query('#new-password');
var confirmInput = query('#confirm-password');
var adminEntry = query('#admin-entry');
var adminLink = query('#admin-link');
var serviceNote = query('#service-note');
var navigationLocked = false;
var currentUsername = '';
function onBeforeUnload(event) {
if (!navigationLocked) {
return undefined;
}
event.preventDefault();
event.returnValue = '';
return '';
}
function setNavigationLocked(locked) {
navigationLocked = Boolean(locked);
if (navigationLocked) {
global.addEventListener('beforeunload', onBeforeUnload);
} else {
global.removeEventListener('beforeunload', onBeforeUnload);
}
}
// While a mandatory password change is outstanding, no link on this page
// may navigate away. The session stays usable for the change itself.
document.addEventListener(
'click',
function (event) {
if (!navigationLocked) {
return;
}
var target = event.target;
var anchor = target && typeof target.closest === 'function' ? target.closest('a[href]') : null;
if (!anchor) {
return;
}
event.preventDefault();
setMessage(
passwordMessage,
'Finish changing your password before leaving this page.',
'error'
);
},
true
);
function openPasswordView() {
// The settings page needs a usable session, and this view is the one
// state where there is not one yet, so the link stays hidden here.
api.show(passwordSettings, false);
api.show(loginView, false);
api.show(passwordView, true);
api.show(adminEntry, false);
api.show(adminLink, false);
setNavigationLocked(true);
setMessage(passwordMessage, '', null);
renderPolicyHint();
if (global.history && global.history.replaceState) {
global.history.replaceState(null, '', '#change-password');
}
if (currentInput) {
currentInput.focus();
}
}
function openLoginView() {
setNavigationLocked(false);
api.show(passwordView, false);
api.show(loginView, true);
api.show(adminEntry, true);
api.show(adminLink, true);
}
function busy(isBusy) {
api.disable(loginSubmit, isBusy);
api.disable(passwordSubmit, isBusy);
api.setBusy(loginForm, isBusy);
api.setBusy(passwordForm, isBusy);
}
function describeLoginFailure(error) {
if (!error) {
return 'Sign in failed. Try again.';
}
if (error.code === 'invalid_credentials' || error.status === 401) {
// Deliberately identical for an unknown account, a wrong password, a
// locked account, and a suspended tenant. The response must not be
// usable to discover which accounts exist.
return 'Sign in failed. Check your email address and password.';
}
if (error.code === 'password_policy') {
return api.describeRequirements(error);
}
return api.describeError(error);
}
if (loginForm) {
loginForm.addEventListener('submit', function (event) {
event.preventDefault();
var email = usernameInput ? usernameInput.value.trim() : '';
var password = passwordInput ? passwordInput.value : '';
if (!email || !password) {
setMessage(loginMessage, 'Enter your email address and password to continue.', 'error');
return;
}
setMessage(loginMessage, 'Signing in…', null);
hideVerifyPanel();
busy(true);
api.login(email, password).then(
function (data) {
busy(false);
if (passwordInput) {
passwordInput.value = '';
}
currentUsername = (data && data.user && data.user.username) || '';
if (data && data.must_change_password) {
openPasswordView();
return;
}
var role = data && data.user ? data.user.role : null;
global.location.replace(destinationFor(role));
},
function (error) {
busy(false);
if (passwordInput) {
passwordInput.value = '';
}
// Reached only once the password has already been proven correct, so
// naming the reason here does not turn the form into a way to test
// whether an address has an account.
if (error && error.code === 'email_not_verified') {
showVerifyPanel(email);
return;
}
setMessage(loginMessage, describeLoginFailure(error), 'error');
if (usernameInput) {
usernameInput.focus();
}
}
);
});
}
function showVerifyPanel(email) {
if (verifyPanelText) {
verifyPanelText.textContent =
'Your password is correct, but ' + email + ' has not been confirmed yet. ' +
'Open the confirmation link in your inbox, or paste it below.';
}
if (verifyPanel) {
verifyPanel.hidden = false;
}
if (loginMessage) {
setMessage(loginMessage, '', null);
}
}
function hideVerifyPanel() {
if (verifyPanel) {
verifyPanel.hidden = true;
}
if (verifyPanelMessage) {
setMessage(verifyPanelMessage, '', null);
}
}
if (resendButton) {
resendButton.addEventListener('click', function () {
var email = usernameInput ? usernameInput.value.trim() : '';
if (!email) {
setMessage(verifyPanelMessage, 'Enter your email address above first.', 'error');
return;
}
api.disable(resendButton, true);
setMessage(verifyPanelMessage, 'Sending a new link…', null);
api.resendVerification(email).then(
function () {
api.disable(resendButton, false);
// The same answer whether or not the address has an account, so this
// wording is deliberate and must not be made more specific.
setMessage(
verifyPanelMessage,
'If that address has an account awaiting confirmation, a new link is on its way.',
'success'
);
},
function (error) {
api.disable(resendButton, false);
setMessage(verifyPanelMessage, api.describeError(error), 'error');
}
);
});
}
if (passwordForm) {
passwordForm.addEventListener('submit', function (event) {
event.preventDefault();
var current = currentInput ? currentInput.value : '';
var next = newInput ? newInput.value : '';
var confirmation = confirmInput ? confirmInput.value : '';
if (!current || !next) {
setMessage(passwordMessage, 'Enter your current password and a new password.', 'error');
return;
}
if (next !== confirmation) {
setMessage(passwordMessage, 'The two new passwords do not match.', 'error');
return;
}
if (next.length < api.PASSWORD_MIN_LENGTH) {
setMessage(
passwordMessage,
'The new password must be at least ' + api.PASSWORD_MIN_LENGTH + ' characters.',
'error'
);
return;
}
setMessage(passwordMessage, 'Updating your password…', null);
busy(true);
api.changeFirstPassword(current, next).then(
function (data) {
busy(false);
if (currentInput) {
currentInput.value = '';
}
if (newInput) {
newInput.value = '';
}
if (confirmInput) {
confirmInput.value = '';
}
if (data && data.must_change_password) {
setMessage(
passwordMessage,
'The password was not changed. Choose a new one and try again.',
'error'
);
return;
}
setNavigationLocked(false);
if (global.history && global.history.replaceState) {
global.history.replaceState(null, '', global.location.pathname);
}
var role = data && data.user ? data.user.role : null;
global.location.replace(destinationFor(role));
},
function (error) {
busy(false);
if (currentInput) {
currentInput.value = '';
}
if (newInput) {
newInput.value = '';
}
if (confirmInput) {
confirmInput.value = '';
}
if (error && (error.code === 'invalid_credentials' || error.status === 401)) {
if (error.code === 'invalid_credentials') {
setMessage(
passwordMessage,
'The temporary password you entered is not correct.',
'error'
);
} else {
// No usable session: the change cannot be attempted. Return to
// the sign-in form so the account can sign in again.
openLoginView();
setMessage(loginMessage, 'Your session ended. Sign in again to continue.', 'error');
}
} else if (error && error.code === 'password_policy') {
setMessage(passwordMessage, api.describeRequirements(error), 'error');
} else {
setMessage(passwordMessage, api.describeError(error), 'error');
}
if (currentInput) {
currentInput.focus();
}
}
);
});
}
var signoutInstead = query('#password-signout');
if (signoutInstead) {
signoutInstead.addEventListener('click', function () {
busy(true);
// Whether or not the revocation count comes back, the server clears the
// session cookie on this route, so the page always returns to sign-in.
var leave = function () {
setNavigationLocked(false);
if (global.history && global.history.replaceState) {
global.history.replaceState(null, '', global.location.pathname);
}
global.location.replace(global.location.pathname);
};
api.logout().then(leave, leave);
});
}
var policyUsername = query('#policy-username');
if (policyUsername) {
policyUsername.textContent = 'Must not contain your account name.';
renderPolicyHint();
}
// The policy forbids a password containing the account's username, which is a
// display handle and is often absent. It is deliberately not the sign-in
// field: that collects an email address, and telling the user their password
// must avoid their own email while the rule checks something else would be
// worse than saying nothing.
function renderPolicyHint() {
if (!policyUsername) {
return;
}
var handle = currentUsername || '';
policyUsername.textContent = handle
? 'Must not contain “' + handle + '”.'
: 'Must not contain your account name.';
}
wirePasswordToggle('toggle-login-password', 'login-password');
wirePasswordToggle('toggle-current-password', 'current-password');
wirePasswordToggle('toggle-new-password', 'new-password');
// `GET /api/health` is unauthenticated and issues the CSRF cookie the
// mandatory change needs, so it runs before any form is submitted.
api.health().then(
function (data) {
if (!serviceNote) {
return;
}
var parts = [];
if (data && data.service) {
parts.push(data.service);
}
if (data && data.version) {
parts.push('version ' + data.version);
}
if (data && data.environment) {
parts.push(data.environment);
}
serviceNote.textContent = parts.length > 0 ? parts.join(' · ') : 'Service reachable.';
serviceNote.classList.add('prototype-note--live');
},
function () {
if (serviceNote) {
serviceNote.textContent = 'The service could not be reached. Sign-in may fail.';
}
}
);
if (global.location.hash === '#change-password') {
// Another page redirected here because this session still owes a
// password change. Show the view without trying to resolve the session:
// `GET /api/auth/me` rejects a session in this state by design.
openPasswordView();
} else {
openLoginView();
}
if (usernameInput) {
usernameInput.focus();
}
}
/* ----------------------------------------------------------- workspace */
function setupWorkspace() {
var main = query('#overview');
if (!main) {
return;
}
api.setSessionLostHandler(function () {
api.forgetLocalState();
global.location.replace('index.html');
});
api.setPasswordChangeHandler(function () {
global.location.replace('index.html#change-password');
});
function renderIdentity(session) {
var user = (session && session.user) || {};
var role = (session && session.role) || user.role || 'member';
var displayName = user.display_name || user.username || '';
var organization = session && session.organization_id ? session.organization_id : null;
var nameNode = query('#rail-display-name');
if (nameNode) {
nameNode.textContent = displayName || 'Unknown account';
}
var roleNode = query('#rail-role');
if (roleNode) {
roleNode.textContent = organization
? api.label(role) + ' · org ' + organization
: api.label(role);
}
var avatar = query('#rail-avatar');
if (avatar) {
avatar.textContent = api.initials(displayName, user.username);
}
var breadcrumb = query('#breadcrumb-identity');
if (breadcrumb) {
breadcrumb.textContent = user.username || displayName || 'Account';
}
var title = query('#workspace-title');
if (title) {
title.textContent = displayName || 'Workspace';
}
var subtitle = query('#workspace-subtitle');
if (subtitle) {
subtitle.textContent =
'Signed in as ' + (user.username || 'this account') +
' with the ' + api.label(role).toLowerCase() + ' role.';
}
var pill = query('#workspace-session-pill');
if (pill) {
pill.className = 'status-pill status-pill--success';
pill.textContent = 'Session active';
}
var isAdmin = role === 'platform_admin' || role === 'organization_admin';
['#workspace-admin-link', '#workspace-admin-topbar', '#workspace-admin-hero'].forEach(
function (selector) {
var node = query(selector);
if (node) {
api.show(node, isAdmin);
}
}
);
}
/* ------------------------------------------------------- the dashboard */
// The one response that fills the page. Rendered from `snapshot` and
// re-filtered from it locally, so switching between "all" and "assigned to
// me" can never show a mix of two fetches - and so a filter can never widen
// what the server decided this session may see.
var snapshot = null;
var filter = 'all';
var STATUS_TEXT = {
active: 'Active',
on_hold: 'On hold',
released: 'Released',
archived: 'Archived'
};
function statusPill(status) {
var tone =
status === 'active' ? 'success'
: status === 'on_hold' ? 'warning'
: status === 'released' ? 'info'
: 'muted';
return api.el('span', {
class: 'status-pill status-pill--' + tone,
text: STATUS_TEXT[status] || api.label(status)
});
}
function projectCard(project) {
var meta = [
api.el('span', { class: 'project-card__slug', text: '/' + project.slug }),
api.el('span', { text: project.assigned_count === 1 ? '1 person assigned' : project.assigned_count + ' assigned' })
];
if (project.updated_at) {
meta.push(api.el('span', { text: 'updated ' + api.formatDateTime(project.updated_at) }));
}
// Who is working on it is a fact about the project; whether that includes
// *you* is a fact about the viewer. Both are shown, and neither filters the
// row out. Visibility inside an organization is not per-assignment.
var side = [statusPill(project.status)];
if (project.assigned_to_me) {
side.push(api.el('span', { class: 'status-pill marker--mine', text: 'On your projects' }));
} else if (!project.assigned_count) {
side.push(api.el('span', { class: 'status-pill marker--unclaimed', text: 'Needs someone' }));
}
return api.el('article', { class: 'project-card project-card--' + project.status }, [
api.el('div', { class: 'project-card__main' }, [
api.el('span', { class: 'project-card__name', text: project.name }),
api.el('div', { class: 'project-card__meta' }, meta)
]),
api.el('div', { class: 'project-card__side' }, side)
]);
}
function setMetric(selector, value) {
var node = query(selector);
if (!node) {
return;
}
// A zero is a real measurement and is shown as 0. A dash means "we do not
// know yet", and is kept visually distinct so it is not read as zero.
node.textContent = value === null || value === undefined ? '—' : String(value);
if (value === null || value === undefined) {
node.dataset.empty = 'true';
} else {
delete node.dataset.empty;
}
}
function renderProjects() {
var list = query('#projects-list');
var empty = query('#projects-empty');
var summary = query('#projects-summary');
var heading = query('#projects-title');
var eyebrow = query('#projects-eyebrow');
if (!list || !snapshot) {
return;
}
var organization = snapshot.organization;
if (eyebrow) {
// The organization is the heading the work hangs under, so it is the
// heading - not the person's name, which is in the rail.
eyebrow.textContent = organization ? organization.name : 'Platform';
}
if (heading) {
heading.textContent = filter === 'mine' ? 'Your projects' : 'Work in this organization';
}
var all = snapshot.projects || [];
var mine = snapshot.assigned_to_me || [];
var visible = filter === 'mine' ? mine : all;
api.clearNode(list);
visible.forEach(function (project) {
list.appendChild(projectCard(project));
});
if (summary) {
summary.className = 'list-summary';
if (!organization) {
// A platform administrator belongs to no tenant, so there is no
// organization's work to show. Said plainly rather than as zero.
summary.textContent =
'You administer the platform rather than working in one organization. ' +
'Choose an organization from the control panel to see its projects.';
} else if (visible.length === 0) {
summary.textContent =
filter === 'mine'
? 'No projects are assigned to you in ' + organization.name + ' yet.'
: 'No projects in ' + organization.name + ' yet.';
} else if (filter === 'mine') {
summary.textContent =
'Showing ' + visible.length + ' of ' + all.length + ' project' +
(all.length === 1 ? '' : 's') + ' in ' + organization.name + '.';
} else {
summary.textContent =
'Showing all ' + all.length + ' project' + (all.length === 1 ? '' : 's') +
' in ' + organization.name + '. Everyone here sees every project; ' +
'being assigned says who is working on it.';
}
}
if (empty) {
var nothingAtAll = all.length === 0;
var nothingForMe = !nothingAtAll && visible.length === 0;
api.show(empty, nothingAtAll || nothingForMe);
var title = query('#projects-empty-title');
var body = query('#projects-empty-body');
if (title) {
title.textContent = nothingAtAll ? 'No projects yet' : 'Nothing assigned to you';
}
if (body) {
body.textContent = nothingAtAll
? 'This organization has no projects yet. An organization administrator can create the first one from the control panel.'
: filter === 'mine'
? 'No project in ' + organization.name + ' is assigned to you. You can still see all ' + all.length + ' of them below the filter.'
: '';
}
}
['#project-filter-all', '#project-filter-mine'].forEach(function (selector) {
var button = query(selector);
if (!button) {
return;
}
var active = button.id === (filter === 'mine' ? 'project-filter-mine' : 'project-filter-all');
button.className = active ? 'chip chip--active' : 'chip';
button.setAttribute('aria-pressed', active ? 'true' : 'false');
});
}
function renderMetrics() {
if (!snapshot) {
return;
}
var all = snapshot.projects || [];
var unclaimed = all.filter(function (project) { return !project.assigned_count; }).length;
var inProgress = all.filter(function (project) { return project.status === 'active'; }).length;
setMetric('#metric-projects', all.length);
setMetric('#metric-assigned', (snapshot.assigned_to_me || []).length);
setMetric('#metric-active', inProgress);
setMetric('#metric-unclaimed', unclaimed);
}
function bindFilter() {
[['#project-filter-all', 'all'], ['#project-filter-mine', 'mine']].forEach(function (entry) {
var button = query(entry[0]);
if (!button) {
return;
}
button.addEventListener('click', function () {
if (filter === entry[1]) {
return;
}
filter = entry[1];
renderProjects();
});
});
}
function loadWorkspace() {
return api.workspace().then(function (data) {
snapshot = data;
var organization = data.organization;
var title = query('#workspace-title');
if (title) {
// The organization names the page, because that is what the work
// belongs to. The account's own name stays in the rail.
title.textContent = organization ? organization.name : 'Platform';
}
var railRole = query('#rail-role');
if (railRole && organization) {
railRole.textContent = api.label((data.user && data.user.role) || 'member') + ' · ' + organization.name;
}
renderMetrics();
renderProjects();
}, function (error) {
var summary = query('#projects-summary');
if (summary && error && error.status !== 401 && error.code !== 'password_change_required') {
// Named as an error rather than left blank, so an empty list is never
// read as "this organization has no projects" when in fact nothing
// loaded.
summary.className = 'list-summary list-summary--error';
summary.textContent = 'Projects could not be loaded: ' + api.describeError(error);
}
});
}
function renderFailure(message) {
var subtitle = query('#workspace-subtitle');
if (subtitle) {
subtitle.textContent = message;
}
var pill = query('#workspace-session-pill');
if (pill) {
pill.className = 'status-pill status-pill--warning';
pill.textContent = 'Session unavailable';
}
}
// The session cookie is cleared by the server on this route, so the page
// returns to sign-in whether or not the revocation count comes back.
var leaveWorkspace = function () {
global.location.replace('index.html');
};
queryAll('[data-signout]').forEach(function (button) {
button.addEventListener('click', function () {
api.disable(button, true);
api.logout().then(leaveWorkspace, leaveWorkspace);
});
});
bindFilter();
// The session identifies the caller; the workspace says what they can see.
// Both are needed and neither subsumes the other, so they are two requests -
// but the dashboard renders from the second one alone, and never from a
// mixture of the two.
api.me().then(function (session) {
renderIdentity(session);
return loadWorkspace();
}, function (error) {
// A 401 and a 403 password_change_required are both already being
// handled by the redirect handlers above. This covers anything else.
if (error && error.status === 401) {
return;
}
if (error && error.code === 'password_change_required') {
return;
}
renderFailure(api.describeError(error));
});
}
setupSignIn();
setupWorkspace();
offerSetupIfRequired();
})(window);