Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
28 changes: 28 additions & 0 deletions apps/web-console/__tests__/sign-in-next-redirect.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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(<SignInPage />);
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(<SignInPage />);
expect(screen.queryByRole("link", { name: /create one/i })).toBeNull();
expect(
screen.getByText(/accounts on this deployment are created by invitation/i),
).toBeTruthy();
});
});
80 changes: 80 additions & 0 deletions apps/web-console/__tests__/sign-up-next-redirect.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => ({}) }),
Expand Down Expand Up @@ -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(<SignUpPage />);
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(<SignUpPage />);
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(<SignUpPage />);
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(<SignUpPage />);
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);
});
});
26 changes: 17 additions & 9 deletions apps/web-console/app/auth/sign-in/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -93,15 +94,22 @@ export default function SignInPage() {
: "Manage API keys, credits, and usage analytics for your workspace."
}
footer={
<>
Don&rsquo;t have an account?{" "}
<a
href={appendNextParam("/auth/sign-up", nextParam)}
className="text-[var(--color-accent)] underline-offset-4 hover:underline"
>
Create one
</a>
</>
isSelfServeSignupEnabled() ? (
<>
Don&rsquo;t have an account?{" "}
<a
href={appendNextParam("/auth/sign-up", nextParam)}
className="text-[var(--color-accent)] underline-offset-4 hover:underline"
>
Create one
</a>
</>
) : (
// 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.</>
)
}
>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
Expand Down
102 changes: 90 additions & 12 deletions apps/web-console/app/auth/sign-up/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@

import { createClient } from "@/lib/supabase/browser";
import { useState, useRef, useEffect, type FormEvent } from "react";
import { Mail } from "lucide-react";
import { Mail, ShieldCheck } from "lucide-react";

import { AuthShell } from "@/components/app-shell/auth-shell";
import { Button } from "@/components/ui/button";
import { Button, buttonVariants } from "@/components/ui/button";
import { Field, Input } from "@/components/ui/input";
import { toUserFacingAuthMessage } from "@/lib/auth/auth-error";
import {
SIGN_UP_UNAVAILABLE_MESSAGE,
toUserFacingSignUpMessage,
} from "@/lib/auth/auth-error";
import { appendNextParam } from "@/lib/auth/next-target";
import { isSelfServeSignupEnabled } from "@/lib/auth/self-serve";

// Minimal ambient type for the Cloudflare Turnstile widget. The full SDK type
// is not installed as a dev dependency; we only need the render/remove surface.
Expand All @@ -35,21 +39,93 @@ const TURNSTILE_SITE_KEY =
? process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ?? ""
: "";

/**
* Reads an inbound next= target post-mount (window is unavailable during SSR).
*
* Carries an OAuth consent round-trip started on chat through to the "Sign in"
* cross-link and into the verification-email redirect, so it survives the
* signup, confirm-email, callback chain instead of dropping the user onto the
* plain console dashboard (live UI/UX pass, 2026-07-26). Shared by both
* branches below: a visitor who lands here mid-consent needs the way back
* whether or not signup is open.
*/
function useNextParam(): string | null {
const [nextParam, setNextParam] = useState<string | null>(null);

useEffect(() => {
const search = new URLSearchParams(window.location.search);
setNextParam(search.get("next"));
}, []);

return nextParam;
}

/**
* Route gate for issue #1328.
*
* Every deployment this repo ships refuses POST /auth/v1/signup, at the
* gateway and at the GoTrue flag both, so the form below could never be
* completed there and the gateway 404 read as an outage. The page is not
* deleted, because the refusal is a deployment posture rather than a property
* of this console: a deployment that opens self-serve signup gets the form
* back by setting one variable, with no code change.
*/
export default function SignUpPage() {
return isSelfServeSignupEnabled() ? <SignUpForm /> : <SignUpUnavailable />;
}

function SignUpUnavailable() {
const nextParam = useNextParam();
const signInHref = appendNextParam("/auth/sign-in", nextParam);

return (
<AuthShell
eyebrow="Get started"
title="Accounts are created by invitation"
subtitle="This Hive deployment does not open account creation to the public."
footer={
<>
Already have an account?{" "}
<a
href={signInHref}
className="text-[var(--color-accent)] underline-offset-4 hover:underline"
>
Sign in
</a>
</>
}
>
<div className="flex flex-col gap-4">
<div
role="status"
className="flex items-start gap-3 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-inset)] px-4 py-3 text-sm text-[var(--color-ink-2)]"
>
<ShieldCheck
size={16}
className="mt-0.5 shrink-0 text-[var(--color-accent)]"
/>
<span>{SIGN_UP_UNAVAILABLE_MESSAGE}</span>
</div>
<a
href={signInHref}
className={buttonVariants({ variant: "primary", size: "lg" })}
>
Go to sign in
</a>
</div>
</AuthShell>
);
}

