From dad9dcd835b255b85c34254949ada5340d5563db Mon Sep 17 00:00:00 2001 From: James Date: Mon, 23 Feb 2026 02:45:57 -0500 Subject: [PATCH] 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 --- internal/db/migrate.go | 13 ++++ internal/handler/handler.go | 6 ++ internal/handler/team.go | 146 ++++++++++++++++++++++++++++++++++++ internal/model/models.go | 11 +++ templates/layout.templ | 5 ++ templates/team.templ | 124 ++++++++++++++++++++++++++++++ 6 files changed, 305 insertions(+) create mode 100644 internal/handler/team.go create mode 100644 templates/team.templ diff --git a/internal/db/migrate.go b/internal/db/migrate.go index 7e19781..7466244 100644 --- a/internal/db/migrate.go +++ b/internal/db/migrate.go @@ -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 diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 63da212..47536a5 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -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)) diff --git a/internal/handler/team.go b/internal/handler/team.go new file mode 100644 index 0000000..013580e --- /dev/null +++ b/internal/handler/team.go @@ -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, `
+

Invite link for %s:

+
+ + +
+

Expires in 7 days. Role: %s

+
`, 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) +} diff --git a/internal/model/models.go b/internal/model/models.go index 97f9a10..af22adb 100644 --- a/internal/model/models.go +++ b/internal/model/models.go @@ -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 { diff --git a/templates/layout.templ b/templates/layout.templ index 814ef5f..affc5c4 100644 --- a/templates/layout.templ +++ b/templates/layout.templ @@ -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() { } +templ svgTeam() { + +} + templ svgShield() { } diff --git a/templates/team.templ b/templates/team.templ new file mode 100644 index 0000000..99ef41e --- /dev/null +++ b/templates/team.templ @@ -0,0 +1,124 @@ +package templates + +import "dealroom/internal/model" +import "fmt" + +templ TeamPage(profile *model.Profile, members []*model.Profile) { + @Layout(profile, "team") { +
+
+
+

Team

+

{ fmt.Sprintf("%d members", len(members)) } in your organization.

+
+ if profile.Role == "owner" || profile.Role == "admin" { + + } +
+ +
+ + + + + + + + + + + for _, m := range members { + + + + + + + } + +
NameEmailRoleJoined
+
+
+ { initials(m.FullName) } +
+ { m.FullName } +
+
{ m.Email }@roleBadge(m.Role){ m.CreatedAt.Format("Jan 2, 2006") }
+
+
+ + + if profile.Role == "owner" || profile.Role == "admin" { + + } + } +} + +templ InviteAccept(token string, email string, orgName string) { + + + + + + Join { orgName } - Dealspace AI + + + +
+
+
+
+ +
+ Dealspace AI +
+ +

Join { orgName }

+

You've been invited as { email }. Set your name and password to continue.

+ +
+ +
+ + +
+
+ + +
+ +
+
+
+ + +}