From 54f99ec026cad423dc6327a404bf88b571691469 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sun, 17 May 2026 13:08:08 -0400 Subject: [PATCH 1/3] fix: clean up magic link broadcast --- src/views/VerifyMagicLink.tsx | 33 +++++++++++++++++++++++++++++---- tests/VerifyMagicLink.test.tsx | 28 +++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/views/VerifyMagicLink.tsx b/src/views/VerifyMagicLink.tsx index 06b4137..63fbba3 100644 --- a/src/views/VerifyMagicLink.tsx +++ b/src/views/VerifyMagicLink.tsx @@ -21,9 +21,15 @@ const VerifyMagicLink: React.FC = () => { const authClient = useAuthClient(); useEffect(() => { + let mounted = true; + let channel: BroadcastChannel | null = null; + let redirectTimeout: ReturnType | null = null; + const verify = async () => { if (!token) { - setError('Missing token for verification.'); + if (mounted) { + setError('Missing token for verification.'); + } console.error('No token found', token); return; } @@ -33,14 +39,20 @@ const VerifyMagicLink: React.FC = () => { if (!response.ok) { console.error('Failed to verify token'); - setError('Failed to verify token'); + if (mounted) { + setError('Failed to verify token'); + } return; } } catch (error) { console.error(error); } - const channel = new BroadcastChannel('seamless-auth'); + if (!mounted) { + return; + } + + channel = new BroadcastChannel('seamless-auth'); channel.postMessage({ type: 'MAGIC_LINK_AUTH_SUCCESS', @@ -50,11 +62,24 @@ const VerifyMagicLink: React.FC = () => { 'You have been verified on the device and browser that initiated this request' ); - setTimeout(() => { + redirectTimeout = setTimeout(() => { + if (!mounted) { + return; + } + navigate('/'); }, 900); }; verify(); + + return () => { + mounted = false; + channel?.close(); + + if (redirectTimeout) { + clearTimeout(redirectTimeout); + } + }; }, [token, authClient, navigate]); return ( diff --git a/tests/VerifyMagicLink.test.tsx b/tests/VerifyMagicLink.test.tsx index eb53ba1..b1c7108 100644 --- a/tests/VerifyMagicLink.test.tsx +++ b/tests/VerifyMagicLink.test.tsx @@ -25,6 +25,7 @@ describe('VerifyMagicLink', () => { verifyMagicLink: jest.fn(), }; const postMessage = jest.fn(); + const close = jest.fn(); beforeEach(() => { jest.useFakeTimers(); @@ -34,7 +35,7 @@ describe('VerifyMagicLink', () => { global.BroadcastChannel = jest.fn(() => ({ postMessage, - close: jest.fn(), + close, })) as any; jest.clearAllMocks(); @@ -120,6 +121,31 @@ describe('VerifyMagicLink', () => { expect(navigate).toHaveBeenCalledWith('/'); }); + test('cleans up broadcast channel and redirect timeout on unmount', async () => { + (useSearchParams as jest.Mock).mockReturnValue([ + new URLSearchParams('?token=abc123'), + ]); + + mockAuthClient.verifyMagicLink.mockResolvedValue({ + ok: true, + }); + + const { unmount } = render(); + + await screen.findByText(/login verified/i); + + unmount(); + + expect(close).toHaveBeenCalledTimes(1); + expect(jest.getTimerCount()).toBe(0); + + act(() => { + jest.advanceTimersByTime(900); + }); + + expect(navigate).not.toHaveBeenCalled(); + }); + test('shows spinner during verification', () => { (useSearchParams as jest.Mock).mockReturnValue([ new URLSearchParams('?token=abc123'), From 97dbb1bf21446f90b529266e93b270cc0f0c3b24 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sun, 17 May 2026 13:11:00 -0400 Subject: [PATCH 2/3] fix: update credentials after mutations --- src/AuthProvider.tsx | 21 +++++- tests/authProvider.test.tsx | 137 ++++++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+), 3 deletions(-) diff --git a/src/AuthProvider.tsx b/src/AuthProvider.tsx index f70fd56..eb2ba01 100644 --- a/src/AuthProvider.tsx +++ b/src/AuthProvider.tsx @@ -182,7 +182,18 @@ export const AuthProvider: React.FC = ({ }); if (response.ok) { - return response.json(); + const responseBody = await response.json(); + const updatedCredential = responseBody.credential ?? responseBody; + + setCredentials(currentCredentials => + currentCredentials.map(currentCredential => + currentCredential.id === updatedCredential.id + ? { ...currentCredential, ...updatedCredential } + : currentCredential + ) + ); + + return responseBody; } throw new Error('Failed to update credential'); @@ -192,10 +203,14 @@ export const AuthProvider: React.FC = ({ const response = await authClient.deleteCredential(credentialId); if (response.ok) { - return response.json(); + const responseBody = await response.json(); + setCredentials(currentCredentials => + currentCredentials.filter(credential => credential.id !== credentialId) + ); + return responseBody; } - throw new Error('Failed to update credential'); + throw new Error('Failed to delete credential'); }; const refreshStepUpStatus = useCallback(async () => { diff --git a/tests/authProvider.test.tsx b/tests/authProvider.test.tsx index 4ce758f..0ea2878 100644 --- a/tests/authProvider.test.tsx +++ b/tests/authProvider.test.tsx @@ -17,17 +17,52 @@ const mockFetchWithAuthImpl = jest.fn(); const Consumer = () => { const auth = useAuth(); + const firstCredential = auth.credentials[0]; + return (
{auth.user ? auth.user.email : 'none'} {String(auth.isAuthenticated)} {String(auth.hasRole('admin'))} {String(auth.stepUpStatus?.fresh ?? false)} + + {auth.credentials.map(credential => credential.friendlyName).join(',')} + + +
); }; +const buildCredential = (overrides = {}) => + ({ + id: 'cred-1', + counter: 0, + transports: [], + deviceType: 'singleDevice', + backedup: false, + friendlyName: 'Old passkey', + lastUsedAt: null, + platform: 'mac', + browser: 'chrome', + deviceInfo: 'mac chrome', + ...overrides, + }) as any; + describe('AuthProvider', () => { const apiHost = 'https://api.example.com/'; @@ -134,4 +169,106 @@ describe('AuthProvider', () => { expect(screen.getByTestId('stepUpFresh')).toHaveTextContent('true'); }); }); + + it('updates credential state after a successful credential update', async () => { + const credential = buildCredential(); + const updatedCredential = buildCredential({ friendlyName: 'Renamed passkey' }); + + mockFetchWithAuthImpl + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + user: { + id: '1', + email: 'test@example.com', + phone: '555-1234', + roles: ['admin'], + }, + credentials: [credential], + }), + } as any) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + message: 'Credential updated', + credential: updatedCredential, + }), + } as any); + + await act(async () => { + render( + + + + ); + }); + + await waitFor(() => { + expect(screen.getByTestId('credentials')).toHaveTextContent('Old passkey'); + }); + + fireEvent.click(screen.getByRole('button', { name: /update credential/i })); + + await waitFor(() => { + expect(screen.getByTestId('credentials')).toHaveTextContent('Renamed passkey'); + }); + + expect(screen.getByTestId('credentials')).not.toHaveTextContent('Old passkey'); + expect(mockFetchWithAuthImpl).toHaveBeenCalledTimes(2); + expect( + mockFetchWithAuthImpl.mock.calls.filter(([path]) => path === 'users/me') + ).toHaveLength(1); + }); + + it('removes deleted credentials from provider state after a successful delete', async () => { + const credential = buildCredential(); + const secondCredential = buildCredential({ + id: 'cred-2', + friendlyName: 'Backup passkey', + }); + + mockFetchWithAuthImpl + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + user: { + id: '1', + email: 'test@example.com', + phone: '555-1234', + roles: ['admin'], + }, + credentials: [credential, secondCredential], + }), + } as any) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + message: 'Credential deleted', + }), + } as any); + + await act(async () => { + render( + + + + ); + }); + + await waitFor(() => { + expect(screen.getByTestId('credentials')).toHaveTextContent('Old passkey'); + }); + + fireEvent.click(screen.getByRole('button', { name: /delete credential/i })); + + await waitFor(() => { + expect(screen.getByTestId('credentials')).not.toHaveTextContent('Old passkey'); + }); + + expect(screen.getByTestId('credentials')).toHaveTextContent('Backup passkey'); + expect(mockFetchWithAuthImpl).toHaveBeenCalledTimes(2); + expect( + mockFetchWithAuthImpl.mock.calls.filter(([path]) => path === 'users/me') + ).toHaveLength(1); + }); }); From 8fe0f96aff5e48b264b45d8803d217f58b0260e5 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sun, 17 May 2026 13:14:43 -0400 Subject: [PATCH 3/3] fix: normalize apiHost url to just work if given with a / or not --- README.md | 1 + src/fetchWithAuth.ts | 6 ++++-- tests/fetchWithAuth.test.tsx | 20 +++++++++++++++++--- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a6290aa..6ed4a8b 100644 --- a/README.md +++ b/README.md @@ -328,6 +328,7 @@ This package assumes a Seamless Auth-compatible backend with cookie-based auth f - In `web` mode, requests target `${apiHost}/...` - In `server` mode, requests target `${apiHost}/auth/...` +- `apiHost` may be provided with or without a trailing slash - Requests are sent with `credentials: 'include'` - `AuthProvider` validates the current session by calling `/users/me` on load diff --git a/src/fetchWithAuth.ts b/src/fetchWithAuth.ts index 81e59df..b142620 100644 --- a/src/fetchWithAuth.ts +++ b/src/fetchWithAuth.ts @@ -18,9 +18,11 @@ export const createFetchWithAuth = (opts: FetchWithAuthOptions) => { input: string, init?: RequestInit ): Promise { - const base = authMode === 'server' ? `auth` : ''; + const host = authHost?.replace(/\/+$/, '') ?? ''; + const base = authMode === 'server' ? '/auth' : ''; + const path = input.startsWith('/') ? input : `/${input}`; - const url = `${authHost}${base}${input.startsWith('/') ? input : `/${input}`}`; + const url = `${host}${base}${path}`; const requestInit: RequestInit = { ...init, diff --git a/tests/fetchWithAuth.test.tsx b/tests/fetchWithAuth.test.tsx index 21041b9..b84cc65 100644 --- a/tests/fetchWithAuth.test.tsx +++ b/tests/fetchWithAuth.test.tsx @@ -31,7 +31,7 @@ describe('createFetchWithAuth', () => { expect(options.credentials).toBe('include'); }); - it('builds correct URL in server mode', async () => { + it('builds correct URL in server mode when auth host has a trailing slash', async () => { const fetchWithAuth = createFetchWithAuth({ authMode: 'server', authHost: 'https://api.example.com/', @@ -39,10 +39,24 @@ describe('createFetchWithAuth', () => { mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); - await fetchWithAuth('/auth/me'); + await fetchWithAuth('/users/me'); const [url] = mockFetch.mock.calls[0]; - expect(url).toBe('https://api.example.com/auth/auth/me'); + expect(url).toBe('https://api.example.com/auth/users/me'); + }); + + it('builds correct URL in server mode when auth host has no trailing slash', async () => { + const fetchWithAuth = createFetchWithAuth({ + authMode: 'server', + authHost: 'https://api.example.com', + }); + + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + await fetchWithAuth('users/me'); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toBe('https://api.example.com/auth/users/me'); }); it('returns the raw response when fetch response is not ok', async () => {