chore(v2): restructure monorepo to src/ layout with uv
Aligns the repo with the python-project-spec-design.md template chosen for V2.0.0. Big move, no logic changes. The 3 pre-existing test failures (test_recipes::test_update_recipe, test_recipes:: test_recipe_versioning, test_tasks::test_reorder_tasks, plus the client test_save_measurement_proxy) survive unchanged. Layout changes - server/ -> src/backend/ - server/middleware/ -> src/backend/api/middleware/ - server/routers/ -> src/backend/api/routers/ - server/models/ -> src/backend/models/orm/ - server/schemas/ -> src/backend/models/api/ - server/uploads/ -> uploads/ (project root, mounted volume) - server/tests/ -> src/backend/tests/ - client/ -> src/frontend/flask_app/ (Flask kept; React deroga is documented in CLAUDE.md, justified by tablet UX, USB caliper/barcode workflow and Fabric.js integration) Tooling - pyproject.toml: monorepo with [project] core deps and optional-dependencies server / client / dev. Replaces both server/requirements.txt and client/requirements.txt. - uv.lock + .python-version (3.11) committed for reproducible builds. - Dockerfile (root, backend) and Dockerfile.frontend rewritten to use uv sync --frozen --no-dev --extra server|client; legacy Dockerfiles preserved as Dockerfile.legacy for reference but excluded from build context via .dockerignore. - docker-compose.dev.yml + docker-compose.yml: build context now ".", dockerfile pointing to the root files. Code adjustments forced by the move - Every "from config|database|models|schemas|services|routers|middleware import ..." rewritten to its src.backend.* equivalent (50+ files including indented inline imports inside test bodies). - src/backend/migrations/env.py: insert project root into sys.path so alembic can resolve src.backend.* imports regardless of cwd. - src/backend/config.py: env_file ../../.env (was ../.env), upload_path resolves project root via parents[2]. - src/backend/tests/conftest.py + tests: import ... from src.backend.* instead of bare names; old per-directory pytest.ini files removed in favor of root pyproject.toml [tool.pytest.ini_options]. - .gitignore: uploads/ at root, src/frontend/flask_app/static/css/ tailwind.css path; .dockerignore tightened. - CLAUDE.md: rewrote sections "Layout del repository", "Comandi di Sviluppo", "Database & Migrations", "Test", "i18n", and all path references throughout the architecture sections. Verified - uv lock resolves 77 packages; uv sync --extra server --extra client --extra dev installs cleanly. - uv run pytest: 171 passed, 4 pre-existing failures. - uv run alembic -c src/backend/migrations/alembic.ini check loads config and metadata (errors only on the absent local MySQL). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,934 @@
|
||||
<!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;
|
||||
}
|
||||
// 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>
|
||||
Reference in New Issue
Block a user