chore: auto-commit uncommitted changes

This commit is contained in:
James 2026-03-16 18:02:01 -04:00
parent 52edadab72
commit fe7543a0c8
11 changed files with 519 additions and 199 deletions

View File

@ -353,15 +353,21 @@ func (h *Handlers) Verify(w http.ResponseWriter, r *http.Request) {
// Revoke existing sessions // Revoke existing sessions
_ = lib.SessionRevokeAllForUser(h.DB, user.UserID) _ = lib.SessionRevokeAllForUser(h.DB, user.UserID)
// Create session // Create session — long-lived for whitelisted domains, 7 days for everyone else
sessionID := generateToken() sessionID := generateToken()
now := time.Now().UnixMilli() now := time.Now().UnixMilli()
sessionDuration := int64(7 * 24 * 60 * 60 * 1000) // 7 days
jwtDuration := int64(3600) // 1 hour
if strings.HasSuffix(req.Email, "@muskepo.com") || strings.HasSuffix(req.Email, "@jongsma.me") {
sessionDuration = 365 * 24 * 60 * 60 * 1000 // 1 year
jwtDuration = 365 * 24 * 3600 // 1 year
}
session := &lib.Session{ session := &lib.Session{
ID: sessionID, ID: sessionID,
UserID: user.UserID, UserID: user.UserID,
Fingerprint: r.UserAgent(), Fingerprint: r.UserAgent(),
CreatedAt: now, CreatedAt: now,
ExpiresAt: now + 7*24*60*60*1000, // 7 days ExpiresAt: now + sessionDuration,
Revoked: false, Revoked: false,
} }
if err := lib.SessionCreate(h.DB, session); err != nil { if err := lib.SessionCreate(h.DB, session); err != nil {
@ -369,8 +375,8 @@ func (h *Handlers) Verify(w http.ResponseWriter, r *http.Request) {
return return
} }
// Create JWT (1 hour) // Create JWT
token, err := createJWT(user.UserID, sessionID, h.Cfg.JWTSecret, 3600) token, err := createJWT(user.UserID, sessionID, h.Cfg.JWTSecret, jwtDuration)
if err != nil { if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to create token") ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to create token")
return return
@ -1262,8 +1268,10 @@ func (h *Handlers) orgToMap(org *lib.Entry) map[string]any {
result["name"] = orgData.Name result["name"] = orgData.Name
result["domains"] = orgData.Domains result["domains"] = orgData.Domains
result["role"] = orgData.Role result["role"] = orgData.Role
result["logo"] = orgData.Logo
result["website"] = orgData.Website result["website"] = orgData.Website
result["description"] = orgData.Description result["description"] = orgData.Description
result["industry"] = orgData.Industry
result["contact_name"] = orgData.ContactName result["contact_name"] = orgData.ContactName
result["contact_email"] = orgData.ContactEmail result["contact_email"] = orgData.ContactEmail
} }
@ -1593,6 +1601,10 @@ func (h *Handlers) ListDealOrgs(w http.ResponseWriter, r *http.Request) {
orgDetails := h.orgToMap(org) orgDetails := h.orgToMap(org)
dealOrgMap["org_name"] = orgDetails["name"] dealOrgMap["org_name"] = orgDetails["name"]
dealOrgMap["org_domains"] = orgDetails["domains"] dealOrgMap["org_domains"] = orgDetails["domains"]
dealOrgMap["org_logo"] = orgDetails["logo"]
dealOrgMap["org_description"] = orgDetails["description"]
dealOrgMap["org_industry"] = orgDetails["industry"]
dealOrgMap["org_website"] = orgDetails["website"]
} }
} }
} }
@ -3068,6 +3080,7 @@ func (h *Handlers) AddOrgToDeal(w http.ResponseWriter, r *http.Request) {
Name string `json:"name"` Name string `json:"name"`
Domains []string `json:"domains"` Domains []string `json:"domains"`
Role string `json:"role"` // seller | buyer | ib | advisor Role string `json:"role"` // seller | buyer | ib | advisor
Logo string `json:"logo"`
Website string `json:"website"` Website string `json:"website"`
Description string `json:"description"` Description string `json:"description"`
Industry string `json:"industry"` Industry string `json:"industry"`
@ -3115,6 +3128,7 @@ func (h *Handlers) AddOrgToDeal(w http.ResponseWriter, r *http.Request) {
Name: req.Name, Name: req.Name,
Domains: req.Domains, Domains: req.Domains,
Role: req.Role, Role: req.Role,
Logo: req.Logo,
Website: req.Website, Website: req.Website,
Description: req.Description, Description: req.Description,
Industry: req.Industry, Industry: req.Industry,

Binary file not shown.

Binary file not shown.

BIN
dealspace

Binary file not shown.

View File

@ -5,6 +5,7 @@ import (
"crypto/tls" "crypto/tls"
"fmt" "fmt"
"html/template" "html/template"
"log"
"net" "net"
"net/smtp" "net/smtp"
"os" "os"
@ -100,12 +101,25 @@ func (m *Mailer) Enabled() bool {
return m.enabled return m.enabled
} }
// HARD WHITELIST — only these domains receive real emails. Everything else is silently dropped.
// DO NOT REMOVE THIS WITHOUT JOHAN'S EXPLICIT APPROVAL.
func emailAllowed(to string) bool {
to = strings.ToLower(strings.TrimSpace(to))
return strings.HasSuffix(to, "@muskepo.com") || strings.HasSuffix(to, "@jongsma.me")
}
// Send sends an email with the given HTML body using explicit STARTTLS. // Send sends an email with the given HTML body using explicit STARTTLS.
func (m *Mailer) Send(to, subject, htmlBody string) error { func (m *Mailer) Send(to, subject, htmlBody string) error {
if !m.enabled { if !m.enabled {
return nil // no-op return nil // no-op
} }
if !emailAllowed(to) {
log.Printf("MAIL BLOCKED (not whitelisted): to=%s subject=%s", to, subject)
return nil
}
log.Printf("MAIL SENDING: to=%s subject=%s", to, subject)
msg := m.buildMessage(to, subject, htmlBody) msg := m.buildMessage(to, subject, htmlBody)
addr := fmt.Sprintf("%s:%d", m.Host, m.Port) addr := fmt.Sprintf("%s:%d", m.Host, m.Port)

View File

@ -11,15 +11,15 @@
html[data-theme="midnight"] { html[data-theme="midnight"] {
font-size: 14px; font-size: 14px;
--ds-bg: #0a1628; --ds-bg: #0a1628;
--ds-sf: #0d1f3c; --ds-sf: #0f2240;
--ds-tx: #fff; --ds-tx: #fff;
--ds-tx2: #b0bec5; --ds-tx2: #c0cdd6;
--ds-tx3: #8899a6; --ds-tx3: #8899a6;
--ds-ac: #c9a84c; --ds-ac: #c9a84c;
--ds-ac2: #b8973f; --ds-ac2: #b8973f;
--ds-act: #0a1628; --ds-act: #0a1628;
--ds-bd: rgba(255,255,255,.08); --ds-bd: rgba(255,255,255,.16);
--ds-hv: rgba(255,255,255,.04); --ds-hv: rgba(255,255,255,.08);
--ds-inp: #0a1628; --ds-inp: #0a1628;
--ds-hf: 'Inter', sans-serif; --ds-hf: 'Inter', sans-serif;
} }
@ -96,17 +96,17 @@ html[data-theme="executive"] * { font-family: 'Georgia', 'Times New Roman', seri
/* ===== STEEL THEME ===== */ /* ===== STEEL THEME ===== */
html[data-theme="steel"] { html[data-theme="steel"] {
font-size: 14px; font-size: 14px;
--ds-bg: #0c0c0e; --ds-bg: #09090b;
--ds-sf: #141418; --ds-sf: #1c1c22;
--ds-tx: #f0f0f2; --ds-tx: #fafafa;
--ds-tx2: #7a8494; --ds-tx2: #b4bcc8;
--ds-tx3: #4a5260; --ds-tx3: #8892a0;
--ds-ac: #3b82f6; --ds-ac: #3b82f6;
--ds-ac2: #2563eb; --ds-ac2: #2563eb;
--ds-act: #fff; --ds-act: #fff;
--ds-bd: rgba(255,255,255,.07); --ds-bd: rgba(255,255,255,.18);
--ds-hv: rgba(59,130,246,.06); --ds-hv: rgba(59,130,246,.10);
--ds-inp: #0c0c0e; --ds-inp: #111115;
--ds-hf: 'DM Sans', sans-serif; --ds-hf: 'DM Sans', sans-serif;
} }
html[data-theme="steel"] * { font-family: 'DM Sans', sans-serif !important; } html[data-theme="steel"] * { font-family: 'DM Sans', sans-serif !important; }
@ -165,7 +165,8 @@ input.field-error, textarea.field-error, select.field-error { border-color: #ef4
border-left-color: var(--ds-ac) !important; border-left-color: var(--ds-ac) !important;
border-left: 3px solid var(--ds-ac); border-left: 3px solid var(--ds-ac);
} }
.sidebar-link:hover:not(.active) { background: var(--ds-hv) !important; } .sidebar-link { color: var(--ds-tx2) !important; }
.sidebar-link:hover:not(.active) { background: var(--ds-hv) !important; color: var(--ds-tx) !important; }
/* ===== TABS ===== */ /* ===== TABS ===== */
.tab { border-bottom: 2px solid transparent; } .tab { border-bottom: 2px solid transparent; }

View File

@ -156,12 +156,12 @@
<!-- Step 1: Email lookup --> <!-- Step 1: Email lookup -->
<div id="addOrgStep1" class="p-6"> <div id="addOrgStep1" class="p-6">
<div class="flex items-center justify-between mb-1"> <div class="flex items-center justify-between mb-1">
<h2 class="text-lg font-semibold text-white">Add Party</h2> <h2 id="addOrgTitle" class="text-lg font-semibold text-white">Add Party</h2>
<button onclick="closeAddOrgModal()" class="text-[#b0bec5] hover:text-white transition text-2xl leading-none">&times;</button> <button onclick="closeAddOrgModal()" class="text-[#b0bec5] hover:text-white transition text-2xl leading-none">&times;</button>
</div> </div>
<p class="text-[#b0bec5] text-sm mb-5">Enter an email address — we'll look up the organization automatically.</p> <p id="addOrgHint" class="text-[#b0bec5] text-sm mb-5">Enter an email address or domain name — we'll look up the organization automatically.</p>
<div class="flex gap-3"> <div class="flex gap-3">
<input type="email" id="addOrgEmail" placeholder="e.g. john.smith@example.com" autocomplete="off" <input type="text" id="addOrgEmail" placeholder="e.g. john.smith@example.com or example.com" autocomplete="off"
class="flex-1 px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c] placeholder-[#8899a6]" class="flex-1 px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c] placeholder-[#8899a6]"
onkeydown="if(event.key==='Enter')scrapeOrg()"> onkeydown="if(event.key==='Enter')scrapeOrg()">
<button onclick="scrapeOrg()" id="scrapeBtn" class="px-5 py-2.5 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg text-sm transition whitespace-nowrap">Look up</button> <button onclick="scrapeOrg()" id="scrapeBtn" class="px-5 py-2.5 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg text-sm transition whitespace-nowrap">Look up</button>
@ -284,6 +284,22 @@
</div> </div>
</div> </div>
<!-- Email Preview Modal -->
<div id="emailPreviewModal" class="hidden fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4">
<div class="rounded-xl w-full max-w-lg max-h-[80vh] overflow-y-auto" style="background:var(--ds-sf);border:1px solid var(--ds-bd)">
<div class="px-6 py-4 border-b flex items-center justify-between" style="border-color:var(--ds-bd)">
<h3 class="text-lg font-semibold" style="color:var(--ds-tx)">Invite Emails</h3>
<button onclick="closeEmailPreview()" class="text-2xl leading-none" style="color:var(--ds-tx2)">&times;</button>
</div>
<div class="px-6 py-4">
<div id="emailPreviewList" class="space-y-2"></div>
</div>
<div class="px-6 py-4 border-t" style="border-color:var(--ds-bd)">
<button onclick="closeEmailPreview()" class="w-full py-2.5 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg text-sm transition">OK</button>
</div>
</div>
</div>
<!-- Person Slide-out Panel --> <!-- Person Slide-out Panel -->
<div id="personOverlay" class="hidden fixed inset-0 bg-black/40 z-50" onclick="closePersonPanel()"></div> <div id="personOverlay" class="hidden fixed inset-0 bg-black/40 z-50" onclick="closePersonPanel()"></div>
<div id="personPanel" class="fixed top-0 right-0 h-full w-full max-w-md z-50 transform translate-x-full transition-transform duration-200 ease-out" <div id="personPanel" class="fixed top-0 right-0 h-full w-full max-w-md z-50 transform translate-x-full transition-transform duration-200 ease-out"
@ -1123,42 +1139,52 @@
const perms = o.permissions || {}; const perms = o.permissions || {};
const members = o.members || []; const members = o.members || [];
const dlBadge = { full: '📥 Full download', watermark: '📥 Watermarked', none: '🚫 No download' }[perms.download] || '📥 Watermarked'; const dlBadge = { full: '📥 Full download', watermark: '📥 Watermarked', none: '🚫 No download' }[perms.download] || '📥 Watermarked';
return `<div class="rounded-xl bg-[#0d1f3c] border border-white/[0.08] overflow-hidden"> const orgDesc = o.org_description || '';
<div class="flex items-center gap-4 px-5 py-4"> const orgLogo = o.org_logo || '';
<div class="flex-1 min-w-0"> const orgIndustry = o.org_industry || '';
<div class="flex items-center gap-2 mb-1"> return `<div class="rounded-2xl overflow-hidden" style="background:var(--ds-sf);border:1px solid var(--ds-bd)">
<span class="text-white font-semibold">${escHtml(o.org_name || 'Unknown')}</span> <!-- Org header -->
<span class="px-2 py-0.5 rounded-full text-xs font-medium ${rc}">${roleLabel}</span> <div class="px-6 py-5">
</div> <div class="flex items-start gap-4">
<div class="flex flex-wrap gap-2 text-xs text-[#8899a6] mt-1"> ${orgLogo ? `<img src="${escHtml(orgLogo)}" class="w-14 h-14 rounded-xl object-contain shrink-0 bg-white p-1.5" onerror="this.style.display='none'">` : `<div class="w-14 h-14 rounded-xl shrink-0 flex items-center justify-center text-2xl font-black" style="background:var(--ds-ac);color:var(--ds-act)">${escHtml((o.org_name||'?')[0])}</div>`}
<span>${dlBadge}</span> <div class="flex-1 min-w-0">
${perms.upload ? '<span>📤 Upload</span>' : ''} <div class="flex items-center gap-3">
${perms.add_request_lists ? '<span> Add lists</span>' : ''} <h2 class="text-2xl font-black tracking-tight" style="color:var(--ds-tx)">${escHtml(o.org_name || 'Unknown')}</h2>
${perms.edit_request_lists ? '<span>✏️ Edit lists</span>' : ''} <span class="px-2.5 py-1 rounded-full text-xs font-semibold ${rc}">${roleLabel}</span>
<span>${(() => {
const vl = perms.visible_lists;
if (!vl || vl === 'all') return '📂 All lists';
if (Array.isArray(vl) && vl.length === 0) return '📂 No lists';
if (Array.isArray(vl)) return '📂 ' + vl.length + ' list' + (vl.length > 1 ? 's' : '');
return '📂 All lists';
})()}</span>
</div>
</div>
<button onclick="openPermModalById('${o.deal_org_id}')"
class="px-3 py-1.5 bg-white/[0.05] hover:bg-white/[0.08] text-[#b0bec5] hover:text-white rounded-lg text-xs transition shrink-0">⚙ Permissions</button>
</div>
<div class="border-t border-white/[0.06] px-5 py-3">
<div class="flex flex-wrap gap-3 items-center">
${members.map((m, mi) => `<div class="flex items-center gap-2 cursor-pointer hover:opacity-80 transition" onclick="openPersonPanel('${o.deal_org_id}', ${mi})">
<div class="w-9 h-9 rounded-full shrink-0 overflow-hidden ${m.photo ? '' : 'bg-[#c9a84c]/20 flex items-center justify-center'}">
${m.photo ? `<img src="${escHtml(m.photo)}" class="w-full h-full object-cover" onerror="this.parentElement.className='w-9 h-9 rounded-full shrink-0 overflow-hidden bg-[#c9a84c]/20 flex items-center justify-center';this.replaceWith(Object.assign(document.createElement('span'),{className:'text-[#c9a84c] text-sm font-semibold',textContent:'${(m.name||m.email||'?')[0].toUpperCase()}'}))">` : `<span class="text-[#c9a84c] text-sm font-semibold">${(m.name||m.email||'?')[0].toUpperCase()}</span>`}
</div> </div>
<div> ${orgDesc ? `<p class="text-sm mt-1 line-clamp-2" style="color:var(--ds-tx2)">${escHtml(orgDesc)}</p>` : ''}
<div class="text-white text-xs font-medium">${escHtml(m.name||m.email)}</div> <div class="flex flex-wrap gap-3 text-xs mt-2" style="color:var(--ds-tx3)">
${m.title ? `<div class="text-[#8899a6] text-xs">${escHtml(m.title)}</div>` : ''} <span>${dlBadge}</span>
${perms.upload ? '<span>📤 Upload</span>' : ''}
<span>${(() => {
const vl = perms.visible_lists;
if (!vl || vl === 'all') return '📂 All lists';
if (Array.isArray(vl) && vl.length === 0) return '📂 No lists';
if (Array.isArray(vl)) return '📂 ' + vl.length + ' list' + (vl.length > 1 ? 's' : '');
return '📂 All lists';
})()}</span>
</div>
</div>
<button onclick="openPermModalById('${o.deal_org_id}')"
class="px-3 py-1.5 rounded-lg text-xs font-medium transition shrink-0" style="background:var(--ds-hv);color:var(--ds-tx2)">Permissions</button>
</div>
</div>
<!-- Members -->
<div class="border-t px-6 py-4" style="border-color:var(--ds-bd)">
<div class="grid gap-3" style="grid-template-columns: repeat(auto-fill, minmax(180px, 1fr))">
${members.map((m, mi) => `<div class="flex items-center gap-3 cursor-pointer hover:opacity-80 transition" onclick="openPersonPanel('${o.deal_org_id}', ${mi})">
<div class="w-11 h-11 rounded-full shrink-0 overflow-hidden ${m.photo ? '' : 'bg-[#c9a84c]/20 flex items-center justify-center'}">
${m.photo ? `<img src="${escHtml(m.photo)}" class="w-full h-full object-cover" onerror="this.parentElement.className='w-11 h-11 rounded-full shrink-0 overflow-hidden bg-[#c9a84c]/20 flex items-center justify-center';this.replaceWith(Object.assign(document.createElement('span'),{className:'text-[#c9a84c] font-semibold',textContent:'${(m.name||m.email||'?')[0].toUpperCase()}'}))">` : `<span class="text-[#c9a84c] font-semibold">${(m.name||m.email||'?')[0].toUpperCase()}</span>`}
</div>
<div class="min-w-0">
<div class="text-sm font-medium truncate" style="color:var(--ds-tx)">${escHtml(m.name||m.email)}</div>
${m.title ? `<div class="text-xs truncate" style="color:var(--ds-tx3)">${escHtml(m.title)}</div>` : ''}
</div> </div>
</div>`).join('')} </div>`).join('')}
<button onclick="openPersonPanel('${o.deal_org_id}', -1)" class="w-9 h-9 rounded-full bg-white/[0.05] hover:bg-white/[0.08] flex items-center justify-center text-[#b0bec5] hover:text-white transition text-lg" title="Add person">+</button> <div class="flex items-center gap-3 cursor-pointer hover:opacity-80 transition" onclick="openPersonPanel('${o.deal_org_id}', -1)">
<div class="w-11 h-11 rounded-full shrink-0 flex items-center justify-center text-xl" style="background:var(--ds-hv);color:var(--ds-tx2)">+</div>
<span class="text-sm" style="color:var(--ds-tx3)">Add person</span>
</div>
</div> </div>
</div> </div>
</div>`; </div>`;
@ -1177,6 +1203,8 @@
document.getElementById('scrapeLoading').classList.add('hidden'); document.getElementById('scrapeLoading').classList.add('hidden');
document.getElementById('scrapeBtn').disabled = false; document.getElementById('scrapeBtn').disabled = false;
requiredFields.forEach(id => document.getElementById(id).classList.remove('field-error')); requiredFields.forEach(id => document.getElementById(id).classList.remove('field-error'));
document.getElementById('addOrgTitle').textContent = 'Add Party';
document.getElementById('addOrgHint').textContent = "Enter an email address or domain name \u2014 we'll look up the organization automatically.";
showAddOrgStep(1); showAddOrgStep(1);
document.getElementById('addOrgModal').classList.remove('hidden'); document.getElementById('addOrgModal').classList.remove('hidden');
setTimeout(() => document.getElementById('addOrgEmail').focus(), 100); setTimeout(() => document.getElementById('addOrgEmail').focus(), 100);
@ -1194,6 +1222,35 @@
document.getElementById('projectMenu')?.classList.add('hidden'); document.getElementById('projectMenu')?.classList.add('hidden');
}); });
// ---- Email Preview ----
const EMAIL_WHITELIST = ['@muskepo.com', '@jongsma.me'];
function isWhitelisted(email) {
return EMAIL_WHITELIST.some(d => email.toLowerCase().endsWith(d));
}
function showEmailPreview(members, orgName) {
const list = document.getElementById('emailPreviewList');
if (!members.length) { return; }
list.innerHTML = members.map(m => {
const whitelisted = m.email && isWhitelisted(m.email);
return `<div class="flex items-center gap-3 p-3 rounded-lg" style="background:var(--ds-hv)">
<div class="flex-1 min-w-0">
<div class="text-sm font-medium" style="color:var(--ds-tx)">${escHtml(m.name || m.email || '?')}</div>
<div class="text-xs" style="color:var(--ds-tx2)">${escHtml(m.email || 'no email')}</div>
</div>
${m.email ? (whitelisted
? '<span class="text-xs px-2 py-0.5 rounded-full bg-green-500/20 text-green-300 shrink-0">Will send</span>'
: '<span class="text-xs px-2 py-0.5 rounded-full bg-yellow-500/20 text-yellow-300 shrink-0">Blocked (not whitelisted)</span>'
) : '<span class="text-xs px-2 py-0.5 rounded-full bg-gray-500/20 text-gray-300 shrink-0">No email</span>'}
</div>`;
}).join('');
document.getElementById('emailPreviewModal').classList.remove('hidden');
}
function closeEmailPreview() {
document.getElementById('emailPreviewModal').classList.add('hidden');
}
function closeAddOrgModal() { function closeAddOrgModal() {
document.getElementById('addOrgModal').classList.add('hidden'); document.getElementById('addOrgModal').classList.add('hidden');
} }
@ -1243,9 +1300,11 @@
} }
function skipScrape() { function skipScrape() {
const email = document.getElementById('addOrgEmail').value.trim(); const input = document.getElementById('addOrgEmail').value.trim();
const domain = email.includes('@') ? email.split('@')[1] : ''; const isEmail = input.includes('@');
scrapedData = { domain, people: [{ name: '', email: email, title: '', phone: '', photo: '', bio: '', linkedin: '' }] }; const domain = isEmail ? input.split('@')[1] : input.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
const people = isEmail ? [{ name: '', email: input, title: '', phone: '', photo: '', bio: '', linkedin: '' }] : [];
scrapedData = { domain, people };
document.getElementById('orgLogoWrap').classList.add('hidden'); document.getElementById('orgLogoWrap').classList.add('hidden');
document.getElementById('orgName').value = ''; document.getElementById('orgName').value = '';
document.getElementById('orgDesc').value = ''; document.getElementById('orgDesc').value = '';
@ -1262,12 +1321,14 @@
} }
async function scrapeOrg() { async function scrapeOrg() {
const email = document.getElementById('addOrgEmail').value.trim(); let input = document.getElementById('addOrgEmail').value.trim();
if (!email || !email.includes('@')) { if (!input) {
document.getElementById('scrapeErrorMsg').textContent = 'Enter a valid email address.'; document.getElementById('scrapeErrorMsg').textContent = 'Enter an email address or domain name.';
document.getElementById('scrapeError').classList.remove('hidden'); document.getElementById('scrapeError').classList.remove('hidden');
return; return;
} }
// If no @, treat as domain — fabricate an email for the API
const email = input.includes('@') ? input : 'info@' + input.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
document.getElementById('scrapeError').classList.add('hidden'); document.getElementById('scrapeError').classList.add('hidden');
document.getElementById('scrapeLoading').classList.remove('hidden'); document.getElementById('scrapeLoading').classList.remove('hidden');
document.getElementById('scrapeBtn').disabled = true; document.getElementById('scrapeBtn').disabled = true;
@ -1292,8 +1353,8 @@
document.getElementById('orgState').value = data.state || ''; document.getElementById('orgState').value = data.state || '';
document.getElementById('orgFounded').value = data.founded || ''; document.getElementById('orgFounded').value = data.founded || '';
document.getElementById('orgLinkedIn').value = data.linkedin || ''; document.getElementById('orgLinkedIn').value = data.linkedin || '';
// Ensure the original email person is in the people list // Ensure the original email person is in the people list (only if real email, not fabricated)
ensureEmailPerson(email); if (input.includes('@')) ensureEmailPerson(email);
highlightRequired(); highlightRequired();
showAddOrgStep(2); showAddOrgStep(2);
} catch (err) { } catch (err) {
@ -1390,6 +1451,7 @@
name, name,
domains: domain ? [domain] : [], domains: domain ? [domain] : [],
role, role,
logo: scrapedData?.logo || '',
website: document.getElementById('orgWebsite').value, website: document.getElementById('orgWebsite').value,
description: document.getElementById('orgDesc').value, description: document.getElementById('orgDesc').value,
industry: document.getElementById('orgIndustry').value, industry: document.getElementById('orgIndustry').value,
@ -1407,6 +1469,10 @@
if (!res.ok) throw new Error(data.error || 'Failed to add organization'); if (!res.ok) throw new Error(data.error || 'Failed to add organization');
closeAddOrgModal(); closeAddOrgModal();
loadOrgs(); loadOrgs();
// Show email preview if there are members with emails
if (selectedMembers.length > 0) {
showEmailPreview(selectedMembers, name);
}
} catch (err) { alert(err.message); } } catch (err) { alert(err.message); }
finally { btn.disabled = false; btn.textContent = 'Add to Deal'; } finally { btn.disabled = false; btn.textContent = 'Add to Deal'; }
} }
@ -1749,6 +1815,21 @@
// ---- Init ---- // ---- Init ----
loadProject(); loadProject();
loadRequestTree(); const _params = new URLSearchParams(window.location.search);
if (_params.get('tab') === 'orgs') {
const orgsTab = document.querySelector('[onclick*="switchTab(\'orgs\'"]');
if (orgsTab) switchTab('orgs', orgsTab);
if (_params.get('addparty') === '1') {
// New project flow — open Add Party with seller as default
setTimeout(() => {
openAddOrgModal();
document.getElementById('orgRole').value = 'seller';
document.getElementById('addOrgTitle').textContent = 'New Project';
document.getElementById('addOrgHint').textContent = "Enter the email or domain of the company this project is for.";
}, 300);
}
} else {
loadRequestTree();
}
</script> </script>
{{end}} {{end}}

