diff --git a/.env.example b/.env.example
index cc1331259..6125c0a43 100644
--- a/.env.example
+++ b/.env.example
@@ -850,6 +850,14 @@ ENTERPRISE_SITE_URL=http://localhost:3000
ENTERPRISE_REDIRECT_ALLOW_LIST=
# Default true: regulated deployments use admin-provisioned users only.
# Set to false to re-enable self-serve signup (e.g. internal dev environment).
+# This one variable now drives three things that used to be able to
+# disagree: GoTrue's own flag, the gateway refusal in
+# deploy/docker/Caddyfile.supabase, and whether web-console renders the
+# sign-up form at all. While it is true, the console says accounts are
+# created by invitation instead of shipping a form the gateway 404s
+# (issue #1328). Re-enabling self-serve signup still needs the Caddy
+# refusal lifted as well, and the tenant provisioning path in
+# .wolf/decisions.md D-023.
ENTERPRISE_DISABLE_SIGNUP=true
ENTERPRISE_MAILER_AUTOCONFIRM=true
diff --git a/apps/web-console/__tests__/sign-in-next-redirect.test.tsx b/apps/web-console/__tests__/sign-in-next-redirect.test.tsx
index 96f6a1368..c95759d93 100644
--- a/apps/web-console/__tests__/sign-in-next-redirect.test.tsx
+++ b/apps/web-console/__tests__/sign-in-next-redirect.test.tsx
@@ -45,6 +45,11 @@ describe("app/auth/sign-in/page.tsx next-target redirect", () => {
vi.clearAllMocks();
mockSignInWithPassword.mockResolvedValue({ error: null });
window.history.pushState({}, "", "/auth/sign-in");
+ // The cross-link cases below are the self-serve-enabled shape. The
+ // flag fails closed (lib/auth/self-serve.ts), so it has to be set
+ // explicitly here or every link assertion would be testing the
+ // invitation-only footer instead (issue #1328).
+ process.env.NEXT_PUBLIC_DISABLE_SELF_SERVE_SIGNUP = "false";
});
async function submitForm() {
@@ -290,3 +295,26 @@ describe("app/auth/sign-in/page.tsx next-target redirect", () => {
expect(mockNavigate).not.toHaveBeenCalled();
});
});
+
+describe("app/auth/sign-in/page.tsx sign-up cross-link gating", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ window.history.pushState({}, "", "/auth/sign-in");
+ });
+
+ it("offers the sign-up link when this deployment accepts self-serve signup", async () => {
+ process.env.NEXT_PUBLIC_DISABLE_SELF_SERVE_SIGNUP = "false";
+ render();
+ const link = await screen.findByRole("link", { name: /create one/i });
+ expect(link.getAttribute("href")).toBe("/auth/sign-up");
+ });
+
+ it("does not link to a sign-up page this deployment refuses (issue #1328)", () => {
+ process.env.NEXT_PUBLIC_DISABLE_SELF_SERVE_SIGNUP = "true";
+ render();
+ expect(screen.queryByRole("link", { name: /create one/i })).toBeNull();
+ expect(
+ screen.getByText(/accounts on this deployment are created by invitation/i),
+ ).toBeTruthy();
+ });
+});
diff --git a/apps/web-console/__tests__/sign-up-next-redirect.test.tsx b/apps/web-console/__tests__/sign-up-next-redirect.test.tsx
index aa8221fdb..978241e95 100644
--- a/apps/web-console/__tests__/sign-up-next-redirect.test.tsx
+++ b/apps/web-console/__tests__/sign-up-next-redirect.test.tsx
@@ -32,6 +32,10 @@ describe("app/auth/sign-up/page.tsx", () => {
mockSignUp.mockResolvedValue({ error: null });
window.history.pushState({}, "", "/auth/sign-up");
process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000";
+ // Every case in this describe exercises the form, which only renders
+ // where the deployment accepts self-serve signup. The flag fails
+ // closed, so it is set explicitly (issue #1328).
+ process.env.NEXT_PUBLIC_DISABLE_SELF_SERVE_SIGNUP = "false";
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) }),
@@ -124,3 +128,79 @@ describe("app/auth/sign-up/page.tsx", () => {
);
});
});
+
+/**
+ * Issue #1328: this deployment refuses POST /auth/v1/signup at the gateway
+ * and at the GoTrue flag, so the console must say accounts are created by
+ * invitation instead of shipping a form that cannot complete, and must report
+ * a refusal that does reach the endpoint as a refusal rather than an outage.
+ */
+describe("app/auth/sign-up/page.tsx self-serve gating", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockSignUp.mockResolvedValue({ error: null });
+ window.history.pushState({}, "", "/auth/sign-up");
+ process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000";
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) }),
+ );
+ });
+
+ it("renders no sign-up form when the deployment refuses self-serve signup", () => {
+ process.env.NEXT_PUBLIC_DISABLE_SELF_SERVE_SIGNUP = "true";
+ render();
+ expect(screen.queryByLabelText(/^email/i)).toBeNull();
+ expect(screen.queryByLabelText(/^password/i)).toBeNull();
+ expect(screen.queryByRole("button", { name: /create account/i })).toBeNull();
+ });
+
+ it("says accounts are created by invitation, and points at sign-in", async () => {
+ process.env.NEXT_PUBLIC_DISABLE_SELF_SERVE_SIGNUP = "true";
+ render();
+ expect(
+ screen.getByRole("heading", { name: /accounts are created by invitation/i }),
+ ).toBeTruthy();
+ expect(
+ screen.getByText(/sign-up is not available on this deployment/i),
+ ).toBeTruthy();
+ const link = await screen.findByRole("link", { name: /go to sign in/i });
+ expect(link.getAttribute("href")).toBe("/auth/sign-in");
+ });
+
+ it("carries an inbound next param into the sign-in link on the gated page", async () => {
+ process.env.NEXT_PUBLIC_DISABLE_SELF_SERVE_SIGNUP = "true";
+ window.history.pushState(
+ {},
+ "",
+ `/auth/sign-up?next=${encodeURIComponent(
+ "/oauth/consent?authorization_id=auth-req-123",
+ )}`,
+ );
+ render();
+ const link = await screen.findByRole("link", { name: /go to sign in/i });
+ expect(link.getAttribute("href")).toBe(
+ `/auth/sign-in?next=${encodeURIComponent(
+ "/oauth/consent?authorization_id=auth-req-123",
+ )}`,
+ );
+ });
+
+ it("reports a gateway refusal as a refusal, not as an outage on our end", async () => {
+ process.env.NEXT_PUBLIC_DISABLE_SELF_SERVE_SIGNUP = "false";
+ mockSignUp.mockResolvedValue({
+ error: { name: "AuthUnknownError", message: "Unexpected end of JSON input" },
+ });
+ render();
+ fireEvent.change(screen.getByLabelText(/^email/i), {
+ target: { value: "user@example.com" },
+ });
+ fireEvent.change(screen.getByLabelText(/^password/i), {
+ target: { value: "hunter2hunter2" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: /create account/i }));
+ const alert = await screen.findByRole("alert");
+ expect(alert.textContent).toMatch(/sign-up is not available on this deployment/i);
+ expect(alert.textContent).not.toMatch(/something went wrong on our end/i);
+ });
+});
diff --git a/apps/web-console/app/auth/sign-in/page.tsx b/apps/web-console/app/auth/sign-in/page.tsx
index 44ad2883e..02e489d0d 100644
--- a/apps/web-console/app/auth/sign-in/page.tsx
+++ b/apps/web-console/app/auth/sign-in/page.tsx
@@ -11,6 +11,7 @@ import { Field, Input } from "@/components/ui/input";
import { toUserFacingAuthMessage } from "@/lib/auth/auth-error";
import { appendNextParam, resolveNextTarget } from "@/lib/auth/next-target";
import { navigate } from "@/lib/navigate";
+import { isSelfServeSignupEnabled } from "@/lib/auth/self-serve";
export default function SignInPage() {
const supabase = createClient();
@@ -93,15 +94,22 @@ export default function SignInPage() {
: "Manage API keys, credits, and usage analytics for your workspace."
}
footer={
- <>
- Don’t have an account?{" "}
-
- Create one
-
- >
+ isSelfServeSignupEnabled() ? (
+ <>
+ Don’t have an account?{" "}
+
+ Create one
+
+ >
+ ) : (
+ // Issue #1328: every deployment this repo ships refuses self-serve
+ // signup, so a link to a page that cannot complete is a promise the
+ // gateway breaks.
+ <>Accounts on this deployment are created by invitation.>
+ )
}
>