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:
parent
c2a8808983
commit
dad9dcd835
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
Loading…
Reference in New Issue