From 769e1581527f0d2ae1b17daff13b77ae11b3c363 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 14:46:48 +0000 Subject: [PATCH] fix(login): offer only the sign-in providers switched on in Supabase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A provider not yet enabled (Apple, here) still had a button, and pressing it left the person on Supabase's bare JSON 'provider is not enabled' page, which the app never sees. The login page now reads Auth's public settings (external.) and shows only what is on; with none, no buttons and no 'or' divider. Settings that cannot be read offer none — the email code still works. The invite hint mentions Apple's Share My Email only when the Apple button is there. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ns6xSyytpwWuhH7vCJ8Dam --- docs/SELF-HOSTING.md | 5 ++- suite/app/(app)/login/page.test.tsx | 41 ++++++++++++++++-- suite/app/(app)/login/page.tsx | 65 ++++++++++++++++++---------- suite/lib/accounts/providers.test.ts | 39 +++++++++++++++++ suite/lib/accounts/providers.ts | 21 +++++++++ 5 files changed, 143 insertions(+), 28 deletions(-) create mode 100644 suite/lib/accounts/providers.test.ts create mode 100644 suite/lib/accounts/providers.ts diff --git a/docs/SELF-HOSTING.md b/docs/SELF-HOSTING.md index 6cf26a4..2d6fe0c 100644 --- a/docs/SELF-HOSTING.md +++ b/docs/SELF-HOSTING.md @@ -135,8 +135,9 @@ All of it is configured in the Supabase dashboard: Supabase → Authentication → Providers → Apple. **The secret expires after six months at most** — put renewing it in a calendar, or Apple sign-in stops. -A provider left disabled answers the button with Supabase's "provider is not -enabled" error, shown on the sign-in page. +The sign-in page asks Supabase which providers are switched on and shows a +button only for those, so a provider you have not set up simply does not +appear — enable it and its button does. ## 4. Apply the migrations diff --git a/suite/app/(app)/login/page.test.tsx b/suite/app/(app)/login/page.test.tsx index c888659..ebccf2a 100644 --- a/suite/app/(app)/login/page.test.tsx +++ b/suite/app/(app)/login/page.test.tsx @@ -4,9 +4,10 @@ import { afterEach, expect, test, vi } from "vitest"; const signInWithOtp = vi.fn(async (_: unknown) => ({ error: null })); const verifyOtp = vi.fn(async (_: unknown) => ({ error: null })); const signInWithOAuth = vi.fn(async (_: unknown): Promise<{ error: { message: string } | null }> => ({ error: null })); -vi.mock("@/lib/accounts/browserClient", () => ({ - browserClient: () => ({ auth: { signInWithOtp, verifyOtp, signInWithOAuth } }), -})); +const client = { auth: { signInWithOtp, verifyOtp, signInWithOAuth } }; +vi.mock("@/lib/accounts/browserClient", () => ({ browserClient: () => client })); +const enabledProviders = vi.fn(async (): Promise => ["google", "apple"]); +vi.mock("@/lib/accounts/providers", () => ({ enabledProviders })); const { default: LoginPage } = await import("./page"); @@ -80,3 +81,37 @@ test("an ordinary sign-in says nothing about invites", async () => { await act(async () => render()); expect(screen.queryByText("Share My Email")).toBeNull(); }); + +test("only the providers switched on in Supabase are offered", async () => { + enabledProviders.mockResolvedValueOnce(["google"]); + vi.stubGlobal("location", { ...window.location, origin: "https://app.example", search: "" }); + await act(async () => render()); + expect(screen.getByRole("button", { name: /continue with google/i })).toBeTruthy(); + expect(screen.queryByRole("button", { name: /continue with apple/i })).toBeNull(); +}); + +test("with none switched on there are no buttons and no 'or' — just the email code", async () => { + enabledProviders.mockResolvedValueOnce([]); + vi.stubGlobal("location", { ...window.location, origin: "https://app.example", search: "" }); + await act(async () => render()); + expect(screen.queryByRole("button", { name: /continue with/i })).toBeNull(); + expect(screen.queryByRole("separator")).toBeNull(); + expect(screen.getByRole("button", { name: /send me a code/i })).toBeTruthy(); +}); + +test("settings that cannot be read offer no buttons, and the email code still works", async () => { + enabledProviders.mockRejectedValueOnce(new Error("Auth settings answered 500.")); + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.stubGlobal("location", { ...window.location, origin: "https://app.example", search: "" }); + await act(async () => render()); + expect(screen.queryByRole("button", { name: /continue with/i })).toBeNull(); + expect(screen.getByRole("button", { name: /send me a code/i })).toBeTruthy(); +}); + +test("the Share My Email advice appears only when Apple is offered", async () => { + enabledProviders.mockResolvedValueOnce(["google"]); + vi.stubGlobal("location", { ...window.location, origin: "https://app.example", search: "?next=%2Finvite%2Fabc123" }); + await act(async () => render()); + expect(screen.getByText(/Sign in with the address your invite was sent to/)).toBeTruthy(); + expect(screen.queryByText("Share My Email")).toBeNull(); +}); diff --git a/suite/app/(app)/login/page.tsx b/suite/app/(app)/login/page.tsx index 60ccaf3..0103375 100644 --- a/suite/app/(app)/login/page.tsx +++ b/suite/app/(app)/login/page.tsx @@ -3,10 +3,9 @@ import { type JSX, useEffect, useState } from "react"; import { Mail } from "lucide-react"; import { browserClient } from "@/lib/accounts/browserClient"; +import { enabledProviders, type Provider } from "@/lib/accounts/providers"; import { Button, TextField } from "@/components/ui/controls"; -type Provider = "google" | "apple"; - function GoogleMark() { return (

