Files
TieMeasureFlow/server/templates/setup/setup.html
T
Adriano 429c94da94 fix: improve SPC dashboard UX — hover toolbar, visible report errors
- Change Plotly modebar to hover-only mode to avoid overlapping chart content
- Remove redundant Plotly titles (container headers already provide them)
- Add Content-Type check and inline error display for report downloads
- Fix setup login validation and seed idempotency for existing data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 16:42:47 +01:00

935 lines
38 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>TieMeasureFlow &mdash; Setup</title>
<style>
/* --- Reset & Base -------------------------------------------------- */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #f0f4ff 0%, #e8ecf4 100%);
color: #1e293b;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 16px;
}
/* --- Typography ---------------------------------------------------- */
h1 { font-size: 1.75rem; font-weight: 700; color: #0f172a; }
h2 { font-size: 1.15rem; font-weight: 600; color: #334155; margin-bottom: 12px; }
p { line-height: 1.5; color: #64748b; }
.brand-sub { font-size: 0.85rem; color: #64748b; margin-top: 4px; }
.mono { font-family: 'JetBrains Mono', 'Fira Code', monospace; }
/* --- Layout -------------------------------------------------------- */
.container { width: 100%; max-width: 600px; }
.logo-header {
text-align: center;
margin-bottom: 32px;
}
.logo-header .icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 56px; height: 56px;
background: #2563eb;
border-radius: 14px;
color: #fff;
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 12px;
}
/* --- Card ---------------------------------------------------------- */
.card {
background: #fff;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,.08), 0 4px 16px rgba(0,0,0,.04);
padding: 24px;
margin-bottom: 20px;
}
.card.danger { border-left: 4px solid #dc2626; }
/* --- Form elements ------------------------------------------------- */
label {
display: block;
font-size: 0.82rem;
font-weight: 500;
color: #475569;
margin-bottom: 4px;
}
input[type="text"],
input[type="password"],
input[type="email"] {
width: 100%;
padding: 10px 12px;
border: 1px solid #cbd5e1;
border-radius: 8px;
font-size: 0.9rem;
font-family: inherit;
color: #1e293b;
background: #f8fafc;
transition: border-color .15s, box-shadow .15s;
margin-bottom: 14px;
}
input:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37,99,235,.15);
}
/* --- Buttons ------------------------------------------------------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 20px;
border: none;
border-radius: 8px;
font-family: inherit;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background .15s, transform .1s, opacity .15s;
width: 100%;
}
.btn:active { transform: scale(.98); }
.btn:disabled { opacity: .55; cursor: not-allowed; transform: none; }
.btn-primary { background: #2563eb; color: #fff; }
.btn-primary:hover:not(:disabled) { background: #1d4ed8; }
.btn-success { background: #16a34a; color: #fff; }
.btn-success:hover:not(:disabled) { background: #15803d; }
.btn-danger { background: #dc2626; color: #fff; }
.btn-danger:hover:not(:disabled) { background: #b91c1c; }
.btn-outline {
background: transparent;
color: #2563eb;
border: 1.5px solid #2563eb;
}
.btn-outline:hover:not(:disabled) { background: #eff6ff; }
/* --- Status pills -------------------------------------------------- */
.status-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 500;
margin-bottom: 12px;
}
.status-bar.ok { background: #f0fdf4; color: #166534; border: 1px solid #bbf7d0; }
.status-bar.warn { background: #fffbeb; color: #92400e; border: 1px solid #fde68a; }
.status-bar.err { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }
.dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.dot.green { background: #16a34a; }
.dot.yellow { background: #eab308; }
.dot.red { background: #dc2626; }
/* --- Table list ---------------------------------------------------- */
.table-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.table-chip {
background: #f1f5f9;
color: #475569;
padding: 3px 10px;
border-radius: 6px;
font-size: 0.78rem;
font-family: 'JetBrains Mono', monospace;
}
/* --- Checkbox ------------------------------------------------------ */
.checkbox-row {
display: flex;
align-items: center;
gap: 8px;
margin: 12px 0 16px;
font-size: 0.85rem;
color: #64748b;
}
.checkbox-row input[type="checkbox"] {
width: 16px; height: 16px;
accent-color: #dc2626;
}
/* --- Toast --------------------------------------------------------- */
#toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 8px;
max-width: 380px;
}
.toast {
padding: 12px 18px;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 500;
color: #fff;
box-shadow: 0 4px 12px rgba(0,0,0,.15);
animation: slideIn .25s ease-out;
word-break: break-word;
}
.toast.success { background: #16a34a; }
.toast.error { background: #dc2626; }
.toast.info { background: #2563eb; }
@keyframes slideIn {
from { opacity: 0; transform: translateX(30px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; transform: translateY(-10px); }
}
/* --- Spinner ------------------------------------------------------- */
.spinner {
display: inline-block;
width: 16px; height: 16px;
border: 2px solid rgba(255,255,255,.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin .6s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* --- Separator ----------------------------------------------------- */
.sep { border: none; border-top: 1px solid #e2e8f0; margin: 18px 0; }
/* --- Helpers ------------------------------------------------------- */
.hidden { display: none !important; }
.mt-2 { margin-top: 8px; }
.mt-4 { margin-top: 16px; }
.text-sm { font-size: 0.82rem; }
.text-muted { color: #94a3b8; }
/* --- User Management ----------------------------------------------- */
.user-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
margin-top: 12px;
}
.user-table th {
text-align: left;
padding: 8px 10px;
background: #f8fafc;
color: #475569;
font-weight: 600;
border-bottom: 2px solid #e2e8f0;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.user-table td {
padding: 8px 10px;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
}
.user-table tr:hover td { background: #f8fafc; }
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.02em;
}
.badge-maker { background: #dbeafe; color: #1d4ed8; }
.badge-measurement { background: #dcfce7; color: #166534; }
.badge-metrologist { background: #fef3c7; color: #92400e; }
.badge-admin { background: #fce7f3; color: #9d174d; }
.badge-active { background: #dcfce7; color: #166534; }
.badge-inactive { background: #fee2e2; color: #991b1b; }
.btn-sm {
padding: 4px 10px;
font-size: 0.75rem;
border-radius: 6px;
border: none;
cursor: pointer;
font-weight: 500;
font-family: inherit;
transition: background .15s;
}
.btn-sm-primary { background: #2563eb; color: #fff; }
.btn-sm-primary:hover { background: #1d4ed8; }
.btn-sm-warning { background: #f59e0b; color: #fff; }
.btn-sm-warning:hover { background: #d97706; }
.btn-sm-danger { background: #ef4444; color: #fff; }
.btn-sm-danger:hover { background: #dc2626; }
.btn-sm-success { background: #10b981; color: #fff; }
.btn-sm-success:hover { background: #059669; }
.action-btns { display: flex; gap: 4px; flex-wrap: wrap; }
/* --- Modal --------------------------------------------------------- */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,.4);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
animation: fadeInOverlay .2s ease-out;
}
@keyframes fadeInOverlay { from { opacity: 0; } to { opacity: 1; } }
.modal {
background: #fff;
border-radius: 12px;
box-shadow: 0 8px 30px rgba(0,0,0,.2);
padding: 24px;
width: 90%;
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
animation: scaleIn .2s ease-out;
}
@keyframes scaleIn { from { transform: scale(.95); opacity: 0; } to { transform: scale(1); opacity: 1; } }
.modal h2 { margin-bottom: 16px; }
.modal .btn { margin-top: 8px; }
.modal-actions {
display: flex;
gap: 8px;
margin-top: 16px;
}
.modal-actions .btn { flex: 1; }
/* --- Checkbox group ------------------------------------------------ */
.checkbox-group {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 14px;
}
.checkbox-group label {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.85rem;
margin-bottom: 0;
cursor: pointer;
}
.checkbox-group input[type="checkbox"] {
width: 16px; height: 16px;
accent-color: #2563eb;
}
</style>
</head>
<body>
<!-- Toast container -->
<div id="toast-container"></div>
<!-- ================================================================== -->
<!-- LOGIN SCREEN -->
<!-- ================================================================== -->
<div class="container" id="login-screen">
<div class="logo-header">
<div class="icon">TM</div>
<h1>TieMeasureFlow</h1>
<p class="brand-sub">System Setup</p>
</div>
<div class="card">
<h2>Setup Password</h2>
<p class="text-sm text-muted" style="margin-bottom:16px;">
Enter the <span class="mono">SETUP_PASSWORD</span> from your <span class="mono">.env</span> file to continue.
</p>
<label for="setup-pwd">Password</label>
<input type="password" id="setup-pwd" placeholder="Enter setup password" autofocus>
<button class="btn btn-primary" id="btn-login" onclick="doLogin()">Unlock Setup</button>
</div>
</div>
<!-- ================================================================== -->
<!-- SETUP PANEL (hidden until login) -->
<!-- ================================================================== -->
<div class="container hidden" id="setup-panel">
<div class="logo-header">
<div class="icon">TM</div>
<h1>TieMeasureFlow</h1>
<p class="brand-sub">System Setup Panel</p>
</div>
<!-- 1. DB Status --------------------------------------------------- -->
<div class="card" id="card-status">
<h2>Database Status</h2>
<div id="status-content">
<p class="text-sm text-muted">Loading...</p>
</div>
</div>
<!-- 2. Init DB ----------------------------------------------------- -->
<div class="card">
<h2>Initialize Database</h2>
<p class="text-sm text-muted" style="margin-bottom:14px;">
Create all required tables in the database. Safe to run multiple times.
</p>
<button class="btn btn-primary" id="btn-init" onclick="initDb()">Create Tables</button>
</div>
<!-- 3. Create Admin ------------------------------------------------ -->
<div class="card">
<h2>Create Admin User</h2>
<p class="text-sm text-muted" style="margin-bottom:14px;">
Create a new administrator with all roles enabled.
</p>
<label for="admin-username">Username</label>
<input type="text" id="admin-username" placeholder="admin">
<label for="admin-password">Password</label>
<input type="password" id="admin-password" placeholder="Strong password">
<label for="admin-email">Email</label>
<input type="email" id="admin-email" placeholder="admin@example.com">
<label for="admin-displayname">Display Name</label>
<input type="text" id="admin-displayname" placeholder="Administrator">
<button class="btn btn-success" id="btn-admin" onclick="createAdmin()">Create Admin</button>
</div>
<!-- 4. Seed Demo --------------------------------------------------- -->
<div class="card">
<h2>Seed Demo Data</h2>
<p class="text-sm text-muted" style="margin-bottom:14px;">
Populate the database with sample users, a demo recipe, and 180 measurements for SPC testing.
Creates users: <span class="mono">admin</span>, <span class="mono">maker1</span>,
<span class="mono">tec1</span>, <span class="mono">metro1</span>.
</p>
<button class="btn btn-success" id="btn-seed" onclick="seedData()">Load Demo Data</button>
</div>
<!-- 5. User Management ------------------------------------------------ -->
<div class="card" id="card-users">
<h2>User Management</h2>
<p class="text-sm text-muted" style="margin-bottom:14px;">
Create, edit, and manage user accounts. Changes take effect immediately.
</p>
<button class="btn btn-primary" style="width:auto; margin-bottom:14px;" onclick="openCreateUserModal()">+ New User</button>
<div id="users-table-container">
<p class="text-sm text-muted">Unlock the setup page to load users.</p>
</div>
</div>
<!-- 6. Reset DB ---------------------------------------------------- -->
<div class="card danger">
<h2 style="color:#dc2626;">Reset Database</h2>
<p class="text-sm" style="color:#991b1b; margin-bottom:8px;">
This will <strong>DROP ALL TABLES</strong> and recreate them. All data will be permanently lost.
</p>
<div class="checkbox-row">
<input type="checkbox" id="reset-confirm">
<label for="reset-confirm" style="margin-bottom:0; cursor:pointer;">I understand this action is irreversible</label>
</div>
<button class="btn btn-danger" id="btn-reset" onclick="resetDb()" disabled>Reset Database</button>
</div>
</div>
<!-- ================================================================== -->
<!-- MODALS -->
<!-- ================================================================== -->
<!-- Create / Edit User Modal -->
<div class="modal-overlay hidden" id="modal-user">
<div class="modal">
<h2 id="modal-user-title">New User</h2>
<input type="hidden" id="mu-user-id">
<label for="mu-username">Username</label>
<input type="text" id="mu-username" placeholder="username">
<label for="mu-password">Password <span id="mu-pwd-hint" class="text-muted text-sm">(required)</span></label>
<input type="password" id="mu-password" placeholder="Password">
<label for="mu-displayname">Display Name</label>
<input type="text" id="mu-displayname" placeholder="Full Name">
<label for="mu-email">Email</label>
<input type="email" id="mu-email" placeholder="user@example.com">
<label>Roles</label>
<div class="checkbox-group">
<label><input type="checkbox" id="mu-role-maker" value="Maker"> Maker</label>
<label><input type="checkbox" id="mu-role-tec" value="MeasurementTec"> MeasurementTec</label>
<label><input type="checkbox" id="mu-role-metro" value="Metrologist"> Metrologist</label>
</div>
<div class="checkbox-group">
<label><input type="checkbox" id="mu-admin"> Administrator</label>
</div>
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeModal('modal-user')">Cancel</button>
<button class="btn btn-primary" id="btn-save-user" onclick="saveUser()">Save User</button>
</div>
</div>
</div>
<!-- Change Password Modal -->
<div class="modal-overlay hidden" id="modal-password">
<div class="modal">
<h2>Change Password</h2>
<input type="hidden" id="mp-user-id">
<p class="text-sm text-muted" style="margin-bottom:14px;">
Changing password for: <strong id="mp-username"></strong>
</p>
<label for="mp-new-password">New Password</label>
<input type="password" id="mp-new-password" placeholder="New password">
<label for="mp-confirm-password">Confirm Password</label>
<input type="password" id="mp-confirm-password" placeholder="Confirm password">
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeModal('modal-password')">Cancel</button>
<button class="btn btn-primary" onclick="changePassword()">Change Password</button>
</div>
</div>
</div>
<!-- ================================================================== -->
<!-- JAVASCRIPT -->
<!-- ================================================================== -->
<script>
/* ------------------------------------------------------------------ */
/* State */
/* ------------------------------------------------------------------ */
let setupPassword = '';
/* ------------------------------------------------------------------ */
/* Toast notifications */
/* ------------------------------------------------------------------ */
function showToast(message, type) {
type = type || 'info';
const container = document.getElementById('toast-container');
const el = document.createElement('div');
el.className = 'toast ' + type;
el.textContent = message;
container.appendChild(el);
setTimeout(function() {
el.style.animation = 'fadeOut .3s ease-out forwards';
setTimeout(function() { el.remove(); }, 300);
}, 4000);
}
/* ------------------------------------------------------------------ */
/* Loading helpers */
/* ------------------------------------------------------------------ */
function setLoading(btnId, loading) {
const btn = document.getElementById(btnId);
if (!btn) return;
if (loading) {
btn.disabled = true;
btn._origText = btn.innerHTML;
btn.innerHTML = '<span class="spinner"></span> Working...';
} else {
btn.disabled = false;
btn.innerHTML = btn._origText || btn.innerHTML;
}
}
/* ------------------------------------------------------------------ */
/* API helpers */
/* ------------------------------------------------------------------ */
async function apiPost(path, body) {
const resp = await fetch('/api/setup' + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await resp.json();
if (!resp.ok) {
throw new Error(data.detail || 'Request failed (' + resp.status + ')');
}
return data;
}
/* ------------------------------------------------------------------ */
/* Check DB status */
/* ------------------------------------------------------------------ */
async function checkStatus() {
const container = document.getElementById('status-content');
try {
const resp = await fetch('/api/setup/status');
if (!resp.ok) throw new Error('Status endpoint returned ' + resp.status);
const data = await resp.json();
let html = '';
if (data.tables_exist) {
html += '<div class="status-bar ok"><span class="dot green"></span>'
+ 'Database initialized &mdash; ' + data.table_count + ' table(s)</div>';
html += '<div class="table-list">';
data.tables.forEach(function(t) {
html += '<span class="table-chip">' + escHtml(t) + '</span>';
});
html += '</div>';
} else {
html += '<div class="status-bar warn"><span class="dot yellow"></span>'
+ 'No tables found. Click "Create Tables" below.</div>';
}
container.innerHTML = html;
} catch (e) {
container.innerHTML = '<div class="status-bar err"><span class="dot red"></span>'
+ 'Could not fetch status: ' + escHtml(e.message) + '</div>';
}
}
/* ------------------------------------------------------------------ */
/* Login */
/* ------------------------------------------------------------------ */
async function doLogin() {
const pwd = document.getElementById('setup-pwd').value.trim();
if (!pwd) { showToast('Please enter a password', 'error'); return; }
setLoading('btn-login', true);
try {
const resp = await fetch('/api/setup/status');
if (resp.status === 404) {
showToast('Setup is disabled. Set SETUP_PASSWORD in .env', 'error');
return;
}
// Validate password via init-db (idempotent, no tables needed)
await apiPost('/init-db', { password: pwd });
setupPassword = pwd;
document.getElementById('login-screen').classList.add('hidden');
document.getElementById('setup-panel').classList.remove('hidden');
await checkStatus();
await loadUsers();
showToast('Setup panel unlocked', 'success');
} catch (e) {
if (e.message.indexOf('403') !== -1 || e.message.indexOf('Invalid') !== -1) {
showToast('Invalid setup password', 'error');
} else {
showToast('Connection error: ' + e.message, 'error');
}
} finally {
setLoading('btn-login', false);
}
}
/* Allow Enter key on password field */
document.getElementById('setup-pwd').addEventListener('keydown', function(e) {
if (e.key === 'Enter') doLogin();
});
/* ------------------------------------------------------------------ */
/* Init DB */
/* ------------------------------------------------------------------ */
async function initDb() {
setLoading('btn-init', true);
try {
const data = await apiPost('/init-db', { password: setupPassword });
showToast(data.message, 'success');
await checkStatus();
} catch (e) {
showToast(e.message, 'error');
} finally {
setLoading('btn-init', false);
}
}
/* ------------------------------------------------------------------ */
/* Create Admin */
/* ------------------------------------------------------------------ */
async function createAdmin() {
const username = document.getElementById('admin-username').value.trim();
const password = document.getElementById('admin-password').value;
const email = document.getElementById('admin-email').value.trim();
const display = document.getElementById('admin-displayname').value.trim();
if (!username || !password || !email || !display) {
showToast('All fields are required', 'error');
return;
}
setLoading('btn-admin', true);
try {
const data = await apiPost('/create-admin', {
password: setupPassword,
admin_username: username,
admin_password: password,
admin_email: email,
admin_display_name: display,
});
showToast(data.message, 'success');
// Clear form
document.getElementById('admin-username').value = '';
document.getElementById('admin-password').value = '';
document.getElementById('admin-email').value = '';
document.getElementById('admin-displayname').value = '';
} catch (e) {
showToast(e.message, 'error');
} finally {
setLoading('btn-admin', false);
}
}
/* ------------------------------------------------------------------ */
/* Seed Demo Data */
/* ------------------------------------------------------------------ */
async function seedData() {
setLoading('btn-seed', true);
try {
const data = await apiPost('/seed', { password: setupPassword });
var msg = data.message + ' (users: ' + data.users_created.join(', ');
if (data.measurements_created) msg += ', measurements: ' + data.measurements_created;
msg += ')';
showToast(msg, 'success');
await loadUsers();
} catch (e) {
showToast(e.message, 'error');
} finally {
setLoading('btn-seed', false);
}
}
/* ------------------------------------------------------------------ */
/* Reset DB */
/* ------------------------------------------------------------------ */
/* Enable reset button only when checkbox is checked */
document.getElementById('reset-confirm').addEventListener('change', function() {
document.getElementById('btn-reset').disabled = !this.checked;
});
async function resetDb() {
if (!document.getElementById('reset-confirm').checked) {
showToast('Please confirm the checkbox first', 'error');
return;
}
if (!confirm('Are you absolutely sure? This will destroy ALL data!')) {
return;
}
setLoading('btn-reset', true);
try {
const data = await apiPost('/reset-db', {
password: setupPassword,
confirm: true,
});
showToast(data.message, 'success');
document.getElementById('reset-confirm').checked = false;
document.getElementById('btn-reset').disabled = true;
await checkStatus();
} catch (e) {
showToast(e.message, 'error');
} finally {
setLoading('btn-reset', false);
}
}
/* ================================================================== */
/* USER MANAGEMENT */
/* ================================================================== */
function escHtml(str) {
if (!str) return '';
var d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}
function roleBadge(role) {
var cls = 'badge ';
if (role === 'Maker') cls += 'badge-maker';
else if (role === 'MeasurementTec') cls += 'badge-measurement';
else if (role === 'Metrologist') cls += 'badge-metrologist';
return '<span class="' + cls + '">' + escHtml(role) + '</span> ';
}
async function loadUsers() {
var container = document.getElementById('users-table-container');
try {
var data = await apiPost('/list-users', { password: setupPassword });
if (!data.users || data.users.length === 0) {
container.innerHTML = '<p class="text-sm text-muted">No users found. Use Seed Demo Data or create one.</p>';
return;
}
var html = '<table class="user-table"><thead><tr>'
+ '<th>ID</th><th>Username</th><th>Display Name</th>'
+ '<th>Roles</th><th>Status</th><th>Actions</th>'
+ '</tr></thead><tbody>';
data.users.forEach(function(u) {
var roles = '';
(u.roles || []).forEach(function(r) { roles += roleBadge(r); });
if (u.is_admin) roles += '<span class="badge badge-admin">Admin</span> ';
var statusBadge = u.active
? '<span class="badge badge-active">Active</span>'
: '<span class="badge badge-inactive">Inactive</span>';
var toggleLabel = u.active ? 'Deactivate' : 'Activate';
var toggleClass = u.active ? 'btn-sm-danger' : 'btn-sm-success';
html += '<tr>'
+ '<td class="mono">' + u.id + '</td>'
+ '<td class="mono">' + escHtml(u.username) + '</td>'
+ '<td>' + escHtml(u.display_name) + '</td>'
+ '<td>' + roles + '</td>'
+ '<td>' + statusBadge + '</td>'
+ '<td><div class="action-btns">'
+ '<button class="btn-sm btn-sm-primary" onclick="openEditUserModal(' + u.id + ')">Edit</button>'
+ '<button class="btn-sm btn-sm-warning" onclick="openPasswordModal(' + u.id + ', \'' + escHtml(u.username).replace(/'/g, "\\'") + '\')">Password</button>'
+ '<button class="btn-sm ' + toggleClass + '" onclick="toggleActive(' + u.id + ', \'' + escHtml(u.username).replace(/'/g, "\\'") + '\', ' + u.active + ')">' + toggleLabel + '</button>'
+ '</div></td></tr>';
});
html += '</tbody></table>';
container.innerHTML = html;
} catch (e) {
container.innerHTML = '<div class="status-bar err"><span class="dot red"></span>' + escHtml(e.message) + '</div>';
}
}
/* -- Modal helpers ------------------------------------------------- */
function closeModal(id) {
document.getElementById(id).classList.add('hidden');
}
function openCreateUserModal() {
document.getElementById('modal-user-title').textContent = 'New User';
document.getElementById('mu-user-id').value = '';
document.getElementById('mu-username').value = '';
document.getElementById('mu-password').value = '';
document.getElementById('mu-displayname').value = '';
document.getElementById('mu-email').value = '';
document.getElementById('mu-role-maker').checked = false;
document.getElementById('mu-role-tec').checked = false;
document.getElementById('mu-role-metro').checked = false;
document.getElementById('mu-admin').checked = false;
document.getElementById('mu-pwd-hint').textContent = '(required)';
document.getElementById('modal-user').classList.remove('hidden');
}
/* Store user data for edit lookup */
var _usersCache = [];
async function openEditUserModal(userId) {
/* Reload users to get fresh data */
try {
var data = await apiPost('/list-users', { password: setupPassword });
_usersCache = data.users || [];
} catch (e) {
showToast('Failed to load user: ' + e.message, 'error');
return;
}
var user = _usersCache.find(function(u) { return u.id === userId; });
if (!user) { showToast('User not found', 'error'); return; }
document.getElementById('modal-user-title').textContent = 'Edit User';
document.getElementById('mu-user-id').value = user.id;
document.getElementById('mu-username').value = user.username;
document.getElementById('mu-password').value = '';
document.getElementById('mu-displayname').value = user.display_name;
document.getElementById('mu-email').value = user.email || '';
document.getElementById('mu-role-maker').checked = (user.roles || []).indexOf('Maker') !== -1;
document.getElementById('mu-role-tec').checked = (user.roles || []).indexOf('MeasurementTec') !== -1;
document.getElementById('mu-role-metro').checked = (user.roles || []).indexOf('Metrologist') !== -1;
document.getElementById('mu-admin').checked = user.is_admin;
document.getElementById('mu-pwd-hint').textContent = '(leave blank to keep current)';
document.getElementById('modal-user').classList.remove('hidden');
}
async function saveUser() {
var userId = document.getElementById('mu-user-id').value;
var username = document.getElementById('mu-username').value.trim();
var pwd = document.getElementById('mu-password').value;
var displayName = document.getElementById('mu-displayname').value.trim();
var email = document.getElementById('mu-email').value.trim();
if (!username || !displayName) {
showToast('Username and Display Name are required', 'error');
return;
}
if (!userId && !pwd) {
showToast('Password is required for new users', 'error');
return;
}
var roles = [];
if (document.getElementById('mu-role-maker').checked) roles.push('Maker');
if (document.getElementById('mu-role-tec').checked) roles.push('MeasurementTec');
if (document.getElementById('mu-role-metro').checked) roles.push('Metrologist');
setLoading('btn-save-user', true);
try {
var body = {
password: setupPassword,
user_id: userId ? parseInt(userId) : null,
username: username,
display_name: displayName,
email: email || null,
roles: roles,
is_admin: document.getElementById('mu-admin').checked,
};
if (pwd) body.user_password = pwd;
var data = await apiPost('/manage-user', body);
showToast(data.message, 'success');
closeModal('modal-user');
await loadUsers();
} catch (e) {
showToast(e.message, 'error');
} finally {
setLoading('btn-save-user', false);
}
}
function openPasswordModal(userId, username) {
document.getElementById('mp-user-id').value = userId;
document.getElementById('mp-username').textContent = username;
document.getElementById('mp-new-password').value = '';
document.getElementById('mp-confirm-password').value = '';
document.getElementById('modal-password').classList.remove('hidden');
}
async function changePassword() {
var userId = document.getElementById('mp-user-id').value;
var newPwd = document.getElementById('mp-new-password').value;
var confirmPwd = document.getElementById('mp-confirm-password').value;
if (!newPwd) { showToast('Password cannot be empty', 'error'); return; }
if (newPwd !== confirmPwd) { showToast('Passwords do not match', 'error'); return; }
try {
var data = await apiPost('/change-user-password', {
password: setupPassword,
user_id: parseInt(userId),
new_password: newPwd,
});
showToast(data.message, 'success');
closeModal('modal-password');
} catch (e) {
showToast(e.message, 'error');
}
}
async function toggleActive(userId, username, currentlyActive) {
var action = currentlyActive ? 'deactivate' : 'activate';
if (!confirm('Are you sure you want to ' + action + ' user "' + username + '"?')) return;
try {
var data = await apiPost('/toggle-user-active', {
password: setupPassword,
user_id: userId,
});
showToast(data.message, 'success');
await loadUsers();
} catch (e) {
showToast(e.message, 'error');
}
}
</script>
</body>
</html>