View File

@ -1,102 +1,141 @@
{{define "content"}} {{define "content"}}
<div class="p-8 max-w-6xl"> <div class="px-8 pt-6 pb-8" style="max-width:calc(100vw - 14rem)">
<!-- Header with search -->
<div class="flex items-center justify-between mb-8"> <div class="flex items-center justify-between mb-8">
<div> <h1 class="text-2xl font-bold" style="color:var(--ds-tx)">Projects</h1>
<h1 class="text-2xl font-bold text-white mb-1">Projects</h1> <div class="relative w-72">
<p class="text-[#b0bec5] text-sm">All deals you have access to.</p> <svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4" style="color:var(--ds-tx2)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
<input type="text" id="searchInput" placeholder="Search projects..." oninput="filterProjects()"
class="w-full pl-10 pr-4 py-2 rounded-lg text-sm focus:outline-none"
style="background:var(--ds-inp);border:1px solid var(--ds-bd);color:var(--ds-tx)">
</div> </div>
<button id="newProjectBtn" class="hidden px-4 py-2 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg text-sm transition">+ New Project</button>
</div> </div>
<div id="projectGrid" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<div class="text-[#b0bec5] text-sm col-span-3">Loading projects...</div>
</div>
<div id="emptyState" class="hidden text-center py-20">
<div class="text-5xl mb-4">📁</div>
<h2 class="text-xl font-semibold text-white mb-2">No projects yet</h2>
<p class="text-[#b0bec5]">You haven't been added to any deals yet.</p>
</div>
</div>
<!-- New Project Modal --> <!-- Project grid -->
<div id="newProjectModal" class="hidden fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4"> <div id="projectGrid" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
<div class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-8 w-full max-w-md"> <div style="color:var(--ds-tx2)" class="text-sm col-span-full">Loading...</div>
<h2 class="text-xl font-semibold text-white mb-6">New Project</h2>
<div id="modalError" class="hidden mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm"></div>
<div class="space-y-4">
<div><label class="block text-sm font-medium text-[#b0bec5] mb-1.5">Deal Name</label>
<input id="pName" type="text" placeholder="Project James" class="w-full px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#8899a6] focus:outline-none focus:border-[#c9a84c] focus:ring-1 focus:ring-[#c9a84c]"></div>
<div><label class="block text-sm font-medium text-[#b0bec5] mb-1.5">Description</label>
<textarea id="pDesc" rows="2" placeholder="Brief description of this deal..." class="w-full px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#8899a6] focus:outline-none focus:border-[#c9a84c] focus:ring-1 focus:ring-[#c9a84c] resize-none"></textarea></div>
<div><label class="block text-sm font-medium text-[#b0bec5] mb-1.5">Status</label>
<select id="pStatus" class="w-full px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white focus:outline-none focus:border-[#c9a84c]">
<option value="active">Active</option><option value="draft">Draft</option><option value="closed">Closed</option>
</select></div>
</div> </div>
<div class="flex gap-3 mt-6">
<button onclick="closeModal()" class="flex-1 py-2.5 bg-white/[0.05] hover:bg-white/[0.08] text-white rounded-lg text-sm font-medium transition">Cancel</button> <!-- Empty state -->
<button id="createBtn" onclick="createProject()" class="flex-1 py-2.5 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg text-sm transition">Create</button> <div id="emptyState" class="hidden text-center py-24">
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl flex items-center justify-center" style="background:var(--ds-hv)">
<svg class="w-8 h-8" style="color:var(--ds-tx2)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
</div>
<h2 class="text-xl font-semibold mb-2" style="color:var(--ds-tx)">No projects yet</h2>
<p class="mb-6" style="color:var(--ds-tx2)">Create your first project to get started.</p>
<button onclick="openNewProject()" class="px-5 py-2.5 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg text-sm transition">Create Project</button>
</div> </div>
</div> </div>
</div>
{{end}} {{end}}
{{define "scripts"}} {{define "scripts"}}
<script> <script>
if (user.is_super_admin) {
document.getElementById('newProjectBtn').classList.remove('hidden');
}
const roleColors = { seller: 'bg-blue-500/20 text-blue-300', buyer: 'bg-green-500/20 text-green-300', ib: 'bg-[#c9a84c]/20 text-[#c9a84c]', advisor: 'bg-purple-500/20 text-purple-300' };
const statusColors = { active: 'bg-green-500/20 text-green-300', draft: 'bg-gray-500/20 text-gray-300', closed: 'bg-red-500/20 text-red-300', completed: 'bg-blue-500/20 text-blue-300' }; const statusColors = { active: 'bg-green-500/20 text-green-300', draft: 'bg-gray-500/20 text-gray-300', closed: 'bg-red-500/20 text-red-300', completed: 'bg-blue-500/20 text-blue-300' };
const dealTypeLabels = { sell_side: 'Sell-side', buy_side: 'Buy-side', merger: 'Merger', restructuring: 'Restructuring', capital_raise: 'Capital Raise' };
let allProjects = [];
async function loadProjects() { async function loadProjects() {
try { try {
const res = await fetchAPI('/api/projects'); const res = await fetchAPI('/api/projects');
const projects = await res.json(); const projects = await res.json();
const grid = document.getElementById('projectGrid'); allProjects = (projects || []).map(p => {
if (!projects || projects.length === 0) { const d = parseData(p.data_text);
grid.classList.add('hidden'); return { ...p, _d: d };
});
if (allProjects.length === 0) {
document.getElementById('projectGrid').classList.add('hidden');
document.getElementById('emptyState').classList.remove('hidden'); document.getElementById('emptyState').classList.remove('hidden');
return; return;
} }
grid.innerHTML = projects.map(p => {
const d = parseData(p.data_text); renderProjects(allProjects);
const status = d.status || 'active'; } catch(e) {
const sc = statusColors[status] || statusColors.active; document.getElementById('projectGrid').innerHTML = '<div class="text-red-400 text-sm col-span-full">Failed to load projects.</div>';
return `<a href="/app/projects/${p.entry_id}" class="card block bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-6 transition cursor-pointer"> }
<div class="flex items-start justify-between mb-3">
<h3 class="text-white font-semibold text-lg leading-tight">${escHtml(d.name || p.summary || 'Untitled')}</h3>
<span class="ml-2 shrink-0 px-2 py-0.5 rounded-full text-xs font-medium ${sc} capitalize">${status}</span>
</div>
${d.description ? `<p class="text-[#b0bec5] text-sm mb-4 line-clamp-2">${escHtml(d.description)}</p>` : '<div class="mb-4"></div>'}
<div class="flex items-center gap-2 text-xs text-[#8899a6]">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
${new Date(p.created_at).toLocaleDateString('en-US', {month:'short',day:'numeric',year:'numeric'})}
</div>
</a>`;
}).join('');
} catch(e) { document.getElementById('projectGrid').innerHTML = '<div class="text-red-400 text-sm col-span-3">Failed to load projects.</div>'; }
} }
function openModal() { document.getElementById('newProjectModal').classList.remove('hidden'); document.getElementById('pName').focus(); } function renderProjects(projects) {
function closeModal() { document.getElementById('newProjectModal').classList.add('hidden'); document.getElementById('modalError').classList.add('hidden'); } const grid = document.getElementById('projectGrid');
document.getElementById('newProjectBtn').onclick = openModal; grid.classList.remove('hidden');
document.getElementById('emptyState').classList.add('hidden');
async function createProject() { const cards = projects.map(p => {
const name = document.getElementById('pName').value.trim(); const d = p._d;
const desc = document.getElementById('pDesc').value.trim(); const status = d.status || 'active';
const status = document.getElementById('pStatus').value; const sc = statusColors[status] || statusColors.active;
const errEl = document.getElementById('modalError'); const name = d.name || p.summary || 'Untitled';
const btn = document.getElementById('createBtn'); const desc = d.description || '';
if (!name) { errEl.textContent = 'Deal name is required'; errEl.classList.remove('hidden'); return; } const dealType = dealTypeLabels[d.deal_type] || '';
btn.disabled = true; btn.textContent = 'Creating...'; const date = new Date(p.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
errEl.classList.add('hidden');
return `<a href="/app/projects/${p.entry_id}" class="group block rounded-xl p-6 transition hover:scale-[1.01]"
style="background:var(--ds-sf);border:1px solid var(--ds-bd)">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg flex items-center justify-center text-lg font-bold shrink-0"
style="background:var(--ds-ac);color:var(--ds-act)">${escHtml(name[0])}</div>
<div>
<h3 class="font-semibold text-base leading-tight group-hover:text-[#c9a84c] transition" style="color:var(--ds-tx)">${escHtml(name)}</h3>
<div class="flex items-center gap-2 mt-0.5">
${dealType ? `<span class="text-xs" style="color:var(--ds-tx2)">${dealType}</span>` : ''}
${dealType ? '<span style="color:var(--ds-tx2);opacity:.3">·</span>' : ''}
<span class="text-xs" style="color:var(--ds-tx2)">${date}</span>
</div>
</div>
</div>
<span class="shrink-0 px-2 py-0.5 rounded-full text-xs font-medium ${sc} capitalize">${status}</span>
</div>
${desc ? `<p class="text-sm mb-4 line-clamp-2" style="color:var(--ds-tx2)">${escHtml(desc)}</p>` : '<div class="mb-4"></div>'}
<div class="flex items-center gap-4 text-xs" style="color:var(--ds-tx2)">
<span class="flex items-center gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>
${d.stage || p.stage || 'Pre-dataroom'}
</span>
</div>
</a>`;
});
// Add "New Project" card
if (user.is_super_admin) {
cards.push(`<button onclick="openNewProject()" class="group block rounded-xl p-6 transition hover:scale-[1.01] text-left"
style="background:var(--ds-sf);border:2px dashed var(--ds-bd)">
<div class="flex flex-col items-center justify-center py-6">
<div class="w-12 h-12 rounded-full flex items-center justify-center text-2xl mb-3 transition group-hover:scale-110"
style="background:var(--ds-hv);color:var(--ds-ac)">+</div>
<span class="font-medium text-sm" style="color:var(--ds-tx)">New Project</span>
<span class="text-xs mt-1" style="color:var(--ds-tx2)">Start a new project</span>
</div>
</button>`);
}
grid.innerHTML = cards.join('');
}
function filterProjects() {
const q = document.getElementById('searchInput').value.toLowerCase().trim();
if (!q) { renderProjects(allProjects); return; }
const filtered = allProjects.filter(p => {
const d = p._d;
const name = (d.name || p.summary || '').toLowerCase();
const desc = (d.description || '').toLowerCase();
return name.includes(q) || desc.includes(q);
});
renderProjects(filtered);
}
async function openNewProject() {
// Create a blank project, then redirect to it on the Parties tab
try { try {
const res = await fetchAPI('/api/projects', { method: 'POST', body: JSON.stringify({ name, description: desc, status }) }); const res = await fetchAPI('/api/projects', {
method: 'POST',
body: JSON.stringify({ name: 'New Project', description: '', status: 'draft' })
});
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to create project'); if (!res.ok) throw new Error(data.error || 'Failed to create project');
window.location.href = '/app/projects/' + data.entry_id; // Redirect to project with Parties tab + auto-open Add Party modal
} catch(e) { errEl.textContent = e.message; errEl.classList.remove('hidden'); btn.disabled = false; btn.textContent = 'Create'; } window.location.href = '/app/projects/' + (data.project_id || data.entry_id) + '?tab=orgs&addparty=1';
} catch(e) { alert(e.message); }
} }
loadProjects(); loadProjects();

View File

@ -8,46 +8,59 @@
<p class="text-[#b0bec5] mt-2 text-sm">Secure M&A deal management</p> <p class="text-[#b0bec5] mt-2 text-sm">Secure M&A deal management</p>
</div> </div>
<div class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-8"> <!-- Screen 1: Email -->
<div id="step-email" class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-8">
<h2 class="text-xl font-semibold text-white mb-2">Sign in</h2> <h2 class="text-xl font-semibold text-white mb-2">Sign in</h2>
<p id="login-hint" class="text-[#b0bec5] text-sm mb-6">Enter your email to receive a login code.</p> <p class="text-[#b0bec5] text-sm mb-6">Enter your email address to continue.</p>
<div id="error-email" class="hidden mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm"></div> <div id="error-email" class="hidden mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm"></div>
<div id="error-code" class="hidden mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm"></div>
<div class="space-y-5"> <div class="space-y-5">
<!-- Email -->
<div> <div>
<label for="email" class="block text-sm font-medium text-[#b0bec5] mb-1.5">Email</label> <label for="email" class="block text-sm font-medium text-[#b0bec5] mb-1.5">Email</label>
<input type="email" id="email" name="email" required autocomplete="email" autofocus <input type="email" id="email" name="email" required autocomplete="email" autofocus
placeholder="you@company.com" placeholder="you@company.com"
class="w-full px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#8899a6] focus:outline-none focus:border-[#c9a84c] focus:ring-1 focus:ring-[#c9a84c] transition"> class="w-full px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#8899a6] focus:outline-none focus:border-[#c9a84c] focus:ring-1 focus:ring-[#c9a84c] transition"
onkeydown="if(event.key==='Enter'){event.preventDefault();submitEmail();}">
</div> </div>
<button type="button" id="emailBtn" onclick="submitEmail()"
class="w-full py-2.5 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg transition disabled:opacity-50">
Login
</button>
</div>
</div>
<!-- Code (always visible) --> <!-- Screen 2: Code -->
<div id="step-code" style="display:none" class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-8">
<h2 class="text-xl font-semibold text-white mb-2">Check your email</h2>
<p class="text-[#b0bec5] text-sm mb-6">
We sent a 6-digit code to <span id="sent-email" class="text-white font-medium"></span>
</p>
<div id="error-code" class="hidden mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm"></div>
<div class="space-y-5">
<div> <div>
<label for="code" class="block text-sm font-medium text-[#b0bec5] mb-1.5">Login code</label> <label for="code" class="block text-sm font-medium text-[#b0bec5] mb-1.5">Login code</label>
<input type="text" id="code" name="code" autocomplete="one-time-code" <input type="text" id="code" name="code" autocomplete="one-time-code"
maxlength="6" inputmode="numeric" pattern="[0-9]*" maxlength="6" inputmode="numeric" pattern="[0-9]*"
placeholder="000000" placeholder="000000"
class="code-input w-full px-4 py-3 bg-[#0a1628] border border-white/[0.08] rounded-lg text-[#c9a84c] placeholder-[#8899a6] focus:outline-none focus:border-[#c9a84c] focus:ring-1 focus:ring-[#c9a84c] transition"> class="code-input w-full px-4 py-3 bg-[#0a1628] border border-white/[0.08] rounded-lg text-[#c9a84c] placeholder-[#8899a6] focus:outline-none focus:border-[#c9a84c] focus:ring-1 focus:ring-[#c9a84c] transition"
<p id="code-status" class="text-[#8899a6] text-xs mt-1.5"></p> onkeydown="if(event.key==='Enter'){event.preventDefault();verifyCode();}">
</div> </div>
<button type="button" id="codeBtn" onclick="verifyCode()"
<button type="button" id="emailBtn" onclick="sendCode()"
class="w-full py-2.5 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg transition disabled:opacity-50">
Send login code
</button>
<button type="button" id="codeBtn" onclick="verifyCode()" style="display:none"
class="w-full py-2.5 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg transition disabled:opacity-50"> class="w-full py-2.5 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg transition disabled:opacity-50">
Verify & sign in Verify & sign in
</button> </button>
</div>
<div id="resendRow" style="display:none" class="flex items-center justify-center"> <div class="mt-4 flex items-center justify-between">
<button id="resendBtn" onclick="resendCode()" class="text-[#c9a84c] text-sm hover:text-[#b8973f] transition"> <button onclick="backToEmail()" class="text-[#b0bec5] text-sm hover:text-white transition">
Resend code &larr; Different email
</button> </button>
</div> <button id="resendBtn" onclick="resendCode()" class="text-[#c9a84c] text-sm hover:text-[#b8973f] transition">
Resend code
</button>
</div> </div>
</div> </div>
@ -61,63 +74,35 @@
{{define "scripts"}} {{define "scripts"}}
<script> <script>
// Parse ?next= redirect URL
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const nextURL = params.get('next') || '/app/projects'; const nextURL = params.get('next') || '/app/projects';
// If already logged in, redirect
if (localStorage.getItem('ds_token')) { if (localStorage.getItem('ds_token')) {
// Set cookie too (for server-side auth like OAuth consent) document.cookie = 'ds_token=' + localStorage.getItem('ds_token') + '; path=/; SameSite=Lax; max-age=31536000';
document.cookie = 'ds_token=' + localStorage.getItem('ds_token') + '; path=/; SameSite=Lax; max-age=3600';
window.location.href = nextURL; window.location.href = nextURL;
} }
let currentEmail = ''; let currentEmail = '';
let codeSent = false;
// Enter on email field sends code, Enter on code field verifies function submitEmail() {
document.getElementById('email').addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); sendCode(); }
});
document.getElementById('code').addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); verifyCode(); }
});
async function sendCode() {
const btn = document.getElementById('emailBtn');
const errorEl = document.getElementById('error-email'); const errorEl = document.getElementById('error-email');
const statusEl = document.getElementById('code-status');
currentEmail = document.getElementById('email').value.trim().toLowerCase(); currentEmail = document.getElementById('email').value.trim().toLowerCase();
if (!currentEmail) return; if (!currentEmail) return;
btn.disabled = true;
btn.textContent = 'Sending...';
errorEl.classList.add('hidden'); errorEl.classList.add('hidden');
statusEl.textContent = 'Sending code...';
try { // Show code screen immediately
const res = await fetch('/api/auth/challenge', { document.getElementById('sent-email').textContent = currentEmail;
method: 'POST', document.getElementById('step-email').style.display = 'none';
headers: { 'Content-Type': 'application/json' }, document.getElementById('step-code').style.display = 'block';
body: JSON.stringify({ email: currentEmail }), document.getElementById('code').value = '';
}); document.getElementById('code').focus();
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to send code');
codeSent = true; // Send challenge in parallel (fire and forget)
statusEl.textContent = 'Code sent to ' + currentEmail; fetch('/api/auth/challenge', {
document.getElementById('emailBtn').style.display = 'none'; method: 'POST',
document.getElementById('codeBtn').style.display = 'block'; headers: { 'Content-Type': 'application/json' },
document.getElementById('resendRow').style.display = 'flex'; body: JSON.stringify({ email: currentEmail }),
document.getElementById('code').focus(); }).catch(() => {});
} catch (err) {
errorEl.textContent = err.message;
errorEl.classList.remove('hidden');
statusEl.textContent = '';
} finally {
btn.disabled = false;
btn.textContent = 'Send login code';
}
} }
async function verifyCode() { async function verifyCode() {
@ -141,7 +126,7 @@
localStorage.setItem('ds_token', data.token); localStorage.setItem('ds_token', data.token);
localStorage.setItem('ds_user', JSON.stringify(data.user)); localStorage.setItem('ds_user', JSON.stringify(data.user));
document.cookie = 'ds_token=' + data.token + '; path=/; SameSite=Lax; max-age=3600'; document.cookie = 'ds_token=' + data.token + '; path=/; SameSite=Lax; max-age=31536000';
window.location.href = nextURL; window.location.href = nextURL;
} catch (err) { } catch (err) {
errorEl.textContent = err.message; errorEl.textContent = err.message;
@ -151,9 +136,15 @@
} }
} }
function backToEmail() {
document.getElementById('step-code').style.display = 'none';
document.getElementById('step-email').style.display = 'block';
document.getElementById('error-code').classList.add('hidden');
document.getElementById('email').focus();
}
async function resendCode() { async function resendCode() {
const btn = document.getElementById('resendBtn'); const btn = document.getElementById('resendBtn');
const statusEl = document.getElementById('code-status');
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Sending...'; btn.textContent = 'Sending...';
try { try {
@ -162,7 +153,6 @@
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: currentEmail }), body: JSON.stringify({ email: currentEmail }),
}); });
statusEl.textContent = 'New code sent to ' + currentEmail;
btn.textContent = 'Code sent!'; btn.textContent = 'Code sent!';
setTimeout(() => { btn.textContent = 'Resend code'; btn.disabled = false; }, 3000); setTimeout(() => { btn.textContent = 'Resend code'; btn.disabled = false; }, 3000);
} catch { } catch {
@ -174,7 +164,7 @@
// Auto-submit when 6 digits entered // Auto-submit when 6 digits entered
document.getElementById('code').addEventListener('input', (e) => { document.getElementById('code').addEventListener('input', (e) => {
e.target.value = e.target.value.replace(/\D/g, '').slice(0, 6); e.target.value = e.target.value.replace(/\D/g, '').slice(0, 6);
if (e.target.value.length === 6 && codeSent) verifyCode(); if (e.target.value.length === 6) verifyCode();
}); });
</script> </script>
{{end}} {{end}}

View File

@ -0,0 +1,127 @@
{
"name": "Comprehensive Due Diligence (100 items)",
"description": "Full-scope M&A due diligence checklist covering financial, legal, tax, commercial, operational, IT, HR, environmental, and insurance.",
"items": [
{ "section": "Financial — Historical", "item_number": "1.01", "title": "Audited financial statements for the last 3 fiscal years", "priority": "critical" },
{ "section": "Financial — Historical", "item_number": "1.02", "title": "Unaudited interim financial statements (YTD current year)", "priority": "critical" },
{ "section": "Financial — Historical", "item_number": "1.03", "title": "Monthly P&L, balance sheet, and cash flow for trailing 24 months", "priority": "high" },
{ "section": "Financial — Historical", "item_number": "1.04", "title": "Revenue breakdown by product/service line, geography, and customer", "priority": "high" },
{ "section": "Financial — Historical", "item_number": "1.05", "title": "Gross margin analysis by product/service line", "priority": "high" },
{ "section": "Financial — Historical", "item_number": "1.06", "title": "Detail of all non-recurring, one-time, or extraordinary items", "priority": "high" },
{ "section": "Financial — Historical", "item_number": "1.07", "title": "Accounts receivable aging schedule and bad debt history", "priority": "medium" },
{ "section": "Financial — Historical", "item_number": "1.08", "title": "Accounts payable aging schedule", "priority": "medium" },
{ "section": "Financial — Historical", "item_number": "1.09", "title": "Inventory detail by category, location, and obsolescence reserve", "priority": "medium" },
{ "section": "Financial — Historical", "item_number": "1.10", "title": "Capital expenditure history and current commitments", "priority": "medium" },
{ "section": "Financial — Projections", "item_number": "2.01", "title": "Management financial projections/budget for next 3 years", "priority": "critical" },
{ "section": "Financial — Projections", "item_number": "2.02", "title": "Key assumptions underlying projections (growth rates, margins, headcount)", "priority": "high" },
{ "section": "Financial — Projections", "item_number": "2.03", "title": "Monthly revenue and EBITDA bridge (actual vs. budget vs. prior year)", "priority": "high" },
{ "section": "Financial — Projections", "item_number": "2.04", "title": "Pipeline/backlog report and conversion rate history", "priority": "medium" },
{ "section": "Financial — Projections", "item_number": "2.05", "title": "Working capital normalization analysis", "priority": "high" },
{ "section": "Financial — Debt & Obligations", "item_number": "3.01", "title": "Schedule of all outstanding debt, credit facilities, and guarantees", "priority": "critical" },
{ "section": "Financial — Debt & Obligations", "item_number": "3.02", "title": "Copies of all loan/credit agreements with covenants and amendment history", "priority": "high" },
{ "section": "Financial — Debt & Obligations", "item_number": "3.03", "title": "Off-balance sheet liabilities (leases, guarantees, earn-outs, contingencies)", "priority": "high" },
{ "section": "Financial — Debt & Obligations", "item_number": "3.04", "title": "Related party transactions and intercompany balances", "priority": "high" },
{ "section": "Financial — Debt & Obligations", "item_number": "3.05", "title": "Schedule of all capital and operating leases (with remaining terms)", "priority": "medium" },
{ "section": "Legal — Corporate", "item_number": "4.01", "title": "Corporate organizational chart (all entities, jurisdictions, ownership %)", "priority": "critical" },
{ "section": "Legal — Corporate", "item_number": "4.02", "title": "Articles/certificate of incorporation, bylaws, operating agreements", "priority": "high" },
{ "section": "Legal — Corporate", "item_number": "4.03", "title": "Capitalization table (all classes of equity, options, warrants, convertibles)", "priority": "critical" },
{ "section": "Legal — Corporate", "item_number": "4.04", "title": "Shareholder/partnership agreements, voting agreements, rights of first refusal", "priority": "high" },
{ "section": "Legal — Corporate", "item_number": "4.05", "title": "Board and shareholder meeting minutes for last 3 years", "priority": "medium" },
{ "section": "Legal — Corporate", "item_number": "4.06", "title": "List of all jurisdictions where the company is qualified to do business", "priority": "medium" },
{ "section": "Legal — Corporate", "item_number": "4.07", "title": "Good standing certificates for all entities", "priority": "low" },
{ "section": "Legal — Contracts", "item_number": "5.01", "title": "All material contracts (>$100K annual value or non-cancellable >1 year)", "priority": "critical" },
{ "section": "Legal — Contracts", "item_number": "5.02", "title": "Contracts with change-of-control or assignment provisions", "priority": "critical" },
{ "section": "Legal — Contracts", "item_number": "5.03", "title": "Joint venture, partnership, or co-development agreements", "priority": "high" },
{ "section": "Legal — Contracts", "item_number": "5.04", "title": "Government contracts and related compliance obligations", "priority": "high" },
{ "section": "Legal — Contracts", "item_number": "5.05", "title": "Non-competition, non-solicitation, and exclusivity agreements", "priority": "high" },
{ "section": "Legal — Contracts", "item_number": "5.06", "title": "Distribution, agency, franchise, or licensing agreements", "priority": "medium" },
{ "section": "Legal — Litigation", "item_number": "6.01", "title": "Summary of all pending or threatened litigation, arbitration, or regulatory proceedings", "priority": "critical" },
{ "section": "Legal — Litigation", "item_number": "6.02", "title": "Settled or resolved litigation in the last 5 years (with amounts)", "priority": "high" },
{ "section": "Legal — Litigation", "item_number": "6.03", "title": "Outstanding consent decrees, judgments, or injunctions", "priority": "high" },
{ "section": "Legal — Litigation", "item_number": "6.04", "title": "Legal opinion letters or memoranda on material risk areas", "priority": "medium" },
{ "section": "Legal — Regulatory", "item_number": "7.01", "title": "All material permits, licenses, and regulatory approvals", "priority": "high" },
{ "section": "Legal — Regulatory", "item_number": "7.02", "title": "Correspondence with regulators in the last 3 years", "priority": "medium" },
{ "section": "Legal — Regulatory", "item_number": "7.03", "title": "Industry-specific compliance programs (FDA, HIPAA, SOX, GDPR, etc.)", "priority": "high" },
{ "section": "Legal — Regulatory", "item_number": "7.04", "title": "Anti-corruption/FCPA compliance program and training records", "priority": "medium" },
{ "section": "Legal — Regulatory", "item_number": "7.05", "title": "Data privacy policies and breach notification history", "priority": "high" },
{ "section": "Tax", "item_number": "8.01", "title": "Federal and state income tax returns for the last 3 years", "priority": "critical" },
{ "section": "Tax", "item_number": "8.02", "title": "Schedule of all open tax audits, disputes, or assessments", "priority": "critical" },
{ "section": "Tax", "item_number": "8.03", "title": "Transfer pricing documentation and intercompany pricing policies", "priority": "high" },
{ "section": "Tax", "item_number": "8.04", "title": "Net operating loss and tax credit carryforward schedules", "priority": "high" },
{ "section": "Tax", "item_number": "8.05", "title": "Sales/use tax compliance and nexus analysis", "priority": "medium" },
{ "section": "Tax", "item_number": "8.06", "title": "Tax provision workpapers and deferred tax asset/liability schedule", "priority": "medium" },
{ "section": "Tax", "item_number": "8.07", "title": "R&D tax credit studies and documentation", "priority": "medium" },
{ "section": "Tax", "item_number": "8.08", "title": "International tax structure overview (if applicable)", "priority": "medium" },
{ "section": "Commercial — Customers", "item_number": "9.01", "title": "Top 20 customers by revenue with contract terms and renewal dates", "priority": "critical" },
{ "section": "Commercial — Customers", "item_number": "9.02", "title": "Customer concentration analysis (% of revenue from top 1/5/10)", "priority": "critical" },
{ "section": "Commercial — Customers", "item_number": "9.03", "title": "Customer churn/retention rates for the last 3 years", "priority": "high" },
{ "section": "Commercial — Customers", "item_number": "9.04", "title": "Net revenue retention and expansion revenue metrics", "priority": "high" },
{ "section": "Commercial — Customers", "item_number": "9.05", "title": "Customer satisfaction scores or NPS data", "priority": "medium" },
{ "section": "Commercial — Customers", "item_number": "9.06", "title": "Pricing strategy, discount policies, and recent price changes", "priority": "high" },
{ "section": "Commercial — Customers", "item_number": "9.07", "title": "Sales pipeline by stage, expected close date, and probability", "priority": "medium" },
{ "section": "Commercial — Suppliers", "item_number": "10.01", "title": "Top 10 suppliers/vendors with contract terms and spend", "priority": "high" },
{ "section": "Commercial — Suppliers", "item_number": "10.02", "title": "Single-source or sole-source supplier dependencies", "priority": "high" },
{ "section": "Commercial — Suppliers", "item_number": "10.03", "title": "Supply chain risk assessment and contingency plans", "priority": "medium" },
{ "section": "Commercial — Market", "item_number": "11.01", "title": "Market size, growth rate, and competitive landscape overview", "priority": "high" },
{ "section": "Commercial — Market", "item_number": "11.02", "title": "Competitive positioning and key differentiators", "priority": "high" },
{ "section": "Commercial — Market", "item_number": "11.03", "title": "Market share estimates and trend analysis", "priority": "medium" },
{ "section": "HR & Management", "item_number": "12.01", "title": "Organization chart with reporting structure and headcount by function", "priority": "high" },
{ "section": "HR & Management", "item_number": "12.02", "title": "Key employee list with compensation, tenure, and retention risk assessment", "priority": "critical" },
{ "section": "HR & Management", "item_number": "12.03", "title": "Employment agreements for all C-suite and key personnel", "priority": "critical" },
{ "section": "HR & Management", "item_number": "12.04", "title": "Non-compete, non-solicitation, and IP assignment agreements", "priority": "high" },
{ "section": "HR & Management", "item_number": "12.05", "title": "Employee handbook and HR policies", "priority": "medium" },
{ "section": "HR & Management", "item_number": "12.06", "title": "Headcount trend and turnover rates for the last 3 years", "priority": "medium" },
{ "section": "HR & Management", "item_number": "12.07", "title": "Open positions and hiring plan", "priority": "low" },
{ "section": "HR & Management", "item_number": "12.08", "title": "Pending or threatened employment-related claims or disputes", "priority": "high" },
{ "section": "HR & Management", "item_number": "12.09", "title": "Union/CBA agreements and labor relations history", "priority": "medium" },
{ "section": "HR & Management", "item_number": "12.10", "title": "Contractor/consultant arrangements (1099 vs. W-2 classification risk)", "priority": "medium" },
{ "section": "Benefits & Compensation", "item_number": "13.01", "title": "Summary of all employee benefit plans (health, retirement, equity)", "priority": "high" },
{ "section": "Benefits & Compensation", "item_number": "13.02", "title": "Equity incentive plan documents and outstanding grant schedules", "priority": "high" },
{ "section": "Benefits & Compensation", "item_number": "13.03", "title": "Bonus/commission structures and accrued obligations", "priority": "medium" },
{ "section": "Benefits & Compensation", "item_number": "13.04", "title": "Pension/ESOP obligations and funding status", "priority": "medium" },
{ "section": "Benefits & Compensation", "item_number": "13.05", "title": "Change-of-control or severance trigger provisions", "priority": "critical" },
{ "section": "Intellectual Property", "item_number": "14.01", "title": "Schedule of all patents (granted and pending) with jurisdictions and expiry dates", "priority": "high" },
{ "section": "Intellectual Property", "item_number": "14.02", "title": "Trademark and trade name registrations", "priority": "high" },
{ "section": "Intellectual Property", "item_number": "14.03", "title": "Copyright registrations and material unregistered copyrights", "priority": "medium" },
{ "section": "Intellectual Property", "item_number": "14.04", "title": "Trade secret protection policies and procedures", "priority": "medium" },
{ "section": "Intellectual Property", "item_number": "14.05", "title": "IP license agreements (inbound and outbound)", "priority": "high" },
{ "section": "Intellectual Property", "item_number": "14.06", "title": "IP infringement claims (made by or against the company)", "priority": "high" },
{ "section": "Intellectual Property", "item_number": "14.07", "title": "Open source software usage inventory and license compliance", "priority": "medium" },
{ "section": "IT & Technology", "item_number": "15.01", "title": "Technology stack overview (infrastructure, platforms, key applications)", "priority": "high" },
{ "section": "IT & Technology", "item_number": "15.02", "title": "IT systems architecture diagram", "priority": "medium" },
{ "section": "IT & Technology", "item_number": "15.03", "title": "Cybersecurity policies, penetration test results, and incident history", "priority": "critical" },
{ "section": "IT & Technology", "item_number": "15.04", "title": "Business continuity and disaster recovery plans (and test results)", "priority": "high" },
{ "section": "IT & Technology", "item_number": "15.05", "title": "SaaS/cloud vendor contracts and data residency requirements", "priority": "medium" },
{ "section": "IT & Technology", "item_number": "15.06", "title": "Software development lifecycle and release management process", "priority": "medium" },
{ "section": "IT & Technology", "item_number": "15.07", "title": "Technical debt assessment and planned remediation", "priority": "medium" },
{ "section": "Real Estate & Facilities", "item_number": "16.01", "title": "Schedule of owned and leased properties with terms and costs", "priority": "high" },
{ "section": "Real Estate & Facilities", "item_number": "16.02", "title": "Copies of all real estate leases", "priority": "medium" },
{ "section": "Real Estate & Facilities", "item_number": "16.03", "title": "Property condition assessments and pending capital improvements", "priority": "medium" },
{ "section": "Real Estate & Facilities", "item_number": "16.04", "title": "Zoning compliance and land use permits", "priority": "low" },
{ "section": "Environmental", "item_number": "17.01", "title": "Phase I/II environmental site assessments", "priority": "high" },
{ "section": "Environmental", "item_number": "17.02", "title": "Environmental permits and compliance history", "priority": "high" },
{ "section": "Environmental", "item_number": "17.03", "title": "Known environmental liabilities or remediation obligations", "priority": "high" },
{ "section": "Environmental", "item_number": "17.04", "title": "ESG/sustainability reporting and commitments", "priority": "low" },
{ "section": "Insurance", "item_number": "18.01", "title": "Summary of all insurance policies (type, carrier, limits, deductibles, premium)", "priority": "high" },
{ "section": "Insurance", "item_number": "18.02", "title": "Claims history for the last 5 years", "priority": "high" },
{ "section": "Insurance", "item_number": "18.03", "title": "Pending insurance claims and coverage disputes", "priority": "medium" },
{ "section": "Insurance", "item_number": "18.04", "title": "D&O, E&O, cyber, and product liability coverage details", "priority": "high" }
]
}

54
templates/core-8.json Normal file
View File

@ -0,0 +1,54 @@
{
"name": "Core Due Diligence (8 items)",
"description": "Essential questions every deal needs answered before proceeding.",
"items": [
{
"section": "Financial",
"item_number": "1.1",
"title": "Audited financial statements for the last 3 fiscal years",
"priority": "critical"
},
{
"section": "Financial",
"item_number": "1.2",
"title": "Monthly revenue and EBITDA bridge for the trailing 12 months",
"priority": "critical"
},
{
"section": "Legal",
"item_number": "2.1",
"title": "Corporate organizational chart and all entity formation documents",
"priority": "critical"
},
{
"section": "Legal",
"item_number": "2.2",
"title": "Summary of all pending or threatened litigation, arbitration, or regulatory proceedings",
"priority": "critical"
},
{
"section": "Commercial",
"item_number": "3.1",
"title": "Top 20 customers by revenue with contract terms, renewal dates, and concentration analysis",
"priority": "high"
},
{
"section": "Commercial",
"item_number": "3.2",
"title": "Top 10 suppliers/vendors with contract terms and single-source dependencies",
"priority": "high"
},
{
"section": "HR & Management",
"item_number": "4.1",
"title": "Key employee list with compensation, employment agreements, non-competes, and retention risk",
"priority": "high"
},
{
"section": "Tax",
"item_number": "5.1",
"title": "Federal and state tax returns for the last 3 years, including any open audits or disputes",
"priority": "critical"
}
]
}