Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 67 additions & 9 deletions admin/console.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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(`<p>You've been invited to join Mu.</p><p><a href="%s">Sign up here</a></p><p>This link is single-use.</p>`, 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 := `<p class="text-muted text-sm">Mail is not configured — copy the link above and send it manually.</p>`
if emailSent {
emailedMsg = `<p class="text-muted text-sm">Link has been emailed to them. Single use.</p>`
}
content := fmt.Sprintf(`<div class="card">
<h4>Invite sent</h4>
<p>Invite created for <strong>%s</strong></p>
<p><a href="%s">%s</a></p>
<p class="text-muted text-sm">Link has been emailed (if mail is configured). Single use.</p>
<p><a href="/admin/invite">Invite another →</a> · <a href="/home">Home →</a></p>
</div>`, email, link, link)
%s
<p><a href="/admin/invite">Back to invites →</a></p>
</div>`, email, link, link, emailedMsg)
w.Write([]byte(app.RenderHTML("Invite Sent", "Invite sent", content)))
return
}

content := `<div class="card">
<h4>Invite a user</h4>
<p class="text-sm">Enter their email address. They'll receive a single-use signup link.</p>
// GET: show pending requests + ad-hoc invite form.
var sb strings.Builder
sb.WriteString(`<p><a href="/admin">← Admin</a></p>`)

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

sb.WriteString(fmt.Sprintf(`<div class="card"><h4>Invite requests (%d pending)</h4>`, pending))
if len(requests) == 0 {
sb.WriteString(`<p class="text-muted">No requests yet.</p>`)
} else {
sb.WriteString(`<table class="admin-table"><thead><tr><th>Email</th><th>Reason</th><th>When</th><th>Status</th><th class="center">Actions</th></tr></thead><tbody>`)
for _, req := range requests {
reason := req.Reason
if reason == "" {
reason = `<span class="text-muted">—</span>`
}
status := `pending`
if req.Invited {
status = fmt.Sprintf(`invited %s`, req.InvitedAt.Format("2 Jan"))
}
actions := ""
if !req.Invited {
actions = fmt.Sprintf(
`<form method="POST" class="d-inline"><input type="hidden" name="email" value="%s"><button type="submit" style="font-size:12px;padding:2px 8px;border-radius:4px;border:1px solid #22c55e;background:#fff;color:#22c55e;cursor:pointer">Send invite</button></form> <form method="POST" class="d-inline" onsubmit="return confirm('Reject %s?')"><input type="hidden" name="action" value="reject"><input type="hidden" name="email" value="%s"><button type="submit" class="btn-danger" style="font-size:12px;padding:2px 8px">Reject</button></form>`,
req.Email, req.Email, req.Email)
} else {
actions = fmt.Sprintf(
`<form method="POST" class="d-inline" onsubmit="return confirm('Resend invite to %s?')"><input type="hidden" name="email" value="%s"><button type="submit" style="font-size:12px;padding:2px 8px">Resend</button></form>`,
req.Email, req.Email)
}
sb.WriteString(fmt.Sprintf(`<tr><td><strong>%s</strong></td><td style="max-width:300px">%s</td><td class="text-muted text-sm">%s</td><td>%s</td><td class="center">%s</td></tr>`,
req.Email, reason, req.RequestedAt.Format("2 Jan 15:04"), status, actions))
}
sb.WriteString(`</tbody></table>`)
}
sb.WriteString(`</div>`)

sb.WriteString(`<div class="card" style="margin-top:16px">
<h4>Invite someone directly</h4>
<p class="text-sm">Enter an email — they'll get a single-use signup link.</p>
<form method="POST" action="/admin/invite" class="mt-4">
<input type="email" name="email" placeholder="user@example.com" required class="form-input">
<button type="submit" class="mt-2">Send invite</button>
</form>
</div>`
w.Write([]byte(app.RenderHTML("Invite User", "Invite a user", content)))
</div>`)

w.Write([]byte(app.RenderHTML("Invites", "Invite requests and send invites", sb.String())))
}

// ConsoleHandler provides an admin console.
Expand Down
12 changes: 11 additions & 1 deletion home/home.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<span id="home-date-actions"><a href="/admin/invite" style="color:#555;text-decoration:none">+ Invite</a></span>`
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(`<span id="home-date-actions"><a href="/admin/invite" style="color:#555;text-decoration:none">%s</a></span>`, label)
}
b.WriteString(fmt.Sprintf(`<div id="home-date"><span id="home-date-text">%s</span><span id="home-date-weather"></span>%s</div>`, now.Format("Monday, 2 January 2006"), inviteHTML))
// Inline script reads cached weather summary from localStorage
Expand Down
78 changes: 74 additions & 4 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<p>Mu is currently invite-only. Leave your email and we'll send you an invite when we open up more seats.</p>`
}
body := fmt.Sprintf(`<div class="card" style="max-width:440px;margin:0 auto">
<h3>Request an invite</h3>
%s
<form method="POST" action="/request-invite" style="margin-top:12px">
<input type="email" name="email" placeholder="your@email.com" required style="width:100%%;margin-bottom:8px">
<input type="text" name="reason" placeholder="Why you'd like to join (optional)" maxlength="500" style="width:100%%;margin-bottom:8px">
%s
<button type="submit">Request invite</button>
</form>
<p class="text-muted text-sm mt-3">Already have an invite? <a href="/login">Log in</a> or paste your link.</p>
</div>`, 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(`<p class="text-error">%s</p>`, err.Error()))
return
}

// Per-IP rate limit reuses the signup bucket — same spam concern.
ip := ClientIP(r)
if !SignupRateLimit(ip) {
renderRequestInvitePage(w, r, `<p class="text-error">Too many requests from your network. Please try again later.</p>`)
return
}

email := strings.TrimSpace(r.FormValue("email"))
reason := strings.TrimSpace(r.FormValue("reason"))
if email == "" || !strings.Contains(email, "@") {
renderRequestInvitePage(w, r, `<p class="text-error">Please enter a valid email address.</p>`)
return
}

if err := auth.CreateInviteRequest(email, reason, ip); err != nil {
renderRequestInvitePage(w, r, fmt.Sprintf(`<p class="text-error">%s</p>`, err.Error()))
return
}
Log("auth", "Invite request from %s (%s)", email, ip)

body := fmt.Sprintf(`<div class="card" style="max-width:440px;margin:0 auto">
<h3>Thanks — we got your request</h3>
<p>We'll email <strong>%s</strong> if we have a seat for you.</p>
<p class="mt-3"><a href="/">← Back</a></p>
</div>`, 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.
Expand Down Expand Up @@ -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 := `<div class="card"><h3>Invite only</h3><p>Mu is currently invite-only. Ask an existing member for an invitation.</p></div>`
w.Write([]byte(RenderHTML("Invite Only", "Signup is invite-only", body)))
if invCode == "" {
renderRequestInvitePage(w, r, "")
return
}
w.Write([]byte(renderSignup(fmt.Sprintf(`<p class="text-error">%s</p>`, err.Error()))))
Expand Down
112 changes: 112 additions & 0 deletions internal/auth/invite.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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.
Expand Down Expand Up @@ -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)
}
2 changes: 2 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading