Some checks failed
Build & Release / build (push) Has been cancelled
Cookie-based auth was unreliable through Cloudflare/nginx proxy — cookie was being sent for some requests but not others. Switch to returning JWT in login response, storing in localStorage, and sending as Authorization Bearer header on all API calls. Eliminates all cookie/SameSite/Secure proxy issues.
781 lines
31 KiB
HTML
781 lines
31 KiB
HTML
<!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>Name</th>
|
||
<th>Status</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="9">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="customer-name">Customer Name</label>
|
||
<input id="customer-name" type="text" placeholder="Acme Shop" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="slug">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;
|
||
|
||
function authHeaders() {
|
||
const token = localStorage.getItem('token');
|
||
return token ? { 'Authorization': 'Bearer ' + token } : {};
|
||
}
|
||
|
||
function apiFetch(url, options = {}) {
|
||
return fetch(url, {
|
||
...options,
|
||
headers: { ...authHeaders(), ...(options.headers || {}) },
|
||
});
|
||
}
|
||
|
||
// ── Auth ──────────────────────────────────────────────────────────────────
|
||
|
||
async function checkAuth() {
|
||
if (!localStorage.getItem('token')) {
|
||
document.getElementById('login-view').style.display = 'flex';
|
||
return;
|
||
}
|
||
const res = await apiFetch('/api/auth/me');
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setUser(data.username);
|
||
showPage('customers');
|
||
loadCustomers();
|
||
} else {
|
||
localStorage.removeItem('token');
|
||
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');
|
||
localStorage.setItem('token', data.token);
|
||
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() {
|
||
localStorage.removeItem('token');
|
||
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 apiFetch('/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="9">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>';
|
||
const statusColor = r.status === 'provisioned' ? '#3fb950' : r.status === 'failed' ? '#f85149' : '#e3a04a';
|
||
const STEP_LABELS = { database: 'DB', database_user: 'User', database_setup: 'Schema', pool: 'Pool', chart: 'Chart' };
|
||
const stepsHtml = Object.entries(STEP_LABELS).map(([key, label]) => {
|
||
const s = (r.steps || {})[key];
|
||
const icon = s === 'done' ? '✓' : s === 'failed' ? '✗' : '·';
|
||
const color = s === 'done' ? '#3fb950' : s === 'failed' ? '#f85149' : '#484f58';
|
||
return `<span style="color:${color};font-size:0.75rem;margin-right:6px" title="${key}">${icon} ${label}</span>`;
|
||
}).join('');
|
||
return `<tr>
|
||
<td style="font-weight:500;color:#e6edf3">${r.slug}</td>
|
||
<td>${r.name || '<span style="color:#484f58">—</span>'}</td>
|
||
<td>
|
||
<div style="color:${statusColor};font-size:0.78rem;font-weight:500;margin-bottom:3px">${r.status}</div>
|
||
<div>${stepsHtml}</div>
|
||
</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 style="display:flex;gap:0.4rem;align-items:center">
|
||
<button class="btn btn-danger btn-sm" onclick="openDeleteDialog('${r.slug}')">Delete</button>
|
||
<button class="btn btn-slate btn-sm" onclick="removeRecord('${r.slug}')" title="Remove from manager DB only">Record Only</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 apiFetch(`/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';
|
||
}
|
||
}
|
||
|
||
async function removeRecord(slug) {
|
||
if (!confirm(`Remove "${slug}" from the manager database only?\n\nThis will NOT delete the ArgoCD app or DO database — use this only for failed partial deployments.`)) return;
|
||
const res = await apiFetch(`/api/customers/${slug}/record`, { method: 'DELETE' });
|
||
if (res.ok) {
|
||
loadCustomers();
|
||
} else {
|
||
const d = await res.json().catch(() => ({}));
|
||
alert(d.message ?? 'Failed to remove record');
|
||
}
|
||
}
|
||
|
||
// ── Provision ─────────────────────────────────────────────────────────────
|
||
|
||
function toggleModule(checkbox) {
|
||
const label = checkbox.closest('.module-check');
|
||
label.classList.toggle('checked', checkbox.checked);
|
||
}
|
||
|
||
async function provision() {
|
||
const name = document.getElementById('customer-name').value.trim();
|
||
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);
|
||
if (!name) return;
|
||
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 apiFetch('/api/customers', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ slug, name, 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('customer-name').value = '';
|
||
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 apiFetch('/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>
|