From 6d6839a8bbf18925b8fdbd32aa26fab038b070ab Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 04:43:27 +0000 Subject: [PATCH 1/2] feat: drive SSO button visibility from server auth config Instead of hardcoding Google/Apple buttons, fetch enabled social providers from the server's /api/v1/auth/config endpoint on connect and hydrate. The sign-in and sign-up screens now render only the providers the server reports, filtered by platform support (e.g. Apple only on iOS). Unknown providers fall back to a generic label. https://claude.ai/code/session_014dJB1cy241fXfHdxg8MZbw --- __tests__/e2e/auth-flow.e2e.test.tsx | 5 +++ app/(auth)/sign-in.tsx | 65 +++++++++++++++++----------- app/(auth)/sign-up.tsx | 65 +++++++++++++++++----------- lib/server-auth-config.ts | 30 +++++++++++++ stores/server-store.ts | 18 ++++++-- 5 files changed, 130 insertions(+), 53 deletions(-) create mode 100644 lib/server-auth-config.ts diff --git a/__tests__/e2e/auth-flow.e2e.test.tsx b/__tests__/e2e/auth-flow.e2e.test.tsx index bde9a11..b25cdb0 100644 --- a/__tests__/e2e/auth-flow.e2e.test.tsx +++ b/__tests__/e2e/auth-flow.e2e.test.tsx @@ -35,6 +35,11 @@ jest.mock("~/lib/auth-client", () => ({ getAuthBaseURL: () => "http://localhost:3000", })); +jest.mock("~/stores/server-store", () => ({ + useServerStore: (selector: (s: { ssoProviders: string[] | null }) => unknown) => + selector({ ssoProviders: ["google"] }), +})); + jest.spyOn(Alert, "alert"); import SignInScreen from "~/app/(auth)/sign-in"; diff --git a/app/(auth)/sign-in.tsx b/app/(auth)/sign-in.tsx index e3cd618..e700dc7 100644 --- a/app/(auth)/sign-in.tsx +++ b/app/(auth)/sign-in.tsx @@ -12,9 +12,21 @@ import { SafeAreaView } from "react-native-safe-area-context"; import { Button } from "~/components/ui/Button"; import { Input } from "~/components/ui/Input"; import { authClient } from "~/lib/auth-client"; +import { useServerStore } from "~/stores/server-store"; + +const SSO_PROVIDER_LABELS: Record = { + google: "Continue with Google", + apple: "Continue with Apple", + github: "Continue with GitHub", +}; + +const PLATFORM_RESTRICTED: Record = { + apple: ["ios"], +}; export default function SignInScreen() { const router = useRouter(); + const ssoProviders = useServerStore((s) => s.ssoProviders); const [email, setEmail] = React.useState(""); const [password, setPassword] = React.useState(""); const [loading, setLoading] = React.useState(false); @@ -42,11 +54,11 @@ export default function SignInScreen() { } }; - const handleSocialSignIn = async (provider: "google" | "apple") => { + const handleSocialSignIn = async (provider: string) => { setLoading(true); try { await authClient.signIn.social({ - provider, + provider: provider as Parameters[0]["provider"], callbackURL: "/(tabs)", }); } catch { @@ -56,6 +68,11 @@ export default function SignInScreen() { } }; + const visibleProviders = (ssoProviders ?? []).filter((id) => { + const platforms = PLATFORM_RESTRICTED[id]; + return !platforms || platforms.includes(Platform.OS); + }); + return ( - - - or - - + {visibleProviders.length > 0 && ( + <> + + + or + + - - - {Platform.OS === "ios" && ( - - )} - + + {visibleProviders.map((id) => ( + + ))} + + + )} diff --git a/app/(auth)/sign-up.tsx b/app/(auth)/sign-up.tsx index 7bee637..00dd988 100644 --- a/app/(auth)/sign-up.tsx +++ b/app/(auth)/sign-up.tsx @@ -12,9 +12,21 @@ import { SafeAreaView } from "react-native-safe-area-context"; import { Button } from "~/components/ui/Button"; import { Input } from "~/components/ui/Input"; import { authClient } from "~/lib/auth-client"; +import { useServerStore } from "~/stores/server-store"; + +const SSO_PROVIDER_LABELS: Record = { + google: "Continue with Google", + apple: "Continue with Apple", + github: "Continue with GitHub", +}; + +const PLATFORM_RESTRICTED: Record = { + apple: ["ios"], +}; export default function SignUpScreen() { const router = useRouter(); + const ssoProviders = useServerStore((s) => s.ssoProviders); const [name, setName] = React.useState(""); const [email, setEmail] = React.useState(""); const [password, setPassword] = React.useState(""); @@ -48,11 +60,11 @@ export default function SignUpScreen() { } }; - const handleSocialSignIn = async (provider: "google" | "apple") => { + const handleSocialSignIn = async (provider: string) => { setLoading(true); try { await authClient.signIn.social({ - provider, + provider: provider as Parameters[0]["provider"], callbackURL: "/(tabs)", }); } catch { @@ -62,6 +74,11 @@ export default function SignUpScreen() { } }; + const visibleProviders = (ssoProviders ?? []).filter((id) => { + const platforms = PLATFORM_RESTRICTED[id]; + return !platforms || platforms.includes(Platform.OS); + }); + return ( - - - or - - + {visibleProviders.length > 0 && ( + <> + + + or + + - - - {Platform.OS === "ios" && ( - - )} - + + {visibleProviders.map((id) => ( + + ))} + + + )} diff --git a/lib/server-auth-config.ts b/lib/server-auth-config.ts new file mode 100644 index 0000000..8b16c88 --- /dev/null +++ b/lib/server-auth-config.ts @@ -0,0 +1,30 @@ +/** + * Fetch the server's auth configuration, including which SSO providers are + * enabled. The server exposes this at `/api/v1/auth/config`. + */ + +export interface ServerAuthConfig { + /** Social provider IDs enabled on this server (e.g. "google", "apple"). */ + socialProviders: string[]; +} + +/** + * Fetch auth configuration from the server. + * Returns `null` when the server is unreachable or the endpoint is absent. + */ +export async function fetchServerAuthConfig( + baseUrl: string, +): Promise { + try { + const normalised = baseUrl.replace(/\/+$/, ""); + const res = await fetch(`${normalised}/api/v1/auth/config`, { + method: "GET", + }); + if (!res.ok) return null; + const data = await res.json(); + if (!Array.isArray(data?.socialProviders)) return null; + return { socialProviders: data.socialProviders as string[] }; + } catch { + return null; + } +} diff --git a/stores/server-store.ts b/stores/server-store.ts index 98c6fe2..1970b63 100644 --- a/stores/server-store.ts +++ b/stores/server-store.ts @@ -6,12 +6,19 @@ import { getServerUrl, setServerUrl, } from "~/lib/server-url"; +import { fetchServerAuthConfig } from "~/lib/server-auth-config"; interface ServerState { /** The configured ObjectStack server base URL, or `null` if unset. */ serverUrl: string | null; /** True once the persisted URL has been read from storage on startup. */ isReady: boolean; + /** + * Social provider IDs enabled on the connected server (e.g. ["google"]). + * `null` while the config hasn't been fetched yet; empty array when the + * server reports none or the config endpoint is unavailable. + */ + ssoProviders: string[] | null; /** Load the persisted server URL and point the auth/data clients at it. */ hydrate: () => Promise; /** Persist a new server URL and re-target the auth/data clients reactively. */ @@ -40,20 +47,25 @@ function retargetClients(url: string) { export const useServerStore = create((set) => ({ serverUrl: null, isReady: false, + ssoProviders: null, hydrate: async () => { const url = await getServerUrl(); if (url) { retargetClients(url); + const authConfig = await fetchServerAuthConfig(url); + set({ serverUrl: url, isReady: true, ssoProviders: authConfig?.socialProviders ?? [] }); + } else { + set({ serverUrl: null, isReady: true, ssoProviders: [] }); } - set({ serverUrl: url, isReady: true }); }, connect: async (url) => { await setServerUrl(url); retargetClients(url); - set({ serverUrl: url }); + const authConfig = await fetchServerAuthConfig(url); + set({ serverUrl: url, ssoProviders: authConfig?.socialProviders ?? [] }); }, reset: async () => { await clearServerUrl(); - set({ serverUrl: null }); + set({ serverUrl: null, ssoProviders: null }); }, })); From 42843f253b3b42394a20465d977833e5968372c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 04:54:33 +0000 Subject: [PATCH 2/2] test: add unit tests for server-auth-config and server-store Cover fetchServerAuthConfig (all success and error branches) and the server store's hydrate/connect/reset actions including the new ssoProviders field. This fixes the ./stores/ coverage threshold that was failing because server-store.ts had no tests. https://claude.ai/code/session_014dJB1cy241fXfHdxg8MZbw --- __tests__/lib/server-auth-config.test.ts | 83 +++++++++++++ __tests__/lib/server-store.test.ts | 146 +++++++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 __tests__/lib/server-auth-config.test.ts create mode 100644 __tests__/lib/server-store.test.ts diff --git a/__tests__/lib/server-auth-config.test.ts b/__tests__/lib/server-auth-config.test.ts new file mode 100644 index 0000000..8d08c40 --- /dev/null +++ b/__tests__/lib/server-auth-config.test.ts @@ -0,0 +1,83 @@ +import { fetchServerAuthConfig } from "~/lib/server-auth-config"; + +describe("fetchServerAuthConfig", () => { + beforeEach(() => { + jest.restoreAllMocks(); + }); + + it("returns config with socialProviders on success", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ socialProviders: ["google", "apple"] }), + }) as jest.Mock; + + const result = await fetchServerAuthConfig("https://api.example.com"); + expect(result).toEqual({ socialProviders: ["google", "apple"] }); + expect(global.fetch).toHaveBeenCalledWith( + "https://api.example.com/api/v1/auth/config", + expect.objectContaining({ method: "GET" }), + ); + }); + + it("strips trailing slashes from the base URL", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ socialProviders: ["google"] }), + }) as jest.Mock; + + await fetchServerAuthConfig("https://api.example.com///"); + expect(global.fetch).toHaveBeenCalledWith( + "https://api.example.com/api/v1/auth/config", + expect.any(Object), + ); + }); + + it("returns null when response is not ok", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 404, + }) as jest.Mock; + + const result = await fetchServerAuthConfig("https://api.example.com"); + expect(result).toBeNull(); + }); + + it("returns null when socialProviders is missing", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ someOtherField: "value" }), + }) as jest.Mock; + + const result = await fetchServerAuthConfig("https://api.example.com"); + expect(result).toBeNull(); + }); + + it("returns null when socialProviders is not an array", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ socialProviders: "google" }), + }) as jest.Mock; + + const result = await fetchServerAuthConfig("https://api.example.com"); + expect(result).toBeNull(); + }); + + it("returns null on network error", async () => { + global.fetch = jest + .fn() + .mockRejectedValue(new Error("Network error")) as jest.Mock; + + const result = await fetchServerAuthConfig("https://unreachable.com"); + expect(result).toBeNull(); + }); + + it("returns config with empty socialProviders array", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ socialProviders: [] }), + }) as jest.Mock; + + const result = await fetchServerAuthConfig("https://api.example.com"); + expect(result).toEqual({ socialProviders: [] }); + }); +}); diff --git a/__tests__/lib/server-store.test.ts b/__tests__/lib/server-store.test.ts new file mode 100644 index 0000000..40eb6d5 --- /dev/null +++ b/__tests__/lib/server-store.test.ts @@ -0,0 +1,146 @@ +const mockReinitializeAuthClient = jest.fn(); +const mockSetObjectStackApiUrl = jest.fn(); +const mockGetServerUrl = jest.fn(); +const mockSetServerUrl = jest.fn(); +const mockClearServerUrl = jest.fn(); +const mockFetchServerAuthConfig = jest.fn(); + +jest.mock("~/lib/auth-client", () => ({ + reinitializeAuthClient: (...args: unknown[]) => + mockReinitializeAuthClient(...args), +})); + +jest.mock("~/lib/objectstack", () => ({ + setObjectStackApiUrl: (...args: unknown[]) => + mockSetObjectStackApiUrl(...args), +})); + +jest.mock("~/lib/server-url", () => ({ + getServerUrl: (...args: unknown[]) => mockGetServerUrl(...args), + setServerUrl: (...args: unknown[]) => mockSetServerUrl(...args), + clearServerUrl: (...args: unknown[]) => mockClearServerUrl(...args), +})); + +jest.mock("~/lib/server-auth-config", () => ({ + fetchServerAuthConfig: (...args: unknown[]) => + mockFetchServerAuthConfig(...args), +})); + +import { useServerStore } from "~/stores/server-store"; + +describe("server-store", () => { + beforeEach(() => { + jest.clearAllMocks(); + useServerStore.setState({ + serverUrl: null, + isReady: false, + ssoProviders: null, + }); + }); + + it("has correct initial state", () => { + const state = useServerStore.getState(); + expect(state.serverUrl).toBeNull(); + expect(state.isReady).toBe(false); + expect(state.ssoProviders).toBeNull(); + }); + + describe("hydrate", () => { + it("sets serverUrl, isReady, and ssoProviders when URL is stored", async () => { + mockGetServerUrl.mockResolvedValue("https://api.example.com"); + mockFetchServerAuthConfig.mockResolvedValue({ + socialProviders: ["google", "apple"], + }); + + await useServerStore.getState().hydrate(); + + const state = useServerStore.getState(); + expect(state.serverUrl).toBe("https://api.example.com"); + expect(state.isReady).toBe(true); + expect(state.ssoProviders).toEqual(["google", "apple"]); + }); + + it("retargets clients on hydrate with a URL", async () => { + mockGetServerUrl.mockResolvedValue("https://api.example.com"); + mockFetchServerAuthConfig.mockResolvedValue({ socialProviders: [] }); + + await useServerStore.getState().hydrate(); + + expect(mockReinitializeAuthClient).toHaveBeenCalledWith( + "https://api.example.com", + ); + expect(mockSetObjectStackApiUrl).toHaveBeenCalledWith( + "https://api.example.com", + ); + }); + + it("sets ssoProviders to [] when auth config fetch fails", async () => { + mockGetServerUrl.mockResolvedValue("https://api.example.com"); + mockFetchServerAuthConfig.mockResolvedValue(null); + + await useServerStore.getState().hydrate(); + + expect(useServerStore.getState().ssoProviders).toEqual([]); + }); + + it("sets isReady and empty ssoProviders when no URL is stored", async () => { + mockGetServerUrl.mockResolvedValue(null); + + await useServerStore.getState().hydrate(); + + const state = useServerStore.getState(); + expect(state.serverUrl).toBeNull(); + expect(state.isReady).toBe(true); + expect(state.ssoProviders).toEqual([]); + }); + }); + + describe("connect", () => { + it("persists URL, retargets clients, and sets ssoProviders", async () => { + mockSetServerUrl.mockResolvedValue(undefined); + mockFetchServerAuthConfig.mockResolvedValue({ + socialProviders: ["google"], + }); + + await useServerStore.getState().connect("https://new.example.com"); + + expect(mockSetServerUrl).toHaveBeenCalledWith("https://new.example.com"); + expect(mockReinitializeAuthClient).toHaveBeenCalledWith( + "https://new.example.com", + ); + expect(mockSetObjectStackApiUrl).toHaveBeenCalledWith( + "https://new.example.com", + ); + + const state = useServerStore.getState(); + expect(state.serverUrl).toBe("https://new.example.com"); + expect(state.ssoProviders).toEqual(["google"]); + }); + + it("sets ssoProviders to [] when auth config is unavailable", async () => { + mockSetServerUrl.mockResolvedValue(undefined); + mockFetchServerAuthConfig.mockResolvedValue(null); + + await useServerStore.getState().connect("https://new.example.com"); + + expect(useServerStore.getState().ssoProviders).toEqual([]); + }); + }); + + describe("reset", () => { + it("clears serverUrl and ssoProviders", async () => { + useServerStore.setState({ + serverUrl: "https://api.example.com", + ssoProviders: ["google"], + }); + mockClearServerUrl.mockResolvedValue(undefined); + + await useServerStore.getState().reset(); + + const state = useServerStore.getState(); + expect(state.serverUrl).toBeNull(); + expect(state.ssoProviders).toBeNull(); + expect(mockClearServerUrl).toHaveBeenCalled(); + }); + }); +});