feat: invite system and team page

Add invites table, invite create/accept routes and handlers.
Add Team page with member list, invite modal with link generation.
Add invite accept page for new users to set name/password and join org.
Add Team nav item in sidebar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
James 2026-02-23 02:45:57 -05:00
parent c2a8808983
commit dad9dcd835
6 changed files with 305 additions and 0 deletions

View File

@ -18,6 +18,7 @@ func Migrate(db *sql.DB) error {
createDealActivity,
createSessions,
createIndexes,
createInvites,
}
for i, m := range migrations {
@ -189,6 +190,18 @@ CREATE INDEX IF NOT EXISTS idx_activity_deal ON deal_activity(deal_id);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
`
const createInvites = `
CREATE TABLE IF NOT EXISTS invites (
token TEXT PRIMARY KEY,
org_id TEXT NOT NULL,
email TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
invited_by TEXT NOT NULL,
expires_at DATETIME NOT NULL,
used_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`
// Additive migrations - each statement is run individually, errors ignored (for already-existing columns)
var additiveMigrationStmts = []string{
// Section 1: org_type

View File

@ -69,6 +69,12 @@ mux.HandleFunc("/auth/logout", h.handleLogout)
mux.HandleFunc("/admin/organizations/save", h.requireAdmin(h.handleAdminOrgSave))
mux.HandleFunc("/admin/organizations/delete", h.requireAdmin(h.handleAdminOrgDelete))
// Team & Invites
mux.HandleFunc("/team", h.requireAuth(h.handleTeam))
mux.HandleFunc("/invites/create", h.requireAuth(h.handleInviteCreate))
mux.HandleFunc("/invites/accept", h.handleInviteAcceptPage)
mux.HandleFunc("/invites/accept-submit", h.handleInviteAccept)
// Deal creation
mux.HandleFunc("/deals/create", h.requireAuth(h.handleCreateDeal))

146
internal/handler/team.go Normal file
View File

@ -0,0 +1,146 @@
package handler
import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"strings"
"time"
"dealroom/internal/model"
"dealroom/templates"
"golang.org/x/crypto/bcrypt"
)
func (h *Handler) handleTeam(w http.ResponseWriter, r *http.Request) {
profile := getProfile(r.Context())
rows, err := h.db.Query("SELECT id, email, full_name, role, created_at FROM profiles WHERE organization_id = ? ORDER BY full_name", profile.OrganizationID)
if err != nil {
http.Error(w, "Error loading team", 500)
return
}
defer rows.Close()
var members []*model.Profile
for rows.Next() {
m := &model.Profile{}
rows.Scan(&m.ID, &m.Email, &m.FullName, &m.Role, &m.CreatedAt)
members = append(members, m)
}
templates.TeamPage(profile, members).Render(r.Context(), w)
}
func (h *Handler) handleInviteCreate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
profile := getProfile(r.Context())
if profile.Role != "owner" && profile.Role != "admin" {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
email := strings.TrimSpace(r.FormValue("email"))
role := r.FormValue("role")
if email == "" {
http.Error(w, "Email is required", 400)
return
}
if role == "" {
role = "member"
}
// Generate 32-byte hex token
b := make([]byte, 32)
rand.Read(b)
token := hex.EncodeToString(b)
expiresAt := time.Now().Add(7 * 24 * time.Hour)
_, err := h.db.Exec("INSERT INTO invites (token, org_id, email, role, invited_by, expires_at) VALUES (?, ?, ?, ?, ?, ?)",
token, profile.OrganizationID, email, role, profile.ID, expiresAt)
if err != nil {
http.Error(w, "Error creating invite", 500)
return
}
// Return the invite link as HTML partial for HTMX
inviteLink := fmt.Sprintf("/invites/accept?token=%s", token)
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<div class="p-4 bg-gray-800 rounded-lg border border-gray-700">
<p class="text-sm text-gray-300 mb-2">Invite link for <span class="font-medium text-teal-400">%s</span>:</p>
<div class="flex items-center gap-2">
<input type="text" readonly value="%s" id="inviteLink" class="flex-1 px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-xs text-gray-100 font-mono"/>
<button onclick="navigator.clipboard.writeText(document.getElementById('inviteLink').value); this.textContent='Copied!'" class="px-3 py-2 bg-teal-500 text-white text-xs rounded-lg hover:bg-teal-600 transition">Copy</button>
</div>
<p class="text-xs text-gray-500 mt-2">Expires in 7 days. Role: %s</p>
</div>`, email, inviteLink, role)
}
func (h *Handler) handleInviteAcceptPage(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
if token == "" {
http.Error(w, "Invalid invite link", 400)
return
}
var email, orgName string
err := h.db.QueryRow(`SELECT i.email, o.name FROM invites i JOIN organizations o ON i.org_id = o.id WHERE i.token = ? AND i.expires_at > datetime('now') AND i.used_at IS NULL`, token).Scan(&email, &orgName)
if err != nil {
http.Error(w, "Invite is invalid or expired", 400)
return
}
templates.InviteAccept(token, email, orgName).Render(r.Context(), w)
}
func (h *Handler) handleInviteAccept(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
token := r.FormValue("token")
name := strings.TrimSpace(r.FormValue("name"))
password := r.FormValue("password")
if token == "" || name == "" || password == "" {
http.Error(w, "All fields are required", 400)
return
}
var invite model.Invite
err := h.db.QueryRow(`SELECT token, org_id, email, role FROM invites WHERE token = ? AND expires_at > datetime('now') AND used_at IS NULL`, token).Scan(&invite.Token, &invite.OrgID, &invite.Email, &invite.Role)
if err != nil {
http.Error(w, "Invite is invalid or expired", 400)
return
}
// Create profile
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
http.Error(w, "Error creating account", 500)
return
}
userID := generateID("user")
_, err = h.db.Exec("INSERT INTO profiles (id, email, full_name, organization_id, role, password_hash) VALUES (?, ?, ?, ?, ?, ?)",
userID, invite.Email, name, invite.OrgID, invite.Role, string(hash))
if err != nil {
http.Error(w, "Error creating account (email may already exist)", 400)
return
}
// Mark invite as used
h.db.Exec("UPDATE invites SET used_at = datetime('now') WHERE token = ?", token)
// Auto login
h.createSession(w, userID)
http.Redirect(w, r, "/", http.StatusSeeOther)
}

