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