mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-23 07:29:14 +02:00
Compare commits
1 Commits
fix/selfho
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6a5ef0aa8 |
28
.env.example
28
.env.example
@@ -6,16 +6,6 @@ POSTGRES_PORT=5432
|
||||
DATABASE_URL=postgres://multica:multica@localhost:5432/multica?sslmode=disable
|
||||
|
||||
# Server
|
||||
# APP_ENV gates dev-only auth shortcuts (primarily the 888888 master code).
|
||||
# - Docker self-host: docker-compose.selfhost.yml already pins APP_ENV to
|
||||
# "production" by default, so 888888 is DISABLED — a public instance can't
|
||||
# be logged into with any email + 888888.
|
||||
# - Local dev (make dev): leave APP_ENV unset so 888888 works out of the box.
|
||||
# - Docker self-host on a private network you fully control, or evaluation
|
||||
# without Resend: set APP_ENV=development to re-enable 888888. Do NOT
|
||||
# enable on a publicly reachable instance.
|
||||
# See SELF_HOSTING.md for the full login setup.
|
||||
APP_ENV=
|
||||
PORT=8080
|
||||
JWT_SECRET=change-me-in-production
|
||||
MULTICA_SERVER_URL=ws://localhost:8080/ws
|
||||
@@ -32,8 +22,7 @@ MULTICA_CODEX_WORKDIR=
|
||||
MULTICA_CODEX_TIMEOUT=20m
|
||||
|
||||
# Email (Resend)
|
||||
# For local/dev use, leave RESEND_API_KEY empty — codes print to stdout, and
|
||||
# master code 888888 works (only when APP_ENV != "production"; see above).
|
||||
# For local/dev use, leave RESEND_API_KEY empty — codes print to stdout, and master code 888888 works.
|
||||
# For production, set your Resend API key and change RESEND_FROM_EMAIL to a domain verified in your Resend account.
|
||||
RESEND_API_KEY=
|
||||
RESEND_FROM_EMAIL=noreply@multica.ai
|
||||
@@ -74,18 +63,3 @@ NEXT_PUBLIC_WS_URL=
|
||||
# Remote API (optional) — set to proxy local frontend to a remote backend
|
||||
# Leave empty to use local backend (localhost:8080)
|
||||
# REMOTE_API_URL=https://multica-api.copilothub.ai
|
||||
|
||||
# ==================== Self-hosting: Control Signups (fixes #930) ====================
|
||||
# Set to "false" to completely disable new user signups (recommended for private instances)
|
||||
ALLOW_SIGNUP=true
|
||||
# Must match ALLOW_SIGNUP for the UI to reflect the same signup setting.
|
||||
# Note: in typical Next.js builds, NEXT_PUBLIC_* values are baked into the client bundle,
|
||||
# so changing this usually requires rebuilding/redeploying the frontend (not just restarting the backend).
|
||||
NEXT_PUBLIC_ALLOW_SIGNUP=true
|
||||
|
||||
# Optional: Only allow emails from these domains (comma-separated)
|
||||
ALLOWED_EMAIL_DOMAINS=
|
||||
|
||||
# Optional: Only allow these exact email addresses (comma-separated)
|
||||
ALLOWED_EMAILS=
|
||||
|
||||
|
||||
3
Makefile
3
Makefile
@@ -66,8 +66,7 @@ selfhost:
|
||||
echo " Frontend: http://localhost:$${FRONTEND_PORT:-3000}"; \
|
||||
echo " Backend: http://localhost:$${PORT:-8080}"; \
|
||||
echo ""; \
|
||||
echo "Log in: configure RESEND_API_KEY in .env for email codes,"; \
|
||||
echo " or set APP_ENV=development in .env (private networks only) to enable code 888888."; \
|
||||
echo "Log in with any email + verification code: 888888"; \
|
||||
echo ""; \
|
||||
echo "Next — install the CLI and connect your machine:"; \
|
||||
echo " brew install multica-ai/tap/multica"; \
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { githubUrl } from "../components/shared";
|
||||
import type { LandingDict } from "./types";
|
||||
|
||||
export const ALLOW_SIGNUP = process.env.NEXT_PUBLIC_ALLOW_SIGNUP !== "false";
|
||||
|
||||
export const en: LandingDict = {
|
||||
header: {
|
||||
github: "GitHub",
|
||||
@@ -122,10 +120,9 @@ export const en: LandingDict = {
|
||||
headlineFaded: "in the next hour.",
|
||||
steps: [
|
||||
{
|
||||
title: ALLOW_SIGNUP ? "Sign up & create your workspace" : "Login to your workspace",
|
||||
description: ALLOW_SIGNUP
|
||||
? "Enter your email, verify with a code, and you\u2019re in. Your workspace is created automatically \u2014 no setup wizard, no configuration forms."
|
||||
: "Enter your email, verify with a code, and you\u2019re logged into your workspace \u2014 no setup wizard, no configuration forms.",
|
||||
title: "Sign up & create your workspace",
|
||||
description:
|
||||
"Enter your email, verify with a code, and you\u2019re in. Your workspace is created automatically \u2014 no setup wizard, no configuration forms.",
|
||||
},
|
||||
{
|
||||
title: "Install the CLI & connect your machine",
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { githubUrl } from "../components/shared";
|
||||
import type { LandingDict } from "./types";
|
||||
|
||||
export const ALLOW_SIGNUP = process.env.NEXT_PUBLIC_ALLOW_SIGNUP !== "false";
|
||||
|
||||
export const zh: LandingDict = {
|
||||
header: {
|
||||
github: "GitHub",
|
||||
@@ -122,10 +120,9 @@ export const zh: LandingDict = {
|
||||
headlineFaded: "\u53ea\u9700\u4e00\u5c0f\u65f6\u3002",
|
||||
steps: [
|
||||
{
|
||||
title: ALLOW_SIGNUP ? "注册并创建您的工作空间" : "登录到您的工作空间",
|
||||
description: ALLOW_SIGNUP
|
||||
? "输入您的邮箱,验证代码后即可使用。工作空间会自动创建——无需设置向导或配置表单。"
|
||||
: "输入您的邮箱,验证代码后即可登录到您的工作空间——无需设置向导或配置表单。",
|
||||
title: "\u6ce8\u518c\u5e76\u521b\u5efa\u5de5\u4f5c\u533a",
|
||||
description:
|
||||
"\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",
|
||||
},
|
||||
{
|
||||
title: "\u5b89\u88c5 CLI \u5e76\u8fde\u63a5\u4f60\u7684\u673a\u5668",
|
||||
|
||||
@@ -32,8 +32,6 @@ import { useEditorState } from "@tiptap/react";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { posToDOMRect } from "@tiptap/core";
|
||||
import { NodeSelection } from "@tiptap/pm/state";
|
||||
import { toast } from "sonner";
|
||||
import { useCreateIssue } from "@multica/core/issues/mutations";
|
||||
import { Toggle } from "@multica/ui/components/ui/toggle";
|
||||
import { Separator } from "@multica/ui/components/ui/separator";
|
||||
import {
|
||||
@@ -66,8 +64,6 @@ import {
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
FilePlus,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -348,106 +344,11 @@ function ListDropdown({ editor, onOpenChange, isBullet, isOrdered }: { editor: E
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create Sub-Issue Button
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Turns the current selection into a sub-issue of `parentIssueId` and replaces
|
||||
* the selection with a mention link to the new issue. Title is the selected
|
||||
* text (trimmed, collapsed whitespace, capped). Only rendered when a parent
|
||||
* issue is in scope; otherwise there's no meaningful "sub-issue of" target.
|
||||
*/
|
||||
function CreateSubIssueButton({
|
||||
editor,
|
||||
parentIssueId,
|
||||
}: {
|
||||
editor: Editor;
|
||||
parentIssueId: string;
|
||||
}) {
|
||||
const createIssue = useCreateIssue();
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const handleClick = useCallback(async () => {
|
||||
if (pending) return;
|
||||
const { from, to } = editor.state.selection;
|
||||
if (from === to) return;
|
||||
|
||||
// Title from selection: collapse whitespace, cap length. The full selection
|
||||
// still becomes the link text — only the issue title is capped.
|
||||
const rawTitle = editor.state.doc.textBetween(from, to, " ", " ").trim();
|
||||
const title = rawTitle.replace(/\s+/g, " ").slice(0, 200);
|
||||
if (!title) return;
|
||||
|
||||
setPending(true);
|
||||
try {
|
||||
const newIssue = await createIssue.mutateAsync({
|
||||
title,
|
||||
parent_issue_id: parentIssueId,
|
||||
});
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(
|
||||
{ from, to },
|
||||
[
|
||||
{
|
||||
type: "mention",
|
||||
attrs: {
|
||||
id: newIssue.id,
|
||||
label: newIssue.identifier,
|
||||
type: "issue",
|
||||
},
|
||||
},
|
||||
{ type: "text", text: " " },
|
||||
],
|
||||
)
|
||||
.run();
|
||||
toast.success(`Created ${newIssue.identifier}`);
|
||||
} catch {
|
||||
toast.error("Failed to create sub-issue");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}, [editor, parentIssueId, createIssue, pending]);
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={false}
|
||||
disabled={pending}
|
||||
onPressedChange={handleClick}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{pending ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<FilePlus className="size-3.5" />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={8}>
|
||||
Create sub-issue from selection
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Bubble Menu — @floating-ui/dom + portal to body
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EditorBubbleMenu({
|
||||
editor,
|
||||
currentIssueId,
|
||||
}: {
|
||||
editor: Editor;
|
||||
currentIssueId?: string;
|
||||
}) {
|
||||
function EditorBubbleMenu({ editor }: { editor: Editor }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [mode, setMode] = useState<"toolbar" | "link-edit">("toolbar");
|
||||
const floatingRef = useRef<HTMLDivElement>(null);
|
||||
@@ -601,12 +502,6 @@ function EditorBubbleMenu({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={8}>Quote</TooltipContent>
|
||||
</Tooltip>
|
||||
{currentIssueId && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-0.5 h-5" />
|
||||
<CreateSubIssueButton editor={editor} parentIssueId={currentIssueId} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
@@ -75,12 +75,6 @@ interface ContentEditorProps {
|
||||
showBubbleMenu?: boolean;
|
||||
/** When true, bare Enter submits (chat-style). Mod-Enter always submits. */
|
||||
submitOnEnter?: boolean;
|
||||
/**
|
||||
* ID of the issue this editor belongs to. When set, the bubble menu exposes
|
||||
* a "Create sub-issue from selection" action that parents the new issue
|
||||
* under this ID and replaces the selection with a mention link.
|
||||
*/
|
||||
currentIssueId?: string;
|
||||
}
|
||||
|
||||
interface ContentEditorRef {
|
||||
@@ -110,7 +104,6 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
onUploadFile,
|
||||
showBubbleMenu = true,
|
||||
submitOnEnter = false,
|
||||
currentIssueId,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
@@ -265,9 +258,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
onMouseDown={handleContainerMouseDown}
|
||||
>
|
||||
<EditorContent className="flex-1 min-h-full" editor={editor} />
|
||||
{editable && showBubbleMenu && (
|
||||
<EditorBubbleMenu editor={editor} currentIssueId={currentIssueId} />
|
||||
)}
|
||||
{editable && showBubbleMenu && <EditorBubbleMenu editor={editor} />}
|
||||
<LinkHoverCard {...hover} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
|
||||
/**
|
||||
* Escape → blur the editor. Without this, pressing ESC inside the
|
||||
* contenteditable does nothing (browsers don't blur contenteditables by
|
||||
* default), leaving users stuck in the editor with no keyboard escape hatch.
|
||||
*/
|
||||
export function createBlurShortcutExtension() {
|
||||
return Extension.create({
|
||||
name: "blurShortcut",
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Escape: ({ editor }) => {
|
||||
editor.commands.blur();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -40,7 +40,6 @@ import { createMentionSuggestion } from "./mention-suggestion";
|
||||
import { CodeBlockView } from "./code-block-view";
|
||||
import { createMarkdownPasteExtension } from "./markdown-paste";
|
||||
import { createSubmitExtension } from "./submit-shortcut";
|
||||
import { createBlurShortcutExtension } from "./blur-shortcut";
|
||||
import { createFileUploadExtension } from "./file-upload";
|
||||
import { FileCardExtension } from "./file-card";
|
||||
import { ImageView } from "./image-view";
|
||||
@@ -138,7 +137,6 @@ export function createEditorExtensions(
|
||||
},
|
||||
{ submitOnEnter: options.submitOnEnter ?? false },
|
||||
),
|
||||
createBlurShortcutExtension(),
|
||||
createFileUploadExtension(options.onUploadFileRef!),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -291,7 +291,6 @@ function CommentRow({
|
||||
onSubmit={saveEdit}
|
||||
onUploadFile={(file) => uploadWithToast(file, { issueId })}
|
||||
debounceMs={100}
|
||||
currentIssueId={issueId}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
@@ -512,7 +511,6 @@ function CommentCard({
|
||||
onSubmit={saveEdit}
|
||||
onUploadFile={(file) => uploadWithToast(file, { issueId })}
|
||||
debounceMs={100}
|
||||
currentIssueId={issueId}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
|
||||
@@ -63,7 +63,6 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
onSubmit={handleSubmit}
|
||||
onUploadFile={handleUpload}
|
||||
debounceMs={100}
|
||||
currentIssueId={issueId}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-1 right-1.5 flex items-center gap-1">
|
||||
|
||||
@@ -1049,7 +1049,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
onUpdate={(md) => handleUpdateField({ description: md })}
|
||||
onUploadFile={handleDescriptionUpload}
|
||||
debounceMs={1500}
|
||||
currentIssueId={id}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1 mt-3">
|
||||
|
||||
@@ -110,7 +110,6 @@ function ReplyInput({
|
||||
onSubmit={handleSubmit}
|
||||
onUploadFile={handleUpload}
|
||||
debounceMs={100}
|
||||
currentIssueId={issueId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -70,13 +70,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
|
||||
}
|
||||
|
||||
cfSigner := auth.NewCloudFrontSignerFromEnv()
|
||||
|
||||
signupConfig := handler.Config{
|
||||
AllowSignup: os.Getenv("ALLOW_SIGNUP") != "false",
|
||||
AllowedEmails: splitAndTrim(os.Getenv("ALLOWED_EMAILS")),
|
||||
AllowedEmailDomains: splitAndTrim(os.Getenv("ALLOWED_EMAIL_DOMAINS")),
|
||||
}
|
||||
h := handler.New(queries, pool, hub, bus, emailSvc, store, cfSigner, signupConfig)
|
||||
h := handler.New(queries, pool, hub, bus, emailSvc, store, cfSigner)
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
@@ -420,18 +414,3 @@ func parseUUID(s string) pgtype.UUID {
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func splitAndTrim(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
res := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
trimmed := strings.TrimSpace(p)
|
||||
if trimmed != "" {
|
||||
res = append(res, trimmed)
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/binary"
|
||||
@@ -23,18 +22,6 @@ import (
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
// SignupError represents signup restriction errors
|
||||
type SignupError struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e SignupError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
var ErrSignupProhibited = SignupError{Message: "user registration is disabled on this self-hosted instance"}
|
||||
var ErrEmailNotAllowed = SignupError{Message: "email address or domain not allowed on this instance"}
|
||||
|
||||
type UserResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -91,70 +78,23 @@ func (h *Handler) issueJWT(user db.User) (string, error) {
|
||||
|
||||
func (h *Handler) findOrCreateUser(ctx context.Context, email string) (db.User, error) {
|
||||
user, err := h.Queries.GetUserByEmail(ctx, email)
|
||||
isNewUser := isNotFound(err)
|
||||
if err != nil && !isNewUser {
|
||||
return db.User{}, err
|
||||
}
|
||||
|
||||
if err := h.checkSignupAllowed(email, isNewUser); err != nil {
|
||||
return db.User{}, err
|
||||
}
|
||||
|
||||
if !isNewUser {
|
||||
return user, nil
|
||||
}
|
||||
|
||||
name := email
|
||||
if at := strings.Index(email, "@"); at > 0 {
|
||||
name = email[:at]
|
||||
}
|
||||
return h.Queries.CreateUser(ctx, db.CreateUserParams{
|
||||
Name: name,
|
||||
Email: email,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) checkSignupAllowed(email string, isNewUser bool) error {
|
||||
if !isNewUser {
|
||||
return nil // existing users always allowed to log in
|
||||
}
|
||||
|
||||
email = strings.ToLower(email)
|
||||
domain := ""
|
||||
if at := strings.Index(email, "@"); at > 0 {
|
||||
domain = email[at+1:]
|
||||
}
|
||||
|
||||
// 1. explicit email whitelist always wins
|
||||
if len(h.cfg.AllowedEmails) > 0 && contains(h.cfg.AllowedEmails, email) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 2. domain whitelist always wins
|
||||
if len(h.cfg.AllowedEmailDomains) > 0 && contains(h.cfg.AllowedEmailDomains, domain) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 3. general signup flag
|
||||
if !h.cfg.AllowSignup {
|
||||
return ErrSignupProhibited
|
||||
}
|
||||
|
||||
// 4. if allowlists are set but didn't match, block
|
||||
if len(h.cfg.AllowedEmailDomains) > 0 || len(h.cfg.AllowedEmails) > 0 {
|
||||
return ErrSignupProhibited
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func contains(slice []string, s string) bool {
|
||||
for _, item := range slice {
|
||||
if strings.EqualFold(item, s) {
|
||||
return true
|
||||
if err != nil {
|
||||
if !isNotFound(err) {
|
||||
return db.User{}, err
|
||||
}
|
||||
name := email
|
||||
if at := strings.Index(email, "@"); at > 0 {
|
||||
name = email[:at]
|
||||
}
|
||||
user, err = h.Queries.CreateUser(ctx, db.CreateUserParams{
|
||||
Name: name,
|
||||
Email: email,
|
||||
})
|
||||
if err != nil {
|
||||
return db.User{}, err
|
||||
}
|
||||
}
|
||||
return false
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (h *Handler) SendCode(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -170,40 +110,6 @@ func (h *Handler) SendCode(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check signup restrictions before sending magic link
|
||||
_, err := h.Queries.GetUserByEmail(r.Context(), email)
|
||||
if err != nil {
|
||||
if !isNotFound(err) {
|
||||
// Real database/query error → return 500
|
||||
writeError(w, http.StatusInternalServerError, "failed to lookup user")
|
||||
return
|
||||
}
|
||||
// User does not exist → treat as new user
|
||||
isNewUser := true
|
||||
if err := h.checkSignupAllowed(email, isNewUser); err != nil {
|
||||
var signupErr SignupError
|
||||
if errors.As(err, &signupErr) {
|
||||
writeError(w, http.StatusForbidden, signupErr.Error())
|
||||
} else {
|
||||
writeError(w, http.StatusForbidden, "user registration is disabled")
|
||||
}
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// User already exists → always allowed to login
|
||||
isNewUser := false
|
||||
if err := h.checkSignupAllowed(email, isNewUser); err != nil {
|
||||
// This should rarely happen, but handle it anyway
|
||||
var signupErr SignupError
|
||||
if errors.As(err, &signupErr) {
|
||||
writeError(w, http.StatusForbidden, signupErr.Error())
|
||||
} else {
|
||||
writeError(w, http.StatusForbidden, "user registration is disabled")
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limit: max 1 code per 60 seconds per email
|
||||
latest, err := h.Queries.GetLatestCodeByEmail(r.Context(), email)
|
||||
if err == nil && time.Since(latest.CreatedAt.Time) < 60*time.Second {
|
||||
@@ -274,11 +180,6 @@ func (h *Handler) VerifyCode(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
user, err := h.findOrCreateUser(r.Context(), email)
|
||||
if err != nil {
|
||||
var signupErr SignupError
|
||||
if errors.As(err, &signupErr) {
|
||||
writeError(w, http.StatusForbidden, signupErr.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to create user")
|
||||
return
|
||||
}
|
||||
@@ -435,11 +336,6 @@ func (h *Handler) GoogleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
user, err := h.findOrCreateUser(r.Context(), email)
|
||||
if err != nil {
|
||||
var signupErr SignupError
|
||||
if errors.As(err, &signupErr) {
|
||||
writeError(w, http.StatusForbidden, signupErr.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to create user")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
func newTestHandler(cfg Config) *Handler {
|
||||
return &Handler{
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignupGating(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg Config
|
||||
email string
|
||||
isNew bool
|
||||
wantErr bool
|
||||
}{
|
||||
{"allow_signup_true_new", Config{AllowSignup: true}, "a@x.com", true, false},
|
||||
{"allow_signup_false_new", Config{AllowSignup: false}, "a@x.com", true, true},
|
||||
{"allow_signup_false_existing", Config{AllowSignup: false}, "a@x.com", false, false},
|
||||
{"domain_allowlist_match", Config{AllowSignup: false, AllowedEmailDomains: []string{"company.com"}}, "user@company.com", true, false},
|
||||
{"domain_allowlist_mismatch", Config{AllowSignup: false, AllowedEmailDomains: []string{"company.com"}}, "user@other.com", true, true},
|
||||
{"email_allowlist_match", Config{AllowSignup: false, AllowedEmails: []string{"boss@x.com"}}, "boss@x.com", true, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
h := newTestHandler(tt.cfg)
|
||||
err := h.checkSignupAllowed(tt.email, tt.isNew)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("got err=%v wantErr=%v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type mockDB struct {
|
||||
db.DBTX
|
||||
getUserErr error
|
||||
}
|
||||
|
||||
func (m *mockDB) QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row {
|
||||
return &mockRow{err: m.getUserErr}
|
||||
}
|
||||
|
||||
func (m *mockDB) Exec(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error) {
|
||||
return pgconn.NewCommandTag("INSERT 1"), nil
|
||||
}
|
||||
|
||||
type mockRow struct {
|
||||
pgx.Row
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockRow) Scan(dest ...interface{}) error {
|
||||
return m.err
|
||||
}
|
||||
|
||||
func TestFindOrCreateUserGating(t *testing.T) {
|
||||
t.Run("new_user_blocked", func(t *testing.T) {
|
||||
cfg := Config{AllowSignup: false}
|
||||
h := newTestHandler(cfg)
|
||||
h.Queries = db.New(&mockDB{getUserErr: pgx.ErrNoRows})
|
||||
|
||||
_, err := h.findOrCreateUser(context.Background(), "new@blocked.com")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for new user when signup disabled")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "registration is disabled") {
|
||||
t.Fatalf("expected registration disabled error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("existing_user_allowed", func(t *testing.T) {
|
||||
cfg := Config{AllowSignup: false}
|
||||
h := newTestHandler(cfg)
|
||||
// mockDB returns nil error for Scan, simulating user found
|
||||
h.Queries = db.New(&mockDB{getUserErr: nil})
|
||||
|
||||
_, err := h.findOrCreateUser(context.Background(), "existing@test.com")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error for existing user, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("whitelisted_user_allowed", func(t *testing.T) {
|
||||
cfg := Config{AllowSignup: false, AllowedEmails: []string{"whitelisted@test.com"}}
|
||||
h := newTestHandler(cfg)
|
||||
h.Queries = db.New(&mockDB{getUserErr: pgx.ErrNoRows})
|
||||
|
||||
// This will pass checkSignupAllowed and move to CreateUser.
|
||||
// Our mockDB Exec returns success, but Queries.CreateUser might expect QueryRow for RETURNING id.
|
||||
// Let's see if it works.
|
||||
_, err := h.findOrCreateUser(context.Background(), "whitelisted@test.com")
|
||||
if err != nil && strings.Contains(err.Error(), "registration is disabled") {
|
||||
t.Fatalf("expected whitelisted user to pass signup check, but got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -31,12 +31,6 @@ type dbExecutor interface {
|
||||
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
AllowSignup bool
|
||||
AllowedEmails []string
|
||||
AllowedEmailDomains []string
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
Queries *db.Queries
|
||||
DB dbExecutor
|
||||
@@ -50,10 +44,9 @@ type Handler struct {
|
||||
UpdateStore *UpdateStore
|
||||
Storage storage.Storage
|
||||
CFSigner *auth.CloudFrontSigner
|
||||
cfg Config
|
||||
}
|
||||
|
||||
func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus, emailService *service.EmailService, store storage.Storage, cfSigner *auth.CloudFrontSigner, cfg Config) *Handler {
|
||||
func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *events.Bus, emailService *service.EmailService, store storage.Storage, cfSigner *auth.CloudFrontSigner) *Handler {
|
||||
var executor dbExecutor
|
||||
if candidate, ok := txStarter.(dbExecutor); ok {
|
||||
executor = candidate
|
||||
@@ -73,7 +66,6 @@ func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *event
|
||||
UpdateStore: NewUpdateStore(),
|
||||
Storage: store,
|
||||
CFSigner: cfSigner,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ func TestMain(m *testing.M) {
|
||||
go hub.Run()
|
||||
bus := events.New()
|
||||
emailSvc := service.NewEmailService()
|
||||
testHandler = New(queries, pool, hub, bus, emailSvc, nil, nil, Config{AllowSignup: true})
|
||||
testHandler = New(queries, pool, hub, bus, emailSvc, nil, nil)
|
||||
testPool = pool
|
||||
|
||||
testUserID, testWorkspaceID, err = setupHandlerTestFixture(ctx, pool)
|
||||
@@ -821,39 +821,6 @@ func TestSendCode(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestSendCodeDbError(t *testing.T) {
|
||||
// We can't easily mock the DB here without changing architecture,
|
||||
// but we can simulate a DB error by closing the pool temporarily or
|
||||
// using a cancelled context if the query respects it.
|
||||
|
||||
// Create a handler with a "broken" queries object is hard because it's a struct.
|
||||
// Instead, let's use a context that is already cancelled.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
body := map[string]string{"email": "dberror-test@multica.ai"}
|
||||
var buf bytes.Buffer
|
||||
json.NewEncoder(&buf).Encode(body)
|
||||
req := httptest.NewRequest("POST", "/auth/send-code", &buf)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
testHandler.SendCode(w, req)
|
||||
|
||||
// If the DB query respects the cancelled context, it should return an error.
|
||||
// pgx usually returns context.Canceled which is not what isNotFound checks for.
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("SendCode (db error): expected 500, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp["error"] != "failed to lookup user" {
|
||||
t.Fatalf("SendCode (db error): expected error message 'failed to lookup user', got '%s'", resp["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendCodeRateLimit(t *testing.T) {
|
||||
const email = "ratelimit-test@multica.ai"
|
||||
t.Cleanup(func() {
|
||||
|
||||
Reference in New Issue
Block a user