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
+{ fmt.Sprintf("%d members", len(members)) } in your organization.
+| Name | +Role | +Joined | +|
|---|---|---|---|
|
+
+
+
+ { initials(m.FullName) }
+
+ { m.FullName }
+ |
+ { m.Email } | +@roleBadge(m.Role) | +{ m.CreatedAt.Format("Jan 2, 2006") } | +
You've been invited as { email }. Set your name and password to continue.
+ + +