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