chore: auto-commit uncommitted changes
This commit is contained in:
parent
52edadab72
commit
fe7543a0c8
|
|
@ -353,15 +353,21 @@ func (h *Handlers) Verify(w http.ResponseWriter, r *http.Request) {
|
|||
// Revoke existing sessions
|
||||
_ = lib.SessionRevokeAllForUser(h.DB, user.UserID)
|
||||
|
||||
// Create session
|
||||
// Create session — long-lived for whitelisted domains, 7 days for everyone else
|
||||
sessionID := generateToken()
|
||||
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{
|
||||
ID: sessionID,
|
||||
UserID: user.UserID,
|
||||
Fingerprint: r.UserAgent(),
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now + 7*24*60*60*1000, // 7 days
|
||||
ExpiresAt: now + sessionDuration,
|
||||
Revoked: false,
|
||||
}
|
||||
if err := lib.SessionCreate(h.DB, session); err != nil {
|
||||
|
|
@ -369,8 +375,8 @@ func (h *Handlers) Verify(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// Create JWT (1 hour)
|
||||
token, err := createJWT(user.UserID, sessionID, h.Cfg.JWTSecret, 3600)
|
||||
// Create JWT
|
||||
token, err := createJWT(user.UserID, sessionID, h.Cfg.JWTSecret, jwtDuration)
|
||||
if err != nil {
|
||||
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to create token")
|
||||
return
|
||||
|
|
@ -1262,8 +1268,10 @@ func (h *Handlers) orgToMap(org *lib.Entry) map[string]any {
|
|||
result["name"] = orgData.Name
|
||||
result["domains"] = orgData.Domains
|
||||
result["role"] = orgData.Role
|
||||
result["logo"] = orgData.Logo
|
||||
result["website"] = orgData.Website
|
||||
result["description"] = orgData.Description
|
||||
result["industry"] = orgData.Industry
|
||||
result["contact_name"] = orgData.ContactName
|
||||
result["contact_email"] = orgData.ContactEmail
|
||||
}
|
||||
|
|
@ -1593,6 +1601,10 @@ func (h *Handlers) ListDealOrgs(w http.ResponseWriter, r *http.Request) {
|
|||
orgDetails := h.orgToMap(org)
|
||||
dealOrgMap["org_name"] = orgDetails["name"]
|
||||
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"`
|
||||
Domains []string `json:"domains"`
|
||||
Role string `json:"role"` // seller | buyer | ib | advisor
|
||||
Logo string `json:"logo"`
|
||||
Website string `json:"website"`
|
||||
Description string `json:"description"`
|
||||
Industry string `json:"industry"`
|
||||
|
|
@ -3115,6 +3128,7 @@ func (h *Handlers) AddOrgToDeal(w http.ResponseWriter, r *http.Request) {
|
|||
Name: req.Name,
|
||||
Domains: req.Domains,
|
||||
Role: req.Role,
|
||||
Logo: req.Logo,
|
||||
Website: req.Website,
|
||||
Description: req.Description,
|
||||
Industry: req.Industry,
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -5,6 +5,7 @@ import (
|
|||
"crypto/tls"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"os"
|
||||
|
|
@ -100,12 +101,25 @@ func (m *Mailer) Enabled() bool {
|
|||
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.
|
||||
func (m *Mailer) Send(to, subject, htmlBody string) error {
|
||||
if !m.enabled {
|
||||
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)
|
||||
addr := fmt.Sprintf("%s:%d", m.Host, m.Port)
|
||||
|
||||
|
|
|
|||
|
|
@ -11,15 +11,15 @@
|
|||
html[data-theme="midnight"] {
|
||||
font-size: 14px;
|
||||
--ds-bg: #0a1628;
|
||||
--ds-sf: #0d1f3c;
|
||||
--ds-sf: #0f2240;
|
||||
--ds-tx: #fff;
|
||||
--ds-tx2: #b0bec5;
|
||||
--ds-tx2: #c0cdd6;
|
||||
--ds-tx3: #8899a6;
|
||||
--ds-ac: #c9a84c;
|
||||
--ds-ac2: #b8973f;
|
||||
--ds-act: #0a1628;
|
||||
--ds-bd: rgba(255,255,255,.08);
|
||||
--ds-hv: rgba(255,255,255,.04);
|
||||
--ds-bd: rgba(255,255,255,.16);
|
||||
--ds-hv: rgba(255,255,255,.08);
|
||||
--ds-inp: #0a1628;
|
||||
--ds-hf: 'Inter', sans-serif;
|
||||
}
|
||||
|
|
@ -96,17 +96,17 @@ html[data-theme="executive"] * { font-family: 'Georgia', 'Times New Roman', seri
|
|||
/* ===== STEEL THEME ===== */
|
||||
html[data-theme="steel"] {
|
||||
font-size: 14px;
|
||||
--ds-bg: #0c0c0e;
|
||||
--ds-sf: #141418;
|
||||
--ds-tx: #f0f0f2;
|
||||
--ds-tx2: #7a8494;
|
||||
--ds-tx3: #4a5260;
|
||||
--ds-bg: #09090b;
|
||||
--ds-sf: #1c1c22;
|
||||
--ds-tx: #fafafa;
|
||||
--ds-tx2: #b4bcc8;
|
||||
--ds-tx3: #8892a0;
|
||||
--ds-ac: #3b82f6;
|
||||
--ds-ac2: #2563eb;
|
||||
--ds-act: #fff;
|
||||
--ds-bd: rgba(255,255,255,.07);
|
||||
--ds-hv: rgba(59,130,246,.06);
|
||||
--ds-inp: #0c0c0e;
|
||||
--ds-bd: rgba(255,255,255,.18);
|
||||
--ds-hv: rgba(59,130,246,.10);
|
||||
--ds-inp: #111115;
|
||||
--ds-hf: 'DM Sans', sans-serif;
|
||||
}
|
||||
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: 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 ===== */
|
||||
.tab { border-bottom: 2px solid transparent; }
|
||||
|
|
|
|||
|
|
@ -156,12 +156,12 @@
|
|||
<!-- Step 1: Email lookup -->
|
||||
<div id="addOrgStep1" class="p-6">
|
||||
<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">×</button>
|
||||
</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">
|
||||
<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]"
|
||||
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>
|
||||
|
|
@ -284,6 +284,22 @@
|
|||
</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)">×</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 -->
|
||||
<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"
|
||||
|
|
@ -1123,42 +1139,52 @@
|
|||
const perms = o.permissions || {};
|
||||
const members = o.members || [];
|
||||
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">
|
||||
<div class="flex items-center gap-4 px-5 py-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-white font-semibold">${escHtml(o.org_name || 'Unknown')}</span>
|
||||
<span class="px-2 py-0.5 rounded-full text-xs font-medium ${rc}">${roleLabel}</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 text-xs text-[#8899a6] mt-1">
|
||||
<span>${dlBadge}</span>
|
||||
${perms.upload ? '<span>📤 Upload</span>' : ''}
|
||||
${perms.add_request_lists ? '<span>➕ Add lists</span>' : ''}
|
||||
${perms.edit_request_lists ? '<span>✏️ Edit lists</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>`}
|
||||
const orgDesc = o.org_description || '';
|
||||
const orgLogo = o.org_logo || '';
|
||||
const orgIndustry = o.org_industry || '';
|
||||
return `<div class="rounded-2xl overflow-hidden" style="background:var(--ds-sf);border:1px solid var(--ds-bd)">
|
||||
<!-- Org header -->
|
||||
<div class="px-6 py-5">
|
||||
<div class="flex items-start gap-4">
|
||||
${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>`}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-2xl font-black tracking-tight" style="color:var(--ds-tx)">${escHtml(o.org_name || 'Unknown')}</h2>
|
||||
<span class="px-2.5 py-1 rounded-full text-xs font-semibold ${rc}">${roleLabel}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-white text-xs font-medium">${escHtml(m.name||m.email)}</div>
|
||||
${m.title ? `<div class="text-[#8899a6] text-xs">${escHtml(m.title)}</div>` : ''}
|
||||
${orgDesc ? `<p class="text-sm mt-1 line-clamp-2" style="color:var(--ds-tx2)">${escHtml(orgDesc)}</p>` : ''}
|
||||
<div class="flex flex-wrap gap-3 text-xs mt-2" style="color:var(--ds-tx3)">
|
||||
<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>`).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>`;
|
||||
|
|
@ -1177,6 +1203,8 @@
|
|||
document.getElementById('scrapeLoading').classList.add('hidden');
|
||||
document.getElementById('scrapeBtn').disabled = false;
|
||||
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);
|
||||
document.getElementById('addOrgModal').classList.remove('hidden');
|
||||
setTimeout(() => document.getElementById('addOrgEmail').focus(), 100);
|
||||
|
|
@ -1194,6 +1222,35 @@
|
|||
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() {
|
||||
document.getElementById('addOrgModal').classList.add('hidden');
|
||||
}
|
||||
|
|
@ -1243,9 +1300,11 @@
|
|||
}
|
||||
|
||||
function skipScrape() {
|
||||
const email = document.getElementById('addOrgEmail').value.trim();
|
||||
const domain = email.includes('@') ? email.split('@')[1] : '';
|
||||
scrapedData = { domain, people: [{ name: '', email: email, title: '', phone: '', photo: '', bio: '', linkedin: '' }] };
|
||||
const input = document.getElementById('addOrgEmail').value.trim();
|
||||
const isEmail = input.includes('@');
|
||||
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('orgName').value = '';
|
||||
document.getElementById('orgDesc').value = '';
|
||||
|
|
@ -1262,12 +1321,14 @@
|
|||
}
|
||||
|
||||
async function scrapeOrg() {
|
||||
const email = document.getElementById('addOrgEmail').value.trim();
|
||||
if (!email || !email.includes('@')) {
|
||||
document.getElementById('scrapeErrorMsg').textContent = 'Enter a valid email address.';
|
||||
let input = document.getElementById('addOrgEmail').value.trim();
|
||||
if (!input) {
|
||||
document.getElementById('scrapeErrorMsg').textContent = 'Enter an email address or domain name.';
|
||||
document.getElementById('scrapeError').classList.remove('hidden');
|
||||
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('scrapeLoading').classList.remove('hidden');
|
||||
document.getElementById('scrapeBtn').disabled = true;
|
||||
|
|
@ -1292,8 +1353,8 @@
|
|||
document.getElementById('orgState').value = data.state || '';
|
||||
document.getElementById('orgFounded').value = data.founded || '';
|
||||
document.getElementById('orgLinkedIn').value = data.linkedin || '';
|
||||
// Ensure the original email person is in the people list
|
||||
ensureEmailPerson(email);
|
||||
// Ensure the original email person is in the people list (only if real email, not fabricated)
|
||||
if (input.includes('@')) ensureEmailPerson(email);
|
||||
highlightRequired();
|
||||
showAddOrgStep(2);
|
||||
} catch (err) {
|
||||
|
|
@ -1390,6 +1451,7 @@
|
|||
name,
|
||||
domains: domain ? [domain] : [],
|
||||
role,
|
||||
logo: scrapedData?.logo || '',
|
||||
website: document.getElementById('orgWebsite').value,
|
||||
description: document.getElementById('orgDesc').value,
|
||||
industry: document.getElementById('orgIndustry').value,
|
||||
|
|
@ -1407,6 +1469,10 @@
|
|||
if (!res.ok) throw new Error(data.error || 'Failed to add organization');
|
||||
closeAddOrgModal();
|
||||
loadOrgs();
|
||||
// Show email preview if there are members with emails
|
||||
if (selectedMembers.length > 0) {
|
||||
showEmailPreview(selectedMembers, name);
|
||||
}
|
||||
} catch (err) { alert(err.message); }
|
||||
finally { btn.disabled = false; btn.textContent = 'Add to Deal'; }
|
||||
}
|
||||
|
|
@ -1749,6 +1815,21 @@
|
|||
|
||||
// ---- Init ----
|
||||
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>
|
||||
{{end}}
|
||||
|
|
|
|||
|
|
@ -1,102 +1,141 @@
|
|||
{{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>
|
||||
<h1 class="text-2xl font-bold text-white mb-1">Projects</h1>
|
||||
<p class="text-[#b0bec5] text-sm">All deals you have access to.</p>
|
||||
<h1 class="text-2xl font-bold" style="color:var(--ds-tx)">Projects</h1>
|
||||
<div class="relative w-72">
|
||||
<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>
|
||||
<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 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 -->
|
||||
<div id="newProjectModal" class="hidden fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-8 w-full max-w-md">
|
||||
<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>
|
||||
<!-- Project grid -->
|
||||
<div id="projectGrid" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
|
||||
<div style="color:var(--ds-tx2)" class="text-sm col-span-full">Loading...</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>
|
||||
<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>
|
||||
|
||||
<!-- Empty state -->
|
||||
<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>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<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 dealTypeLabels = { sell_side: 'Sell-side', buy_side: 'Buy-side', merger: 'Merger', restructuring: 'Restructuring', capital_raise: 'Capital Raise' };
|
||||
let allProjects = [];
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const res = await fetchAPI('/api/projects');
|
||||
const projects = await res.json();
|
||||
const grid = document.getElementById('projectGrid');
|
||||
if (!projects || projects.length === 0) {
|
||||
grid.classList.add('hidden');
|
||||
allProjects = (projects || []).map(p => {
|
||||
const d = parseData(p.data_text);
|
||||
return { ...p, _d: d };
|
||||
});
|
||||
|
||||
if (allProjects.length === 0) {
|
||||
document.getElementById('projectGrid').classList.add('hidden');
|
||||
document.getElementById('emptyState').classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = projects.map(p => {
|
||||
const d = parseData(p.data_text);
|
||||
const status = d.status || 'active';
|
||||
const sc = statusColors[status] || statusColors.active;
|
||||
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>'; }
|
||||
|
||||
renderProjects(allProjects);
|
||||
} catch(e) {
|
||||
document.getElementById('projectGrid').innerHTML = '<div class="text-red-400 text-sm col-span-full">Failed to load projects.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function openModal() { document.getElementById('newProjectModal').classList.remove('hidden'); document.getElementById('pName').focus(); }
|
||||
function closeModal() { document.getElementById('newProjectModal').classList.add('hidden'); document.getElementById('modalError').classList.add('hidden'); }
|
||||
document.getElementById('newProjectBtn').onclick = openModal;
|
||||
function renderProjects(projects) {
|
||||
const grid = document.getElementById('projectGrid');
|
||||
grid.classList.remove('hidden');
|
||||
document.getElementById('emptyState').classList.add('hidden');
|
||||
|
||||
async function createProject() {
|
||||
const name = document.getElementById('pName').value.trim();
|
||||
const desc = document.getElementById('pDesc').value.trim();
|
||||
const status = document.getElementById('pStatus').value;
|
||||
const errEl = document.getElementById('modalError');
|
||||
const btn = document.getElementById('createBtn');
|
||||
if (!name) { errEl.textContent = 'Deal name is required'; errEl.classList.remove('hidden'); return; }
|
||||
btn.disabled = true; btn.textContent = 'Creating...';
|
||||
errEl.classList.add('hidden');
|
||||
const cards = projects.map(p => {
|
||||
const d = p._d;
|
||||
const status = d.status || 'active';
|
||||
const sc = statusColors[status] || statusColors.active;
|
||||
const name = d.name || p.summary || 'Untitled';
|
||||
const desc = d.description || '';
|
||||
const dealType = dealTypeLabels[d.deal_type] || '';
|
||||
const date = new Date(p.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
|
||||
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 {
|
||||
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();
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to create project');
|
||||
window.location.href = '/app/projects/' + data.entry_id;
|
||||
} catch(e) { errEl.textContent = e.message; errEl.classList.remove('hidden'); btn.disabled = false; btn.textContent = 'Create'; }
|
||||
// Redirect to project with Parties tab + auto-open Add Party modal
|
||||
window.location.href = '/app/projects/' + (data.project_id || data.entry_id) + '?tab=orgs&addparty=1';
|
||||
} catch(e) { alert(e.message); }
|
||||
}
|
||||
|
||||
loadProjects();
|
||||
|
|
|
|||
|
|
@ -8,46 +8,59 @@
|
|||
<p class="text-[#b0bec5] mt-2 text-sm">Secure M&A deal management</p>
|
||||
</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>
|
||||
<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-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">
|
||||
<!-- Email -->
|
||||
<div>
|
||||
<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
|
||||
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>
|
||||
<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>
|
||||
<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"
|
||||
maxlength="6" inputmode="numeric" pattern="[0-9]*"
|
||||
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">
|
||||
<p id="code-status" class="text-[#8899a6] text-xs mt-1.5"></p>
|
||||
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"
|
||||
onkeydown="if(event.key==='Enter'){event.preventDefault();verifyCode();}">
|
||||
</div>
|
||||
|
||||
<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"
|
||||
<button type="button" id="codeBtn" onclick="verifyCode()"
|
||||
class="w-full py-2.5 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg transition disabled:opacity-50">
|
||||
Verify & sign in
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="resendRow" style="display:none" class="flex items-center justify-center">
|
||||
<button id="resendBtn" onclick="resendCode()" class="text-[#c9a84c] text-sm hover:text-[#b8973f] transition">
|
||||
Resend code
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<button onclick="backToEmail()" class="text-[#b0bec5] text-sm hover:text-white transition">
|
||||
← Different email
|
||||
</button>
|
||||
<button id="resendBtn" onclick="resendCode()" class="text-[#c9a84c] text-sm hover:text-[#b8973f] transition">
|
||||
Resend code
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -61,63 +74,35 @@
|
|||
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
// Parse ?next= redirect URL
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const nextURL = params.get('next') || '/app/projects';
|
||||
|
||||
// If already logged in, redirect
|
||||
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=3600';
|
||||
document.cookie = 'ds_token=' + localStorage.getItem('ds_token') + '; path=/; SameSite=Lax; max-age=31536000';
|
||||
window.location.href = nextURL;
|
||||
}
|
||||
|
||||
let currentEmail = '';
|
||||
let codeSent = false;
|
||||
|
||||
// Enter on email field sends code, Enter on code field verifies
|
||||
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');
|
||||
function submitEmail() {
|
||||
const errorEl = document.getElementById('error-email');
|
||||
const statusEl = document.getElementById('code-status');
|
||||
currentEmail = document.getElementById('email').value.trim().toLowerCase();
|
||||
if (!currentEmail) return;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Sending...';
|
||||
errorEl.classList.add('hidden');
|
||||
statusEl.textContent = 'Sending code...';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/challenge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: currentEmail }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to send code');
|
||||
// Show code screen immediately
|
||||
document.getElementById('sent-email').textContent = currentEmail;
|
||||
document.getElementById('step-email').style.display = 'none';
|
||||
document.getElementById('step-code').style.display = 'block';
|
||||
document.getElementById('code').value = '';
|
||||
document.getElementById('code').focus();
|
||||
|
||||
codeSent = true;
|
||||
statusEl.textContent = 'Code sent to ' + currentEmail;
|
||||
document.getElementById('emailBtn').style.display = 'none';
|
||||
document.getElementById('codeBtn').style.display = 'block';
|
||||
document.getElementById('resendRow').style.display = 'flex';
|
||||
document.getElementById('code').focus();
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
errorEl.classList.remove('hidden');
|
||||
statusEl.textContent = '';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Send login code';
|
||||
}
|
||||
// Send challenge in parallel (fire and forget)
|
||||
fetch('/api/auth/challenge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: currentEmail }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
async function verifyCode() {
|
||||
|
|
@ -141,7 +126,7 @@
|
|||
|
||||
localStorage.setItem('ds_token', data.token);
|
||||
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;
|
||||
} catch (err) {
|
||||
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() {
|
||||
const btn = document.getElementById('resendBtn');
|
||||
const statusEl = document.getElementById('code-status');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Sending...';
|
||||
try {
|
||||
|
|
@ -162,7 +153,6 @@
|
|||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: currentEmail }),
|
||||
});
|
||||
statusEl.textContent = 'New code sent to ' + currentEmail;
|
||||
btn.textContent = 'Code sent!';
|
||||
setTimeout(() => { btn.textContent = 'Resend code'; btn.disabled = false; }, 3000);
|
||||
} catch {
|
||||
|
|
@ -174,7 +164,7 @@
|
|||
// Auto-submit when 6 digits entered
|
||||
document.getElementById('code').addEventListener('input', (e) => {
|
||||
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>
|
||||
{{end}}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue