From 7f2cf14d38a3e65186be2408578d5167f000aa91 Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 5 Apr 2026 17:09:32 +0000 Subject: [PATCH] feat: initial user provisioning with welcome email, resend welcome - Provision form accepts optional initial admin user (no password needed) - POST /customers/:slug/resend-welcome sends welcome email via customer backend - Kebab menu "Resend Welcome" option with email input dialog - Query latest version from backend image tags instead of chart tags Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/index.html | 87 ++++++++++++++++++++++++++++++++++++++++- src/routes/customers.ts | 38 ++++++++++++++---- 2 files changed, 117 insertions(+), 8 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 15c0448..b84bdd7 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -331,10 +331,27 @@
Pause
Upgrade Chart
+
Resend Welcome
Decommission
Remove Record Only
+ +
+
+

Resend Welcome Email

+

Send a welcome email with a password setup link to a user on .

+
+ + +
+
+ + +
+
+
+
@@ -380,6 +397,23 @@
+
+ +
+
+ + +
+
+ + +
+
+
+ + +
+
@@ -630,6 +664,7 @@ else if (action === 'deactivate') deactivate(slug); else if (action === 'reactivate') reactivate(slug); else if (action === 'upgrade') upgradeCustomer(slug); + else if (action === 'resend-welcome') openResendWelcome(slug); else if (action === 'delete') openDeleteDialog(slug); else if (action === 'record') removeRecord(slug); } @@ -836,6 +871,44 @@ // ── Delete ──────────────────────────────────────────────────────────────── + let resendSlug = null; + function openResendWelcome(slug) { + resendSlug = slug; + document.getElementById('resend-slug-label').textContent = slug; + document.getElementById('resend-email').value = ''; + document.getElementById('resend-overlay').classList.add('open'); + } + function closeResendDialog() { + resendSlug = null; + document.getElementById('resend-overlay').classList.remove('open'); + } + async function confirmResendWelcome() { + const slug = resendSlug; + const email = document.getElementById('resend-email').value.trim(); + if (!slug || !email) return; + const btn = document.getElementById('resend-confirm-btn'); + btn.disabled = true; + btn.textContent = 'Sending…'; + try { + const res = await apiFetch(`/api/customers/${slug}/resend-welcome`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }); + if (!res.ok) { + const d = await res.json().catch(() => ({})); + throw new Error(d.message ?? 'Failed to send'); + } + closeResendDialog(); + alert('Welcome email sent to ' + email); + } catch (err) { + alert(err.message); + } finally { + btn.disabled = false; + btn.textContent = 'Send'; + } + } + function openDeleteDialog(slug) { pendingDeleteSlug = slug; document.getElementById('delete-slug-label').textContent = slug; @@ -945,7 +1018,16 @@ 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 initialFirst = document.getElementById('initial-first').value.trim(); + const initialLast = document.getElementById('initial-last').value.trim(); + const initialEmail = document.getElementById('initial-email').value.trim(); if (!name || !slug) return; + + const body = { slug, name, modules, startDate, expirationDate }; + if (initialEmail && initialFirst && initialLast) { + body.initialUser = { email: initialEmail, firstName: initialFirst, lastName: initialLast }; + } + const btn = document.getElementById('provision-btn'); const status = document.getElementById('provision-status'); btn.disabled = true; @@ -955,7 +1037,7 @@ const res = await apiFetch('/api/customers', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ slug, name, modules, startDate, expirationDate }), + body: JSON.stringify(body), }); const data = await res.json(); if (!res.ok) throw new Error(data.message ?? 'Unknown error'); @@ -963,6 +1045,9 @@ document.getElementById('customer-name').value = ''; document.getElementById('slug').value = ''; document.getElementById('expiration-date').value = ''; + document.getElementById('initial-first').value = ''; + document.getElementById('initial-last').value = ''; + document.getElementById('initial-email').value = ''; document.querySelectorAll('.module-check').forEach(l => { l.classList.remove('checked'); l.querySelector('input').checked = false; }); loadCustomers(); } catch (err) { diff --git a/src/routes/customers.ts b/src/routes/customers.ts index 96bf3f6..e3a00a1 100644 --- a/src/routes/customers.ts +++ b/src/routes/customers.ts @@ -24,9 +24,8 @@ const ProvisionSchema = z.object({ modules: z.array(z.enum(MODULES)).default([]), startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).default(() => new Date().toISOString().slice(0, 10)), expirationDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().default(null), - initialEmail: z.object({ + initialUser: z.object({ email: z.string().email(), - password: z.string().min(12).max(128), firstName: z.string().min(1).max(100), lastName: z.string().min(1).max(100), }).optional(), @@ -145,11 +144,10 @@ export async function customerRoutes(app: FastifyInstance) { "resend-api-key": config.resendApiKey, "mail-from": `noreply@${slug}.lunarfront.tech`, "business-name": body.name, - ...(body.initialEmail ? { - "initial-user-email": body.initialEmail.email, - "initial-user-password": body.initialEmail.password, - "initial-user-first-name": body.initialEmail.firstName, - "initial-user-last-name": body.initialEmail.lastName, + ...(body.initialUser ? { + "initial-user-email": body.initialUser.email, + "initial-user-first-name": body.initialUser.firstName, + "initial-user-last-name": body.initialUser.lastName, } : {}), }); await setStep("storage", "done"); @@ -361,6 +359,32 @@ export async function customerRoutes(app: FastifyInstance) { return reply.send({ upgraded: slugs, version }); }); + // Resend welcome email — generates a new reset link via the customer's backend + app.post("/customers/:slug/resend-welcome", async (req, reply) => { + const { slug } = req.params as { slug: string }; + const { email } = req.body as { email: string }; + if (!email) return reply.code(400).send({ message: "email is required" }); + + const [customer] = await db`SELECT * FROM customers WHERE slug = ${slug}`; + if (!customer) return reply.code(404).send({ message: "Not found" }); + + // Call the customer's forgot-password endpoint with welcome type + const res = await fetch(`http://customer-${slug}-backend.customer-${slug}.svc:8000/v1/auth/forgot-password?type=welcome`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }); + + if (!res.ok) { + const body = await res.text(); + app.log.error({ slug, email, status: res.status, body }, "Failed to send welcome email"); + return reply.code(502).send({ message: "Failed to send welcome email" }); + } + + app.log.info({ slug, email }, "Welcome email resent"); + return reply.send({ message: "Welcome email sent" }); + }); + // Remove only the manager DB record without touching infrastructure app.delete("/customers/:slug/record", async (req, reply) => { const { slug } = req.params as { slug: string };