fix: allow invited users to sign up when registration is disabled (#418)
* fix: allow invited users to sign up when registration is disabled Move sign-up restriction logic from better-auth's disableSignUp config to the existing user.create.before database hook, which already checks for pending invitations. The frontend signup and login pages now detect invite flows (?next=/invite/...) and bypass the disabled UI accordingly. Closes #411 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add regression tests for sign-up hook invite bypass Verify that the user.create.before database hook correctly: - allows sign-up when registration is not disabled - blocks sign-up when disabled and no invitation exists - allows sign-up when disabled but a pending invitation exists - respects BETTER_AUTH_ALLOWED_DOMAINS in combination with invites Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add OIDC/social sign-up path coverage for invite bypass Address review suggestion: add explicit tests verifying the user.create.before hook handles OIDC/social sign-ups the same way as email/password — invited users are allowed, uninvited users are blocked. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -58,7 +58,7 @@ export default function LoginPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isSignUpDisabled && (
|
||||
{(!isSignUpDisabled || redirect?.startsWith("/invite/")) && (
|
||||
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
|
||||
<Trans>
|
||||
Don't have an account?{" "}
|
||||
|
||||
@@ -28,7 +28,9 @@ export default function SignUpPage() {
|
||||
setMagicLinkRecipient(recipient);
|
||||
};
|
||||
|
||||
if (isSignUpDisabled) {
|
||||
const isInviteFlow = redirect?.startsWith("/invite/");
|
||||
|
||||
if (isSignUpDisabled && !isInviteFlow) {
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t`Sign up | kan.bn`} />
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"dev": "tsc",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit --emitDeclarationOnly false"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
195
packages/auth/src/hooks.test.ts
Normal file
195
packages/auth/src/hooks.test.ts
Normal file
@@ -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<typeof vi.fn>;
|
||||
const mockGetByEmailAndStatus =
|
||||
memberRepo.getByEmailAndStatus as ReturnType<typeof vi.fn>;
|
||||
|
||||
const db = {} as Parameters<typeof createDatabaseHooks>[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;
|
||||
});
|
||||
});
|
||||
});
|
||||
14
packages/auth/vitest.config.ts
Normal file
14
packages/auth/vitest.config.ts
Normal file
@@ -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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user