View File

@ -136,6 +136,17 @@ type Session struct {
CreatedAt time.Time
}
type Invite struct {
Token string
OrgID string
Email string
Role string
InvitedBy string
ExpiresAt time.Time
UsedAt *time.Time
CreatedAt time.Time
}
// StageName returns human-readable stage name
func StageName(stage string) string {
switch stage {

View File

@ -52,6 +52,7 @@ templ Layout(profile *model.Profile, activePage string) {
@sidebarLink("/requests", "Request Lists", activePage == "requests", svgClipboard())
@sidebarLink("/analytics", "Analytics", activePage == "analytics", svgChart())
@sidebarLink("/contacts", "Contacts", activePage == "contacts", svgUsers())
@sidebarLink("/team", "Team", activePage == "team", svgTeam())
@sidebarLink("/audit", "Audit Log", activePage == "audit", svgShield())
@sidebarLink("/admin", "Admin", activePage == "admin", svgCog())
}
@ -159,6 +160,10 @@ templ svgCog() {
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
}
templ svgTeam() {
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path></svg>
}
templ svgShield() {
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
}

124
templates/team.templ Normal file
View File

@ -0,0 +1,124 @@
package templates
import "dealroom/internal/model"
import "fmt"
templ TeamPage(profile *model.Profile, members []*model.Profile) {
@Layout(profile, "team") {
<div class="space-y-5">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold">Team</h1>
<p class="text-sm text-gray-500 mt-1">{ fmt.Sprintf("%d members", len(members)) } in your organization.</p>
</div>
if profile.Role == "owner" || profile.Role == "admin" {
<button onclick="document.getElementById('inviteModal').classList.remove('hidden')" class="h-9 px-4 rounded-lg bg-teal-500 text-white text-sm font-medium flex items-center gap-1.5 hover:bg-teal-600 transition">
<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="M12 4v16m8-8H4"></path></svg>
Invite Member
</button>
}
</div>
<div class="bg-gray-900 rounded-lg border border-gray-800">
<table class="w-full">
<thead>
<tr class="border-b border-gray-800">
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Role</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Joined</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-800/50">
for _, m := range members {
<tr class="hover:bg-gray-800/30 transition">
<td class="px-4 py-3">
<div class="flex items-center gap-2.5">
<div class="w-8 h-8 rounded-full bg-teal-500/10 flex items-center justify-center text-xs font-bold text-teal-400">
{ initials(m.FullName) }
</div>
<span class="text-sm font-medium">{ m.FullName }</span>
</div>
</td>
<td class="px-4 py-3 text-xs text-gray-500 font-mono">{ m.Email }</td>
<td class="px-4 py-3">@roleBadge(m.Role)</td>
<td class="px-4 py-3 text-xs text-gray-500">{ m.CreatedAt.Format("Jan 2, 2006") }</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<!-- Invite Modal -->
if profile.Role == "owner" || profile.Role == "admin" {
<div id="inviteModal" class="hidden fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/60" onclick="document.getElementById('inviteModal').classList.add('hidden')"></div>
<div class="relative bg-gray-900 border border-gray-700 rounded-xl shadow-2xl w-full max-w-md mx-4 p-6">
<div class="flex items-center justify-between mb-5">
<h2 class="text-lg font-bold">Invite Team Member</h2>
<button onclick="document.getElementById('inviteModal').classList.add('hidden')" class="text-gray-500 hover:text-gray-300">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
</button>
</div>
<form hx-post="/invites/create" hx-target="#inviteResult" hx-swap="innerHTML" class="space-y-4">
<div>
<label class="block text-xs font-medium text-gray-400 mb-1">Email Address</label>
<input type="email" name="email" required class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-100 focus:border-teal-500 focus:outline-none"/>
</div>
<div>
<label class="block text-xs font-medium text-gray-400 mb-1">Role</label>
<select name="role" class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-100 focus:border-teal-500 focus:outline-none">
<option value="member">Member</option>
<option value="admin">Admin</option>
<option value="viewer">Viewer</option>
</select>
</div>
<button type="submit" class="w-full px-4 py-2 rounded-lg bg-teal-500 text-white text-sm font-medium hover:bg-teal-600 transition">Generate Invite Link</button>
</form>
<div id="inviteResult" class="mt-4"></div>
</div>
</div>
}
}
}
templ InviteAccept(token string, email string, orgName string) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Join { orgName } - Dealspace AI</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen flex items-center justify-center bg-gray-950">
<div class="w-full max-w-md mx-4">
<div class="bg-gray-900 border border-gray-800 rounded-xl p-8">
<div class="flex items-center gap-2 mb-6">
<div class="w-8 h-8 rounded-lg bg-teal-500 flex items-center justify-center">
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"></path></svg>
</div>
<span class="font-bold text-lg text-white">Dealspace AI</span>
</div>
<h1 class="text-xl font-bold text-white mb-2">Join { orgName }</h1>
<p class="text-sm text-gray-400 mb-6">You've been invited as <span class="text-teal-400">{ email }</span>. Set your name and password to continue.</p>
<form action="/invites/accept-submit" method="POST" class="space-y-4">
<input type="hidden" name="token" value={ token }/>
<div>
<label class="block text-xs font-medium text-gray-400 mb-1">Full Name</label>
<input type="text" name="name" required class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-100 focus:border-teal-500 focus:outline-none"/>
</div>
<div>
<label class="block text-xs font-medium text-gray-400 mb-1">Password</label>
<input type="password" name="password" required class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-100 focus:border-teal-500 focus:outline-none"/>
</div>
<button type="submit" class="w-full px-4 py-2 rounded-lg bg-teal-500 text-white text-sm font-medium hover:bg-teal-600 transition">Join Organization</button>
</form>
</div>
</div>
</body>
</html>
}