- Real email/password login backed by SQLite (better-sqlite3) - HttpOnly cookie sessions with 8h sliding TTL - Admin role: invite users via Mailgun magic-link, manage roles/status - Per-user One2Edit username mapping for job filtering - Self-service forgot-password / reset-password via email - Admin console (admin.html) with user table, invite modal, row actions - New pages: change-password, forgot-password, reset-password, accept-invite - Gated /api proxy: requires valid session, anti-hijack sessionId check - Bootstrap initial admins from INITIAL_ADMINS env var on first boot - Remove Oliver login button, SSO buttons, and legacy api.js/login.js - deploy.sh: add build-essential (for native module), npm install, data dir Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
447 lines
17 KiB
JavaScript
447 lines
17 KiB
JavaScript
const CLIENT_ID = '7';
|
|
const REFRESH_INTERVAL_MS = 60_000;
|
|
|
|
// One2Edit returns stepData.name as "<position>. <label>", and the label can
|
|
// be empty for some workflows. stepData.type is the stable identifier:
|
|
// CONTENT → editing/translation step
|
|
// REVIEW → client review/approval step
|
|
const STEP_LABELS = {
|
|
CONTENT: 'Edit',
|
|
REVIEW: 'Client - Review/Approval',
|
|
};
|
|
const REVIEW_STEP_TYPE = 'REVIEW';
|
|
|
|
let allJobs = [];
|
|
let filteredJobs = [];
|
|
let refreshTimer = null;
|
|
let searchTerm = '';
|
|
let workflowValue = 'all';
|
|
|
|
function escapeHtml(str) {
|
|
return String(str)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
function formatLocalDate(value) {
|
|
if (!value || value === 'N/A') return 'N/A';
|
|
const numeric = Number(value);
|
|
const date = Number.isFinite(numeric) && numeric > 0
|
|
? new Date(numeric < 1e12 ? numeric * 1000 : numeric)
|
|
: new Date(value);
|
|
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
|
}
|
|
|
|
function getSession() {
|
|
const authConfigStr = sessionStorage.getItem('authConfig');
|
|
const userId = sessionStorage.getItem('userId');
|
|
const externSessionId = sessionStorage.getItem('externSessionId');
|
|
if (!authConfigStr || !externSessionId) return null;
|
|
return { authConfig: JSON.parse(authConfigStr), userId, externSessionId };
|
|
}
|
|
|
|
function redirectToLogin() {
|
|
sessionStorage.clear();
|
|
window.location.href = 'login.html';
|
|
}
|
|
|
|
function handleEdit(jobId) {
|
|
window.location.href = `editor.html?jobId=${encodeURIComponent(jobId)}`;
|
|
}
|
|
|
|
async function handlePDF(jobId) {
|
|
const jobCard = document.querySelector(`[data-job-id="${jobId}"]`);
|
|
const pdfBtn = jobCard ? jobCard.querySelector('.btn-pdf') : null;
|
|
|
|
if (pdfBtn) {
|
|
pdfBtn.disabled = true;
|
|
pdfBtn.textContent = 'Downloading...';
|
|
}
|
|
|
|
showPdfPopup();
|
|
|
|
try {
|
|
const job = allJobs.find(j => j.id === jobId);
|
|
if (!job) throw new Error('Job not found');
|
|
|
|
const session = getSession();
|
|
if (!session) { redirectToLogin(); return; }
|
|
|
|
const params = new URLSearchParams({
|
|
command: 'job.export.pdf',
|
|
authDomain: 'local',
|
|
sessionId: session.externSessionId,
|
|
clientId: CLIENT_ID,
|
|
id: jobId,
|
|
result: 'file',
|
|
});
|
|
|
|
const response = await fetch(`${session.authConfig.apiBaseUrl}?${params}`);
|
|
if (!response.ok) throw new Error(`Failed to generate PDF (${response.status})`);
|
|
|
|
const blob = await response.blob();
|
|
const blobUrl = URL.createObjectURL(blob);
|
|
const link = document.createElement('a');
|
|
link.href = blobUrl;
|
|
link.download = `${job.title || job.documentName || 'document'}_${jobId}.pdf`;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(blobUrl);
|
|
} catch (error) {
|
|
console.error('PDF export error:', error);
|
|
alert('Failed to export PDF. Please try again.');
|
|
} finally {
|
|
if (pdfBtn) {
|
|
pdfBtn.disabled = false;
|
|
pdfBtn.textContent = 'PDF';
|
|
}
|
|
}
|
|
}
|
|
|
|
function showPdfPopup() {
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'pdf-popup-overlay';
|
|
|
|
const popup = document.createElement('div');
|
|
popup.className = 'pdf-popup';
|
|
popup.innerHTML = `
|
|
<p class="pdf-popup-message">PDF download requested</p>
|
|
<button class="pdf-popup-ok-btn">OK</button>
|
|
`;
|
|
|
|
overlay.appendChild(popup);
|
|
document.body.appendChild(overlay);
|
|
requestAnimationFrame(() => overlay.classList.add('active'));
|
|
|
|
const close = () => {
|
|
overlay.classList.remove('active');
|
|
setTimeout(() => overlay.remove(), 200);
|
|
};
|
|
popup.querySelector('.pdf-popup-ok-btn').addEventListener('click', close);
|
|
overlay.addEventListener('click', e => { if (e.target === overlay) close(); });
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
await initAuth();
|
|
loadJobsFromAPI();
|
|
setupEventListeners();
|
|
startAutoRefresh();
|
|
});
|
|
|
|
async function initAuth() {
|
|
try {
|
|
const res = await fetch('/api/auth/me');
|
|
if (!res.ok) { redirectToLogin(); return; }
|
|
const me = await res.json();
|
|
if (me.mustChangePassword) { window.location.href = 'change-password.html'; return; }
|
|
sessionStorage.setItem('isAuthenticated', 'true');
|
|
sessionStorage.setItem('userId', me.one2editUserId || '');
|
|
sessionStorage.setItem('externSessionId', me.externSessionId || '');
|
|
sessionStorage.setItem('authConfig', JSON.stringify(me.authConfig || {}));
|
|
sessionStorage.setItem('username', me.email || '');
|
|
sessionStorage.setItem('role', me.role || '');
|
|
if (me.role === 'admin') {
|
|
document.getElementById('adminBtn').style.display = 'inline-block';
|
|
}
|
|
} catch {
|
|
redirectToLogin();
|
|
}
|
|
}
|
|
|
|
function setupEventListeners() {
|
|
document.getElementById('refreshBtn').addEventListener('click', loadJobsFromAPI);
|
|
document.getElementById('signOutBtn').addEventListener('click', handleSignOut);
|
|
|
|
const searchInput = document.getElementById('searchInput');
|
|
const workflowFilter = document.getElementById('workflowFilter');
|
|
|
|
searchInput.addEventListener('input', e => {
|
|
searchTerm = e.target.value.trim().toLowerCase();
|
|
applyFilters();
|
|
});
|
|
workflowFilter.addEventListener('change', e => {
|
|
workflowValue = e.target.value;
|
|
applyFilters();
|
|
});
|
|
}
|
|
|
|
function startAutoRefresh() {
|
|
stopAutoRefresh();
|
|
refreshTimer = setInterval(loadJobsFromAPI, REFRESH_INTERVAL_MS);
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.hidden) stopAutoRefresh();
|
|
else if (!refreshTimer) {
|
|
loadJobsFromAPI();
|
|
refreshTimer = setInterval(loadJobsFromAPI, REFRESH_INTERVAL_MS);
|
|
}
|
|
});
|
|
}
|
|
|
|
function stopAutoRefresh() {
|
|
if (refreshTimer) {
|
|
clearInterval(refreshTimer);
|
|
refreshTimer = null;
|
|
}
|
|
}
|
|
|
|
async function handleSignOut() {
|
|
const signOutBtn = document.getElementById('signOutBtn');
|
|
signOutBtn.disabled = true;
|
|
signOutBtn.textContent = 'Signing out...';
|
|
stopAutoRefresh();
|
|
|
|
try {
|
|
await fetch('/api/auth/logout', { method: 'POST' });
|
|
} catch (error) {
|
|
console.error('Sign out error:', error);
|
|
} finally {
|
|
redirectToLogin();
|
|
}
|
|
}
|
|
|
|
async function loadJobsFromAPI() {
|
|
const loadingMessage = document.getElementById('loadingMessage');
|
|
const errorMessage = document.getElementById('errorMessage');
|
|
const jobsGrid = document.getElementById('jobsGrid');
|
|
const emptyState = document.getElementById('emptyState');
|
|
|
|
const isInitialLoad = allJobs.length === 0;
|
|
if (isInitialLoad) loadingMessage.style.display = 'block';
|
|
errorMessage.style.display = 'none';
|
|
|
|
try {
|
|
const session = getSession();
|
|
if (!session || !session.userId) { redirectToLogin(); return; }
|
|
|
|
const params = new URLSearchParams({
|
|
command: 'job.list',
|
|
authDomain: 'local',
|
|
clientId: CLIENT_ID,
|
|
userId: session.userId,
|
|
includeDocumentInfos: '1',
|
|
includeDocumentPreviews: '1',
|
|
includeDocumentWorkflows: '1',
|
|
includeDocumentMetadata: '1',
|
|
});
|
|
params.append('status[0]', 'STARTED');
|
|
params.append('status[1]', 'RUNNING');
|
|
|
|
const response = await fetch(`${session.authConfig.apiBaseUrl}?${params}`, {
|
|
headers: { Accept: 'text/xml' },
|
|
});
|
|
const xmlDoc = new DOMParser().parseFromString(await response.text(), 'text/xml');
|
|
|
|
const errorNode = xmlDoc.querySelector('error');
|
|
if (errorNode) {
|
|
throw new Error(errorNode.querySelector('message')?.textContent || 'Failed to load jobs');
|
|
}
|
|
|
|
allJobs = Array.from(xmlDoc.querySelectorAll('job')).map(parseJobFromXML);
|
|
|
|
loadingMessage.style.display = 'none';
|
|
|
|
if (allJobs.length === 0) {
|
|
jobsGrid.innerHTML = '';
|
|
emptyState.style.display = 'block';
|
|
updateOverallProgress();
|
|
updateResultCount(0);
|
|
} else {
|
|
applyFilters();
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load jobs:', error);
|
|
loadingMessage.style.display = 'none';
|
|
errorMessage.textContent = error.message || 'Failed to load jobs. Please try again.';
|
|
errorMessage.style.display = 'block';
|
|
}
|
|
}
|
|
|
|
function parseJobFromXML(jobNode) {
|
|
const get = sel => jobNode.querySelector(sel)?.textContent || '';
|
|
|
|
const previewNode = jobNode.querySelector('document > preview');
|
|
const thumbnail = previewNode?.textContent
|
|
? `data:image/jpeg;base64,${previewNode.textContent}`
|
|
: null;
|
|
|
|
// Title precedence: document.version -> document.name -> job.name.
|
|
// One2Edit reuses document names across versions; the version label is the
|
|
// most specific human-readable identifier when present.
|
|
const documentVersion = get('document > version');
|
|
const documentName = get('document > name');
|
|
const jobName = get('name');
|
|
|
|
const stepType = get('stepData > type');
|
|
const stepRawName = get('stepData > name');
|
|
const workflowStep = STEP_LABELS[stepType]
|
|
|| stepRawName.replace(/^\d+\.\s*/, '').trim()
|
|
|| 'Unknown';
|
|
|
|
return {
|
|
id: get('id'),
|
|
documentId: get('document > id'),
|
|
title: documentVersion || documentName || jobName || 'Untitled',
|
|
documentName: documentVersion || documentName || 'N/A',
|
|
language: get('translationLanguage > name') || 'N/A',
|
|
pages: get('document > pages') || 'N/A',
|
|
status: get('status'),
|
|
stepType,
|
|
workflowStep,
|
|
isOpened: get('isOpened') === 'true',
|
|
totalItems: parseInt(get('totalItems')) || 0,
|
|
doneItems: parseInt(get('doneItems')) || 0,
|
|
finishItems: parseInt(get('finishItems')) || 0,
|
|
lastUserName: get('lastuser > name') || 'N/A',
|
|
lastEdit: get('lastedit') || 'N/A',
|
|
thumbnail: thumbnail || 'https://via.placeholder.com/300x400/cccccc/666666?text=No+Preview',
|
|
};
|
|
}
|
|
|
|
function applyFilters() {
|
|
filteredJobs = allJobs.filter(job => {
|
|
if (workflowValue !== 'all' && job.workflowStep !== workflowValue) return false;
|
|
if (!searchTerm) return true;
|
|
const haystack = [
|
|
job.title, job.documentName, job.documentId,
|
|
job.language, job.lastUserName, job.workflowStep,
|
|
].join(' ').toLowerCase();
|
|
return haystack.includes(searchTerm);
|
|
});
|
|
renderJobs();
|
|
updateResultCount(filteredJobs.length);
|
|
}
|
|
|
|
function updateResultCount(count) {
|
|
const el = document.getElementById('resultCount');
|
|
if (!el) return;
|
|
const total = allJobs.length;
|
|
el.textContent = count === total
|
|
? `${total} job${total === 1 ? '' : 's'}`
|
|
: `${count} of ${total} job${total === 1 ? '' : 's'}`;
|
|
}
|
|
|
|
function updateOverallProgress() {
|
|
let totalFinishItems = 0;
|
|
let totalDoneItems = 0;
|
|
let totalAllItems = 0;
|
|
|
|
allJobs.forEach(job => {
|
|
totalFinishItems += job.finishItems;
|
|
totalDoneItems += job.doneItems;
|
|
totalAllItems += job.totalItems;
|
|
});
|
|
|
|
const finishPercent = totalAllItems > 0 ? (totalFinishItems / totalAllItems * 100) : 0;
|
|
const donePercent = totalAllItems > 0 ? (totalDoneItems / totalAllItems * 100) : 0;
|
|
const completedItems = totalFinishItems + totalDoneItems;
|
|
const overallPercent = totalAllItems > 0 ? Math.round((completedItems / totalAllItems) * 100) : 0;
|
|
|
|
document.getElementById('overallProgressText').innerHTML =
|
|
`<strong>${completedItems} of ${totalAllItems} elements done (${overallPercent}%)</strong>`;
|
|
document.getElementById('overallDone').style.width = `${donePercent}%`;
|
|
document.getElementById('overallFinish').style.width = `${finishPercent}%`;
|
|
document.getElementById('overallRemaining').style.width = `${100 - finishPercent - donePercent}%`;
|
|
document.getElementById('overallDoneText').textContent = `Done: ${totalDoneItems} (${Math.round(donePercent)}%)`;
|
|
document.getElementById('overallFinishText').textContent = `Finished: ${totalFinishItems} (${Math.round(finishPercent)}%)`;
|
|
document.getElementById('overallTotalText').textContent = `Total: ${totalAllItems}`;
|
|
}
|
|
|
|
function renderJobs() {
|
|
const jobsGrid = document.getElementById('jobsGrid');
|
|
const emptyState = document.getElementById('emptyState');
|
|
|
|
jobsGrid.innerHTML = '';
|
|
|
|
if (filteredJobs.length === 0) {
|
|
emptyState.style.display = 'block';
|
|
emptyState.querySelector('p').textContent =
|
|
allJobs.length === 0 ? 'No jobs found' : 'No jobs match the current filters';
|
|
} else {
|
|
emptyState.style.display = 'none';
|
|
filteredJobs.forEach(job => jobsGrid.appendChild(createJobCard(job)));
|
|
}
|
|
updateOverallProgress();
|
|
}
|
|
|
|
function createJobCard(job) {
|
|
const card = document.createElement('div');
|
|
card.className = 'job-card';
|
|
card.setAttribute('data-job-id', job.id);
|
|
|
|
const lockedClass = job.isOpened ? ' job-locked' : '';
|
|
const disabledAttr = job.isOpened ? ' disabled' : '';
|
|
const lockIcon = job.isOpened ? '<div class="lock-icon">🔒</div>' : '';
|
|
const reviewBadge = job.stepType === REVIEW_STEP_TYPE
|
|
? '<span class="workflow-badge workflow-badge-review">Final Review</span>'
|
|
: '';
|
|
|
|
const finishPercent = job.totalItems > 0 ? (job.finishItems / job.totalItems * 100) : 0;
|
|
const donePercent = job.totalItems > 0 ? (job.doneItems / job.totalItems * 100) : 0;
|
|
const completedItems = job.finishItems + job.doneItems;
|
|
const overallPercent = job.totalItems > 0 ? Math.round((completedItems / job.totalItems) * 100) : 0;
|
|
|
|
const title = escapeHtml(job.title);
|
|
const documentName = escapeHtml(job.documentName);
|
|
const documentId = escapeHtml(job.documentId);
|
|
const language = escapeHtml(job.language);
|
|
const pages = escapeHtml(job.pages);
|
|
const lastUserName = escapeHtml(job.lastUserName);
|
|
const lastEdit = escapeHtml(formatLocalDate(job.lastEdit));
|
|
const workflowStep = escapeHtml(job.workflowStep || 'N/A');
|
|
const jobId = escapeHtml(job.id);
|
|
const thumbnail = escapeHtml(job.thumbnail);
|
|
|
|
card.innerHTML = `
|
|
<div class="job-card-header">
|
|
<span class="job-card-title">${title}</span>
|
|
${reviewBadge}
|
|
${lockIcon}
|
|
</div>
|
|
<div class="job-preview">
|
|
<img src="${thumbnail}" alt="${title}" onerror="this.src='https://via.placeholder.com/300x400/cccccc/666666?text=No+Preview'">
|
|
</div>
|
|
<div class="job-details">
|
|
<p class="job-document-name">${documentName}</p>
|
|
<p class="job-info-line"><strong>Document ID:</strong> ${documentId}</p>
|
|
<p class="job-info-line"><strong>Language:</strong> ${language}</p>
|
|
<p class="job-info-line"><strong>Pages:</strong> ${pages}</p>
|
|
<p class="job-info-line"><strong>Workflow Step:</strong> ${workflowStep}</p>
|
|
<p class="job-info-line"><strong>Last User:</strong> ${lastUserName}</p>
|
|
<p class="job-info-line"><strong>Last Edit:</strong> ${lastEdit}</p>
|
|
|
|
<p class="job-info-line"><strong>${completedItems} of ${job.totalItems} elements done (${overallPercent}%)</strong></p>
|
|
|
|
<div class="progress-bar-container">
|
|
<div class="progress-bar">
|
|
<div class="progress-segment progress-done" style="width: ${donePercent}%"></div>
|
|
<div class="progress-segment progress-finish" style="width: ${finishPercent}%"></div>
|
|
<div class="progress-segment progress-remaining" style="width: ${100 - finishPercent - donePercent}%"></div>
|
|
</div>
|
|
<div class="progress-legend">
|
|
<span class="legend-item">
|
|
<span class="legend-color legend-done"></span>
|
|
Done: ${job.doneItems} (${Math.round(donePercent)}%)
|
|
</span>
|
|
<span class="legend-item">
|
|
<span class="legend-color legend-finish"></span>
|
|
Finished: ${job.finishItems} (${Math.round(finishPercent)}%)
|
|
</span>
|
|
<span class="legend-item">
|
|
<span class="legend-color legend-remaining"></span>
|
|
Total: ${job.totalItems}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="job-actions">
|
|
<button class="btn-edit${lockedClass}"${disabledAttr} onclick="handleEdit('${jobId}')">Edit</button>
|
|
<button class="btn-pdf${lockedClass}"${disabledAttr} onclick="handlePDF('${jobId}')">PDF</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
return card;
|
|
}
|