fix: persist auth cookie across refreshes and add customer detail tracking
Some checks failed
Build & Release / build (push) Has been cancelled

- Fix cookie sameSite strict → lax so browser sends it on page refresh
- Add customer name field (separate from slug)
- Add steps JSONB column tracking per-step provisioning state (DB, User, Schema, Pool, Chart)
- Insert customer record before provisioning starts so partial failures are visible
- Show status + step checklist in customers table
- Add DELETE /customers/:slug/record endpoint to clear failed records without touching infra
- Add "Record Only" button in UI for manual cleanup of partial deployments
This commit is contained in:
Ryan Moon
2026-04-03 18:00:46 -05:00
parent 7e63dbac9c
commit accc963883
4 changed files with 100 additions and 17 deletions

View File

@@ -339,6 +339,8 @@
<thead> <thead>
<tr> <tr>
<th class="sortable sort-active" data-col="slug" onclick="setSort('slug')">Slug <span class="sort-icon" id="sort-icon-slug"></span></th> <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>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="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="expiration_date" onclick="setSort('expiration_date')">Expires <span class="sort-icon" id="sort-icon-expiration_date"></span></th>
@@ -348,7 +350,7 @@
</tr> </tr>
</thead> </thead>
<tbody id="customers-tbody"> <tbody id="customers-tbody">
<tr class="empty-row"><td colspan="7">Loading…</td></tr> <tr class="empty-row"><td colspan="9">Loading…</td></tr>
</tbody> </tbody>
</table> </table>
<div class="pagination" id="customers-pagination" style="display:none"> <div class="pagination" id="customers-pagination" style="display:none">
@@ -367,7 +369,11 @@
<div class="page-title">Create Environment</div> <div class="page-title">Create Environment</div>
<div class="card"> <div class="card">
<div class="form-group"> <div class="form-group">
<label for="slug">Customer Slug</label> <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-]+" /> <input id="slug" type="text" placeholder="acme-shop" pattern="[a-z0-9-]+" />
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.8rem"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:0.8rem">
@@ -530,7 +536,7 @@
function renderTable(rows, pagination) { function renderTable(rows, pagination) {
const tbody = document.getElementById('customers-tbody'); const tbody = document.getElementById('customers-tbody');
if (rows.length === 0) { if (rows.length === 0) {
tbody.innerHTML = '<tr class="empty-row"><td colspan="7">No customers found</td></tr>'; tbody.innerHTML = '<tr class="empty-row"><td colspan="9">No customers found</td></tr>';
} else { } else {
tbody.innerHTML = rows.map(r => { tbody.innerHTML = rows.map(r => {
const expired = r.expiration_date && new Date(r.expiration_date) < new Date(); const expired = r.expiration_date && new Date(r.expiration_date) < new Date();
@@ -538,14 +544,30 @@
const expiry = r.expiration_date const expiry = r.expiration_date
? `<span class="${expired ? 'tag tag-expired' : ''}">${fmtDate(r.expiration_date)}</span>` ? `<span class="${expired ? 'tag tag-expired' : ''}">${fmtDate(r.expiration_date)}</span>`
: '<span style="color:#484f58">—</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> return `<tr>
<td style="font-weight:500;color:#e6edf3">${r.slug}</td> <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>${modules}</td>
<td>${fmtDate(r.start_date)}</td> <td>${fmtDate(r.start_date)}</td>
<td>${expiry}</td> <td>${expiry}</td>
<td style="color:#8b949e">${fmtDateTime(r.created_at)}</td> <td style="color:#8b949e">${fmtDateTime(r.created_at)}</td>
<td style="color:#8b949e">${fmtDateTime(r.updated_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> <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>`; </tr>`;
}).join(''); }).join('');
} }
@@ -635,6 +657,17 @@
} }
} }
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 fetch(`/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 ───────────────────────────────────────────────────────────── // ── Provision ─────────────────────────────────────────────────────────────
function toggleModule(checkbox) { function toggleModule(checkbox) {
@@ -643,10 +676,12 @@
} }
async function provision() { async function provision() {
const name = document.getElementById('customer-name').value.trim();
const slug = document.getElementById('slug').value.trim(); const slug = document.getElementById('slug').value.trim();
const startDate = document.getElementById('start-date').value; const startDate = document.getElementById('start-date').value;
const expirationDate = document.getElementById('expiration-date').value || null; const expirationDate = document.getElementById('expiration-date').value || null;
const modules = [...document.querySelectorAll('.module-check input:checked')].map(el => el.value); const modules = [...document.querySelectorAll('.module-check input:checked')].map(el => el.value);
if (!name) return;
const btn = document.getElementById('provision-btn'); const btn = document.getElementById('provision-btn');
const status = document.getElementById('provision-status'); const status = document.getElementById('provision-status');
if (!slug) return; if (!slug) return;
@@ -657,12 +692,13 @@
const res = await fetch('/api/customers', { const res = await fetch('/api/customers', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: slug, modules, startDate, expirationDate }), body: JSON.stringify({ slug, name, modules, startDate, expirationDate }),
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.message ?? 'Unknown error'); if (!res.ok) throw new Error(data.message ?? 'Unknown error');
status.textContent = `${data.slug} provisioned successfully`; status.textContent = `${data.slug} provisioned successfully`;
status.className = 'status success'; status.className = 'status success';
document.getElementById('customer-name').value = '';
document.getElementById('slug').value = ''; document.getElementById('slug').value = '';
document.getElementById('expiration-date').value = ''; document.getElementById('expiration-date').value = '';
document.querySelectorAll('.module-check').forEach(l => { l.classList.remove('checked'); l.querySelector('input').checked = false; }); document.querySelectorAll('.module-check').forEach(l => { l.classList.remove('checked'); l.querySelector('input').checked = false; });

View File

@@ -16,7 +16,9 @@ export async function migrate() {
await db` await db`
CREATE TABLE IF NOT EXISTS customers ( CREATE TABLE IF NOT EXISTS customers (
slug TEXT PRIMARY KEY, slug TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'provisioned', status TEXT NOT NULL DEFAULT 'provisioned',
steps JSONB NOT NULL DEFAULT '{}',
modules TEXT[] NOT NULL DEFAULT '{}', modules TEXT[] NOT NULL DEFAULT '{}',
start_date DATE NOT NULL DEFAULT CURRENT_DATE, start_date DATE NOT NULL DEFAULT CURRENT_DATE,
expiration_date DATE, expiration_date DATE,
@@ -28,6 +30,8 @@ export async function migrate() {
await db`ALTER TABLE users ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`; await db`ALTER TABLE users ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`;
await db` await db`
ALTER TABLE customers ALTER TABLE customers
ADD COLUMN IF NOT EXISTS name TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS steps JSONB NOT NULL DEFAULT '{}',
ADD COLUMN IF NOT EXISTS modules TEXT[] NOT NULL DEFAULT '{}', ADD COLUMN IF NOT EXISTS modules TEXT[] NOT NULL DEFAULT '{}',
ADD COLUMN IF NOT EXISTS start_date DATE NOT NULL DEFAULT CURRENT_DATE, ADD COLUMN IF NOT EXISTS start_date DATE NOT NULL DEFAULT CURRENT_DATE,
ADD COLUMN IF NOT EXISTS expiration_date DATE, ADD COLUMN IF NOT EXISTS expiration_date DATE,

View File

@@ -51,7 +51,7 @@ export async function authRoutes(app: FastifyInstance) {
reply.setCookie("token", token, { reply.setCookie("token", token, {
httpOnly: true, httpOnly: true,
secure: true, secure: true,
sameSite: "strict", sameSite: "lax",
path: "/", path: "/",
maxAge: 60 * 60 * 24 * 7, maxAge: 60 * 60 * 24 * 7,
}); });

View File

@@ -9,7 +9,8 @@ import { db } from "../db/manager";
const MODULES = ["pos", "inventory", "rentals", "scheduling", "repairs", "accounting"] as const; const MODULES = ["pos", "inventory", "rentals", "scheduling", "repairs", "accounting"] as const;
const ProvisionSchema = z.object({ const ProvisionSchema = z.object({
name: z.string().min(2).max(32).regex(/^[a-z0-9-]+$/, "lowercase letters, numbers, and hyphens only"), slug: z.string().min(2).max(32).regex(/^[a-z0-9-]+$/, "lowercase letters, numbers, and hyphens only"),
name: z.string().min(1).max(128),
appVersion: z.string().default("latest"), appVersion: z.string().default("latest"),
modules: z.array(z.enum(MODULES)).default([]), modules: z.array(z.enum(MODULES)).default([]),
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).default(() => new Date().toISOString().slice(0, 10)), startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).default(() => new Date().toISOString().slice(0, 10)),
@@ -52,29 +53,62 @@ export async function customerRoutes(app: FastifyInstance) {
app.post("/customers", async (req, reply) => { app.post("/customers", async (req, reply) => {
const body = ProvisionSchema.parse(req.body); const body = ProvisionSchema.parse(req.body);
const slug = body.name; const slug = body.slug;
app.log.info({ slug }, "provisioning customer"); app.log.info({ slug }, "provisioning customer");
const [, user] = await Promise.all([ type StepStatus = "pending" | "done" | "failed";
createDatabase(slug), const steps: Record<string, StepStatus> = {
createDatabaseUser(slug), database: "pending",
]); database_user: "pending",
database_setup: "pending",
pool: "pending",
chart: "pending",
};
await setupCustomerDatabase(slug, user.name); const setStep = async (step: string, status: StepStatus) => {
await addCustomerToPool(slug, user.password); steps[step] = status;
addCustomerChart(slug, body.appVersion); await db`UPDATE customers SET steps = ${db.json(steps)}, updated_at = NOW() WHERE slug = ${slug}`;
};
// Insert record immediately so partial failures are visible in the UI
await db` await db`
INSERT INTO customers (slug, modules, start_date, expiration_date) INSERT INTO customers (slug, name, modules, start_date, expiration_date, status, steps)
VALUES (${slug}, ${body.modules}, ${body.startDate}, ${body.expirationDate}) VALUES (${slug}, ${body.name}, ${body.modules}, ${body.startDate}, ${body.expirationDate}, 'provisioning', ${db.json(steps)})
ON CONFLICT (slug) DO UPDATE SET ON CONFLICT (slug) DO UPDATE SET
name = EXCLUDED.name,
modules = EXCLUDED.modules, modules = EXCLUDED.modules,
start_date = EXCLUDED.start_date, start_date = EXCLUDED.start_date,
expiration_date = EXCLUDED.expiration_date, expiration_date = EXCLUDED.expiration_date,
status = 'provisioning',
steps = ${db.json(steps)},
updated_at = NOW() updated_at = NOW()
`; `;
try {
const [, user] = await Promise.all([
createDatabase(slug).then(r => { setStep("database", "done"); return r; }),
createDatabaseUser(slug).then(r => { setStep("database_user", "done"); return r; }),
]);
await setupCustomerDatabase(slug, user.name);
await setStep("database_setup", "done");
await addCustomerToPool(slug, user.password);
await setStep("pool", "done");
addCustomerChart(slug, body.appVersion);
await setStep("chart", "done");
await db`UPDATE customers SET status = 'provisioned', updated_at = NOW() WHERE slug = ${slug}`;
} catch (err) {
const failedStep = Object.entries(steps).find(([, v]) => v === "pending")?.[0];
if (failedStep) await setStep(failedStep, "failed");
await db`UPDATE customers SET status = 'failed', updated_at = NOW() WHERE slug = ${slug}`;
app.log.error({ slug, err }, "provisioning failed");
return reply.code(500).send({ message: (err as Error).message ?? "Provisioning failed" });
}
app.log.info({ slug }, "customer provisioned"); app.log.info({ slug }, "customer provisioned");
return reply.code(201).send({ slug, status: "provisioned" }); return reply.code(201).send({ slug, status: "provisioned" });
}); });
@@ -97,4 +131,13 @@ export async function customerRoutes(app: FastifyInstance) {
app.log.info({ slug }, "customer deprovisioned"); app.log.info({ slug }, "customer deprovisioned");
return reply.code(204).send(); return reply.code(204).send();
}); });
// Remove only the manager DB record without touching infrastructure —
// useful for cleaning up failed partial deployments
app.delete("/customers/:slug/record", async (req, reply) => {
const { slug } = req.params as { slug: string };
await db`DELETE FROM customers WHERE slug = ${slug}`;
app.log.info({ slug }, "customer record removed");
return reply.code(204).send();
});
} }