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
1019 lines
31 KiB
JavaScript
1019 lines
31 KiB
JavaScript
// Copyright 2026 Umbra Tools (umbratools.com)
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
/*
|
|
* UmbraTrans browser API client, DOM helpers, and modal utility.
|
|
*
|
|
* Rules enforced by this file, deliberately rather than by convention:
|
|
*
|
|
* - Every request is same-origin and sends `credentials: "same-origin"`.
|
|
* - Every non-GET /api request carries an `X-CSRF-Token` header whose value is
|
|
* read fresh from the readable `umbratrans_csrf` cookie on each call.
|
|
* `POST /api/auth/login` is the only exempt route. Nothing here can turn
|
|
* CSRF enforcement off.
|
|
* - No value that came from the API is ever turned into markup. `el()` builds
|
|
* nodes and assigns text with `textContent`; the HTML-parsing sinks (raw
|
|
* markup assignment, adjacent-HTML insertion, and document writing) are not
|
|
* used anywhere in this file or in the page scripts.
|
|
* - A one-time password is never logged, never written to storage, never put
|
|
* in a URL, and never passed to a toast. Only the provisioning dialog
|
|
* receives it, from the single response that contains it.
|
|
*
|
|
* This file runs on all three pages and assumes almost nothing about the DOM,
|
|
* so every lookup is guarded.
|
|
*/
|
|
(function (global) {
|
|
'use strict';
|
|
|
|
var CSRF_COOKIE = 'umbratrans_csrf';
|
|
var CSRF_HEADER = 'X-CSRF-Token';
|
|
var UNSAFE_METHODS = { POST: true, PUT: true, PATCH: true, DELETE: true };
|
|
var CSRF_EXEMPT = { '/api/auth/login': true };
|
|
var PASSWORD_MIN_LENGTH = 14;
|
|
|
|
/**
|
|
* Token returned in a response body. The server sends the same value in the
|
|
* cookie, so this is only a fallback for the pathological case where the
|
|
* browser withheld the cookie. It is never persisted.
|
|
*/
|
|
var fallbackCsrfToken = '';
|
|
|
|
var sessionLostHandler = null;
|
|
var passwordChangeHandler = null;
|
|
|
|
/* ---------------------------------------------------------------- errors */
|
|
|
|
/**
|
|
* A failed API call. Carries the server's stable error code, its sanitized
|
|
* message, and the optional `fields` / `requirements` lists. It never carries
|
|
* a submitted value: the backend strips those before responding.
|
|
*/
|
|
function ApiError(status, code, message, fields, requirements) {
|
|
this.name = 'ApiError';
|
|
this.status = status || 0;
|
|
this.code = code || 'request_failed';
|
|
this.message = message || 'The request could not be completed.';
|
|
this.fields = Array.isArray(fields) ? fields.slice() : [];
|
|
this.requirements = Array.isArray(requirements) ? requirements.slice() : [];
|
|
}
|
|
ApiError.prototype = Object.create(Error.prototype);
|
|
ApiError.prototype.constructor = ApiError;
|
|
|
|
function describeError(error) {
|
|
if (!error) {
|
|
return 'The request could not be completed.';
|
|
}
|
|
if (error instanceof ApiError) {
|
|
return error.message;
|
|
}
|
|
return 'The request could not be completed.';
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- cookie */
|
|
|
|
function readCookie(name) {
|
|
var jar = typeof document === 'undefined' ? '' : document.cookie;
|
|
if (!jar) {
|
|
return '';
|
|
}
|
|
var parts = jar.split(';');
|
|
for (var i = 0; i < parts.length; i += 1) {
|
|
var part = parts[i];
|
|
var eq = part.indexOf('=');
|
|
if (eq < 0) {
|
|
continue;
|
|
}
|
|
if (part.slice(0, eq).trim() === name) {
|
|
var raw = part.slice(eq + 1).trim();
|
|
try {
|
|
return decodeURIComponent(raw);
|
|
} catch (error) {
|
|
return raw;
|
|
}
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function csrfToken() {
|
|
return readCookie(CSRF_COOKIE) || fallbackCsrfToken || '';
|
|
}
|
|
|
|
/*
|
|
* Make sure a CSRF token exists, fetching one if it does not.
|
|
*
|
|
* The server issues the cookie on any safe API request that arrives without
|
|
* one, so a page only needs one harmless GET before its first state-changing
|
|
* call. Pages that arrive from elsewhere do not get that for free: the
|
|
* confirmation link opens `verify-email.html` directly, and that page had never
|
|
* made a safe request, so its POST was refused here in the browser, before any
|
|
* network call. Nothing reached the server, so nothing appeared in its log and
|
|
* the page simply never confirmed anything.
|
|
*
|
|
* The alternative was exempting `/api/auth/verify-email` from CSRF, the way
|
|
* login and first-run setup are. Those are exempt because nothing about them
|
|
* relies on a session. This one is not, and keeping the exemption list short is
|
|
* worth more than saving one round trip.
|
|
*/
|
|
function ensureCsrfToken() {
|
|
if (csrfToken()) {
|
|
return Promise.resolve(csrfToken());
|
|
}
|
|
return request('/api/health', { method: 'GET' }).then(
|
|
function () {
|
|
var token = csrfToken();
|
|
if (!token) {
|
|
throw new ApiError(
|
|
0,
|
|
'csrf_token_missing',
|
|
'This page could not obtain a security token. Reload and try again.'
|
|
);
|
|
}
|
|
return token;
|
|
},
|
|
function (error) {
|
|
throw new ApiError(
|
|
0,
|
|
'csrf_token_missing',
|
|
'This page could not reach the service to obtain a security token. Check your connection and reload.'
|
|
);
|
|
}
|
|
);
|
|
}
|
|
|
|
/* ------------------------------------------------------------- requests */
|
|
|
|
function buildQuery(params) {
|
|
if (!params) {
|
|
return '';
|
|
}
|
|
var search = new URLSearchParams();
|
|
Object.keys(params).forEach(function (key) {
|
|
var value = params[key];
|
|
if (value === null || value === undefined || value === '') {
|
|
return;
|
|
}
|
|
search.set(key, String(value));
|
|
});
|
|
var text = search.toString();
|
|
return text ? '?' + text : '';
|
|
}
|
|
|
|
function noteSessionFailure(error) {
|
|
if (error.status === 401 && error.code === 'authentication_required') {
|
|
if (typeof sessionLostHandler === 'function') {
|
|
sessionLostHandler(error);
|
|
}
|
|
return;
|
|
}
|
|
if (error.status === 403 && error.code === 'password_change_required') {
|
|
if (typeof passwordChangeHandler === 'function') {
|
|
passwordChangeHandler(error);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Perform one API call.
|
|
*
|
|
* @param {string} path Same-origin path beginning with `/api`.
|
|
* @param {{method?: string, body?: unknown, params?: object, signal?: AbortSignal}} [options]
|
|
* @returns {Promise<object|null>} Parsed JSON body, or null when empty.
|
|
*/
|
|
function request(path, options) {
|
|
var opts = options || {};
|
|
var method = String(opts.method || 'GET').toUpperCase();
|
|
var init = {
|
|
method: method,
|
|
credentials: 'same-origin',
|
|
cache: 'no-store',
|
|
headers: { Accept: 'application/json' }
|
|
};
|
|
|
|
if (opts.signal) {
|
|
init.signal = opts.signal;
|
|
}
|
|
|
|
if (UNSAFE_METHODS[method] && !CSRF_EXEMPT[path]) {
|
|
var token = csrfToken();
|
|
if (!token) {
|
|
return Promise.reject(
|
|
new ApiError(
|
|
0,
|
|
'csrf_token_missing',
|
|
'This page has not received its security token yet. Reload and try again.'
|
|
)
|
|
);
|
|
}
|
|
init.headers[CSRF_HEADER] = token;
|
|
}
|
|
|
|
if (opts.body !== undefined && opts.body !== null) {
|
|
init.headers['Content-Type'] = 'application/json';
|
|
init.body = JSON.stringify(opts.body);
|
|
}
|
|
|
|
return global.fetch(path + buildQuery(opts.params), init).then(
|
|
function (response) {
|
|
var body = null;
|
|
var contentType = response.headers.get('content-type') || '';
|
|
if (contentType.indexOf('application/json') !== -1) {
|
|
return response.json().then(
|
|
function (parsed) {
|
|
body = parsed;
|
|
return { response: response, body: body };
|
|
},
|
|
function (readError) {
|
|
// A body that cannot be read is either an abort or a truncated
|
|
// response. Let an abort propagate so the caller recognises it as
|
|
// a superseded request rather than as an empty result.
|
|
if (readError && readError.name === 'AbortError') {
|
|
throw readError;
|
|
}
|
|
return { response: response, body: null };
|
|
}
|
|
);
|
|
}
|
|
return { response: response, body: null };
|
|
}
|
|
).then(function (result) {
|
|
var response = result.response;
|
|
var body = result.body;
|
|
|
|
if (body && typeof body.csrf_token === 'string' && body.csrf_token) {
|
|
fallbackCsrfToken = body.csrf_token;
|
|
}
|
|
|
|
if (response.ok) {
|
|
return body;
|
|
}
|
|
|
|
var error = new ApiError(
|
|
response.status,
|
|
'request_failed',
|
|
'The request could not be completed.'
|
|
);
|
|
if (body && body.error && typeof body.error === 'object') {
|
|
error.code = typeof body.error.code === 'string' ? body.error.code : error.code;
|
|
if (typeof body.error.message === 'string' && body.error.message) {
|
|
error.message = body.error.message;
|
|
}
|
|
error.fields = Array.isArray(body.error.fields) ? body.error.fields : [];
|
|
error.requirements = Array.isArray(body.error.requirements) ? body.error.requirements : [];
|
|
}
|
|
noteSessionFailure(error);
|
|
throw error;
|
|
}, function (networkError) {
|
|
if (networkError && networkError.name === 'AbortError') {
|
|
throw networkError;
|
|
}
|
|
var error = new ApiError(
|
|
0,
|
|
'network_error',
|
|
'The server could not be reached. Check your connection and retry.'
|
|
);
|
|
throw error;
|
|
});
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ auth */
|
|
|
|
/**
|
|
* Unauthenticated probe. Also the CSRF bootstrap: any GET /api response
|
|
* issues the readable double-submit cookie when the browser does not have
|
|
* one yet, so calling this on page load guarantees the header is available
|
|
* for the first state-changing request.
|
|
*/
|
|
function health() {
|
|
return request('/api/health');
|
|
}
|
|
|
|
function login(email, password, signal) {
|
|
return request('/api/auth/login', {
|
|
method: 'POST',
|
|
body: { email: email, password: password },
|
|
signal: signal
|
|
});
|
|
}
|
|
|
|
function verifyEmail(token, signal) {
|
|
return request('/api/auth/verify-email', {
|
|
method: 'POST',
|
|
body: { token: token },
|
|
signal: signal
|
|
});
|
|
}
|
|
|
|
function passwordResetAvailable(token, signal) {
|
|
// buildQuery already returns a leading "?", so this must not add another.
|
|
// It did, and the request went out as "/api/auth/reset-password??token=...",
|
|
// which the server refused as a 422 with an empty parameter name. The page
|
|
// then read that as "this link is no longer valid" and told the person their
|
|
// link had expired when it had not.
|
|
return request('/api/auth/reset-password' + buildQuery({ token: token }), { method: 'GET', signal: signal });
|
|
}
|
|
|
|
function resetPassword(token, newPassword, signal) {
|
|
return request('/api/auth/reset-password', {
|
|
method: 'POST',
|
|
body: { token: token, new_password: newPassword },
|
|
signal: signal
|
|
});
|
|
}
|
|
|
|
function changeOwnPassword(currentPassword, newPassword, signal) {
|
|
return request('/api/auth/change-password', {
|
|
method: 'POST',
|
|
body: { current_password: currentPassword, new_password: newPassword },
|
|
signal: signal
|
|
});
|
|
}
|
|
|
|
function requestPasswordReset(userId, signal) {
|
|
return request('/api/uadmin/accounts/' + encodeURIComponent(userId) + '/password-reset', {
|
|
method: 'POST',
|
|
signal: signal
|
|
});
|
|
}
|
|
|
|
function resendVerification(email, signal) {
|
|
return request('/api/auth/resend-verification', {
|
|
method: 'POST',
|
|
body: { email: email },
|
|
signal: signal
|
|
});
|
|
}
|
|
|
|
function changeFirstPassword(currentPassword, newPassword, signal) {
|
|
return request('/api/auth/change-first-password', {
|
|
method: 'POST',
|
|
body: { current_password: currentPassword, new_password: newPassword },
|
|
signal: signal
|
|
});
|
|
}
|
|
|
|
function me(signal) {
|
|
return request('/api/auth/me', { signal: signal });
|
|
}
|
|
|
|
function logout(signal) {
|
|
return request('/api/auth/logout', { method: 'POST', signal: signal });
|
|
}
|
|
|
|
function logoutEverywhere(signal) {
|
|
return request('/api/auth/logout-everywhere', { method: 'POST', signal: signal });
|
|
}
|
|
|
|
/* ----------------------------------------------------------------- admin */
|
|
|
|
function getSummary(signal) {
|
|
return request('/api/uadmin/summary', { signal: signal });
|
|
}
|
|
|
|
function listAccounts(filters, signal) {
|
|
return request('/api/uadmin/accounts', { params: filters, signal: signal });
|
|
}
|
|
|
|
/**
|
|
* Provision an account. The response may carry `temporary_password`; that
|
|
* value is returned to the caller untouched and is never logged, stored, or
|
|
* interpolated into any other string.
|
|
*/
|
|
function createAccount(payload, signal) {
|
|
return request('/api/uadmin/accounts', { method: 'POST', body: payload, signal: signal });
|
|
}
|
|
|
|
function setAccountStatus(userId, status, reason, signal) {
|
|
return request('/api/uadmin/accounts/' + encodeURIComponent(userId) + '/status', {
|
|
method: 'PATCH',
|
|
body: { status: status, reason: reason === '' ? null : reason },
|
|
signal: signal
|
|
});
|
|
}
|
|
|
|
function revokeAccountSessions(userId, signal) {
|
|
return request('/api/uadmin/accounts/' + encodeURIComponent(userId) + '/revoke-sessions', {
|
|
method: 'POST',
|
|
signal: signal
|
|
});
|
|
}
|
|
|
|
function listOrganizations(filters, signal) {
|
|
return request('/api/uadmin/organizations', { params: filters, signal: signal });
|
|
}
|
|
|
|
function createOrganization(payload, signal) {
|
|
return request('/api/uadmin/organizations', { method: 'POST', body: payload, signal: signal });
|
|
}
|
|
|
|
function updateOrganization(organizationId, payload, signal) {
|
|
return request('/api/uadmin/organizations/' + encodeURIComponent(organizationId), {
|
|
method: 'PATCH',
|
|
body: payload,
|
|
signal: signal
|
|
});
|
|
}
|
|
|
|
function setOrganizationStatus(organizationId, status, reason, signal) {
|
|
return request(
|
|
'/api/uadmin/organizations/' + encodeURIComponent(organizationId) + '/status',
|
|
{ method: 'PATCH', body: { status: status, reason: reason === '' ? null : reason }, signal: signal }
|
|
);
|
|
}
|
|
|
|
function listAudit(filters, signal) {
|
|
return request('/api/uadmin/audit', { params: filters, signal: signal });
|
|
}
|
|
|
|
/* --------------------------------------------------------------- projects */
|
|
|
|
/**
|
|
* The signed-in caller's organization and its projects, in one response.
|
|
*
|
|
* Deliberately not `listProjects` plus `me` plus `getOrganization`. The server
|
|
* already knows which organization this session belongs to, and asking the
|
|
* browser to pass it back is how a stale or mistimed filter shows one tenant's
|
|
* work to another. There is no organization parameter here to get wrong.
|
|
*/
|
|
function workspace(signal) {
|
|
return request('/api/workspace', { signal: signal });
|
|
}
|
|
|
|
function listProjects(filters, signal) {
|
|
return request('/api/uadmin/projects', { params: filters, signal: signal });
|
|
}
|
|
|
|
function projectStatus(projectId, signal) {
|
|
return request('/api/workspace/' + encodeURIComponent(projectId) + '/status', { signal: signal });
|
|
}
|
|
|
|
function projectDetail(projectId, signal) {
|
|
return request('/api/workspace/' + encodeURIComponent(projectId), { signal: signal });
|
|
}
|
|
|
|
function createProject(payload, signal) {
|
|
return request('/api/uadmin/projects', { method: 'POST', body: payload, signal: signal });
|
|
}
|
|
|
|
function setProjectStatus(projectId, status, reason, signal) {
|
|
return request('/api/uadmin/projects/' + encodeURIComponent(projectId) + '/status', {
|
|
method: 'PATCH',
|
|
body: { status: status, reason: reason === '' ? null : reason },
|
|
signal: signal
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Assign an account to a project. A PUT because the request states a desired
|
|
* state: assigning someone who is already assigned succeeds and reports
|
|
* `changed: false`, so a retry after a dropped response is safe.
|
|
*/
|
|
function assignToProject(projectId, userId, signal) {
|
|
return request(
|
|
'/api/uadmin/projects/' + encodeURIComponent(projectId) + '/members/' + encodeURIComponent(userId),
|
|
{ method: 'PUT', signal: signal }
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Remove an assignment. The project stays visible to the whole organization;
|
|
* only the work stops being anybody's.
|
|
*/
|
|
function unassignFromProject(projectId, userId, signal) {
|
|
return request(
|
|
'/api/uadmin/projects/' + encodeURIComponent(projectId) + '/members/' + encodeURIComponent(userId),
|
|
{ method: 'DELETE', signal: signal }
|
|
);
|
|
}
|
|
|
|
/* ------------------------------------------------- language-project workflow */
|
|
|
|
/**
|
|
* Queue a translation job for one language project.
|
|
*/
|
|
function queueTranslation(projectId, languageTag, signal) {
|
|
return request(
|
|
'/api/workspace/' + encodeURIComponent(projectId) + '/translation/' + encodeURIComponent(languageTag) + '/queue',
|
|
{ method: 'POST', signal: signal }
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Queue an audio generation job for one language project.
|
|
* Requires the language's translation to be complete first.
|
|
*/
|
|
function queueAudio(projectId, languageTag, voiceConfig, signal) {
|
|
return request(
|
|
'/api/workspace/' + encodeURIComponent(projectId) + '/audio/' + encodeURIComponent(languageTag) + '/queue',
|
|
{ method: 'POST', body: { voice_config: voiceConfig || {} }, signal: signal }
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Read format metadata (EPUB/PDF) for one language project.
|
|
*/
|
|
function getFormatMetadata(projectId, languageTag, signal) {
|
|
return request(
|
|
'/api/workspace/' + encodeURIComponent(projectId) + '/format/' + encodeURIComponent(languageTag),
|
|
{ signal: signal }
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Save format metadata (cover, ISBN, blurb, search terms, etc.) for one
|
|
* language project.
|
|
*/
|
|
function setFormatMetadata(projectId, languageTag, metadata, signal) {
|
|
return request(
|
|
'/api/workspace/' + encodeURIComponent(projectId) + '/format/' + encodeURIComponent(languageTag),
|
|
{ method: 'POST', body: metadata, signal: signal }
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Return utterances for one chapter of one language, for speaker-alignment
|
|
* review.
|
|
*/
|
|
function getUtteranceReview(projectId, languageTag, chapterId, signal) {
|
|
return request(
|
|
'/api/workspace/' +
|
|
encodeURIComponent(projectId) +
|
|
'/review/' +
|
|
encodeURIComponent(languageTag) +
|
|
'/chapters/' +
|
|
encodeURIComponent(chapterId),
|
|
{ signal: signal }
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Read front-matter items for one language project.
|
|
*/
|
|
function getFrontMatter(projectId, languageTag, signal) {
|
|
return request(
|
|
'/api/workspace/' + encodeURIComponent(projectId) + '/front-matter/' + encodeURIComponent(languageTag),
|
|
{ signal: signal }
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Save front-matter items for one language project.
|
|
*/
|
|
function saveFrontMatter(projectId, languageTag, items, signal) {
|
|
return request(
|
|
'/api/workspace/' + encodeURIComponent(projectId) + '/front-matter/' + encodeURIComponent(languageTag),
|
|
{ method: 'POST', body: { language_tag: languageTag, items: items }, signal: signal }
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Return chapter metadata for one language project.
|
|
*/
|
|
function getChapters(projectId, languageTag, signal) {
|
|
return request(
|
|
'/api/workspace/' + encodeURIComponent(projectId) + '/chapters/' + encodeURIComponent(languageTag),
|
|
{ signal: signal }
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Return full paragraph content for one chapter (front matter editing).
|
|
*/
|
|
function getChapterContent(projectId, languageTag, chapterId, signal) {
|
|
return request(
|
|
'/api/workspace/' + encodeURIComponent(projectId) + '/chapter-content/' +
|
|
encodeURIComponent(languageTag) + '/' + encodeURIComponent(chapterId),
|
|
{ signal: signal }
|
|
);
|
|
}
|
|
|
|
/* --------------------------------------------------------- DOM utilities */
|
|
|
|
function appendChild(node, child) {
|
|
if (child === null || child === undefined || child === false) {
|
|
return;
|
|
}
|
|
if (Array.isArray(child)) {
|
|
child.forEach(function (item) {
|
|
appendChild(node, item);
|
|
});
|
|
return;
|
|
}
|
|
if (typeof child === 'string' || typeof child === 'number') {
|
|
node.appendChild(document.createTextNode(String(child)));
|
|
return;
|
|
}
|
|
if (child && typeof child === 'object' && child.nodeType) {
|
|
node.appendChild(child);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build one element.
|
|
*
|
|
* `class` and `text` are applied as properties; `dataset` and `on` take
|
|
* objects; a `true` value sets a bare attribute; anything else becomes a
|
|
* string attribute. Text always goes through `textContent`.
|
|
*/
|
|
function el(tag, props, children) {
|
|
var node = document.createElement(tag);
|
|
if (props) {
|
|
Object.keys(props).forEach(function (key) {
|
|
var value = props[key];
|
|
if (value === null || value === undefined || value === false) {
|
|
return;
|
|
}
|
|
if (key === 'class') {
|
|
node.className = value;
|
|
} else if (key === 'text') {
|
|
node.textContent = String(value);
|
|
} else if (key === 'dataset') {
|
|
Object.keys(value).forEach(function (dataKey) {
|
|
node.dataset[dataKey] = String(value[dataKey]);
|
|
});
|
|
} else if (key === 'on') {
|
|
Object.keys(value).forEach(function (type) {
|
|
node.addEventListener(type, value[type]);
|
|
});
|
|
} else if (value === true) {
|
|
node.setAttribute(key, '');
|
|
} else {
|
|
node.setAttribute(key, String(value));
|
|
}
|
|
});
|
|
}
|
|
appendChild(node, children);
|
|
return node;
|
|
}
|
|
|
|
function clearNode(node) {
|
|
if (!node) {
|
|
return;
|
|
}
|
|
while (node.firstChild) {
|
|
node.removeChild(node.firstChild);
|
|
}
|
|
}
|
|
|
|
function setText(node, value) {
|
|
if (!node) {
|
|
return;
|
|
}
|
|
node.textContent = value === null || value === undefined ? '' : String(value);
|
|
}
|
|
|
|
function query(selector, root) {
|
|
return (root || document).querySelector(selector);
|
|
}
|
|
|
|
function queryAll(selector, root) {
|
|
return Array.prototype.slice.call((root || document).querySelectorAll(selector));
|
|
}
|
|
|
|
function show(node, visible) {
|
|
if (!node) {
|
|
return;
|
|
}
|
|
if (visible) {
|
|
node.removeAttribute('hidden');
|
|
} else {
|
|
node.setAttribute('hidden', '');
|
|
}
|
|
}
|
|
|
|
function setBusy(node, busy) {
|
|
if (!node) {
|
|
return;
|
|
}
|
|
if (busy) {
|
|
node.setAttribute('aria-busy', 'true');
|
|
} else {
|
|
node.removeAttribute('aria-busy');
|
|
}
|
|
}
|
|
|
|
function disable(node, disabled) {
|
|
if (!node) {
|
|
return;
|
|
}
|
|
node.disabled = Boolean(disabled);
|
|
}
|
|
|
|
/* ------------------------------------------------------------ formatting */
|
|
|
|
var dateTimeFormat = null;
|
|
function formatDateTime(value) {
|
|
if (!value) {
|
|
return '—';
|
|
}
|
|
var parsed = new Date(value);
|
|
if (isNaN(parsed.getTime())) {
|
|
return '—';
|
|
}
|
|
if (!dateTimeFormat) {
|
|
try {
|
|
dateTimeFormat = new Intl.DateTimeFormat(undefined, {
|
|
dateStyle: 'medium',
|
|
timeStyle: 'short'
|
|
});
|
|
} catch (error) {
|
|
dateTimeFormat = null;
|
|
}
|
|
}
|
|
if (!dateTimeFormat) {
|
|
return parsed.toISOString();
|
|
}
|
|
return dateTimeFormat.format(parsed);
|
|
}
|
|
|
|
var LABELS = {
|
|
platform_admin: 'Platform administrator',
|
|
organization_admin: 'Organization administrator',
|
|
member: 'Member',
|
|
active: 'Active',
|
|
suspended: 'Suspended',
|
|
deactivated: 'Deactivated',
|
|
archived: 'Archived',
|
|
local_password: 'Local password',
|
|
oidc: 'OIDC',
|
|
temporary_password: 'Temporary password',
|
|
success: 'Success',
|
|
failure: 'Failure',
|
|
denied: 'Denied',
|
|
revoked: 'Revoked',
|
|
expired: 'Expired'
|
|
};
|
|
|
|
function label(value) {
|
|
if (value === null || value === undefined || value === '') {
|
|
return '—';
|
|
}
|
|
var text = String(value);
|
|
if (LABELS[text]) {
|
|
return LABELS[text];
|
|
}
|
|
return text.replace(/[_.]+/g, ' ').replace(/^./, function (first) {
|
|
return first.toUpperCase();
|
|
});
|
|
}
|
|
|
|
function shortId(value) {
|
|
if (!value) {
|
|
return '—';
|
|
}
|
|
return String(value);
|
|
}
|
|
|
|
function initials(displayName, username) {
|
|
var source = (displayName || username || '').trim();
|
|
if (!source) {
|
|
return '?';
|
|
}
|
|
var pieces = source.split(/\s+/).filter(function (piece) {
|
|
return piece.length > 0;
|
|
});
|
|
if (pieces.length === 0) {
|
|
return '?';
|
|
}
|
|
if (pieces.length === 1) {
|
|
return pieces[0].slice(0, 2).toUpperCase();
|
|
}
|
|
return (pieces[0].charAt(0) + pieces[pieces.length - 1].charAt(0)).toUpperCase();
|
|
}
|
|
|
|
var PASSWORD_RULE_TEXT = {
|
|
not_empty: 'must not be empty',
|
|
min_length: 'must be at least ' + PASSWORD_MIN_LENGTH + ' characters',
|
|
max_length: 'is too long',
|
|
no_surrounding_whitespace: 'must not begin or end with whitespace',
|
|
must_not_contain_username: 'must not contain your username',
|
|
must_differ_from_current: 'must differ from your current password',
|
|
unmet: 'does not meet the required policy'
|
|
};
|
|
|
|
/**
|
|
* Render a password-policy failure as rule names only. The candidate value is
|
|
* never involved, and the server never sends it.
|
|
*/
|
|
function describeRequirements(error) {
|
|
if (!error || !error.requirements || error.requirements.length === 0) {
|
|
return describeError(error);
|
|
}
|
|
return error.requirements
|
|
.map(function (rule) {
|
|
return PASSWORD_RULE_TEXT[rule] || label(rule);
|
|
})
|
|
.join('; ');
|
|
}
|
|
|
|
/* ----------------------------------------------------------------- toast */
|
|
|
|
var toastTimer = null;
|
|
|
|
function showToast(message, tone) {
|
|
var toast = document.getElementById('toast');
|
|
if (!toast) {
|
|
return;
|
|
}
|
|
clearNode(toast);
|
|
toast.appendChild(document.createTextNode(String(message)));
|
|
toast.classList.add('toast--visible');
|
|
if (tone) {
|
|
toast.classList.add('toast--' + tone);
|
|
}
|
|
if (toastTimer) {
|
|
global.clearTimeout(toastTimer);
|
|
}
|
|
toastTimer = global.setTimeout(function () {
|
|
toast.classList.remove('toast--visible');
|
|
clearNode(toast);
|
|
}, 5000);
|
|
}
|
|
|
|
/* ----------------------------------------------------------------- modal */
|
|
|
|
var FOCUSABLE =
|
|
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), ' +
|
|
'textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
|
|
function focusableWithin(root) {
|
|
return queryAll(FOCUSABLE, root).filter(function (node) {
|
|
if (node.hidden || node.closest('[hidden]')) {
|
|
return false;
|
|
}
|
|
return node.getClientRects().length > 0;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Wrap a `[hidden]` backdrop element. The dialog traps Tab, closes on Escape
|
|
* and on a backdrop click, and returns focus to whatever opened it.
|
|
*/
|
|
function createModal(backdrop, options) {
|
|
var settings = options || {};
|
|
var dialog = backdrop ? backdrop.querySelector('[role="dialog"]') : null;
|
|
var lastFocused = null;
|
|
var open = false;
|
|
|
|
function onKeydown(event) {
|
|
if (!open) {
|
|
return;
|
|
}
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault();
|
|
close();
|
|
return;
|
|
}
|
|
if (event.key !== 'Tab') {
|
|
return;
|
|
}
|
|
var focusable = focusableWithin(dialog || backdrop);
|
|
if (focusable.length === 0) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
var first = focusable[0];
|
|
var last = focusable[focusable.length - 1];
|
|
var active = document.activeElement;
|
|
if (event.shiftKey) {
|
|
if (active === first || !focusable.indexOf(active)) {
|
|
event.preventDefault();
|
|
last.focus();
|
|
}
|
|
} else if (active === last) {
|
|
event.preventDefault();
|
|
first.focus();
|
|
}
|
|
}
|
|
|
|
function onBackdropClick(event) {
|
|
if (event.target === backdrop) {
|
|
close();
|
|
}
|
|
}
|
|
|
|
function openModal() {
|
|
if (!backdrop || open) {
|
|
return;
|
|
}
|
|
lastFocused = document.activeElement;
|
|
backdrop.removeAttribute('hidden');
|
|
document.documentElement.classList.add('modal-open');
|
|
open = true;
|
|
var target = settings.initialFocus ? query(settings.initialFocus, dialog || backdrop) : null;
|
|
var focusable = focusableWithin(dialog || backdrop);
|
|
if (!target && focusable.length > 0) {
|
|
target = focusable[0];
|
|
}
|
|
if (target && typeof target.focus === 'function') {
|
|
target.focus();
|
|
}
|
|
}
|
|
|
|
function close() {
|
|
if (!backdrop || !open) {
|
|
return;
|
|
}
|
|
open = false;
|
|
backdrop.setAttribute('hidden', '');
|
|
document.documentElement.classList.remove('modal-open');
|
|
if (typeof settings.onClose === 'function') {
|
|
settings.onClose();
|
|
}
|
|
if (lastFocused && typeof lastFocused.focus === 'function' && document.contains(lastFocused)) {
|
|
lastFocused.focus();
|
|
}
|
|
lastFocused = null;
|
|
}
|
|
|
|
if (backdrop) {
|
|
backdrop.addEventListener('keydown', onKeydown);
|
|
backdrop.addEventListener('mousedown', onBackdropClick);
|
|
}
|
|
|
|
return {
|
|
backdrop: backdrop,
|
|
dialog: dialog,
|
|
isOpen: function () {
|
|
return open;
|
|
},
|
|
open: openModal,
|
|
close: close
|
|
};
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- export */
|
|
|
|
global.UmbraApi = {
|
|
ApiError: ApiError,
|
|
PASSWORD_MIN_LENGTH: PASSWORD_MIN_LENGTH,
|
|
health: health,
|
|
login: login,
|
|
verifyEmail: verifyEmail,
|
|
passwordResetAvailable: passwordResetAvailable,
|
|
resetPassword: resetPassword,
|
|
changeOwnPassword: changeOwnPassword,
|
|
requestPasswordReset: requestPasswordReset,
|
|
resendVerification: resendVerification,
|
|
changeFirstPassword: changeFirstPassword,
|
|
me: me,
|
|
logout: logout,
|
|
logoutEverywhere: logoutEverywhere,
|
|
getSummary: getSummary,
|
|
listAccounts: listAccounts,
|
|
createAccount: createAccount,
|
|
setAccountStatus: setAccountStatus,
|
|
revokeAccountSessions: revokeAccountSessions,
|
|
listOrganizations: listOrganizations,
|
|
createOrganization: createOrganization,
|
|
updateOrganization: updateOrganization,
|
|
setOrganizationStatus: setOrganizationStatus,
|
|
listAudit: listAudit,
|
|
workspace: workspace,
|
|
listProjects: listProjects,
|
|
createProject: createProject,
|
|
setProjectStatus: setProjectStatus,
|
|
projectStatus: projectStatus,
|
|
projectDetail: projectDetail,
|
|
assignToProject: assignToProject,
|
|
unassignFromProject: unassignFromProject,
|
|
queueTranslation: queueTranslation,
|
|
queueAudio: queueAudio,
|
|
getFormatMetadata: getFormatMetadata,
|
|
setFormatMetadata: setFormatMetadata,
|
|
getUtteranceReview: getUtteranceReview,
|
|
getFrontMatter: getFrontMatter,
|
|
saveFrontMatter: saveFrontMatter,
|
|
getChapters: getChapters,
|
|
getChapterContent: getChapterContent,
|
|
request: request,
|
|
el: el,
|
|
clear: clearNode,
|
|
setText: setText,
|
|
query: query,
|
|
ensureCsrfToken: ensureCsrfToken,
|
|
queryAll: queryAll,
|
|
show: show,
|
|
setBusy: setBusy,
|
|
disable: disable,
|
|
formatDateTime: formatDateTime,
|
|
label: label,
|
|
shortId: shortId,
|
|
initials: initials,
|
|
describeError: describeError,
|
|
describeRequirements: describeRequirements,
|
|
showToast: showToast,
|
|
createModal: createModal,
|
|
readCookie: readCookie,
|
|
setSessionLostHandler: function (handler) {
|
|
sessionLostHandler = handler;
|
|
},
|
|
setPasswordChangeHandler: function (handler) {
|
|
passwordChangeHandler = handler;
|
|
},
|
|
forgetLocalState: function () {
|
|
fallbackCsrfToken = '';
|
|
}
|
|
};
|
|
})(window);
|