- Sign in with the address your invite was sent to. With Apple, choose{" "} - Share My Email. + Sign in with the address your invite was sent to. + {providers.includes("apple") && ( + <> + {" "}With Apple, choose Share My Email. + + )}

)} -
- {PROVIDERS.map(({ id, label, mark: Mark }) => ( - - ))} -
-
- - or - -
+ {offered.length > 0 && ( + <> +
+ {offered.map(({ id, label, mark: Mark }) => ( + + ))} +
+
+ + or + +
+ + )}
{ event.preventDefault(); diff --git a/suite/lib/accounts/providers.test.ts b/suite/lib/accounts/providers.test.ts new file mode 100644 index 0000000..d3276eb --- /dev/null +++ b/suite/lib/accounts/providers.test.ts @@ -0,0 +1,39 @@ +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import { enabledProviders } from "./providers"; + +beforeEach(() => { + vi.stubEnv("NEXT_PUBLIC_SUPABASE_URL", "https://project.supabase.co"); + vi.stubEnv("NEXT_PUBLIC_SUPABASE_ANON_KEY", "anon-key"); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +function settings(body: unknown, ok = true) { + const fetch = vi.fn(async () => ({ ok, status: ok ? 200 : 500, json: async () => body })); + vi.stubGlobal("fetch", fetch); + return fetch; +} + +test("asks Auth's public settings, with the anon key", async () => { + const fetch = settings({ external: { google: true, apple: true } }); + await enabledProviders(); + expect(fetch).toHaveBeenCalledWith("https://project.supabase.co/auth/v1/settings", { headers: { apikey: "anon-key" } }); +}); + +test("offers only what is switched on, in the page's order", async () => { + settings({ external: { apple: true, google: false, github: true, email: true } }); + await expect(enabledProviders()).resolves.toEqual(["apple"]); +}); + +test("nothing switched on is nothing offered", async () => { + settings({ external: {} }); + await expect(enabledProviders()).resolves.toEqual([]); +}); + +test("settings that cannot be read are an error, not a guess", async () => { + settings({}, false); + await expect(enabledProviders()).rejects.toThrow(/500/); +}); diff --git a/suite/lib/accounts/providers.ts b/suite/lib/accounts/providers.ts new file mode 100644 index 0000000..d3523c3 --- /dev/null +++ b/suite/lib/accounts/providers.ts @@ -0,0 +1,21 @@ +/** The social sign-ins the login page knows how to offer. */ +export const PROVIDER_IDS = ["google", "apple"] as const; +export type Provider = (typeof PROVIDER_IDS)[number]; + +/** + * Which of them are switched on in this deployment's Supabase project. + * + * Read from Auth's public settings (`external.: true`) rather than + * assumed: a button for a provider nobody enabled sends the person to a bare + * JSON error page on Supabase's domain, which the app never sees and cannot + * explain. Throws if the settings cannot be read; the caller offers none. + */ +export async function enabledProviders(): Promise { + const url = process.env.NEXT_PUBLIC_SUPABASE_URL; + const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + if (!url || !key) throw new Error("Accounts are not configured."); + const response = await fetch(`${url}/auth/v1/settings`, { headers: { apikey: key } }); + if (!response.ok) throw new Error(`Auth settings answered ${response.status}.`); + const { external } = (await response.json()) as { external?: Record }; + return PROVIDER_IDS.filter((id) => external?.[id] === true); +}