Add REST endpoints and frontend wiring for per-language project actions:
- POST /api/workspace/{project}/translation/{lang}/queue — queue translation
- POST /api/workspace/{project}/audio/{lang}/queue — queue audio generation
- GET/POST /api/workspace/{project}/format/{lang} — format metadata (EPUB/PDF)
- GET /api/workspace/{project}/review/{lang}/chapters/{chapter} — utterance review
- GET/POST /api/workspace/{project}/front-matter/{lang} — front matter editor
- GET /api/workspace/{project}/chapters/{lang} — chapter list
- GET /api/workspace/{project}/chapter-content/{lang}/{chapter} — chapter text
Frontend fixes:
- Review page shows per-language text (English source, Chinese translation, etc.)
- Non-translated languages show 'Queue translation' button only
- Speaker data from English source matched by paragraph ID
- UmbraAudio column layout: Speaker, Variation, Text, Actions
- Chapter tree in left sidebar: Front matter, Back matter, Body
- Audio Format label at top of sidebar
- Pandoc formatting toolbar for front matter text editing
840 lines
30 KiB
JavaScript
840 lines
30 KiB
JavaScript
// Copyright 2026 Umbra Tools (umbratools.com)
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
/*
|
|
* 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) {
|
|
// Every session lands in the Umbra Tools shell, which hosts the control
|
|
// panel, project work, the editor, and audio as activities. Previously this
|
|
// sent administrators straight to `uadmin/` and members to `workspace.html`,
|
|
// which meant the shell was never reached and the two old pages drifted
|
|
// apart. The shell now owns the top banner, left rail, and activity switch,
|
|
// and embeds the control panel rather than replacing the navigation with it.
|
|
return 'umbra-app.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' }));
|
|
}
|
|
|
|
var card = api.el('article', { class: 'project-card project-card--' + project.status, role: 'button', tabIndex: 0 }, [
|
|
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)
|
|
]);
|
|
card.addEventListener('click', function () {
|
|
try {
|
|
var url = 'workspace-project.html?id=' + encodeURIComponent(project.id);
|
|
window.location.href = url;
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
});
|
|
card.addEventListener('keydown', function (ev) {
|
|
if (ev.key === 'Enter' || ev.key === ' ') {
|
|
ev.preventDefault();
|
|
card.click();
|
|
}
|
|
});
|
|
return card;
|
|
}
|
|
|
|
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.clear(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);
|
|
|
|
try { var p = new URL(window.location.href); if (p.searchParams.get('shell') === 'umbra') { window.location.href = 'umbra-app.html'; } } catch(e){}
|