diff --git a/.changeset/oauth-provider-ui.md b/.changeset/oauth-provider-ui.md new file mode 100644 index 0000000..8005606 --- /dev/null +++ b/.changeset/oauth-provider-ui.md @@ -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 " 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. diff --git a/AGENTS.md b/AGENTS.md index 532bd9f..eeae6a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/src/AuthRoutes.tsx b/src/AuthRoutes.tsx index 81d2dd3..0b0a833 100644 --- a/src/AuthRoutes.tsx +++ b/src/AuthRoutes.tsx @@ -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 = () => ( @@ -21,6 +22,7 @@ export const AuthRoutes = () => ( } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/OAuthProviderButtons.tsx b/src/components/OAuthProviderButtons.tsx new file mode 100644 index 0000000..670b1da --- /dev/null +++ b/src/components/OAuthProviderButtons.tsx @@ -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([]); + 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 ( +
+ {providers.map(provider => ( + + ))} + + {error &&

{error}

} +
+ ); +}; + +export default OAuthProviderButtons; diff --git a/src/views/Login.tsx b/src/views/Login.tsx index 923fe54..2bf5f5b 100644 --- a/src/views/Login.tsx +++ b/src/views/Login.tsx @@ -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']; @@ -303,6 +304,8 @@ const Login: React.FC = () => { : 'Already have an account? Sign in'} + + diff --git a/src/views/OAuthCallback.tsx b/src/views/OAuthCallback.tsx new file mode 100644 index 0000000..b5b8e8e --- /dev/null +++ b/src/views/OAuthCallback.tsx @@ -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 ( +
+

{error ? 'Sign-in failed' : 'Completing sign-in...'}

+ {error && ( + <> +

{error}

+ + + )} +
+ ); +}; + +export default OAuthCallback; diff --git a/tests/OAuthCallback.test.tsx b/tests/OAuthCallback.test.tsx new file mode 100644 index 0000000..a1b96e4 --- /dev/null +++ b/tests/OAuthCallback.test.tsx @@ -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(); + + 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(); + + expect(await screen.findByText('Sign-in failed')).toBeInTheDocument(); + expect(finishOAuthLogin).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/OAuthProviderButtons.test.tsx b/tests/OAuthProviderButtons.test.tsx new file mode 100644 index 0000000..9d0a593 --- /dev/null +++ b/tests/OAuthProviderButtons.test.tsx @@ -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(); + + 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(); + + 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'); + }); +}); diff --git a/tests/login.test.tsx b/tests/login.test.tsx index 0ebfa73..8c84936 100644 --- a/tests/login.test.tsx +++ b/tests/login.test.tsx @@ -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), }); @@ -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, }); @@ -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), });