e07a4e4f89
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>
930 lines
38 KiB
HTML
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 — 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 — ' + 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>
|