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
5 changes: 3 additions & 2 deletions docs/SELF-HOSTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
41 changes: 38 additions & 3 deletions suite/app/(app)/login/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]> => ["google", "apple"]);
vi.mock("@/lib/accounts/providers", () => ({ enabledProviders }));

const { default: LoginPage } = await import("./page");

Expand Down Expand Up @@ -80,3 +81,37 @@ test("an ordinary sign-in says nothing about invites", async () => {
await act(async () => render(<LoginPage />));
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(<LoginPage />));
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(<LoginPage />));
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(<LoginPage />));
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(<LoginPage />));
expect(screen.getByText(/Sign in with the address your invite was sent to/)).toBeTruthy();
expect(screen.queryByText("Share My Email")).toBeNull();
});
65 changes: 42 additions & 23 deletions suite/app/(app)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<svg viewBox="0 0 18 18" width={18} height={18} aria-hidden="true">
Expand Down Expand Up @@ -67,6 +66,18 @@ export default function LoginPage() {
}, []);

const client = browserClient();
// Only the providers this project has switched on — none until we know.
const [providers, setProviders] = useState<Provider[]>([]);
useEffect(() => {
if (!client) return;
enabledProviders()
.then(setProviders)
.catch((cause: unknown) => {
// The email code still works; the buttons just are not offered.
console.warn("[login] could not read which sign-in providers are on", cause);
});
}, [client]);
const offered = PROVIDERS.filter(({ id }) => providers.includes(id));
// Where the person was going — an invite, usually. Carried through the
// link, or it lands on /account, which for an invitee is the wrong door.
const [next, setNext] = useState<string | null>(null);
Expand Down Expand Up @@ -217,29 +228,37 @@ export default function LoginPage() {
// An invite opens only for the address it was sent to, and Apple's
// Hide My Email signs in with a relay address that never matches.
<p className="mb-4 text-sm text-slate">
Sign in with the address your invite was sent to. With Apple, choose{" "}
<span className="font-medium text-charcoal">Share My Email</span>.
Sign in with the address your invite was sent to.
{providers.includes("apple") && (
<>
{" "}With Apple, choose <span className="font-medium text-charcoal">Share My Email</span>.
</>
)}
</p>
)}
<div className="space-y-3">
{PROVIDERS.map(({ id, label, mark: Mark }) => (
<button
key={id}
type="button"
onClick={() => void continueWith(id)}
disabled={busy || leavingFor !== null}
className="flex w-full items-center justify-center gap-2.5 rounded border border-charcoal/15 bg-parchment px-3 py-2.5 text-sm font-medium text-charcoal transition hover:border-gold disabled:pointer-events-none disabled:opacity-40"
>
<Mark />
{leavingFor === id ? "Redirecting..." : label}
</button>
))}
</div>
<div className="my-6 flex items-center gap-3 text-xs tracking-[0.14em] text-slate uppercase" role="separator">
<span className="h-px flex-1 bg-charcoal/15" />
or
<span className="h-px flex-1 bg-charcoal/15" />
</div>
{offered.length > 0 && (
<>
<div className="space-y-3">
{offered.map(({ id, label, mark: Mark }) => (
<button
key={id}
type="button"
onClick={() => void continueWith(id)}
disabled={busy || leavingFor !== null}
className="flex w-full items-center justify-center gap-2.5 rounded border border-charcoal/15 bg-parchment px-3 py-2.5 text-sm font-medium text-charcoal transition hover:border-gold disabled:pointer-events-none disabled:opacity-40"
>
<Mark />
{leavingFor === id ? "Redirecting..." : label}
</button>
))}
</div>
<div className="my-6 flex items-center gap-3 text-xs tracking-[0.14em] text-slate uppercase" role="separator">
<span className="h-px flex-1 bg-charcoal/15" />
or
<span className="h-px flex-1 bg-charcoal/15" />
</div>
</>
)}
<form
onSubmit={(event) => {
event.preventDefault();
Expand Down
39 changes: 39 additions & 0 deletions suite/lib/accounts/providers.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
21 changes: 21 additions & 0 deletions suite/lib/accounts/providers.ts
Original file line number Diff line number Diff line change
@@ -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.<provider>: 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<Provider[]> {
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<string, boolean> };
return PROVIDER_IDS.filter((id) => external?.[id] === true);
}
Loading