3m-portal/dashboard.js
Vadym Samoilenko 53a85c788d Add full auth system: SQLite sessions, email invites, admin console
- 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>
2026-05-05 11:26:40 +01:00

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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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">&#x1F512;</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;
}