Files
TieMeasureFlow/server/templates/setup/setup.html
T
Adriano e07a4e4f89 feat: add demo measurements to seed and user management to setup page
Seed now generates 180 realistic measurements (30 per subtask) with
Gaussian distribution for SPC testing. Setup page gains full user CRUD
with list, create/edit, password change, and activate/deactivate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:49:58 +01:00

930 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;
}
// Password accepted (status endpoint is accessible)
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) {
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>