feat: V1.0.1 - Setup page, Docker, README
Add password-protected setup page (/api/setup) for DB initialization, admin creation, and demo data seeding. Dockerize the full stack with server, client, nginx reverse proxy, and MySQL services. Add project README with architecture overview, quick start, and VPS deployment guide. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,546 @@
|
||||
<!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; }
|
||||
</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 and a demo recipe.
|
||||
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. 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>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- 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">' + 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: ' + 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();
|
||||
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 });
|
||||
showToast(data.message + ' (users: ' + data.users_created.join(', ') + ')', 'success');
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user