Skip to content

Commit b049a3b

Browse files
committed
feat: add ALLOW_SIGNUP + ALLOWED_EMAIL_* for self-hosted instances
Closes #930 - Added environment variables to control signups - Updated frontend to hide signup text when disabled - Added backend check to block new user creation via magic link - Updated .env.example
1 parent df920e8 commit b049a3b

4 files changed

Lines changed: 122 additions & 20 deletions

File tree

.env.example

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,18 @@ NEXT_PUBLIC_WS_URL=
6363
# Remote API (optional) — set to proxy local frontend to a remote backend
6464
# Leave empty to use local backend (localhost:8080)
6565
# REMOTE_API_URL=https://multica-api.copilothub.ai
66+
67+
# ==================== Self-hosting: Control Signups (fixes #930) ====================
68+
# Set to "false" to completely disable new user signups (recommended for private instances)
69+
ALLOW_SIGNUP=true
70+
# Must match ALLOW_SIGNUP for the UI to reflect the same signup setting.
71+
# Note: in typical Next.js builds, NEXT_PUBLIC_* values are baked into the client bundle,
72+
# so changing this usually requires rebuilding/redeploying the frontend (not just restarting the backend).
73+
NEXT_PUBLIC_ALLOW_SIGNUP=true
74+
75+
# Optional: Only allow emails from these domains (comma-separated)
76+
ALLOWED_EMAIL_DOMAINS=
77+
78+
# Optional: Only allow these exact email addresses (comma-separated)
79+
ALLOWED_EMAILS=
80+

apps/web/features/landing/i18n/en.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { githubUrl } from "../components/shared";
22
import type { LandingDict } from "./types";
33

