Files
lunarfront-manager/frontend/index.html
Ryan Moon d1c4aa7d6f
All checks were successful
Build & Release / build (push) Successful in 12s
feat: customers management UI with paginated table and full delete
- Fix SSH key missing trailing newline (error in libcrypto)
- Pass env with SSH command through all git operations
- Add customers table (modules, start/expiration dates, created/updated timestamps)
- Idempotent ALTER TABLE for existing deployments
- GET /customers with pagination, search, and sort
- POST /customers persists slug with modules and dates to DB
- DELETE /customers/:slug removes ArgoCD chart, DO DB, pgbouncer pool, and manager record
- Redesigned frontend: dark slate theme, customers table page with search/sort/pagination, delete confirm dialog, module checkboxes, slate buttons
2026-04-03 17:48:25 -05:00

727 lines
29 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LunarFront Manager</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: "Inter", system-ui, sans-serif; background: #0d1117; color: #c9d1d9; min-height: 100vh; }
/* ── Login ── */
#login-view {
display: none;
min-height: 100vh;
align-items: center;
justify-content: center;
}
.login-box {
background: #161b22;
border: 1px solid #30363d;
border-radius: 10px;
padding: 2.5rem;
width: 100%;
max-width: 360px;
}
.login-box h1 { font-size: 1.1rem; color: #e6edf3; margin-bottom: 0.3rem; font-weight: 600; }
.login-box p { font-size: 0.82rem; color: #8b949e; margin-bottom: 1.8rem; }
/* ── App shell ── */
#app-view { display: none; height: 100vh; }
.shell { display: flex; height: 100%; }
/* ── Sidebar ── */
.sidebar {
width: 220px;
background: #13161f;
border-right: 1px solid #21262d;
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.sidebar-brand {
padding: 1.2rem 1.2rem 1rem;
font-size: 0.88rem;
font-weight: 700;
color: #e6edf3;
letter-spacing: 0.06em;
text-transform: uppercase;
border-bottom: 1px solid #21262d;
}
.sidebar-nav { flex: 1; padding: 0.6rem 0.5rem; }
.nav-item {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.55rem 0.8rem;
border-radius: 6px;
font-size: 0.845rem;
color: #8b949e;
cursor: pointer;
transition: background 0.12s, color 0.12s;
user-select: none;
}
.nav-item:hover { background: #1c2128; color: #c9d1d9; }
.nav-item.active { background: #1c2128; color: #e6edf3; }
.nav-icon { font-size: 0.9rem; width: 1.1rem; text-align: center; opacity: 0.7; }
.sidebar-user { padding: 0.7rem; border-top: 1px solid #21262d; }
.user-btn {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.55rem 0.8rem;
border-radius: 6px;
cursor: pointer;
transition: background 0.12s;
width: 100%;
background: none;
border: none;
color: #8b949e;
text-align: left;
}
.user-btn:hover { background: #1c2128; color: #c9d1d9; }
.user-menu { display: none; flex-direction: column; gap: 1px; padding: 0 0.5rem 0.3rem; }
.user-menu.open { display: flex; }
.user-menu-item {
font-size: 0.8rem;
padding: 0.42rem 0.8rem;
border-radius: 6px;
cursor: pointer;
color: #8b949e;
transition: background 0.12s, color 0.12s;
}
.user-menu-item:hover { background: #1c2128; color: #e6edf3; }
.user-menu-item.danger:hover { background: #3d1a1a; color: #f85149; }
.user-avatar {
width: 26px; height: 26px;
border-radius: 50%;
background: #1d3a6e;
border: 1px solid #30363d;
display: flex; align-items: center; justify-content: center;
font-size: 0.72rem; font-weight: 700; color: #79c0ff;
flex-shrink: 0;
}
.user-name { font-size: 0.83rem; }
/* ── Main content ── */
.main { flex: 1; overflow-y: auto; padding: 2rem 2.5rem; }
.page { display: none; }
.page.active { display: block; }
.page-title { font-size: 1rem; font-weight: 600; color: #e6edf3; margin-bottom: 1.5rem; letter-spacing: 0.01em; }
/* ── Cards ── */
.card {
background: #161b22;
border: 1px solid #30363d;
border-radius: 8px;
padding: 1.5rem;
max-width: 520px;
}
/* ── Form elements ── */
.form-group { margin-bottom: 1.1rem; }
label { display: block; font-size: 0.76rem; color: #8b949e; margin-bottom: 0.35rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 500; }
input[type=text], input[type=password], input[type=date] {
width: 100%; padding: 0.55rem 0.75rem;
background: #0d1117; border: 1px solid #30363d; border-radius: 6px;
color: #c9d1d9; font-size: 0.875rem;
}
input:focus { outline: none; border-color: #58a6ff; box-shadow: 0 0 0 3px rgba(88,166,255,0.1); }
input[type=date]::-webkit-calendar-picker-indicator { filter: invert(0.5); }
/* ── Modules checkboxes ── */
.modules-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.4rem;
}
.module-check {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.6rem;
border: 1px solid #30363d;
border-radius: 6px;
cursor: pointer;
font-size: 0.82rem;
color: #8b949e;
transition: border-color 0.12s, color 0.12s, background 0.12s;
user-select: none;
}
.module-check:hover { border-color: #58a6ff; color: #c9d1d9; }
.module-check input { display: none; }
.module-check.checked { border-color: #1d3a6e; background: #0d1f3a; color: #79c0ff; }
/* ── Buttons ── */
.btn {
padding: 0.55rem 1.1rem;
border-radius: 6px;
font-size: 0.875rem;
cursor: pointer;
border: none;
font-weight: 500;
transition: background 0.12s, opacity 0.12s;
}
.btn-primary { background: #1d3a6e; color: #79c0ff; border: 1px solid #1d4ed8; }
.btn-primary:hover { background: #1e4080; }
.btn-primary:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-slate { background: transparent; color: #8b949e; border: 1px solid #30363d; }
.btn-slate:hover { background: #1c2128; color: #c9d1d9; border-color: #484f58; }
.btn-danger { background: transparent; color: #f85149; border: 1px solid #3d1a1a; }
.btn-danger:hover { background: #3d1a1a; border-color: #f85149; }
.btn-sm { padding: 0.3rem 0.7rem; font-size: 0.78rem; }
/* ── Status ── */
.status { margin-top: 1rem; font-size: 0.83rem; padding: 0.55rem 0.8rem; border-radius: 6px; display: none; }
.status.success { background: #0d2818; color: #3fb950; border: 1px solid #1a4731; display: block; }
.status.error { background: #2d0f0f; color: #f85149; border: 1px solid #4a1a1a; display: block; }
/* ── Table ── */
.table-toolbar {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.search-input {
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
padding: 0.5rem 0.75rem;
color: #c9d1d9;
font-size: 0.875rem;
width: 260px;
}
.search-input:focus { outline: none; border-color: #58a6ff; }
.table-wrap {
background: #161b22;
border: 1px solid #30363d;
border-radius: 8px;
overflow: hidden;
}
table { width: 100%; border-collapse: collapse; font-size: 0.845rem; }
thead { background: #1c2128; }
th {
padding: 0.65rem 1rem;
text-align: left;
font-size: 0.76rem;
font-weight: 600;
color: #8b949e;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid #30363d;
white-space: nowrap;
user-select: none;
}
th.sortable { cursor: pointer; }
th.sortable:hover { color: #c9d1d9; }
th .sort-icon { margin-left: 4px; opacity: 0.4; font-size: 0.7rem; }
th.sort-active { color: #58a6ff; }
th.sort-active .sort-icon { opacity: 1; }
td {
padding: 0.7rem 1rem;
border-bottom: 1px solid #21262d;
color: #c9d1d9;
vertical-align: middle;
}
tr:last-child td { border-bottom: none; }
tr:hover td { background: #1c2128; }
.tag {
display: inline-block;
padding: 0.15rem 0.45rem;
border-radius: 4px;
font-size: 0.72rem;
font-weight: 500;
background: #1d3a6e;
color: #79c0ff;
border: 1px solid #1d4ed8;
margin: 1px 2px 1px 0;
}
.tag-expired { background: #2d1a0a; color: #e3956a; border-color: #5a3010; }
.empty-row td { text-align: center; color: #484f58; padding: 2.5rem; }
/* ── Pagination ── */
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-top: 1px solid #21262d;
font-size: 0.8rem;
color: #8b949e;
}
.pagination-btns { display: flex; gap: 0.4rem; align-items: center; }
.pagination-info { font-size: 0.78rem; }
/* ── Confirm overlay ── */
.overlay {
display: none;
position: fixed; inset: 0;
background: rgba(0,0,0,0.6);
align-items: center;
justify-content: center;
z-index: 100;
}
.overlay.open { display: flex; }
.dialog {
background: #161b22;
border: 1px solid #30363d;
border-radius: 10px;
padding: 1.8rem;
max-width: 380px;
width: 100%;
}
.dialog h3 { font-size: 0.95rem; color: #e6edf3; margin-bottom: 0.5rem; }
.dialog p { font-size: 0.845rem; color: #8b949e; margin-bottom: 1.4rem; line-height: 1.5; }
.dialog-actions { display: flex; gap: 0.6rem; justify-content: flex-end; }
</style>
</head>
<body>
<!-- Login -->
<div id="login-view">
<div class="login-box">
<h1>LunarFront Manager</h1>
<p>Sign in to continue</p>
<div class="form-group">
<label for="login-username">Username</label>
<input id="login-username" type="text" autocomplete="username" />
</div>
<div class="form-group">
<label for="login-password">Password</label>
<input id="login-password" type="password" autocomplete="current-password" onkeydown="if(event.key==='Enter')login()" />
</div>
<button class="btn btn-primary" style="width:100%;margin-top:0.5rem" onclick="login()">Sign In</button>
<div id="login-status" class="status"></div>
</div>
</div>
<!-- App -->
<div id="app-view">
<div class="shell">
<aside class="sidebar">
<div class="sidebar-brand">LunarFront</div>
<nav class="sidebar-nav">
<div class="nav-item" id="nav-customers" onclick="showPage('customers')">
<span class="nav-icon"></span>
<span>Customers</span>
</div>
<div class="nav-item" id="nav-create-env" onclick="showPage('create-env')">
<span class="nav-icon"></span>
<span>Create Env</span>
</div>
</nav>
<div class="sidebar-user">
<button class="user-btn" onclick="toggleUserMenu()">
<div class="user-avatar" id="user-initials">?</div>
<span class="user-name" id="user-name-label">...</span>
</button>
<div class="user-menu" id="user-menu">
<div class="user-menu-item" onclick="showPage('account');toggleUserMenu()">Account</div>
<div class="user-menu-item danger" onclick="logout()">Sign Out</div>
</div>
</div>
</aside>
<main class="main">
<!-- Customers -->
<div id="page-customers" class="page">
<div class="page-title">Customers</div>
<div class="table-toolbar">
<input class="search-input" id="customers-search" type="text" placeholder="Search by slug…" oninput="onSearchInput()" />
<button class="btn btn-slate" onclick="loadCustomers()">Refresh</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="sortable sort-active" data-col="slug" onclick="setSort('slug')">Slug <span class="sort-icon" id="sort-icon-slug"></span></th>
<th>Modules</th>
<th class="sortable" data-col="start_date" onclick="setSort('start_date')">Start <span class="sort-icon" id="sort-icon-start_date"></span></th>
<th class="sortable" data-col="expiration_date" onclick="setSort('expiration_date')">Expires <span class="sort-icon" id="sort-icon-expiration_date"></span></th>
<th class="sortable" data-col="created_at" onclick="setSort('created_at')">Created <span class="sort-icon" id="sort-icon-created_at"></span></th>
<th class="sortable" data-col="updated_at" onclick="setSort('updated_at')">Updated <span class="sort-icon" id="sort-icon-updated_at"></span></th>
<th></th>
</tr>
</thead>
<tbody id="customers-tbody">
<tr class="empty-row"><td colspan="7">Loading…</td></tr>
</tbody>
</table>
<div class="pagination" id="customers-pagination" style="display:none">
<span class="pagination-info" id="pagination-info"></span>
<div class="pagination-btns">
<button class="btn btn-slate btn-sm" id="btn-prev" onclick="changePage(-1)">← Prev</button>
<span id="pagination-page" style="font-size:0.8rem;color:#8b949e;min-width:60px;text-align:center"></span>
<button class="btn btn-slate btn-sm" id="btn-next" onclick="changePage(1)">Next →</button>
</div>
</div>
</div>
</div>
<!-- Create Env -->
<div id="page-create-env" class="page">
<div class="page-title">Create Environment</div>
<div class="card">
<div class="form-group">
<label for="slug">Customer Slug</label>
<input id="slug" type="text" placeholder="acme-shop" pattern="[a-z0-9-]+" />
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.8rem">
<div class="form-group">
<label for="start-date">Start Date</label>
<input id="start-date" type="date" />
</div>
<div class="form-group">
<label for="expiration-date">Expiration Date</label>
<input id="expiration-date" type="date" />
</div>
</div>
<div class="form-group">
<label>Modules</label>
<div class="modules-grid">
<label class="module-check" id="mod-pos">
<input type="checkbox" value="pos" onchange="toggleModule(this)" /> POS
</label>
<label class="module-check" id="mod-inventory">
<input type="checkbox" value="inventory" onchange="toggleModule(this)" /> Inventory
</label>
<label class="module-check" id="mod-rentals">
<input type="checkbox" value="rentals" onchange="toggleModule(this)" /> Rentals
</label>
<label class="module-check" id="mod-scheduling">
<input type="checkbox" value="scheduling" onchange="toggleModule(this)" /> Scheduling
</label>
<label class="module-check" id="mod-repairs">
<input type="checkbox" value="repairs" onchange="toggleModule(this)" /> Repairs
</label>
<label class="module-check" id="mod-accounting">
<input type="checkbox" value="accounting" onchange="toggleModule(this)" /> Accounting
</label>
</div>
</div>
<button class="btn btn-primary" id="provision-btn" onclick="provision()" style="margin-top:0.4rem">Provision</button>
<div id="provision-status" class="status"></div>
</div>
</div>
<!-- Account -->
<div id="page-account" class="page">
<div class="page-title">Account</div>
<div class="card">
<div class="form-group">
<label for="new-password">New Password</label>
<input id="new-password" type="password" autocomplete="new-password" />
</div>
<div class="form-group">
<label for="confirm-password">Confirm Password</label>
<input id="confirm-password" type="password" autocomplete="new-password" />
</div>
<button class="btn btn-slate" onclick="changePassword()">Update Password</button>
<div id="account-status" class="status"></div>
</div>
</div>
</main>
</div>
</div>
<!-- Delete confirm dialog -->
<div class="overlay" id="delete-overlay" onclick="if(event.target===this)closeDeleteDialog()">
<div class="dialog">
<h3>Delete Customer</h3>
<p>This will remove the ArgoCD app, database, and all associated resources for <strong id="delete-slug-label" style="color:#e6edf3"></strong>. This cannot be undone.</p>
<div class="dialog-actions">
<button class="btn btn-slate" onclick="closeDeleteDialog()">Cancel</button>
<button class="btn btn-danger" id="delete-confirm-btn" onclick="confirmDelete()">Delete</button>
</div>
</div>
</div>
<script>
let currentUser = null;
let tableState = { page: 1, limit: 25, q: '', sort: 'created_at', order: 'desc', total: 0, totalPages: 1 };
let searchTimer = null;
let pendingDeleteSlug = null;
// ── Auth ──────────────────────────────────────────────────────────────────
async function checkAuth() {
const res = await fetch('/api/auth/me');
if (res.ok) {
const data = await res.json();
setUser(data.username);
showPage('customers');
loadCustomers();
} else {
document.getElementById('login-view').style.display = 'flex';
}
}
function setUser(username) {
currentUser = username;
document.getElementById('user-name-label').textContent = username;
document.getElementById('user-initials').textContent = username[0].toUpperCase();
document.getElementById('app-view').style.display = 'block';
}
async function login() {
const username = document.getElementById('login-username').value.trim();
const password = document.getElementById('login-password').value;
const status = document.getElementById('login-status');
status.className = 'status';
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message ?? 'Login failed');
setUser(data.username);
document.getElementById('login-view').style.display = 'none';
showPage('customers');
loadCustomers();
} catch (err) {
status.textContent = err.message;
status.className = 'status error';
}
}
async function logout() {
await fetch('/api/auth/logout', { method: 'POST' });
document.getElementById('app-view').style.display = 'none';
document.getElementById('login-view').style.display = 'flex';
document.getElementById('login-password').value = '';
}
function toggleUserMenu() {
document.getElementById('user-menu').classList.toggle('open');
}
// ── Navigation ────────────────────────────────────────────────────────────
function showPage(name) {
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
document.getElementById('page-' + name).classList.add('active');
const navEl = document.getElementById('nav-' + name);
if (navEl) navEl.classList.add('active');
document.getElementById('user-menu').classList.remove('open');
}
// ── Customers table ───────────────────────────────────────────────────────
async function loadCustomers() {
const s = tableState;
const params = new URLSearchParams({ page: s.page, limit: s.limit, sort: s.sort, order: s.order });
if (s.q) params.set('q', s.q);
const res = await fetch('/api/customers?' + params);
if (!res.ok) return;
const { data, pagination } = await res.json();
tableState.total = pagination.total;
tableState.totalPages = pagination.totalPages;
renderTable(data, pagination);
}
function renderTable(rows, pagination) {
const tbody = document.getElementById('customers-tbody');
if (rows.length === 0) {
tbody.innerHTML = '<tr class="empty-row"><td colspan="7">No customers found</td></tr>';
} else {
tbody.innerHTML = rows.map(r => {
const expired = r.expiration_date && new Date(r.expiration_date) < new Date();
const modules = (r.modules || []).map(m => `<span class="tag">${m}</span>`).join('') || '<span style="color:#484f58">—</span>';
const expiry = r.expiration_date
? `<span class="${expired ? 'tag tag-expired' : ''}">${fmtDate(r.expiration_date)}</span>`
: '<span style="color:#484f58">—</span>';
return `<tr>
<td style="font-weight:500;color:#e6edf3">${r.slug}</td>
<td>${modules}</td>
<td>${fmtDate(r.start_date)}</td>
<td>${expiry}</td>
<td style="color:#8b949e">${fmtDateTime(r.created_at)}</td>
<td style="color:#8b949e">${fmtDateTime(r.updated_at)}</td>
<td><button class="btn btn-danger btn-sm" onclick="openDeleteDialog('${r.slug}')">Delete</button></td>
</tr>`;
}).join('');
}
const pagEl = document.getElementById('customers-pagination');
pagEl.style.display = 'flex';
const start = (pagination.page - 1) * pagination.limit + 1;
const end = Math.min(pagination.page * pagination.limit, pagination.total);
document.getElementById('pagination-info').textContent =
pagination.total === 0 ? '0 results' : `${start}${end} of ${pagination.total}`;
document.getElementById('pagination-page').textContent = `Page ${pagination.page} / ${pagination.totalPages}`;
document.getElementById('btn-prev').disabled = pagination.page <= 1;
document.getElementById('btn-next').disabled = pagination.page >= pagination.totalPages;
// Update sort headers
document.querySelectorAll('th[data-col]').forEach(th => {
const col = th.dataset.col;
const icon = document.getElementById('sort-icon-' + col);
th.classList.remove('sort-active');
if (icon) icon.textContent = '↕';
if (col === tableState.sort) {
th.classList.add('sort-active');
if (icon) icon.textContent = tableState.order === 'asc' ? '↑' : '↓';
}
});
}
function setSort(col) {
if (tableState.sort === col) {
tableState.order = tableState.order === 'asc' ? 'desc' : 'asc';
} else {
tableState.sort = col;
tableState.order = 'asc';
}
tableState.page = 1;
loadCustomers();
}
function changePage(delta) {
const next = tableState.page + delta;
if (next < 1 || next > tableState.totalPages) return;
tableState.page = next;
loadCustomers();
}
function onSearchInput() {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
tableState.q = document.getElementById('customers-search').value.trim();
tableState.page = 1;
loadCustomers();
}, 300);
}
// ── Delete ────────────────────────────────────────────────────────────────
function openDeleteDialog(slug) {
pendingDeleteSlug = slug;
document.getElementById('delete-slug-label').textContent = slug;
document.getElementById('delete-overlay').classList.add('open');
}
function closeDeleteDialog() {
pendingDeleteSlug = null;
document.getElementById('delete-overlay').classList.remove('open');
}
async function confirmDelete() {
const slug = pendingDeleteSlug;
if (!slug) return;
const btn = document.getElementById('delete-confirm-btn');
btn.disabled = true;
btn.textContent = 'Deleting…';
try {
const res = await fetch(`/api/customers/${slug}`, { method: 'DELETE' });
if (!res.ok) {
const d = await res.json().catch(() => ({}));
throw new Error(d.message ?? 'Delete failed');
}
closeDeleteDialog();
loadCustomers();
} catch (err) {
alert(err.message);
} finally {
btn.disabled = false;
btn.textContent = 'Delete';
}
}
// ── Provision ─────────────────────────────────────────────────────────────
function toggleModule(checkbox) {
const label = checkbox.closest('.module-check');
label.classList.toggle('checked', checkbox.checked);
}
async function provision() {
const slug = document.getElementById('slug').value.trim();
const startDate = document.getElementById('start-date').value;
const expirationDate = document.getElementById('expiration-date').value || null;
const modules = [...document.querySelectorAll('.module-check input:checked')].map(el => el.value);
const btn = document.getElementById('provision-btn');
const status = document.getElementById('provision-status');
if (!slug) return;
btn.disabled = true;
btn.textContent = 'Provisioning…';
status.className = 'status';
try {
const res = await fetch('/api/customers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: slug, modules, startDate, expirationDate }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message ?? 'Unknown error');
status.textContent = `${data.slug} provisioned successfully`;
status.className = 'status success';
document.getElementById('slug').value = '';
document.getElementById('expiration-date').value = '';
document.querySelectorAll('.module-check').forEach(l => { l.classList.remove('checked'); l.querySelector('input').checked = false; });
} catch (err) {
status.textContent = `${err.message}`;
status.className = 'status error';
} finally {
btn.disabled = false;
btn.textContent = 'Provision';
}
}
// ── Account ───────────────────────────────────────────────────────────────
async function changePassword() {
const pw = document.getElementById('new-password').value;
const confirm = document.getElementById('confirm-password').value;
const status = document.getElementById('account-status');
status.className = 'status';
if (pw !== confirm) {
status.textContent = 'Passwords do not match';
status.className = 'status error';
return;
}
try {
const res = await fetch('/api/auth/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pw }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message ?? 'Failed to update password');
status.textContent = 'Password updated';
status.className = 'status success';
document.getElementById('new-password').value = '';
document.getElementById('confirm-password').value = '';
} catch (err) {
status.textContent = err.message;
status.className = 'status error';
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
function fmtDate(val) {
if (!val) return '—';
return new Date(val).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}
function fmtDateTime(val) {
if (!val) return '—';
return new Date(val).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
// ── Init ──────────────────────────────────────────────────────────────────
document.getElementById('start-date').value = new Date().toISOString().slice(0, 10);
checkAuth();
</script>
</body>
</html>