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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isSignUpDisabled && (
|
{(!isSignUpDisabled || redirect?.startsWith("/invite/")) && (
|
||||||
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
|
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
|
||||||
<Trans>
|
<Trans>
|
||||||
Don't have an account?{" "}
|
Don't have an account?{" "}
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ export default function SignUpPage() {
|
|||||||
setMagicLinkRecipient(recipient);
|
setMagicLinkRecipient(recipient);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isSignUpDisabled) {
|
const isInviteFlow = redirect?.startsWith("/invite/");
|
||||||
|
|
||||||
|
if (isSignUpDisabled && !isInviteFlow) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHead title={t`Sign up | kan.bn`} />
|
<PageHead title={t`Sign up | kan.bn`} />
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"dev": "tsc",
|
"dev": "tsc",
|
||||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
|
"test": "vitest run",
|
||||||
"typecheck": "tsc --noEmit --emitDeclarationOnly false"
|
"typecheck": "tsc --noEmit --emitDeclarationOnly false"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -33,8 +33,10 @@ export const initAuth = (db: dbClient) => {
|
|||||||
},
|
},
|
||||||
emailAndPassword: {
|
emailAndPassword: {
|
||||||
enabled: env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true",
|
enabled: env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true",
|
||||||
disableSignUp:
|
// Sign-up restriction is handled by the user.create.before database
|
||||||
env("NEXT_PUBLIC_DISABLE_SIGN_UP")?.toLowerCase() === "true",
|
// hook which checks for pending invitations, allowing invited users
|
||||||
|
// to register even when public sign-up is disabled.
|
||||||
|
disableSignUp: false,
|
||||||
sendResetPassword: async (data) => {
|
sendResetPassword: async (data) => {
|
||||||
await sendEmail(data.user.email, "Reset Password", "RESET_PASSWORD", {
|
await sendEmail(data.user.email, "Reset Password", "RESET_PASSWORD", {
|
||||||
resetPasswordUrl: data.url,
|
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