From e9f0f5b13091e351121668b577cbbcb3f4fb638d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Apr 2026 19:42:55 +0000 Subject: [PATCH] Let users request an invite, show pending requests to admins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the dead-end "invite only" page with a request form. People who want to join can leave their email (+ optional reason) and it goes into a pending queue for admin review. auth.InviteRequest store (invite_requests.json): - CreateInviteRequest(email, reason, ip) — new or refreshed entry per email (same address can't flood the list) - ListInviteRequests() — newest first, pending before invited - MarkInviteRequestSent(email) — flagged when an invite is sent - DeleteInviteRequest(email) — admin rejection Signup flow: - GET /signup without a valid invite → renderRequestInvitePage (card with email + optional reason + captcha) - POST /request-invite → captcha check, per-IP rate limit (reuses SignupRateLimit bucket), then CreateInviteRequest + thank-you page. Also added to the CSRF-exempt list (no session yet). Admin /admin/invite: - Now lists pending requests with one-click "Send invite" and "Reject" buttons. Sending marks the request as fulfilled and drops it to the bottom of the list. - Invited requests get a "Resend" button. - Direct invite form stays at the bottom for ad-hoc invites. Home page: - Admin invite link now shows "+ Invite (N waiting)" when there are pending requests, so it's visible without digging into /admin. https://claude.ai/code/session_01GRGLA9yj7BpqKiyi6xFwnm --- admin/console.go | 76 +++++++++++++++++++++++---- home/home.go | 12 ++++- internal/app/app.go | 78 ++++++++++++++++++++++++++-- internal/auth/invite.go | 112 ++++++++++++++++++++++++++++++++++++++++ main.go | 2 + 5 files changed, 266 insertions(+), 14 deletions(-) diff --git a/admin/console.go b/admin/console.go index 218600a9..1a50ebeb 100644 --- a/admin/console.go +++ b/admin/console.go @@ -30,11 +30,17 @@ func InviteHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "POST" { r.ParseForm() + action := r.FormValue("action") email := strings.TrimSpace(r.FormValue("email")) if email == "" { app.BadRequest(w, r, "Email is required") return } + if action == "reject" { + auth.DeleteInviteRequest(email) + http.Redirect(w, r, "/admin/invite", http.StatusSeeOther) + return + } code, err := auth.CreateInvite(email, sess.Account) if err != nil { app.ServerError(w, r, "Failed to create invite: "+err.Error()) @@ -43,35 +49,87 @@ func InviteHandler(w http.ResponseWriter, r *http.Request) { base := app.PublicURL() link := base + "/signup?invite=" + code - // Try to email the invite. If mail isn't configured, show the link. + emailSent := false if app.EmailSender != nil { plain := fmt.Sprintf("You've been invited to join Mu.\n\nSign up here: %s\n\nThis link is single-use.", link) html := fmt.Sprintf(`

You've been invited to join Mu.

Sign up here

This link is single-use.

`, link) if err := app.EmailSender(email, "You're invited to Mu", plain, html); err != nil { app.Log("admin", "Failed to email invite to %s: %v", email, err) + } else { + emailSent = true } } + // Mark any pending request as fulfilled. + auth.MarkInviteRequestSent(email) + emailedMsg := `

Mail is not configured — copy the link above and send it manually.

` + if emailSent { + emailedMsg = `

Link has been emailed to them. Single use.

` + } content := fmt.Sprintf(`

Invite sent

Invite created for %s

%s

-

Link has been emailed (if mail is configured). Single use.

-

Invite another → · Home →

-
`, email, link, link) +%s +

Back to invites →

+`, email, link, link, emailedMsg) w.Write([]byte(app.RenderHTML("Invite Sent", "Invite sent", content))) return } - content := `
-

Invite a user

-

Enter their email address. They'll receive a single-use signup link.

+ // GET: show pending requests + ad-hoc invite form. + var sb strings.Builder + sb.WriteString(`

← Admin

`) + + requests := auth.ListInviteRequests() + pending := 0 + for _, req := range requests { + if !req.Invited { + pending++ + } + } + + sb.WriteString(fmt.Sprintf(`

Invite requests (%d pending)

`, pending)) + if len(requests) == 0 { + sb.WriteString(`

No requests yet.

`) + } else { + sb.WriteString(``) + for _, req := range requests { + reason := req.Reason + if reason == "" { + reason = `` + } + status := `pending` + if req.Invited { + status = fmt.Sprintf(`invited %s`, req.InvitedAt.Format("2 Jan")) + } + actions := "" + if !req.Invited { + actions = fmt.Sprintf( + ``, + req.Email, req.Email, req.Email) + } else { + actions = fmt.Sprintf( + ``, + req.Email, req.Email) + } + sb.WriteString(fmt.Sprintf(``, + req.Email, reason, req.RequestedAt.Format("2 Jan 15:04"), status, actions)) + } + sb.WriteString(`
EmailReasonWhenStatusActions
%s%s%s%s%s
`) + } + sb.WriteString(`
`) + + sb.WriteString(`
+