4+
export const ALLOW_SIGNUP = process.env.NEXT_PUBLIC_ALLOW_SIGNUP !== "false";
5+
46
export const en: LandingDict = {
57
header: {
68
github: "GitHub",
@@ -119,9 +121,10 @@ export const en: LandingDict = {
119121
headlineFaded: "in the next hour.",
120122
steps: [
121123
{
122-
title: "Sign up & create your workspace",
123-
description:
124-
"Enter your email, verify with a code, and you\u2019re in. Your workspace is created automatically \u2014 no setup wizard, no configuration forms.",
124+
title: ALLOW_SIGNUP ? "Sign up & create your workspace" : "Login to your workspace",
125+
description: ALLOW_SIGNUP
126+
? "Enter your email, verify with a code, and you\u2019re in. Your workspace is created automatically \u2014 no setup wizard, no configuration forms."
127+
: "Enter your email, verify with a code, and you\u2019re logged into your workspace \u2014 no setup wizard, no configuration forms.",
125128
},
126129
{
127130
title: "Install the CLI & connect your machine",

apps/web/features/landing/i18n/zh.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { githubUrl } from "../components/shared";
22
import type { LandingDict } from "./types";
33

4+
export const ALLOW_SIGNUP = process.env.NEXT_PUBLIC_ALLOW_SIGNUP !== "false";
5+
46
export const zh: LandingDict = {
57
header: {
68
github: "GitHub",
@@ -119,9 +121,10 @@ export const zh: LandingDict = {
119121
headlineFaded: "\u53ea\u9700\u4e00\u5c0f\u65f6\u3002",
120122
steps: [
121123
{
122-
title: "\u6ce8\u518c\u5e76\u521b\u5efa\u5de5\u4f5c\u533a",
123-
description:
124-
"\u8f93\u5165\u90ae\u7bb1\uff0c\u9a8c\u8bc1\u7801\u786e\u8ba4\uff0c\u5373\u53ef\u8fdb\u5165\u3002\u5de5\u4f5c\u533a\u81ea\u52a8\u521b\u5efa\u2014\u2014\u65e0\u9700\u8bbe\u7f6e\u5411\u5bfc\uff0c\u65e0\u9700\u914d\u7f6e\u8868\u5355\u3002",
124+
title: ALLOW_SIGNUP ? "注册并创建您的工作空间" : "登录到您的工作空间",
125+
description: ALLOW_SIGNUP
126+
? "输入您的邮箱,验证代码后即可使用。工作空间会自动创建——无需设置向导或配置表单。"
127+
: "输入您的邮箱,验证代码后即可登录到您的工作空间——无需设置向导或配置表单。",
125128
},
126129
{
127130
title: "\u5b89\u88c5 CLI \u5e76\u8fde\u63a5\u4f60\u7684\u673a\u5668",

server/internal/handler/auth.go

Lines changed: 95 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package handler
22

33
import (
44
"context"
5+
"errors"
56
"crypto/rand"
67
"crypto/subtle"
78
"encoding/binary"
@@ -22,6 +23,18 @@ import (
2223
db "github.com/multica-ai/multica/server/pkg/db/generated"
2324
)
2425

26+
// SignupError represents signup restriction errors
27+
type SignupError struct {
28+
Message string
29+
}
30+
31+
func (e SignupError) Error() string {
32+
return e.Message
33+
}
34+
35+
var ErrSignupProhibited = SignupError{Message: "user registration is disabled on this self-hosted instance"}
36+
var ErrEmailNotAllowed = SignupError{Message: "email address or domain not allowed on this instance"}
37+
2538
type UserResponse struct {
2639
ID string `json:"id"`
2740
Name string `json:"name"`
@@ -78,23 +91,68 @@ func (h *Handler) issueJWT(user db.User) (string, error) {
7891

7992
func (h *Handler) findOrCreateUser(ctx context.Context, email string) (db.User, error) {
8093
user, err := h.Queries.GetUserByEmail(ctx, email)
81-
if err != nil {
82-
if !isNotFound(err) {
83-
return db.User{}, err
84-
}
85-
name := email
86-
if at := strings.Index(email, "@"); at > 0 {
87-
name = email[:at]
94+
if err == nil {
95+
return user, nil
96+
}
97+
if !isNotFound(err) {
98+
return db.User{}, err
99+
}
100+
101+
// New user creation path. Check if signups are allowed.
102+
if err := h.checkSignupAllowed(email); err != nil {
103+
return db.User{}, err
104+
}
105+
106+
name := email
107+
if at := strings.Index(email, "@"); at > 0 {
108+
name = email[:at]
109+
}
110+
return h.Queries.CreateUser(ctx, db.CreateUserParams{
111+
Name: name,
112+
Email: email,
113+
})
114+
}
115+
116+
func (h *Handler) checkSignupAllowed(email string) error {
117+
if os.Getenv("ALLOW_SIGNUP") == "false" {
118+
return ErrSignupProhibited
119+
}
120+
121+
allowedDomainsStr := os.Getenv("ALLOWED_EMAIL_DOMAINS")
122+
allowedEmailsStr := os.Getenv("ALLOWED_EMAILS")
123+
124+
if allowedDomainsStr == "" && allowedEmailsStr == "" {
125+
return nil
126+
}
127+
128+
allowed := false
129+
emailLower := strings.ToLower(email)
130+
131+
if allowedDomainsStr != "" {
132+
for _, domain := range strings.Split(allowedDomainsStr, ",") {
133+
domain = strings.TrimSpace(domain)
134+
if domain != "" && strings.HasSuffix(emailLower, "@"+strings.ToLower(domain)) {
135+
allowed = true
136+
break
137+
}
88138
}
89-
user, err = h.Queries.CreateUser(ctx, db.CreateUserParams{
90-
Name: name,
91-
Email: email,
92-
})
93-
if err != nil {
94-
return db.User{}, err
139+
}
140+
141+
if !allowed && allowedEmailsStr != "" {
142+
for _, allowedEmail := range strings.Split(allowedEmailsStr, ",") {
143+
allowedEmail = strings.TrimSpace(allowedEmail)
144+
if allowedEmail != "" && strings.EqualFold(emailLower, allowedEmail) {
145+
allowed = true
146+
break
147+
}
95148
}
96149
}
97-
return user, nil
150+
151+
if !allowed {
152+
return ErrEmailNotAllowed
153+
}
154+
155+
return nil
98156
}
99157

100158
func (h *Handler) SendCode(w http.ResponseWriter, r *http.Request) {
@@ -110,6 +168,19 @@ func (h *Handler) SendCode(w http.ResponseWriter, r *http.Request) {
110168
return
111169
}
112170

171+
// Short-circuit: If not already a user, check if signups are allowed for this email
172+
_, err := h.Queries.GetUserByEmail(r.Context(), email)
173+
if err != nil && isNotFound(err) {
174+
if err := h.checkSignupAllowed(email); err != nil {
175+
msg := "user registration is disabled"
176+
if signupErr, ok := err.(SignupError); ok {
177+
msg = signupErr.Message
178+
}
179+
writeError(w, http.StatusForbidden, msg)
180+
return
181+
}
182+
}
183+
113184
// Rate limit: max 1 code per 60 seconds per email
114185
latest, err := h.Queries.GetLatestCodeByEmail(r.Context(), email)
115186
if err == nil && time.Since(latest.CreatedAt.Time) < 60*time.Second {
@@ -180,6 +251,11 @@ func (h *Handler) VerifyCode(w http.ResponseWriter, r *http.Request) {
180251

181252
user, err := h.findOrCreateUser(r.Context(), email)
182253
if err != nil {
254+
var signupErr SignupError
255+
if errors.As(err, &signupErr) {
256+
writeError(w, http.StatusForbidden, signupErr.Error())
257+
return
258+
}
183259
writeError(w, http.StatusInternalServerError, "failed to create user")
184260
return
185261
}
@@ -336,6 +412,11 @@ func (h *Handler) GoogleLogin(w http.ResponseWriter, r *http.Request) {
336412

337413
user, err := h.findOrCreateUser(r.Context(), email)
338414
if err != nil {
415+
var signupErr SignupError
416+
if errors.As(err, &signupErr) {
417+
writeError(w, http.StatusForbidden, signupErr.Error())
418+
return
419+
}
339420
writeError(w, http.StatusInternalServerError, "failed to create user")
340421
return
341422
}

0 commit comments

Comments
 (0)