← back to Ventura Claw Leads
yolo tick 11: admin invite-teammate flow — multi-user per business
2c5302b843efe265e7e22ac0cb503dae96c8e58e · 2026-05-06 19:27:24 -0700 · Steve Abrams
DB:
- migrations/007_business_invitations.sql: business_invitations table —
HMAC token, business_id FK, recipient email, invited_by_user_id, 7-day
expires_at, consumed_at, ip_hash. Partial index on active.
- POST-DEPLOY GRANT (always required when sudo -u postgres applies a
migration; vcl needs perms on the new table+sequence):
GRANT ALL ON business_invitations TO vcl;
GRANT USAGE,SELECT,UPDATE ON SEQUENCE business_invitations_id_seq TO vcl;
Auth lib:
- lib/auth.js: issueInviteToken / consumeInviteToken / markInviteConsumed
alongside the existing claim-token helpers. 7-day TTL (vs 24h for claim).
Admin routes:
- GET /admin/team: list active business_users + pending invitations,
flash messages for invited/removed/error states.
- POST /admin/team/invite: validate email, idempotent on existing member,
mint token, send transactional email via Purelymail SMTP, audit in prod.
- POST /admin/team/invite/:id/cancel + /team/:id/remove (with last-user +
self-remove guards).
Public routes:
- GET/POST /accept-invite/:token: set-password form, bcrypt, session-login,
redirect to dashboard with welcome flash.
Views: admin/team.ejs, public/accept-invite.ejs, dashboard.ejs invite link.
Live test 2026-05-06: /admin/team → 302 auth-redirect, /admin/team/invite
POST → 403 CSRF, /accept-invite/<garbage> → 400 invalid (post-GRANT). 46/46
tests pass.
Files touched
A db/migrations/007_business_invitations.sqlM lib/auth.jsM routes/admin.jsM routes/claim.jsM views/admin/dashboard.ejsA views/admin/team.ejsA views/public/accept-invite.ejs
Diff
commit 2c5302b843efe265e7e22ac0cb503dae96c8e58e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed May 6 19:27:24 2026 -0700
yolo tick 11: admin invite-teammate flow — multi-user per business
DB:
- migrations/007_business_invitations.sql: business_invitations table —
HMAC token, business_id FK, recipient email, invited_by_user_id, 7-day
expires_at, consumed_at, ip_hash. Partial index on active.
- POST-DEPLOY GRANT (always required when sudo -u postgres applies a
migration; vcl needs perms on the new table+sequence):
GRANT ALL ON business_invitations TO vcl;
GRANT USAGE,SELECT,UPDATE ON SEQUENCE business_invitations_id_seq TO vcl;
Auth lib:
- lib/auth.js: issueInviteToken / consumeInviteToken / markInviteConsumed
alongside the existing claim-token helpers. 7-day TTL (vs 24h for claim).
Admin routes:
- GET /admin/team: list active business_users + pending invitations,
flash messages for invited/removed/error states.
- POST /admin/team/invite: validate email, idempotent on existing member,
mint token, send transactional email via Purelymail SMTP, audit in prod.
- POST /admin/team/invite/:id/cancel + /team/:id/remove (with last-user +
self-remove guards).
Public routes:
- GET/POST /accept-invite/:token: set-password form, bcrypt, session-login,
redirect to dashboard with welcome flash.
Views: admin/team.ejs, public/accept-invite.ejs, dashboard.ejs invite link.
Live test 2026-05-06: /admin/team → 302 auth-redirect, /admin/team/invite
POST → 403 CSRF, /accept-invite/<garbage> → 400 invalid (post-GRANT). 46/46
tests pass.
---
db/migrations/007_business_invitations.sql | 19 ++++++
lib/auth.js | 39 ++++++++++-
routes/admin.js | 103 +++++++++++++++++++++++++++++
routes/claim.js | 55 +++++++++++++++
views/admin/dashboard.ejs | 1 +
views/admin/team.ejs | 85 ++++++++++++++++++++++++
views/public/accept-invite.ejs | 33 +++++++++
7 files changed, 334 insertions(+), 1 deletion(-)
diff --git a/db/migrations/007_business_invitations.sql b/db/migrations/007_business_invitations.sql
new file mode 100644
index 0000000..ef4f7f5
--- /dev/null
+++ b/db/migrations/007_business_invitations.sql
@@ -0,0 +1,19 @@
+-- Multi-user-per-business via invite-teammate flow.
+-- The existing schema (003_business_users_and_claims.sql) already has
+-- UNIQUE (business_id, email) on business_users so multiple users per
+-- business is allowed at the table level — we just lacked a flow to
+-- create the second+ user. This migration adds that flow.
+
+CREATE TABLE IF NOT EXISTS business_invitations (
+ id BIGSERIAL PRIMARY KEY,
+ token TEXT UNIQUE NOT NULL, -- HMAC, 32 chars
+ business_id BIGINT NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
+ email TEXT NOT NULL, -- recipient
+ invited_by_user_id BIGINT NOT NULL REFERENCES business_users(id) ON DELETE SET NULL,
+ issued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ expires_at TIMESTAMPTZ NOT NULL,
+ consumed_at TIMESTAMPTZ,
+ ip_hash TEXT
+);
+CREATE INDEX IF NOT EXISTS business_invitations_business_idx ON business_invitations (business_id);
+CREATE INDEX IF NOT EXISTS business_invitations_active_idx ON business_invitations (token) WHERE consumed_at IS NULL;
diff --git a/lib/auth.js b/lib/auth.js
index 4344325..91c14a0 100644
--- a/lib/auth.js
+++ b/lib/auth.js
@@ -126,9 +126,46 @@ async function markClaimConsumed(token) {
await db.query(`UPDATE claim_tokens SET consumed_at = now() WHERE token = $1`, [token]);
}
+// ─── Team invitations (multi-user per business) ────────────────────────
+const INVITE_TTL_HOURS = 7 * 24; // 1 week — invites can sit longer than claims
+
+async function issueInviteToken({ businessId, email, invitedByUserId, ipHash }) {
+ const raw = `invite:${businessId}:${normalizeEmail(email)}:${Date.now()}:${crypto.randomBytes(8).toString('hex')}`;
+ const token = crypto.createHmac('sha256', claimSecret()).update(raw).digest('base64url').slice(0, 32);
+ const expiresAt = new Date(Date.now() + INVITE_TTL_HOURS * 3600 * 1000).toISOString();
+ await db.query(
+ `INSERT INTO business_invitations (token, business_id, email, invited_by_user_id, expires_at, ip_hash)
+ VALUES ($1, $2, $3, $4, $5, $6)`,
+ [token, businessId, normalizeEmail(email), invitedByUserId, expiresAt, ipHash || null]
+ );
+ return { token, expiresAt };
+}
+
+async function consumeInviteToken(token) {
+ const row = await db.one(
+ `SELECT bi.id, bi.business_id, bi.email, bi.invited_by_user_id, bi.consumed_at, bi.expires_at,
+ b.slug, b.business_name,
+ inviter.email AS inviter_email, inviter.display_name AS inviter_name
+ FROM business_invitations bi
+ JOIN businesses b ON b.id = bi.business_id
+ LEFT JOIN business_users inviter ON inviter.id = bi.invited_by_user_id
+ WHERE bi.token = $1`,
+ [token]
+ );
+ if (!row) return { ok: false, reason: 'invalid' };
+ if (row.consumed_at) return { ok: false, reason: 'already_used' };
+ if (new Date(row.expires_at) < new Date()) return { ok: false, reason: 'expired' };
+ return { ok: true, invite: row };
+}
+
+async function markInviteConsumed(token) {
+ await db.query(`UPDATE business_invitations SET consumed_at = now() WHERE token = $1`, [token]);
+}
+
module.exports = {
hashPassword, verifyPassword, normalizeEmail, isEmailShape,
findUserByEmail, createUser, setPassword, recordLogin,
attachBusiness, requireBusiness,
- issueClaimToken, consumeClaimToken, markClaimConsumed
+ issueClaimToken, consumeClaimToken, markClaimConsumed,
+ issueInviteToken, consumeInviteToken, markInviteConsumed
};
diff --git a/routes/admin.js b/routes/admin.js
index 7081ae2..0174aab 100644
--- a/routes/admin.js
+++ b/routes/admin.js
@@ -180,6 +180,109 @@ router.get('/leads.csv', async (req, res, next) => {
} catch (err) { next(err); }
});
+// ─── Team / multi-user per business ────────────────────────────────────
+const compliance = require('../lib/compliance');
+const { sendEmail } = require('../lib/email');
+const crypto = require('node:crypto');
+const PUBLIC_URL_RAW = (process.env.PUBLIC_URL || 'https://leads.venturaclaw.com').replace(/\/+$/, '');
+
+router.get('/team', async (req, res, next) => {
+ try {
+ const bizId = res.locals.currentBusiness.id;
+ const team = await db.many(`
+ SELECT id, email, display_name, last_login_at, created_at,
+ (id = $2) AS is_self
+ FROM business_users WHERE business_id = $1
+ ORDER BY created_at ASC
+ `, [bizId, res.locals.currentBusinessUser.id]);
+ const pendingInvites = await db.many(`
+ SELECT id, email, issued_at, expires_at
+ FROM business_invitations
+ WHERE business_id = $1 AND consumed_at IS NULL AND expires_at > now()
+ ORDER BY issued_at DESC
+ `, [bizId]);
+ res.render('admin/team', {
+ title: 'Team · Ventura Claw',
+ team, pendingInvites,
+ flash: req.query
+ });
+ } catch (err) { next(err); }
+});
+
+router.post('/team/invite', async (req, res, next) => {
+ try {
+ const bizId = res.locals.currentBusiness.id;
+ const inviterId = res.locals.currentBusinessUser.id;
+ const email = auth.normalizeEmail(req.body.email);
+ if (!auth.isEmailShape(email)) return res.redirect('/admin/team?error=bad_email');
+
+ // Already a team member? Idempotent — silent ok.
+ const existing = await db.one(
+ `SELECT id FROM business_users WHERE business_id = $1 AND email = $2`,
+ [bizId, email]
+ );
+ if (existing) return res.redirect('/admin/team?error=already_member');
+
+ const ipHash = crypto.createHash('sha256')
+ .update((req.headers['x-forwarded-for'] || req.ip || '') + (process.env.SESSION_SECRET || ''))
+ .digest('hex').slice(0, 16);
+ const { token } = await auth.issueInviteToken({
+ businessId: bizId, email, invitedByUserId: inviterId, ipHash
+ });
+
+ const link = `${PUBLIC_URL_RAW}/accept-invite/${encodeURIComponent(token)}`;
+ const biz = res.locals.currentBusiness;
+ const inviter = res.locals.currentBusinessUser;
+ const subject = `${inviter.display_name || inviter.email} invited you to manage ${biz.business_name} on Ventura Claw`;
+ const html = `<div style="font-family:Georgia,serif;max-width:560px;margin:0 auto;padding:32px 24px;color:#0e0e0e">
+ <p style="font-size:11px;letter-spacing:0.16em;text-transform:uppercase;color:#b8860b;margin:0 0 8px">Team invitation</p>
+ <h1 style="font-size:24px;font-weight:400;margin:0 0 12px">You've been invited to manage ${escapeHtml(biz.business_name)}</h1>
+ <p style="font-size:15px;line-height:1.55"><strong>${escapeHtml(inviter.display_name || inviter.email)}</strong> added you as a team member on Ventura Claw. Once you accept, you'll see the same dashboard, lead inbox, and profile editor for <strong>${escapeHtml(biz.business_name)}</strong>.</p>
+ <p style="margin:24px 0"><a href="${link}" style="display:inline-block;background:#0e0e0e;color:#fff;padding:14px 22px;text-decoration:none;font-size:14px;letter-spacing:0.04em;border-radius:2px">Accept invitation →</a></p>
+ <p style="font-size:13px;color:#666;line-height:1.55">Link valid for 7 days; works once.</p>
+${process.env.NODE_ENV === 'production' ? compliance.complianceFooter({ campaign: 'team_invite', unsubscribeUrl: null }) : ''}
+</div>`;
+ await sendEmail({ to: email, subject, html, text: `${subject}\n\n${link}\n\n(link expires in 7 days)` });
+ if (process.env.NODE_ENV === 'production') {
+ await compliance.recordAudit({
+ channel: 'email', campaign: 'team_invite', recipient: email, businessId: bizId,
+ decision: 'sent', subject, messageId: null, payload: { token_prefix: token.slice(0, 8) }
+ });
+ }
+ res.redirect('/admin/team?invited=' + encodeURIComponent(email));
+ } catch (err) { next(err); }
+});
+
+router.post('/team/invite/:id(\\d+)/cancel', async (req, res, next) => {
+ try {
+ await db.query(
+ `DELETE FROM business_invitations
+ WHERE id = $1 AND business_id = $2 AND consumed_at IS NULL`,
+ [parseInt(req.params.id, 10), res.locals.currentBusiness.id]
+ );
+ res.redirect('/admin/team');
+ } catch (err) { next(err); }
+});
+
+router.post('/team/:id(\\d+)/remove', async (req, res, next) => {
+ try {
+ const bizId = res.locals.currentBusiness.id;
+ const targetId = parseInt(req.params.id, 10);
+ const selfId = res.locals.currentBusinessUser.id;
+ if (targetId === selfId) return res.redirect('/admin/team?error=self_remove');
+ // Ensure at least one user remains (count after removal).
+ const others = await db.one(
+ `SELECT COUNT(*)::int AS n FROM business_users WHERE business_id = $1 AND id != $2`,
+ [bizId, targetId]
+ );
+ if (others.n === 0) return res.redirect('/admin/team?error=last_user');
+ await db.query(`DELETE FROM business_users WHERE id = $1 AND business_id = $2`, [targetId, bizId]);
+ res.redirect('/admin/team?removed=1');
+ } catch (err) { next(err); }
+});
+
+function escapeHtml(s) { return String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
+
// ─── Billing ───────────────────────────────────────────────────────────
router.get('/billing', (req, res) => {
res.render('admin/billing', {
diff --git a/routes/claim.js b/routes/claim.js
index 1cfb72f..f28e64c 100644
--- a/routes/claim.js
+++ b/routes/claim.js
@@ -142,6 +142,61 @@ router.post('/claim/:token', async (req, res, next) => {
} catch (err) { next(err); }
});
+// ─── Team-invitation acceptance (multi-user per business) ──────────────
+router.get('/accept-invite/:token', async (req, res, next) => {
+ try {
+ const r = await auth.consumeInviteToken(req.params.token);
+ if (!r.ok) {
+ return res.status(400).render('public/accept-invite', {
+ title: 'Invitation not valid', invite: null, ok: false, reason: r.reason
+ });
+ }
+ res.render('public/accept-invite', {
+ title: `Join ${r.invite.business_name}`,
+ invite: r.invite, ok: true, token: req.params.token, error: null
+ });
+ } catch (err) { next(err); }
+});
+
+router.post('/accept-invite/:token', async (req, res, next) => {
+ try {
+ const r = await auth.consumeInviteToken(req.params.token);
+ if (!r.ok) {
+ return res.status(400).render('public/accept-invite', {
+ title: 'Invitation not valid', invite: null, ok: false, reason: r.reason
+ });
+ }
+ const password = String(req.body.password || '');
+ const passwordConfirm = String(req.body.password_confirm || '');
+ if (password.length < 10) {
+ return res.status(400).render('public/accept-invite', {
+ title: 'Password too short', invite: r.invite, ok: true, token: req.params.token,
+ error: 'Pick a password with at least 10 characters.'
+ });
+ }
+ if (password !== passwordConfirm) {
+ return res.status(400).render('public/accept-invite', {
+ title: 'Passwords don\'t match', invite: r.invite, ok: true, token: req.params.token,
+ error: 'The two password fields must match.'
+ });
+ }
+
+ await auth.markInviteConsumed(req.params.token);
+ const passwordHash = await auth.hashPassword(password);
+ const user = await auth.createUser({
+ email: r.invite.email, businessId: r.invite.business_id,
+ passwordHash, displayName: r.invite.email.split('@')[0],
+ emailVerified: true
+ });
+ if (user && !user.password_hash) {
+ await auth.setPassword(user.id, password);
+ }
+ req.session.businessUserId = user.id;
+ await auth.recordLogin(user.id);
+ res.redirect('/admin?welcome=1');
+ } catch (err) { next(err); }
+});
+
function escapeHtml(s) {
return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
diff --git a/views/admin/dashboard.ejs b/views/admin/dashboard.ejs
index 01c74e4..6225f88 100644
--- a/views/admin/dashboard.ejs
+++ b/views/admin/dashboard.ejs
@@ -65,6 +65,7 @@
<ul>
<li><a href="/admin/profile">Edit your public profile</a> — make sure description, hours, contact, and Instagram are current.</li>
<li><a href="/admin/billing">Pick a tier</a> — Free is fine; paid tiers unlock featured placement and category-sidebar feature.</li>
+ <li><a href="/admin/team">Invite a teammate</a> — let multiple people respond to leads.</li>
<li><a href="/business/<%= currentBusiness.slug %>" target="_blank">View your live profile ↗</a></li>
</ul>
</section>
diff --git a/views/admin/team.ejs b/views/admin/team.ejs
new file mode 100644
index 0000000..1cb9ebf
--- /dev/null
+++ b/views/admin/team.ejs
@@ -0,0 +1,85 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<section class="long-form" style="max-width:720px">
+ <p class="kicker">Team</p>
+ <h1 class="display-sm">Manage who can edit <%= currentBusiness.business_name %>.</h1>
+ <p class="lede">Add team members so multiple people can respond to leads. Each member gets the same dashboard.</p>
+
+ <% if (flash.invited) { %><p class="callout" style="background:#dcfce7;border-left:3px solid #16a34a;color:#166534;padding:12px 16px;margin:16px 0;border-radius:4px">Invitation sent to <strong><%= flash.invited %></strong>. They have 7 days to accept.</p><% } %>
+ <% if (flash.removed) { %><p class="callout" style="background:#dcfce7;border-left:3px solid #16a34a;color:#166534;padding:12px 16px;margin:16px 0;border-radius:4px">Team member removed.</p><% } %>
+ <% if (flash.error === 'bad_email') { %><p class="callout" style="background:#fee2e2;border-left:3px solid #dc2626;padding:12px 16px;margin:16px 0;border-radius:4px">That doesn't look like a valid email address.</p><% } %>
+ <% if (flash.error === 'already_member') { %><p class="callout" style="background:#fee2e2;border-left:3px solid #dc2626;padding:12px 16px;margin:16px 0;border-radius:4px">That email is already a team member.</p><% } %>
+ <% if (flash.error === 'last_user') { %><p class="callout" style="background:#fee2e2;border-left:3px solid #dc2626;padding:12px 16px;margin:16px 0;border-radius:4px">Can't remove the last team member — invite someone else first.</p><% } %>
+ <% if (flash.error === 'self_remove') { %><p class="callout" style="background:#fee2e2;border-left:3px solid #dc2626;padding:12px 16px;margin:16px 0;border-radius:4px">Can't remove yourself. Have another team member do it, or contact support.</p><% } %>
+
+ <h2 style="margin-top:32px">Active members</h2>
+ <table style="width:100%;border-collapse:collapse;margin:16px 0;font-size:14px">
+ <thead>
+ <tr style="border-bottom:1px solid var(--border);text-align:left">
+ <th style="padding:10px 8px;font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:var(--fg-muted);font-weight:500">Email</th>
+ <th style="padding:10px 8px;font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:var(--fg-muted);font-weight:500">Joined</th>
+ <th style="padding:10px 8px;font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:var(--fg-muted);font-weight:500">Last sign-in</th>
+ <th style="padding:10px 8px;font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:var(--fg-muted);font-weight:500"></th>
+ </tr>
+ </thead>
+ <tbody>
+ <% team.forEach(function(u){ %>
+ <tr style="border-bottom:1px solid var(--border)">
+ <td style="padding:12px 8px"><%= u.email %><% if (u.is_self) { %> <span class="muted" style="font-size:11px">(you)</span><% } %></td>
+ <td style="padding:12px 8px;color:var(--fg-muted)"><%= new Date(u.created_at).toLocaleDateString('en-US',{ month:'short', day:'numeric', year:'numeric' }) %></td>
+ <td style="padding:12px 8px;color:var(--fg-muted)"><%= u.last_login_at ? new Date(u.last_login_at).toLocaleDateString('en-US',{ month:'short', day:'numeric' }) : '—' %></td>
+ <td style="padding:12px 8px;text-align:right">
+ <% if (!u.is_self) { %>
+ <form method="post" action="/admin/team/<%= u.id %>/remove" style="margin:0" onsubmit="return confirm('Remove <%= u.email %>?');">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+ <button type="submit" class="btn btn-ghost btn-sm" style="font-size:11px;padding:4px 10px">Remove</button>
+ </form>
+ <% } %>
+ </td>
+ </tr>
+ <% }); %>
+ </tbody>
+ </table>
+
+ <% if (pendingInvites.length > 0) { %>
+ <h2 style="margin-top:32px">Pending invitations</h2>
+ <table style="width:100%;border-collapse:collapse;margin:16px 0;font-size:14px">
+ <thead>
+ <tr style="border-bottom:1px solid var(--border);text-align:left">
+ <th style="padding:10px 8px;font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:var(--fg-muted);font-weight:500">Email</th>
+ <th style="padding:10px 8px;font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:var(--fg-muted);font-weight:500">Sent</th>
+ <th style="padding:10px 8px;font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:var(--fg-muted);font-weight:500">Expires</th>
+ <th style="padding:10px 8px"></th>
+ </tr>
+ </thead>
+ <tbody>
+ <% pendingInvites.forEach(function(inv){ %>
+ <tr style="border-bottom:1px solid var(--border)">
+ <td style="padding:12px 8px"><%= inv.email %></td>
+ <td style="padding:12px 8px;color:var(--fg-muted)"><%= new Date(inv.issued_at).toLocaleDateString('en-US',{ month:'short', day:'numeric' }) %></td>
+ <td style="padding:12px 8px;color:var(--fg-muted)"><%= new Date(inv.expires_at).toLocaleDateString('en-US',{ month:'short', day:'numeric' }) %></td>
+ <td style="padding:12px 8px;text-align:right">
+ <form method="post" action="/admin/team/invite/<%= inv.id %>/cancel" style="margin:0">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+ <button type="submit" class="btn btn-ghost btn-sm" style="font-size:11px;padding:4px 10px">Cancel</button>
+ </form>
+ </td>
+ </tr>
+ <% }); %>
+ </tbody>
+ </table>
+ <% } %>
+
+ <h2 style="margin-top:32px">Invite a teammate</h2>
+ <form method="post" action="/admin/team/invite" style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+ <label style="flex:1;min-width:240px">Email
+ <input type="email" name="email" required placeholder="teammate@example.com">
+ </label>
+ <button type="submit" class="btn btn-primary">Send invitation</button>
+ </form>
+ <p class="muted" style="margin-top:12px;font-size:12px">They'll get a magic link valid for 7 days. Once they pick a password, they'll see the same dashboard you see now.</p>
+</section>
+
+<%- include('../partials/footer') %>
diff --git a/views/public/accept-invite.ejs b/views/public/accept-invite.ejs
new file mode 100644
index 0000000..3aee16d
--- /dev/null
+++ b/views/public/accept-invite.ejs
@@ -0,0 +1,33 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<section class="long-form">
+ <% if (!ok) { %>
+ <p class="kicker">Invitation not valid</p>
+ <h1 class="display-sm">This invite can't be used.</h1>
+ <p class="lede"><%
+ if (reason === 'expired') { %>The 7-day window expired. Ask whoever invited you to send a new invitation.<%
+ } else if (reason === 'already_used') { %>This invitation has already been accepted — sign in below.<%
+ } else { %>The link is malformed or doesn't match a current invitation.<%
+ } %></p>
+ <p style="margin-top:24px"><a href="/admin/login" class="btn btn-primary">Sign in</a> <a href="/find" class="btn btn-ghost">Browse the directory</a></p>
+ <% } else { %>
+ <p class="kicker">Invitation · <%= invite.business_name %></p>
+ <h1 class="display-sm">Set a password to join the team.</h1>
+ <p class="lede"><strong><%= invite.inviter_name || invite.inviter_email %></strong> invited you to manage <strong><%= invite.business_name %></strong> on Ventura Claw. Once you set a password, you'll be signed in and dropped on the dashboard alongside the existing team.</p>
+
+ <% if (error) { %>
+ <p class="callout" style="background:#fee2e2;border-left:3px solid #dc2626;padding:12px 16px;margin:16px 0;border-radius:4px"><%= error %></p>
+ <% } %>
+
+ <form method="post" action="/accept-invite/<%= encodeURIComponent(token) %>" style="margin-top:24px;max-width:420px">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+ <label>Email<input type="email" value="<%= invite.email %>" disabled style="background:var(--bg-alt);color:var(--fg-muted)"></label>
+ <label>Password (10+ characters)<input type="password" name="password" required minlength="10" autocomplete="new-password"></label>
+ <label>Confirm password<input type="password" name="password_confirm" required minlength="10" autocomplete="new-password"></label>
+ <button type="submit" class="btn btn-primary btn-lg" style="margin-top:8px">Join <%= invite.business_name %> →</button>
+ </form>
+ <% } %>
+</section>
+
+<%- include('../partials/footer') %>
← f3a4936 yolo tick 10: home-page social proof + /admin/leads date-ran
·
back to Ventura Claw Leads
·
yolo tick 12: 30-day lead-trend sparkline on admin dashboard abf7235 →