Invite someone directly

+

Enter an email — they'll get a single-use signup link.

-
` - w.Write([]byte(app.RenderHTML("Invite User", "Invite a user", content))) +
`) + + w.Write([]byte(app.RenderHTML("Invites", "Invite requests and send invites", sb.String()))) } // ConsoleHandler provides an admin console. diff --git a/home/home.go b/home/home.go index 154efb16..28b5d105 100644 --- a/home/home.go +++ b/home/home.go @@ -292,7 +292,17 @@ func Handler(w http.ResponseWriter, r *http.Request) { _, viewerAcc := auth.TrySession(r) inviteHTML := "" if viewerAcc != nil && viewerAcc.Admin && auth.InviteOnly() { - inviteHTML = `+ Invite` + pending := 0 + for _, req := range auth.ListInviteRequests() { + if !req.Invited { + pending++ + } + } + label := "+ Invite" + if pending > 0 { + label = fmt.Sprintf("+ Invite (%d waiting)", pending) + } + inviteHTML = fmt.Sprintf(`%s`, label) } b.WriteString(fmt.Sprintf(`
%s%s
`, now.Format("Monday, 2 January 2006"), inviteHTML)) // Inline script reads cached weather summary from localStorage diff --git a/internal/app/app.go b/internal/app/app.go index 12b2ea8e..e24a7c61 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -523,6 +523,76 @@ func renderSignup(errHTML string) string { return fmt.Sprintf(SignupTemplate, errHTML, CaptchaHTML(c), inviteField) } +// renderRequestInvitePage shows the "request an invite" form that +// replaces the dead-end "invite only" page. Captcha-protected and +// rate-limited by IP so it can't be flooded. +func renderRequestInvitePage(w http.ResponseWriter, r *http.Request, message string) { + c := NewCaptchaChallenge() + msg := message + if msg == "" { + msg = `

Mu is currently invite-only. Leave your email and we'll send you an invite when we open up more seats.

` + } + body := fmt.Sprintf(`
+

Request an invite

+%s +
+ + + %s + +
+

Already have an invite? Log in or paste your link.

+
`, msg, CaptchaHTML(c)) + w.Write([]byte(RenderHTML("Request an Invite", "Request an invite to Mu", body))) +} + +// RequestInvite handles POST /request-invite — someone is asking to +// join. Validates captcha + rate limit, stores the request for admin +// review. +func RequestInvite(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + renderRequestInvitePage(w, r, "") + return + } + if r.Method != "POST" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + r.ParseForm() + + if err := VerifyCaptchaRequest(r); err != nil { + renderRequestInvitePage(w, r, fmt.Sprintf(`

%s

`, err.Error())) + return + } + + // Per-IP rate limit reuses the signup bucket — same spam concern. + ip := ClientIP(r) + if !SignupRateLimit(ip) { + renderRequestInvitePage(w, r, `

Too many requests from your network. Please try again later.

`) + return + } + + email := strings.TrimSpace(r.FormValue("email")) + reason := strings.TrimSpace(r.FormValue("reason")) + if email == "" || !strings.Contains(email, "@") { + renderRequestInvitePage(w, r, `

Please enter a valid email address.

`) + return + } + + if err := auth.CreateInviteRequest(email, reason, ip); err != nil { + renderRequestInvitePage(w, r, fmt.Sprintf(`

%s

`, err.Error())) + return + } + Log("auth", "Invite request from %s (%s)", email, ip) + + body := fmt.Sprintf(`
+

Thanks — we got your request

+

We'll email %s if we have a seat for you.

+

← Back

+
`, htmlpkg.EscapeString(email)) + w.Write([]byte(RenderHTML("Request Received", "Invite request received", body))) +} + // EmailSender is set by main.go and called to deliver verification // emails. It's a callback to avoid an import cycle (mail imports app). // If nil, email verification is unavailable on this instance. @@ -654,12 +724,12 @@ func Signup(w http.ResponseWriter, r *http.Request) { } currentInviteCode = invCode - // Invite-only mode: reject if no valid code is provided. + // Invite-only mode: reject if no valid code is provided, but show + // a request-invite form instead of a dead end. if auth.InviteOnly() { if err := auth.ValidateInvite(invCode); err != nil { - if r.Method == "GET" && invCode == "" { - body := `

Invite only

Mu is currently invite-only. Ask an existing member for an invitation.

` - w.Write([]byte(RenderHTML("Invite Only", "Signup is invite-only", body))) + if invCode == "" { + renderRequestInvitePage(w, r, "") return } w.Write([]byte(renderSignup(fmt.Sprintf(`

%s

