Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/oauth-provider-ui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@seamless-auth/react": minor
---

Add OAuth provider UI to the built-in auth screens. The sign-in view now lists configured
providers (via listOAuthProviders) as "Continue with <provider>" buttons that start the flow
and redirect to the IdP, and a new /oauth/callback route finishes the login (reads code/state,
calls finishOAuthLogin) and lands the user on the app. Closes the gap where the SDK exposed the
OAuth client methods but had no UI or callback route to drive them.
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,3 +227,4 @@ Avoid these patterns unless the user explicitly asks for them:
- changing endpoint assumptions without checking the server/api repos
- leaving README or repo guidance out of sync with the actual exports
- creating a second source of truth for session state outside `AuthProvider`
- using em dashes (—) in public-facing text: commit messages, code comments, PR/issue descriptions, changesets, and docs. Use a comma, parentheses, or a separate sentence instead.
2 changes: 2 additions & 0 deletions src/AuthRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import PasskeyRegistration from '@/views/PassKeyRegistration';
import PhoneRegistration from '@/views/PhoneRegistration';
import EmailRegistration from '@/views/EmailRegistration';
import VerifyMagicLink from '@/views/VerifyMagicLink';
import OAuthCallback from '@/views/OAuthCallback';
import MagicLinkSent from './components/MagicLinkSent';

