diff --git a/api/handlers.go b/api/handlers.go index b0f1e26..28f03b1 100644 --- a/api/handlers.go +++ b/api/handlers.go @@ -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, diff --git a/data/dealspace.db-shm b/data/dealspace.db-shm index 6b9f756..e477dc7 100644 Binary files a/data/dealspace.db-shm and b/data/dealspace.db-shm differ diff --git a/data/dealspace.db-wal b/data/dealspace.db-wal index ebe772a..70524e9 100644 Binary files a/data/dealspace.db-wal and b/data/dealspace.db-wal differ diff --git a/dealspace b/dealspace index c616f9b..8afb1da 100755 Binary files a/dealspace and b/dealspace differ diff --git a/lib/mailer.go b/lib/mailer.go index be10951..c801a5c 100644 --- a/lib/mailer.go +++ b/lib/mailer.go @@ -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) diff --git a/portal/static/app.css b/portal/static/app.css index 973efbb..55a1a6b 100644 --- a/portal/static/app.css +++ b/portal/static/app.css @@ -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; } diff --git a/portal/templates/app/project.html b/portal/templates/app/project.html index a83104b..926118e 100644 --- a/portal/templates/app/project.html +++ b/portal/templates/app/project.html @@ -156,12 +156,12 @@
-

Add Party

+

Add Party

-

Enter an email address โ€” we'll look up the organization automatically.

+

Enter an email address or domain name โ€” we'll look up the organization automatically.

- @@ -284,6 +284,22 @@
+ + +
-
-
-
- ${escHtml(o.org_name || 'Unknown')} - ${roleLabel} -
-
- ${dlBadge} - ${perms.upload ? '๐Ÿ“ค Upload' : ''} - ${perms.add_request_lists ? 'โž• Add lists' : ''} - ${perms.edit_request_lists ? 'โœ๏ธ Edit lists' : ''} - ${(() => { - 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'; - })()} -
-
- -
-
-
- ${members.map((m, mi) => `
-
- ${m.photo ? `` : `${(m.name||m.email||'?')[0].toUpperCase()}`} + const orgDesc = o.org_description || ''; + const orgLogo = o.org_logo || ''; + const orgIndustry = o.org_industry || ''; + return `
+ +
+
+ ${orgLogo ? `` : `
${escHtml((o.org_name||'?')[0])}
`} +
+
+

${escHtml(o.org_name || 'Unknown')}

+ ${roleLabel}
-
-
${escHtml(m.name||m.email)}
- ${m.title ? `
${escHtml(m.title)}
` : ''} + ${orgDesc ? `

${escHtml(orgDesc)}

` : ''} +
+ ${dlBadge} + ${perms.upload ? '๐Ÿ“ค Upload' : ''} + ${(() => { + 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'; + })()} +
+
+ +
+
+ +
+
+ ${members.map((m, mi) => `
+
+ ${m.photo ? `` : `${(m.name||m.email||'?')[0].toUpperCase()}`} +
+
+
${escHtml(m.name||m.email)}
+ ${m.title ? `
${escHtml(m.title)}
` : ''}
`).join('')} - +
+
+
+ Add person +
`; @@ -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 `
+
+
${escHtml(m.name || m.email || '?')}
+
${escHtml(m.email || 'no email')}
+
+ ${m.email ? (whitelisted + ? 'Will send' + : 'Blocked (not whitelisted)' + ) : 'No email'} +
`; + }).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(); + } {{end}} diff --git a/portal/templates/app/projects.html b/portal/templates/app/projects.html index ff021b7..52e23a0 100644 --- a/portal/templates/app/projects.html +++ b/portal/templates/app/projects.html @@ -1,102 +1,141 @@ {{define "content"}} -
+
+
-
-

Projects

-

All deals you have access to.

+

Projects

+
+ +
-
-
-
Loading projects...
-
- -
- -