fix: persist auth cookie across refreshes and add customer detail tracking
Some checks failed
Build & Release / build (push) Has been cancelled
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:
@@ -339,6 +339,8 @@
|
||||
<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>
|
||||
@@ -348,7 +350,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<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>
|
||||
</table>
|
||||
<div class="pagination" id="customers-pagination" style="display:none">
|
||||
@@ -367,7 +369,11 @@
|
||||
<div class="page-title">Create Environment</div>
|
||||
<div class="card">
|
||||
<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-]+" />
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.8rem">
|
||||
@@ -530,7 +536,7 @@
|
||||
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>';
|
||||
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();
|
||||
@@ -538,14 +544,30 @@
|
||||
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><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>`;
|
||||
}).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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
function toggleModule(checkbox) {
|
||||
@@ -643,10 +676,12 @@
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -657,12 +692,13 @@
|
||||
const res = await fetch('/api/customers', {
|
||||
method: 'POST',
|
||||
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();
|
||||
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; });
|
||||
|
||||
@@ -16,7 +16,9 @@ export async function migrate() {
|
||||
await db`
|
||||
CREATE TABLE IF NOT EXISTS customers (
|
||||
slug TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'provisioned',
|
||||
steps JSONB NOT NULL DEFAULT '{}',
|
||||
modules TEXT[] NOT NULL DEFAULT '{}',
|
||||
start_date DATE NOT NULL DEFAULT CURRENT_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 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 start_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||
ADD COLUMN IF NOT EXISTS expiration_date DATE,
|
||||
|
||||
@@ -51,7 +51,7 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
reply.setCookie("token", token, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "strict",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
});
|
||||
|
||||
@@ -9,7 +9,8 @@ import { db } from "../db/manager";
|
||||
const MODULES = ["pos", "inventory", "rentals", "scheduling", "repairs", "accounting"] as const;
|
||||
|
||||
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"),
|
||||
modules: z.array(z.enum(MODULES)).default([]),
|
||||
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) => {
|
||||
const body = ProvisionSchema.parse(req.body);
|
||||
const slug = body.name;
|
||||
const slug = body.slug;
|
||||
|
||||
app.log.info({ slug }, "provisioning customer");
|
||||
|
||||
const [, user] = await Promise.all([
|
||||
createDatabase(slug),
|
||||
createDatabaseUser(slug),
|
||||
]);
|
||||
type StepStatus = "pending" | "done" | "failed";
|
||||
const steps: Record<string, StepStatus> = {
|
||||
database: "pending",
|
||||
database_user: "pending",
|
||||
database_setup: "pending",
|
||||
pool: "pending",
|
||||
chart: "pending",
|
||||
};
|
||||
|
||||
await setupCustomerDatabase(slug, user.name);
|
||||
await addCustomerToPool(slug, user.password);
|
||||
addCustomerChart(slug, body.appVersion);
|
||||
const setStep = async (step: string, status: StepStatus) => {
|
||||
steps[step] = status;
|
||||
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`
|
||||
INSERT INTO customers (slug, modules, start_date, expiration_date)
|
||||
VALUES (${slug}, ${body.modules}, ${body.startDate}, ${body.expirationDate})
|
||||
INSERT INTO customers (slug, name, modules, start_date, expiration_date, status, steps)
|
||||
VALUES (${slug}, ${body.name}, ${body.modules}, ${body.startDate}, ${body.expirationDate}, 'provisioning', ${db.json(steps)})
|
||||
ON CONFLICT (slug) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
modules = EXCLUDED.modules,
|
||||
start_date = EXCLUDED.start_date,
|
||||
expiration_date = EXCLUDED.expiration_date,
|
||||
status = 'provisioning',
|
||||
steps = ${db.json(steps)},
|
||||
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");
|
||||
return reply.code(201).send({ slug, status: "provisioned" });
|
||||
});
|
||||
@@ -97,4 +131,13 @@ export async function customerRoutes(app: FastifyInstance) {
|
||||
app.log.info({ slug }, "customer deprovisioned");
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user