export const AuthRoutes = () => (
Expand All @@ -21,6 +22,7 @@ export const AuthRoutes = () => (
<Route path="/verifyPhoneOTP" element={<PhoneRegistration />} />
<Route path="/verifyEmailOTP" element={<EmailRegistration />} />
<Route path="/verify-magiclink" element={<VerifyMagicLink />} />
<Route path="/oauth/callback" element={<OAuthCallback />} />
<Route path="/registerPasskey" element={<PasskeyRegistration />} />
<Route path="/magiclinks-sent" element={<MagicLinkSent />} />
<Route path="*" element={<Navigate to="/login" replace />} />
Expand Down
75 changes: 75 additions & 0 deletions src/components/OAuthProviderButtons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* 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 React, { useEffect, useState } from 'react';
import { useAuth } from '@/AuthProvider';
import type { OAuthProvider } from '@/client/createSeamlessAuthClient';

import styles from '../styles/login.module.css';

export const OAUTH_PROVIDER_STORAGE_KEY = 'seamless:oauth:provider';

const OAuthProviderButtons: React.FC = () => {
const { listOAuthProviders, startOAuthLogin } = useAuth();
const [providers, setProviders] = useState<OAuthProvider[]>([]);
const [error, setError] = useState('');

useEffect(() => {
let active = true;

listOAuthProviders()
.then(result => {
if (active) setProviders(result.providers ?? []);
})
.catch(() => {
if (active) setProviders([]);
});

return () => {
active = false;
};
}, [listOAuthProviders]);

if (providers.length === 0) {
return null;
}

const handleSelect = async (providerId: string) => {
setError('');
try {
// The callback route reads this to know which provider to finish with.
sessionStorage.setItem(OAUTH_PROVIDER_STORAGE_KEY, providerId);

const { authorizationUrl } = await startOAuthLogin({
providerId,
redirectUri: `${window.location.origin}/oauth/callback`,
});

window.location.assign(authorizationUrl);
} catch {
setError('Could not start sign-in with this provider.');
}
};

return (
<div className={styles.fallbackActions}>
{providers.map(provider => (
<button
key={provider.id}
type="button"
className={styles.fallbackActionButton}
onClick={() => handleSelect(provider.id)}
>
<span className={styles.actionTitle}>Continue with {provider.name}</span>
</button>
))}

{error && <p className={styles.error}>{error}</p>}
</div>
);
};

export default OAuthProviderButtons;
3 changes: 3 additions & 0 deletions src/views/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { useNavigate } from 'react-router-dom';
import styles from '@/styles/login.module.css';
import { isValidEmail, isValidPhoneNumber } from '../utils';
import AuthFallbackOptions from '@/components/AuthFallbackOptions';
import OAuthProviderButtons from '@/components/OAuthProviderButtons';
import type { LoginMethod, LoginStartResult } from '@/client/createSeamlessAuthClient';

const DEFAULT_LOGIN_METHODS: LoginMethod[] = ['passkey', 'magic_link', 'phone_otp'];
Expand Down Expand Up @@ -303,6 +304,8 @@ const Login: React.FC = () => {
: 'Already have an account? Sign in'}
</button>
</form>

<OAuthProviderButtons />
</>
</div>
</div>
Expand Down
59 changes: 59 additions & 0 deletions src/views/OAuthCallback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/*
* 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 React, { useEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '@/AuthProvider';
import { OAUTH_PROVIDER_STORAGE_KEY } from '@/components/OAuthProviderButtons';

import styles from '@/styles/verifyMagiclink.module.css';

const OAuthCallback: React.FC = () => {
const { finishOAuthLogin } = useAuth();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [error, setError] = useState('');
const startedRef = useRef(false);

useEffect(() => {
if (startedRef.current) return;
startedRef.current = true;

const code = searchParams.get('code');
const state = searchParams.get('state');
const providerId = sessionStorage.getItem(OAUTH_PROVIDER_STORAGE_KEY);

if (!code || !state || !providerId) {
setError('This sign-in link is missing required information.');
return;
}

finishOAuthLogin({ providerId, code, state })
.then(() => {
sessionStorage.removeItem(OAUTH_PROVIDER_STORAGE_KEY);
navigate('/');
})
.catch(() => {
setError('We could not complete sign-in. Please try again.');
});
}, [finishOAuthLogin, navigate, searchParams]);

return (
<div className={styles.container}>
<h2 className={styles.title}>{error ? 'Sign-in failed' : 'Completing sign-in...'}</h2>
{error && (
<>
<p>{error}</p>
<button type="button" onClick={() => navigate('/login')}>
Back to login
</button>
</>
)}
</div>
);
};

export default OAuthCallback;
57 changes: 57 additions & 0 deletions tests/OAuthCallback.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
* 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, waitFor } from '@testing-library/react';
import OAuthCallback from '@/views/OAuthCallback';

import { useAuth } from '@/AuthProvider';
import { useNavigate, useSearchParams } from 'react-router-dom';

jest.mock('@/AuthProvider');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: jest.fn(),
useSearchParams: jest.fn(),
}));

describe('OAuthCallback', () => {
const navigate = jest.fn();
const finishOAuthLogin = jest.fn();

beforeEach(() => {
jest.clearAllMocks();
window.sessionStorage.clear();
(useNavigate as jest.Mock).mockReturnValue(navigate);
(useAuth as jest.Mock).mockReturnValue({ finishOAuthLogin });
});

test('finishes the login and navigates home', async () => {
finishOAuthLogin.mockResolvedValue(undefined);
window.sessionStorage.setItem('seamless:oauth:provider', 'mock');
(useSearchParams as jest.Mock).mockReturnValue([new URLSearchParams('code=abc&state=xyz')]);

render(<OAuthCallback />);

await waitFor(() =>
expect(finishOAuthLogin).toHaveBeenCalledWith({
providerId: 'mock',
code: 'abc',
state: 'xyz',
})
);
await waitFor(() => expect(navigate).toHaveBeenCalledWith('/'));
expect(window.sessionStorage.getItem('seamless:oauth:provider')).toBeNull();
});

test('shows an error when the callback params are missing', async () => {
(useSearchParams as jest.Mock).mockReturnValue([new URLSearchParams('')]);

render(<OAuthCallback />);

expect(await screen.findByText('Sign-in failed')).toBeInTheDocument();
expect(finishOAuthLogin).not.toHaveBeenCalled();
});
});
52 changes: 52 additions & 0 deletions tests/OAuthProviderButtons.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* 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, waitFor } from '@testing-library/react';
import OAuthProviderButtons from '@/components/OAuthProviderButtons';

import { useAuth } from '@/AuthProvider';

jest.mock('@/AuthProvider');

describe('OAuthProviderButtons', () => {
const listOAuthProviders = jest.fn();
const startOAuthLogin = jest.fn();

beforeEach(() => {
jest.clearAllMocks();
window.sessionStorage.clear();
(useAuth as jest.Mock).mockReturnValue({ listOAuthProviders, startOAuthLogin });
});

test('renders nothing when no providers are configured', async () => {
listOAuthProviders.mockResolvedValue({ providers: [] });

const { container } = render(<OAuthProviderButtons />);

await waitFor(() => expect(listOAuthProviders).toHaveBeenCalled());
expect(container).toBeEmptyDOMElement();
});

test('starts the flow and stores the provider when one is selected', async () => {
listOAuthProviders.mockResolvedValue({
providers: [{ id: 'mock', name: 'Mock OIDC', scopes: [] }],
});
startOAuthLogin.mockResolvedValue({ authorizationUrl: 'http://idp.test/authorize' });

render(<OAuthProviderButtons />);

const button = await screen.findByRole('button', { name: /Continue with Mock OIDC/ });
fireEvent.click(button);

await waitFor(() =>
expect(startOAuthLogin).toHaveBeenCalledWith({
providerId: 'mock',
redirectUri: `${window.location.origin}/oauth/callback`,
})
);
expect(window.sessionStorage.getItem('seamless:oauth:provider')).toBe('mock');
});
});
3 changes: 3 additions & 0 deletions tests/login.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ describe('Login', () => {
(useAuth as jest.Mock).mockReturnValue({
apiHost: 'http://localhost',
hasSignedInBefore: true,
listOAuthProviders: jest.fn().mockResolvedValue({ providers: [] }),
login: jest.fn().mockResolvedValue({ ok: true }),
handlePasskeyLogin: jest.fn().mockResolvedValue(false),
});
Expand Down Expand Up @@ -116,6 +117,7 @@ describe('Login', () => {
(useAuth as jest.Mock).mockReturnValue({
apiHost: 'http://localhost',
hasSignedInBefore: true,
listOAuthProviders: jest.fn().mockResolvedValue({ providers: [] }),
login: mockLogin,
handlePasskeyLogin: mockHandlePasskeyLogin,
});
Expand Down Expand Up @@ -166,6 +168,7 @@ describe('Login', () => {
(useAuth as jest.Mock).mockReturnValue({
apiHost: 'http://localhost',
hasSignedInBefore: true,
listOAuthProviders: jest.fn().mockResolvedValue({ providers: [] }),
login: mockLogin,
handlePasskeyLogin: jest.fn().mockResolvedValue(false),
});
Expand Down