function SignUpForm() {
const supabase = createClient();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
// Populated post-mount (window is unavailable during SSR). Carries an
// inbound ?next= target (e.g. an OAuth consent round-trip started on chat)
// through to the "Sign in" cross-link and into the verification-email
// redirect, so it survives the signup -> confirm-email -> callback chain
// instead of dropping the user onto the plain console dashboard (issue
// found in live UI/UX pass, 2026-07-26).
const [nextParam, setNextParam] = useState<string | null>(null);
const nextParam = useNextParam();
// Same pre-hydration submit hazard as /auth/sign-in (see the comment there):
// no form `action` and no input `name` attributes mean a submit fired before
// onSubmit is attached does a native GET that wipes the query string, taking
Expand All @@ -61,7 +137,6 @@ export default function SignUpPage() {
const widgetIdRef = useRef<string | null>(null);

useEffect(() => {
setNextParam(new URLSearchParams(window.location.search).get("next"));
setHydrated(true);
}, []);

Expand Down Expand Up @@ -163,7 +238,10 @@ export default function SignUpPage() {
});

if (signUpError) {
setError(toUserFacingAuthMessage(signUpError.message));
// Not toUserFacingAuthMessage: a deployment that refuses signup at
// the gateway answers with a bare 404, and the generic branch
// reported that policy as an outage on our end (issue #1328).
setError(toUserFacingSignUpMessage(signUpError));
return;
}

Expand Down
2 changes: 1 addition & 1 deletion apps/web-console/app/console/api-keys/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export default async function ApiKeysPage() {
<PageHeader
eyebrow="Authentication"
title="API keys"
description="Issue, rotate, and revoke programmatic credentials. Keys are shown in full only at creation — store them in a secret manager."
description="Issue and revoke programmatic credentials. To rotate a key, create a replacement and revoke the old one. Keys are shown in full only at creation, so store them in a secret manager."
/>

<div className="flex flex-col gap-6">
Expand Down
26 changes: 3 additions & 23 deletions apps/web-console/app/console/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,8 @@ import {
CardTitle,
} from "@/components/ui/card";
import { PageHeader } from "@/components/ui/page-header";
import {
formatCredits,
formatNumber,
formatTokens,
} from "@/lib/format/credits";
import { formatNumber, formatTokens } from "@/lib/format/credits";
import { CreditBalance } from "@/components/billing/credit-balance";

/*
* Next steps shown under the metric row. Every entry is a real console route,
Expand Down Expand Up @@ -171,24 +168,7 @@ export default async function ConsolePage() {
</CardHeader>
<CardContent className="px-5 py-5">
{balance ? (
<div className="flex flex-col gap-1">
<p
className="metric text-3xl text-[var(--color-ink)]"
data-numeric
>
{formatCredits(balance.available_credits)}
</p>
<p className="text-xs text-[var(--color-ink-3)]">
Posted{" "}
<span className="metric text-[var(--color-ink-2)]">
{formatCredits(balance.posted_credits)}
</span>{" "}
· Reserved{" "}
<span className="metric text-[var(--color-ink-2)]">
{formatCredits(balance.reserved_credits)}
</span>
</p>
</div>
<CreditBalance balance={balance} />
) : (
<p className="text-sm text-[var(--color-ink-3)]">
Verify your email to view balances.
Expand Down
9 changes: 0 additions & 9 deletions apps/web-console/components/api-keys/api-key-list.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import Link from "next/link";

import type { ApiKey } from "@/lib/control-plane/client";
import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
import { DataTable, type Column } from "@/components/ui/data-table";
import { formatShortDate } from "@/lib/format/credits";
import { formatUsdFromCredits } from "@/lib/format/model-pricing";
Expand Down Expand Up @@ -117,12 +114,6 @@ export function ApiKeyList({ keys, canManage }: ApiKeyListProps) {
cell: (row) =>
row.status === "active" ? (
<div className="flex items-center justify-end gap-3">
<Link
href={`/console/api-keys/${row.id}/rotate`}
className={buttonVariants({ variant: "ghost", size: "sm" })}
>
Rotate
</Link>
<RevokeConfirmPanel keyId={row.id} keyNickname={row.nickname} />
</div>
) : (
Expand Down
Loading
Loading