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); +}