diff --git a/apps/web/src/views/auth/login/index.tsx b/apps/web/src/views/auth/login/index.tsx index 19aa2cc9..768bd5a8 100644 --- a/apps/web/src/views/auth/login/index.tsx +++ b/apps/web/src/views/auth/login/index.tsx @@ -58,7 +58,7 @@ export default function LoginPage() { )} - {!isSignUpDisabled && ( + {(!isSignUpDisabled || redirect?.startsWith("/invite/")) && (

Don't have an account?{" "} diff --git a/apps/web/src/views/auth/signup/index.tsx b/apps/web/src/views/auth/signup/index.tsx index 42486465..9ea134b3 100644 --- a/apps/web/src/views/auth/signup/index.tsx +++ b/apps/web/src/views/auth/signup/index.tsx @@ -28,7 +28,9 @@ export default function SignUpPage() { setMagicLinkRecipient(recipient); }; - if (isSignUpDisabled) { + const isInviteFlow = redirect?.startsWith("/invite/"); + + if (isSignUpDisabled && !isInviteFlow) { return ( <> diff --git a/packages/auth/package.json b/packages/auth/package.json index 7b482a64..c63e1fcf 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -21,6 +21,7 @@ "dev": "tsc", "format": "prettier --check . --ignore-path ../../.gitignore", "lint": "eslint", + "test": "vitest run", "typecheck": "tsc --noEmit --emitDeclarationOnly false" }, "devDependencies": { diff --git a/packages/auth/src/auth.ts b/packages/auth/src/auth.ts index 6a117ad7..3ca51ef0 100644 --- a/packages/auth/src/auth.ts +++ b/packages/auth/src/auth.ts @@ -33,8 +33,10 @@ export const initAuth = (db: dbClient) => { }, emailAndPassword: { enabled: env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true", - disableSignUp: - env("NEXT_PUBLIC_DISABLE_SIGN_UP")?.toLowerCase() === "true", + // Sign-up restriction is handled by the user.create.before database + // hook which checks for pending invitations, allowing invited users + // to register even when public sign-up is disabled. + disableSignUp: false, sendResetPassword: async (data) => { await sendEmail(data.user.email, "Reset Password", "RESET_PASSWORD", { resetPasswordUrl: data.url, diff --git a/packages/auth/src/hooks.test.ts b/packages/auth/src/hooks.test.ts new file mode 100644 index 00000000..d3aae5d6 --- /dev/null +++ b/packages/auth/src/hooks.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("next-runtime-env", () => ({ + env: vi.fn(), +})); + +vi.mock("@kan/db/repository/member.repo", () => ({ + getByEmailAndStatus: vi.fn(), + getByPublicId: vi.fn(), + acceptInvite: vi.fn(), +})); + +vi.mock("@kan/db/repository/user.repo", () => ({ + update: vi.fn(), +})); + +vi.mock("@kan/email", () => ({ + notificationClient: null, +})); + +vi.mock("@kan/shared", () => ({ + createEmailUnsubscribeLink: vi.fn(), + createS3Client: vi.fn(), +})); + +vi.mock("@aws-sdk/client-s3", () => ({ + PutObjectCommand: vi.fn(), +})); + +vi.mock("@novu/api/models/components", () => ({ + ChatOrPushProviderEnum: { Discord: "discord" }, +})); + +import { env } from "next-runtime-env"; +import * as memberRepo from "@kan/db/repository/member.repo"; +import { createDatabaseHooks } from "./hooks"; + +const mockEnv = env as ReturnType; +const mockGetByEmailAndStatus = + memberRepo.getByEmailAndStatus as ReturnType; + +const db = {} as Parameters[0]; + +const fakeUser = { + id: "user-1", + createdAt: new Date(), + updatedAt: new Date(), + email: "test@example.com", + emailVerified: false, + name: "Test User", +}; + +describe("createDatabaseHooks", () => { + const hooks = createDatabaseHooks(db); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("user.create.before", () => { + it("allows sign-up when DISABLE_SIGN_UP is not set", async () => { + mockEnv.mockReturnValue(undefined); + + const result = await hooks.user.create.before(fakeUser, {}); + expect(result).toBe(true); + expect(mockGetByEmailAndStatus).not.toHaveBeenCalled(); + }); + + it("allows sign-up when DISABLE_SIGN_UP is false", async () => { + mockEnv.mockImplementation((key: string) => + key === "NEXT_PUBLIC_DISABLE_SIGN_UP" ? "false" : undefined, + ); + + const result = await hooks.user.create.before(fakeUser, {}); + expect(result).toBe(true); + expect(mockGetByEmailAndStatus).not.toHaveBeenCalled(); + }); + + it("blocks sign-up when disabled and user has no pending invitation", async () => { + mockEnv.mockImplementation((key: string) => + key === "NEXT_PUBLIC_DISABLE_SIGN_UP" ? "true" : undefined, + ); + mockGetByEmailAndStatus.mockResolvedValue(undefined); + + const result = await hooks.user.create.before(fakeUser, {}); + expect(result).toBe(false); + expect(mockGetByEmailAndStatus).toHaveBeenCalledWith( + db, + "test@example.com", + "invited", + ); + }); + + it("allows sign-up when disabled but user has a pending invitation", async () => { + mockEnv.mockImplementation((key: string) => + key === "NEXT_PUBLIC_DISABLE_SIGN_UP" ? "true" : undefined, + ); + mockGetByEmailAndStatus.mockResolvedValue({ + id: "member-1", + email: "test@example.com", + status: "invited", + }); + + const result = await hooks.user.create.before(fakeUser, {}); + expect(result).toBe(true); + expect(mockGetByEmailAndStatus).toHaveBeenCalledWith( + db, + "test@example.com", + "invited", + ); + }); + + it("blocks sign-up when disabled and invitation exists but domain is not allowed", async () => { + mockEnv.mockImplementation((key: string) => + key === "NEXT_PUBLIC_DISABLE_SIGN_UP" ? "true" : undefined, + ); + process.env.BETTER_AUTH_ALLOWED_DOMAINS = "acme.com"; + mockGetByEmailAndStatus.mockResolvedValue({ + id: "member-1", + email: "test@example.com", + status: "invited", + }); + + const result = await hooks.user.create.before(fakeUser, {}); + expect(result).toBe(false); + + delete process.env.BETTER_AUTH_ALLOWED_DOMAINS; + }); + + // The user.create.before hook fires for ALL sign-up paths including + // OIDC/social — verify invite bypass works regardless of auth method. + it("allows OIDC/social sign-up when disabled but user has a pending invitation", async () => { + mockEnv.mockImplementation((key: string) => + key === "NEXT_PUBLIC_DISABLE_SIGN_UP" ? "true" : undefined, + ); + const oidcUser = { + ...fakeUser, + id: "user-oidc", + email: "sso@corp.com", + image: "https://provider.com/avatar.jpg", + }; + mockGetByEmailAndStatus.mockResolvedValue({ + id: "member-2", + email: "sso@corp.com", + status: "invited", + }); + + const result = await hooks.user.create.before(oidcUser, {}); + expect(result).toBe(true); + expect(mockGetByEmailAndStatus).toHaveBeenCalledWith( + db, + "sso@corp.com", + "invited", + ); + }); + + it("blocks OIDC/social sign-up when disabled and user has no pending invitation", async () => { + mockEnv.mockImplementation((key: string) => + key === "NEXT_PUBLIC_DISABLE_SIGN_UP" ? "true" : undefined, + ); + const oidcUser = { + ...fakeUser, + id: "user-oidc", + email: "random@external.com", + image: "https://provider.com/avatar.jpg", + }; + mockGetByEmailAndStatus.mockResolvedValue(undefined); + + const result = await hooks.user.create.before(oidcUser, {}); + expect(result).toBe(false); + expect(mockGetByEmailAndStatus).toHaveBeenCalledWith( + db, + "random@external.com", + "invited", + ); + }); + + it("allows sign-up when disabled, invitation exists, and domain is allowed", async () => { + mockEnv.mockImplementation((key: string) => + key === "NEXT_PUBLIC_DISABLE_SIGN_UP" ? "true" : undefined, + ); + process.env.BETTER_AUTH_ALLOWED_DOMAINS = "example.com"; + mockGetByEmailAndStatus.mockResolvedValue({ + id: "member-1", + email: "test@example.com", + status: "invited", + }); + + const result = await hooks.user.create.before(fakeUser, {}); + expect(result).toBe(true); + + delete process.env.BETTER_AUTH_ALLOWED_DOMAINS; + }); + }); +}); diff --git a/packages/auth/vitest.config.ts b/packages/auth/vitest.config.ts new file mode 100644 index 00000000..e0c6a873 --- /dev/null +++ b/packages/auth/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; +import { resolve } from "path"; + +export default defineConfig({ + test: { + root: __dirname, + include: ["src/**/*.test.ts"], + }, + resolve: { + alias: { + "@kan/db": resolve(__dirname, "../db/src"), + }, + }, +});