From 799edbc5d67405c2987673cb06c16b8c19b98552 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Thu, 3 Sep 2026 21:23:02 -0400 Subject: [PATCH] feat(magic-link): let the bundled views choose where a link lands requestMagicLink(redirectUri) shipped in 0.10.0, but only the headless client path could reach it. An application using AuthRoutes had no way to set one, so the deployment-wide destination was the only option for the audience least likely to be wiring up its own client. AuthProvider takes magicLinkRedirectUri and useAuthClient hands it to the client as the default for every send. SeamlessAuthClientOptions carries the same field for a directly constructed client. The destination lives on the client rather than at each call site on purpose. The sign-in screen and the resend both send with no argument, so neither view changes here and the two cannot disagree about where the link goes. A resend landing somewhere other than the link it repeats would be a confusing failure and an easy one to miss in review. Omit it and nothing changes: the same empty body is sent and the deployment's destination still applies. An explicit argument still wins. Closes #139 --- .changeset/olive-foxes-gather.md | 24 +++++ README.md | 19 ++++ src/AuthProvider.tsx | 11 +- src/client/createSeamlessAuthClient.ts | 21 ++-- src/hooks/useAuthClient.ts | 5 +- tests/createSeamlessAuthClient.test.ts | 38 +++++++ tests/magicLinkDestination.test.tsx | 134 +++++++++++++++++++++++++ tests/useAuthClient.test.tsx | 15 +++ 8 files changed, 257 insertions(+), 10 deletions(-) create mode 100644 .changeset/olive-foxes-gather.md create mode 100644 tests/magicLinkDestination.test.tsx diff --git a/.changeset/olive-foxes-gather.md b/.changeset/olive-foxes-gather.md new file mode 100644 index 0000000..0e8ee5f --- /dev/null +++ b/.changeset/olive-foxes-gather.md @@ -0,0 +1,24 @@ +--- +'@seamless-auth/react': minor +--- + +Let the bundled screens choose where a magic link lands. + +`requestMagicLink(redirectUri)` arrived in 0.10.0, but only the headless client +path could reach it. An application using `AuthRoutes` had no way to set one, so +the deployment-wide destination was the only option for the audience least likely +to be wiring up its own client. + +`AuthProvider` now takes `magicLinkRedirectUri`, and `useAuthClient()` hands it to +the client as the default for every send. `SeamlessAuthClientOptions` carries the +same field, so a directly constructed client can do this too. + +The destination lives on the client rather than at each call site on purpose. The +sign-in screen and the resend on the "check your email" screen both send with no +argument, so they cannot disagree about where the link goes. A resend that landed +somewhere other than the link it repeats would be a confusing failure and an easy +one to miss in review. + +Nothing changes if you omit it: the same empty body is sent and the deployment's +own destination still applies. An explicit `requestMagicLink(uri)` still wins over +the configured default. diff --git a/README.md b/README.md index f30fa89..84cb7d5 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,25 @@ async function completeLogin() { To disable this auto-detection entirely, pass `autoDetectPreviousSignin={false}` to `AuthProvider`. +### Magic link destination + +By default a magic link lands wherever the deployment is configured to send it. A deployment serving +more than one front end can override that per application with `magicLinkRedirectUri`: + +```tsx + + + +``` + +Every magic link the bundled screens send uses it, including the resend on the "check your email" +screen, so a resent link always lands where the first one did. The deployment validates the value +against its configured origins and refuses anything else, which comes back as an ordinary error +result. + +Custom UIs get the same default through `useAuthClient()`, and can still override a single send with +`requestMagicLink(uri)`. + ### Scoped roles `hasRole(role)` remains an exact role check. Use `hasScopedRole(role)` for colon-separated scoped diff --git a/src/AuthProvider.tsx b/src/AuthProvider.tsx index 7100b89..405d653 100644 --- a/src/AuthProvider.tsx +++ b/src/AuthProvider.tsx @@ -40,6 +40,7 @@ export interface AuthContextType { hasRole: (role: string) => boolean | undefined; hasScopedRole: (role: string | string[]) => boolean | undefined; apiHost: string; + magicLinkRedirectUri?: string; markSignedIn: () => void; hasSignedInBefore: boolean; credentials: Credential[]; @@ -90,12 +91,18 @@ interface AuthProviderProps { children: ReactNode; apiHost: string; autoDetectPreviousSignin?: boolean; + /** + * Where a magic link sent by the bundled screens should land. Both the first + * send and a resend read it from here, so the two cannot drift apart. + */ + magicLinkRedirectUri?: string; } export const AuthProvider: React.FC = ({ children, apiHost, autoDetectPreviousSignin = true, + magicLinkRedirectUri, }) => { const session = useMemo( () => @@ -131,8 +138,8 @@ export const AuthProvider: React.FC = ({ }, [session]); const value = useMemo( - () => ({ ...state, ...session.actions, apiHost }), - [state, session, apiHost] + () => ({ ...state, ...session.actions, apiHost, magicLinkRedirectUri }), + [state, session, apiHost, magicLinkRedirectUri] ); return {children}; diff --git a/src/client/createSeamlessAuthClient.ts b/src/client/createSeamlessAuthClient.ts index cded2d4..da48e29 100644 --- a/src/client/createSeamlessAuthClient.ts +++ b/src/client/createSeamlessAuthClient.ts @@ -64,6 +64,12 @@ import { export interface SeamlessAuthClientOptions { apiHost: string; + /** + * Default destination for `requestMagicLink()`. Every send from this client + * uses it unless a call passes its own, which keeps a resend on the same + * destination as the send it repeats. Omit it to keep the deployment's. + */ + magicLinkRedirectUri?: string; } export interface LoginInput { @@ -246,8 +252,8 @@ export interface SeamlessAuthClient { /** * @param redirectUri Where the emailed link should land. The deployment validates * it against its configured origins and refuses anything else, so a tenant serving - * a web app and a mobile app can send each to its own destination. Omit it to keep - * the deployment's single destination. + * a web app and a mobile app can send each to its own destination. Omit it to fall + * back to the client's `magicLinkRedirectUri`, then to the deployment's own. */ requestMagicLink: (redirectUri?: string) => Promise>; checkMagicLink: () => Promise>; @@ -584,14 +590,17 @@ export const createSeamlessAuthClient = ( // The body is sent even when it is empty, so fetchWithAuth declares a JSON // content type and the request takes a CORS preflight. A bodyless POST is a // simple request and stays reachable cross-site. - requestMagicLink: redirectUri => - requestResult( + requestMagicLink: redirectUri => { + const destination = redirectUri ?? opts.magicLinkRedirectUri; + + return requestResult( fetchWithAuth(`/magic-link`, { method: 'POST', - body: JSON.stringify(redirectUri ? { redirectUri } : {}), + body: JSON.stringify(destination ? { redirectUri: destination } : {}), }), 'Failed to send the magic link.' - ), + ); + }, checkMagicLink: () => requestResult( diff --git a/src/hooks/useAuthClient.ts b/src/hooks/useAuthClient.ts index 683bcab..35b5001 100644 --- a/src/hooks/useAuthClient.ts +++ b/src/hooks/useAuthClient.ts @@ -10,13 +10,14 @@ import { useAuth } from '@/AuthProvider'; import { createSeamlessAuthClient } from '@/client/createSeamlessAuthClient'; export const useAuthClient = () => { - const { apiHost } = useAuth(); + const { apiHost, magicLinkRedirectUri } = useAuth(); return useMemo( () => createSeamlessAuthClient({ apiHost, + magicLinkRedirectUri, }), - [apiHost] + [apiHost, magicLinkRedirectUri] ); }; diff --git a/tests/createSeamlessAuthClient.test.ts b/tests/createSeamlessAuthClient.test.ts index d53f0ab..b604bef 100644 --- a/tests/createSeamlessAuthClient.test.ts +++ b/tests/createSeamlessAuthClient.test.ts @@ -220,6 +220,44 @@ describe('createSeamlessAuthClient', () => { }); }); + it('sends the configured destination when a call names none', async () => { + mockFetchWithAuth.mockResolvedValue({ + ok: true, + json: async () => ({ message: 'Success' }), + }); + + const client = createSeamlessAuthClient({ + apiHost: 'https://api.example.com', + magicLinkRedirectUri: 'https://app.example.com/magic', + }); + + expect((await client.requestMagicLink()).error).toBeNull(); + + expect(mockFetchWithAuth).toHaveBeenCalledWith('/magic-link', { + method: 'POST', + body: JSON.stringify({ redirectUri: 'https://app.example.com/magic' }), + }); + }); + + it('lets a call override the configured destination', async () => { + mockFetchWithAuth.mockResolvedValue({ + ok: true, + json: async () => ({ message: 'Success' }), + }); + + const client = createSeamlessAuthClient({ + apiHost: 'https://api.example.com', + magicLinkRedirectUri: 'https://app.example.com/magic', + }); + + await client.requestMagicLink('https://other.example.com/magic'); + + expect(mockFetchWithAuth).toHaveBeenCalledWith('/magic-link', { + method: 'POST', + body: JSON.stringify({ redirectUri: 'https://other.example.com/magic' }), + }); + }); + // The deployment owns the allowlist, so a refusal is reported rather than // pre-empted here. Guessing at it in the client would mean two allowlists. it('reports a destination the deployment refuses', async () => { diff --git a/tests/magicLinkDestination.test.tsx b/tests/magicLinkDestination.test.tsx new file mode 100644 index 0000000..b2eed36 --- /dev/null +++ b/tests/magicLinkDestination.test.tsx @@ -0,0 +1,134 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { render, screen, fireEvent, act } from '@testing-library/react'; + +import Login from '@/views/Login'; +import MagicLinkSent from '@/components/MagicLinkSent'; +import { useAuth } from '@/AuthProvider'; +import { createFetchWithAuth } from '@/fetchWithAuth'; +import { useNavigate, useLocation } from 'react-router-dom'; + +// `useAuthClient` and the client itself stay real here: the whole point is that +// the destination survives the trip from provider config into the request body. +jest.mock('@/AuthProvider'); +jest.mock('@/fetchWithAuth'); +jest.mock('@/utils', () => ({ + isValidEmail: jest.fn(() => true), + isValidPhoneNumber: jest.fn(() => false), +})); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: jest.fn(), + useLocation: jest.fn(), + useHref: jest.fn((to: string) => to), +})); +jest.mock('@/components/AuthFallbackOptions', () => (props: any) => ( + +)); + +const REDIRECT_URI = 'https://app.example.com/auth/magic'; + +const mockFetchWithAuth = jest.fn(); + +/** The body of every POST the client made to /magic-link, in order. */ +const magicLinkBodies = (): string[] => + mockFetchWithAuth.mock.calls + .filter(([path]) => path === '/magic-link') + .map(([, init]) => init.body); + +describe('magic link destination in the bundled views', () => { + beforeEach(() => { + (createFetchWithAuth as jest.Mock).mockReturnValue(mockFetchWithAuth); + mockFetchWithAuth.mockResolvedValue({ + ok: true, + json: async () => ({ message: 'Success' }), + }); + + (useNavigate as jest.Mock).mockReturnValue(jest.fn()); + (useLocation as jest.Mock).mockReturnValue({ + state: { identifier: 'test@example.com' }, + }); + + (useAuth as jest.Mock).mockReturnValue({ + apiHost: 'https://api.example.com', + magicLinkRedirectUri: REDIRECT_URI, + hasSignedInBefore: true, + refreshSession: jest.fn(), + listOAuthProviders: jest.fn().mockResolvedValue({ providers: [] }), + login: jest.fn().mockResolvedValue({ data: {}, error: null }), + handlePasskeyLogin: jest.fn().mockResolvedValue(false), + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const sendFromLogin = async () => { + render(); + + fireEvent.change(screen.getByPlaceholderText(/email or phone number/i), { + target: { value: 'test@example.com' }, + }); + + await act(async () => { + fireEvent.click(await screen.findByRole('button', { name: /^login$/i })); + }); + + await act(async () => { + fireEvent.click(await screen.findByText('MagicLink')); + }); + }; + + const resendFromMagicLinkSent = async () => { + jest.useFakeTimers(); + try { + render(); + + // The resend button is on a 30s cooldown from mount. + act(() => { + jest.advanceTimersByTime(30_000); + }); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /resend/i })); + }); + } finally { + jest.useRealTimers(); + } + }; + + it('sends the configured destination from the sign-in screen', async () => { + await sendFromLogin(); + + expect(magicLinkBodies()).toEqual([JSON.stringify({ redirectUri: REDIRECT_URI })]); + }); + + // The trap this option exists to avoid: a resend that lands somewhere other + // than the link it repeats. Both views must reach the same destination. + it('resends to the same destination the first link used', async () => { + await sendFromLogin(); + await resendFromMagicLinkSent(); + + const [first, resent] = magicLinkBodies(); + + expect(resent).toBe(first); + expect(resent).toBe(JSON.stringify({ redirectUri: REDIRECT_URI })); + }); + + it('falls back to the deployment destination when none is configured', async () => { + (useAuth as jest.Mock).mockReturnValue({ + ...(useAuth as jest.Mock)(), + magicLinkRedirectUri: undefined, + }); + + await sendFromLogin(); + await resendFromMagicLinkSent(); + + expect(magicLinkBodies()).toEqual([JSON.stringify({}), JSON.stringify({})]); + }); +}); diff --git a/tests/useAuthClient.test.tsx b/tests/useAuthClient.test.tsx index d18e1fd..ad5a50c 100644 --- a/tests/useAuthClient.test.tsx +++ b/tests/useAuthClient.test.tsx @@ -28,4 +28,19 @@ describe('useAuthClient', () => { }); expect(result.current).toBe(client); }); + + it('passes the magic link destination through to the client', () => { + (useAuth as jest.Mock).mockReturnValue({ + apiHost: 'https://api.example.com', + magicLinkRedirectUri: 'https://app.example.com/magic', + }); + (createSeamlessAuthClient as jest.Mock).mockReturnValue({}); + + renderHook(() => useAuthClient()); + + expect(createSeamlessAuthClient).toHaveBeenCalledWith({ + apiHost: 'https://api.example.com', + magicLinkRedirectUri: 'https://app.example.com/magic', + }); + }); });