`, err.Error())))) diff --git a/internal/auth/invite.go b/internal/auth/invite.go index 29b4ca51..e8819480 100644 --- a/internal/auth/invite.go +++ b/internal/auth/invite.go @@ -30,8 +30,23 @@ type Invite struct { var ( inviteMu sync.Mutex invites = map[string]*Invite{} // code → Invite + + requestMu sync.Mutex + requests = map[string]*InviteRequest{} // email → request ) +// InviteRequest is a pending request from someone who wants to join +// on an invite-only instance. Admins review the list and send invites +// to the ones they want to let in. +type InviteRequest struct { + Email string `json:"email"` + Reason string `json:"reason,omitempty"` // optional short message + IP string `json:"ip,omitempty"` + RequestedAt time.Time `json:"requested_at"` + Invited bool `json:"invited,omitempty"` + InvitedAt time.Time `json:"invited_at,omitempty"` +} + func init() { b, err := data.LoadFile("invites.json") if err == nil && len(b) > 0 { @@ -40,6 +55,13 @@ func init() { invites = loaded } } + b, err = data.LoadFile("invite_requests.json") + if err == nil && len(b) > 0 { + var loaded map[string]*InviteRequest + if err := json.Unmarshal(b, &loaded); err == nil { + requests = loaded + } + } } // InviteOnly returns true when signup requires an invite code. @@ -116,3 +138,93 @@ func ListInvites() []*Invite { func saveInvites() { data.SaveJSON("invites.json", invites) } + +// ============================================================ +// Invite requests +// ============================================================ + +// CreateInviteRequest records that someone wants to join. If an earlier +// request from the same email exists, it's updated in place (so the +// same person can't flood the list with duplicate entries). +func CreateInviteRequest(email, reason, ip string) error { + email = strings.ToLower(strings.TrimSpace(email)) + if email == "" { + return errors.New("email is required") + } + if len(reason) > 500 { + reason = reason[:500] + } + requestMu.Lock() + defer requestMu.Unlock() + + now := time.Now() + if existing, ok := requests[email]; ok { + // Refresh timestamp so admins see it near the top again, but + // don't overwrite the invited flag — once sent, stays sent. + existing.RequestedAt = now + if reason != "" { + existing.Reason = reason + } + if ip != "" { + existing.IP = ip + } + } else { + requests[email] = &InviteRequest{ + Email: email, + Reason: reason, + IP: ip, + RequestedAt: now, + } + } + data.SaveJSON("invite_requests.json", requests) + return nil +} + +// ListInviteRequests returns all requests sorted newest first. +// Already-invited requests are at the bottom. +func ListInviteRequests() []*InviteRequest { + requestMu.Lock() + defer requestMu.Unlock() + + list := make([]*InviteRequest, 0, len(requests)) + for _, req := range requests { + list = append(list, req) + } + // Pending first, then invited; newest first within each group. + // Simple bubble is fine — list is tiny. + for i := 0; i < len(list); i++ { + for j := i + 1; j < len(list); j++ { + a, b := list[i], list[j] + if a.Invited == b.Invited { + if b.RequestedAt.After(a.RequestedAt) { + list[i], list[j] = b, a + } + } else if a.Invited && !b.Invited { + list[i], list[j] = b, a + } + } + } + return list +} + +// MarkInviteRequestSent flags a request as invited so it drops to the +// bottom of the admin list. +func MarkInviteRequestSent(email string) { + email = strings.ToLower(strings.TrimSpace(email)) + requestMu.Lock() + defer requestMu.Unlock() + if req, ok := requests[email]; ok { + req.Invited = true + req.InvitedAt = time.Now() + data.SaveJSON("invite_requests.json", requests) + } +} + +// DeleteInviteRequest removes a request (admin rejection). +func DeleteInviteRequest(email string) { + email = strings.ToLower(strings.TrimSpace(email)) + requestMu.Lock() + defer requestMu.Unlock() + delete(requests, email) + data.SaveJSON("invite_requests.json", requests) +} diff --git a/main.go b/main.go index d125cbc5..c824cb17 100644 --- a/main.go +++ b/main.go @@ -817,6 +817,7 @@ func main() { http.HandleFunc("/login", app.Login) http.HandleFunc("/logout", app.Logout) http.HandleFunc("/signup", app.Signup) + http.HandleFunc("/request-invite", app.RequestInvite) http.HandleFunc("/account", app.Account) http.HandleFunc("/verify", app.Verify) http.HandleFunc("/session", app.Session) @@ -1070,6 +1071,7 @@ func main() { isWebhook := r.URL.Path == "/wallet/stripe/webhook" // Skip CSRF for login/signup (no session yet) isAuth := r.URL.Path == "/login" || r.URL.Path == "/signup" || + r.URL.Path == "/request-invite" || strings.HasPrefix(r.URL.Path, "/passkey/") || strings.HasPrefix(r.URL.Path, "/oauth/") // Skip CSRF for SMTP/ActivityPub inbound