Skip to content

Commit 8398d52

Browse files
authored
feat: disable signups by default (#233)
1 parent 75d8635 commit 8398d52

14 files changed

Lines changed: 273 additions & 43 deletions

File tree

internal/api/src/routes/auth/auth.server.test.ts

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -788,7 +788,11 @@ test("POST /signup with redirect returns custom redirect URL", async () => {
788788
});
789789

790790
test("POST /signup rejects duplicate email", async () => {
791-
const { url, helpers } = await serve();
791+
const { url, helpers } = await serve({
792+
bindings: {
793+
enableSignups: true,
794+
},
795+
});
792796
const email = "existing@example.com";
793797

794798
await helpers.createUser({ email });
@@ -871,6 +875,31 @@ test("POST /signup without email verification creates session and redirects to c
871875
expect(cookieString).toContain("last_login_provider=credentials");
872876
});
873877

878+
test("POST /signup rejects when signups are disabled and not first user", async () => {
879+
const { url, helpers } = await serve({
880+
bindings: {
881+
enableSignups: false,
882+
},
883+
});
884+
885+
await helpers.createUser({ email: "existing@example.com" });
886+
887+
const res = await fetch(`${url}/api/auth/signup`, {
888+
method: "POST",
889+
headers: {
890+
"Content-Type": "application/json",
891+
},
892+
body: JSON.stringify({
893+
email: "blocked@example.com",
894+
password: "password123",
895+
}),
896+
});
897+
898+
expect(res.status).toBe(403);
899+
const data = await res.json();
900+
expect(data.error).toBe("Signups are disabled");
901+
});
902+
874903
test("POST /resend-email-verification regenerates token", async () => {
875904
const { url, helpers, bindings } = await serve();
876905
const { user } = await helpers.createUser({
@@ -1105,6 +1134,7 @@ test("POST /signup second user with autoJoinOrganizations gets site_role member"
11051134
bindings: {
11061135
sendEmail: undefined,
11071136
autoJoinOrganizations: true,
1137+
enableSignups: true,
11081138
},
11091139
});
11101140

@@ -1197,6 +1227,7 @@ test("GET /callback/github second user with autoJoinOrganizations gets site_role
11971227
GITHUB_CLIENT_ID: "test-client-id",
11981228
GITHUB_CLIENT_SECRET: "test-client-secret",
11991229
autoJoinOrganizations: true,
1230+
enableSignups: true,
12001231
},
12011232
});
12021233

@@ -1252,6 +1283,65 @@ test("GET /callback/github second user with autoJoinOrganizations gets site_role
12521283
expect(secondUser?.site_role).toBe("member");
12531284
});
12541285

1286+
test("GET /callback/github blocks new user when signups are disabled", async () => {
1287+
const { url, bindings, helpers } = await serve({
1288+
bindings: {
1289+
GITHUB_CLIENT_ID: "test-client-id",
1290+
GITHUB_CLIENT_SECRET: "test-client-secret",
1291+
autoJoinOrganizations: true,
1292+
enableSignups: false,
1293+
},
1294+
});
1295+
1296+
const { user: firstUser } = await helpers.createUser({
1297+
email: "admin@example.com",
1298+
site_role: "admin",
1299+
});
1300+
const db = await bindings.database();
1301+
await db.insertOrganizationWithMembership({
1302+
name: "default",
1303+
kind: "organization",
1304+
created_by: firstUser.id,
1305+
});
1306+
1307+
mswServer.use(
1308+
http.post("https://github.com/login/oauth/access_token", () => {
1309+
return HttpResponse.json(mockOAuthTokenResponse);
1310+
}),
1311+
http.get("https://api.github.com/user", () => {
1312+
return HttpResponse.json({
1313+
...mockGitHubProfile,
1314+
id: 999998,
1315+
email: "blockedoauth@example.com",
1316+
});
1317+
})
1318+
);
1319+
1320+
const { encode } = await import("next-auth/jwt");
1321+
const state = await encode({
1322+
secret: bindings.AUTH_SECRET,
1323+
salt: "oauth-state",
1324+
token: {
1325+
provider: "github",
1326+
nonce: "test-nonce",
1327+
callbackUrl: `${url}/api/auth/callback/github`,
1328+
},
1329+
});
1330+
1331+
const res = await fetch(
1332+
`${url}/api/auth/callback/github?code=test-code&state=${state}`,
1333+
{
1334+
redirect: "manual",
1335+
}
1336+
);
1337+
1338+
expect(res.status).toBe(302);
1339+
expect(res.headers.get("location")).toBe("/login?error=signups_disabled");
1340+
1341+
const blockedUser = await db.selectUserByEmail("blockedoauth@example.com");
1342+
expect(blockedUser).toBeUndefined();
1343+
});
1344+
12551345
// Suspended user tests
12561346

12571347
const oauthProviderConfigs = [

internal/api/src/routes/auth/auth.server.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,24 @@ const providers = {
5656
// ============================================================================
5757

5858
const INVITE_COOKIE = "blink_invite_verified";
59+
type Database = Awaited<ReturnType<Bindings["database"]>>;
60+
61+
const isPublicSignupAllowed = async (
62+
c: Context<{ Bindings: Bindings }>,
63+
db: Database
64+
) => {
65+
if (c.env.enableSignups) {
66+
return true;
67+
}
68+
69+
const teamOrgs = await db.selectTeamOrganizations();
70+
if (teamOrgs.length > 0) {
71+
return false;
72+
}
73+
74+
const users = await db.selectAllUsers({ page: 1, per_page: 1 });
75+
return users.items.length === 0;
76+
};
5977

6078
// ============================================================================
6179
// OAuth
@@ -279,6 +297,11 @@ async function handleOAuthCallback(
279297
);
280298
}
281299
}
300+
301+
if (!(await isPublicSignupAllowed(c, db))) {
302+
return c.redirect("/login?error=signups_disabled");
303+
}
304+
282305
// Create new user
283306
let usedInviteId: string | null = null;
284307

@@ -925,6 +948,10 @@ export default function mountAuth(server: APIServer) {
925948
const { email, password, redirect: redirectTarget } = c.req.valid("json");
926949
const db = await c.env.database();
927950

951+
if (!(await isPublicSignupAllowed(c, db))) {
952+
return c.json({ error: "Signups are disabled" }, 403);
953+
}
954+
928955
// Check if user exists
929956
const existingUser = await db.selectUserByEmail(email);
930957
if (existingUser) {

internal/api/src/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,8 @@ export interface Bindings {
240240

241241
/** When true, auto-add new users to all existing team organizations (self-hosted mode) */
242242
readonly autoJoinOrganizations?: boolean;
243+
/** When true, allow public signups (email/password + OAuth). */
244+
readonly enableSignups?: boolean;
243245

244246
// Optional AWS credentials used by platform logging to CloudWatch
245247
readonly AWS_ACCESS_KEY_ID?: string;

internal/api/src/test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ export const serve = async (options?: ServeOptions) => {
118118
serverVersion: "test",
119119
ONBOARDING_AGENT_BUNDLE_URL:
120120
options?.bindings?.ONBOARDING_AGENT_BUNDLE_URL ?? "override-me-in-test",
121+
enableSignups: options?.bindings?.enableSignups ?? true,
121122
...options?.bindings,
122123
agentStore: (targetID) => {
123124
let store = agentStore.get(targetID);

internal/site/app/(public)/login/page.tsx

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import Link from "next/link";
44
import { redirect } from "next/navigation";
55
import { Badge } from "@/components/ui/badge";
66
import { Button } from "@/components/ui/button";
7-
import { getQuerier } from "@/lib/database";
7+
import { getPublicSignupStatus } from "@/lib/signups";
88
import { LoginForm } from "./form";
99

1010
export const metadata: Metadata = {
@@ -24,9 +24,8 @@ interface LoginPageProps {
2424
}
2525

2626
export default async function LoginPage({ searchParams }: LoginPageProps) {
27-
const db = await getQuerier();
28-
const teamOrgs = await db.selectTeamOrganizations();
29-
if (teamOrgs.length === 0) {
27+
const { isFirstUser, enableSignups } = await getPublicSignupStatus();
28+
if (isFirstUser) {
3029
redirect("/setup");
3130
}
3231

@@ -173,17 +172,19 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
173172
</div>
174173

175174
{/* Footer */}
176-
<div className="mt-6 text-center">
177-
<p className="text-sm text-neutral-600 dark:text-neutral-400">
178-
Don't have an account?{" "}
179-
<a
180-
href={`/signup${redirectQuery}`}
181-
className="text-blue-600 dark:text-blue-400 hover:underline font-medium"
182-
>
183-
Sign up
184-
</a>
185-
</p>
186-
</div>
175+
{enableSignups && (
176+
<div className="mt-6 text-center">
177+
<p className="text-sm text-neutral-600 dark:text-neutral-400">
178+
Don't have an account?{" "}
179+
<a
180+
href={`/signup${redirectQuery}`}
181+
className="text-blue-600 dark:text-blue-400 hover:underline font-medium"
182+
>
183+
Sign up
184+
</a>
185+
</p>
186+
</div>
187+
)}
187188
</div>
188189

189190
{/* Terms */}
@@ -218,6 +219,8 @@ function mapErrorToMessage(
218219
return "Sign-in is temporarily unavailable. Please try again later.";
219220
case "Verification":
220221
return "The verification link is invalid or has expired.";
222+
case "signups_disabled":
223+
return "Signups are disabled on this instance. Contact your administrator.";
221224
default:
222225
return "Sign-in failed. Please try again.";
223226
}

internal/site/app/(public)/signup/page.tsx

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Button } from "@/components/ui/button";
2-
import { getQuerier } from "@/lib/database";
2+
import { getPublicSignupStatus } from "@/lib/signups";
33
import type { Metadata } from "next";
44
import Link from "next/link";
55
import { redirect } from "next/navigation";
@@ -26,11 +26,9 @@ export default async function SignupPage({ searchParams }: SignupPageProps) {
2626
const redirectQuery = redirectTarget
2727
? `?redirect=${encodeURIComponent(redirectTarget)}`
2828
: "";
29+
const loginHref = `/login${redirectQuery}`;
2930

30-
// Check if this is the first user (no team organizations exist yet)
31-
const db = await getQuerier();
32-
const teamOrgs = await db.selectTeamOrganizations();
33-
const isFirstUser = teamOrgs.length === 0;
31+
const { isFirstUser, enableSignups } = await getPublicSignupStatus();
3432

3533
if (isFirstUser) {
3634
const setupQuery = new URLSearchParams();
@@ -40,6 +38,32 @@ export default async function SignupPage({ searchParams }: SignupPageProps) {
4038
redirect(`/setup${queryString ? `?${queryString}` : ""}`);
4139
}
4240

41+
if (!enableSignups) {
42+
return (
43+
<div className="flex items-center justify-center p-4">
44+
<div className="w-full max-w-md">
45+
<div className="text-center mb-8">
46+
<h1 className="text-3xl font-bold text-zinc-900 dark:text-white mb-2">
47+
Signups are disabled
48+
</h1>
49+
</div>
50+
51+
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-200 dark:border-zinc-700 shadow-lg p-8 text-center space-y-4">
52+
<p className="text-sm text-neutral-600 dark:text-neutral-400">
53+
If you already have an account, you can sign in. Otherwise, please
54+
contact your administrator.
55+
</p>
56+
<Link href={loginHref}>
57+
<Button size="lg" className="w-full h-12 text-base font-medium">
58+
Go to login
59+
</Button>
60+
</Link>
61+
</div>
62+
</div>
63+
</div>
64+
);
65+
}
66+
4367
return (
4468
<div className="flex items-center justify-center p-4">
4569
<div className="w-full max-w-md">

internal/site/app/(setup)/setup/page.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { getQuerier } from "@/lib/database";
21
import type { Metadata } from "next";
32
import { redirect } from "next/navigation";
3+
import { getPublicSignupStatus } from "@/lib/signups";
44

55
import { SetupForm } from "./setup-form";
66

@@ -14,12 +14,10 @@ interface SetupPageProps {
1414
}
1515

1616
export default async function SetupPage({ searchParams }: SetupPageProps) {
17-
// Check if this is actually the first user
18-
const db = await getQuerier();
19-
const teamOrgs = await db.selectTeamOrganizations();
17+
const { isFirstUser } = await getPublicSignupStatus();
2018

2119
// If not first user, redirect to normal signup
22-
if (teamOrgs.length > 0) {
20+
if (!isFirstUser) {
2321
redirect("/signup");
2422
}
2523

0 commit comments

Comments
 (0)