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"),
+ },
+ },
+});