feat: initial user provisioning with welcome email, resend welcome
Some checks failed
Build & Release / build (push) Has been cancelled
Some checks failed
Build & Release / build (push) Has been cancelled
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -331,10 +331,27 @@
|
||||
<div class="kebab-item" id="kebab-deactivate" onclick="kebabAction('deactivate')">Pause</div>
|
||||
<div class="kebab-item" id="kebab-reactivate" onclick="kebabAction('reactivate')" style="display:none">Resume</div>
|
||||
<div class="kebab-item" onclick="kebabAction('upgrade')">Upgrade Chart</div>
|
||||
<div class="kebab-item" onclick="kebabAction('resend-welcome')">Resend Welcome</div>
|
||||
<div class="kebab-item danger" onclick="kebabAction('delete')">Decommission</div>
|
||||
<div class="kebab-item" onclick="kebabAction('record')">Remove Record Only</div>
|
||||
</div>
|
||||
|
||||
<!-- Resend welcome dialog -->
|
||||
<div class="overlay" id="resend-overlay" onclick="if(event.target===this)closeResendDialog()">
|
||||
<div class="dialog" style="max-width:380px;width:100%">
|
||||
<h3>Resend Welcome Email</h3>
|
||||
<p style="margin-bottom:0.8rem">Send a welcome email with a password setup link to a user on <strong id="resend-slug-label" style="color:#e6edf3"></strong>.</p>
|
||||
<div class="form-group">
|
||||
<label for="resend-email">Email address</label>
|
||||
<input id="resend-email" type="email" placeholder="user@example.com" />
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<button class="btn btn-slate" onclick="closeResendDialog()">Cancel</button>
|
||||
<button class="btn" id="resend-confirm-btn" onclick="confirmResendWelcome()">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Decommission confirm dialog -->
|
||||
<div class="overlay" id="delete-overlay" onclick="if(event.target===this)closeDeleteDialog()">
|
||||
<div class="dialog">
|
||||
@@ -380,6 +397,23 @@
|
||||
<label class="module-check" id="mod-accounting"><input type="checkbox" value="accounting" onchange="toggleModule(this)" /> Accounting</label>
|
||||
</div>
|
||||
</div>
|
||||
<div style="border-top:1px solid #30363d;margin-top:0.8rem;padding-top:0.8rem">
|
||||
<label style="font-size:0.82rem;color:#8b949e;margin-bottom:0.5rem;display:block">Initial Admin User (optional)</label>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.8rem">
|
||||
<div class="form-group">
|
||||
<label for="initial-first">First Name</label>
|
||||
<input id="initial-first" type="text" placeholder="Jane" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="initial-last">Last Name</label>
|
||||
<input id="initial-last" type="text" placeholder="Doe" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="initial-email">Email</label>
|
||||
<input id="initial-email" type="email" placeholder="admin@example.com" />
|
||||
</div>
|
||||
</div>
|
||||
<div id="provision-status" class="status"></div>
|
||||
<div class="dialog-actions">
|
||||
<button class="btn btn-slate" onclick="closeProvisionModal()">Cancel</button>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user