Add Google/Microsoft SSO buttons and OAuth callback flow (#24) (#41)
Unit Tests / test (push) Successful in 12s

## Summary
- Frontend half of ai_ml_operations/chat_backend#24
- SSO buttons on Sign In / Sign Up (shown when `/public/settings/` reports provider configured)
- New `/auth/callback/` route stores JWTs, loads account, sends new SSO users through Stripe Checkout
- Error states for denied consent, unverified/missing email, link conflict, etc.
- README note on Capacitor / native redirect URI limits

## Pair with
- Backend PR: `chat_backend` branch `feature/sso-oauth-24`

## Test plan
- [ ] `npm test -- --watchAll=false --testPathPattern="SignIn.test|SignUp.test|AuthCallback.test"`
- [ ] With backend OAuth configured, Continue with Google/Microsoft from Sign In works
- [ ] First-time SSO register lands in Stripe Checkout when `needs_checkout=1`
- [ ] Existing linked user signs in and reaches app / TOS
- [ ] Error query (`email_unverified`, `access_denied`) shows message + back linkReviewed-on: #41
This commit was merged in pull request #41.
This commit is contained in:
2026-07-27 05:14:24 -07:00
parent 400549268c
commit 28a805404a
9 changed files with 564 additions and 0 deletions
+12
View File
@@ -107,6 +107,18 @@ These are **baked into the JS at build time**. There is no host secret file for
this frontend (unlike Django apps under `~/Documents/secrets/`). Change the
committed `.env.*` files if API domains change, then redeploy.
### SSO (Google / Microsoft)
OAuth client IDs/secrets live in **chat_backend** env (`GOOGLE_OAUTH_*`,
`MICROSOFT_OAUTH_*`). The SPA only redirects to
`/api/auth/oauth/<provider>/start/` and lands on `/auth/callback/` with JWTs.
SSO buttons appear when `/public/settings/` reports the provider as configured.
**Capacitor / mobile:** IdP redirect URIs must match the backend callback URL
(not the WebView origin). Custom URL schemes / in-app browser for native OAuth
are out of scope for the initial web SSO; use the web app for Google/Microsoft
sign-in until native redirect handling is added.
Optional local overrides (gitignored): `.env.local`, `.env.development.local`,
`.env.production.local`, `.env.mobile.local`.
+2
View File
@@ -3,6 +3,7 @@ import './App.css';
import { Routes, Route, Outlet, Navigate } from 'react-router-dom';
import SignIn from './llm-fe/pages/SignIn/SignIn';
import SignUp from './llm-fe/pages/SignUp/SignUp';
import AuthCallback from './llm-fe/pages/AuthCallback/AuthCallback';
import BillingSuccess from './llm-fe/pages/BillingSuccess/BillingSuccess';
import BillingCancel from './llm-fe/pages/BillingCancel/BillingCancel';
import PasswordReset from './llm-fe/pages/PasswordReset/PasswordReset';
@@ -50,6 +51,7 @@ class App extends Component {
<Route path={"/signin/"} Component={SignIn} />
<Route path={"/signup/"} Component={SignUp} />
<Route path={"/auth/callback/"} Component={AuthCallback} />
<Route path={"/billing/success"} Component={BillingSuccess} />
<Route path={"/billing/cancel"} Component={BillingCancel} />
<Route path={"/password_reset/"} Component={PasswordReset} />
+58
View File
@@ -0,0 +1,58 @@
/**
* SSO helpers for Google / Microsoft OAuth (#24).
* Start URLs hit the backend, which redirects to the IdP.
*/
/**
* Absolute backend OAuth start URL.
* @param {'google'|'microsoft'} provider
* @param {'login'|'signup'} intent
* @returns {string}
*/
export function oauthStartUrl(provider, intent = 'login') {
const base = (process.env.REACT_APP_BACKEND_REST_API_BASE_URL || '').replace(/\/?$/, '/');
const params = new URLSearchParams({ intent });
return `${base}auth/oauth/${provider}/start/?${params.toString()}`;
}
/**
* Begin browser redirect to IdP via backend.
* @param {'google'|'microsoft'} provider
* @param {'login'|'signup'} intent
*/
export function startOAuth(provider, intent = 'login') {
window.location.assign(oauthStartUrl(provider, intent));
}
const ERROR_MESSAGES = {
access_denied: 'Sign-in was cancelled.',
email_missing: 'Your account with this provider has no email address.',
email_unverified: 'Your email with this provider is not verified.',
link_conflict: 'This email is already linked to a different identity for this provider.',
account_not_found: 'No account exists for this email. Contact your administrator or sign up.',
registration_disabled: 'Account registration is currently disabled.',
provider_not_configured: 'This sign-in method is not available.',
invalid_state: 'Sign-in session expired. Please try again.',
missing_code: 'Sign-in did not complete. Please try again.',
token_exchange_failed: 'Could not complete sign-in with the provider. Try again.',
profile_fetch_failed: 'Could not load your profile from the provider.',
profile_incomplete: 'Provider profile was incomplete. Try again.',
provider_error: 'The identity provider returned an error.',
server_error: 'Unexpected sign-in error. Try again.',
invalid_provider: 'Unsupported sign-in provider.',
};
/**
* @param {string|null|undefined} code
* @param {string|null|undefined} description
* @returns {string}
*/
export function oauthErrorMessage(code, description) {
if (code && ERROR_MESSAGES[code]) {
return ERROR_MESSAGES[code];
}
if (description) {
return String(description);
}
return 'Could not complete sign-in. Try again.';
}
@@ -0,0 +1,91 @@
import React from 'react';
import styled from 'styled-components';
import { startOAuth } from '../../auth/sso';
const Divider = styled.div`
display: flex;
align-items: center;
width: 100%;
margin: 1.25rem 0 0.75rem;
color: rgba(255, 255, 255, 0.45);
font-size: 0.8rem;
gap: 0.75rem;
&::before,
&::after {
content: '';
flex: 1;
height: 1px;
background: rgba(255, 255, 255, 0.15);
}
`;
const SsoButton = styled.button`
width: 100%;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 0.5rem;
color: #fff;
padding: 0.85rem 1rem;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s ease, transform 0.2s ease;
margin-top: 0.6rem;
&:hover {
background: rgba(255, 255, 255, 0.12);
transform: translateY(-1px);
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none;
}
`;
export type OAuthProviderFlags = {
google?: boolean;
microsoft?: boolean;
};
type SsoButtonsProps = {
intent: 'login' | 'signup';
providers: OAuthProviderFlags;
disabled?: boolean;
};
const SsoButtons = ({ intent, providers, disabled = false }: SsoButtonsProps): JSX.Element | null => {
const google = Boolean(providers.google);
const microsoft = Boolean(providers.microsoft);
if (!google && !microsoft) {
return null;
}
return (
<>
<Divider>or continue with</Divider>
{google && (
<SsoButton
type="button"
disabled={disabled}
onClick={() => startOAuth('google', intent)}
>
Continue with Google
</SsoButton>
)}
{microsoft && (
<SsoButton
type="button"
disabled={disabled}
onClick={() => startOAuth('microsoft', intent)}
>
Continue with Microsoft
</SsoButton>
)}
</>
);
};
export default SsoButtons;
@@ -0,0 +1,139 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import AuthCallback from './AuthCallback';
import { AuthContext } from '../../contexts/AuthContext';
import { AccountContext } from '../../contexts/AccountContext';
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
const mockPost = jest.fn();
const mockGet = jest.fn();
const mockApplyAccessToken = jest.fn();
const assignMock = jest.fn();
jest.mock('../../../axiosApi', () => ({
axiosInstance: {
post: (...args: unknown[]) => mockPost(...args),
get: (...args: unknown[]) => mockGet(...args),
defaults: { headers: { common: {} as Record<string, string | null> } },
},
applyAccessToken: (...args: unknown[]) => mockApplyAccessToken(...args),
}));
const renderCallback = (query: string) => {
const setAuthentication = jest.fn();
const setNeedsNewPassword = jest.fn();
const setAccount = jest.fn();
render(
<MemoryRouter initialEntries={[`/auth/callback/${query}`]}>
<AuthContext.Provider
value={{
authenticated: false,
setAuthentication,
needsNewPassword: false,
setNeedsNewPassword,
loading: false,
}}
>
<AccountContext.Provider value={{ account: undefined, setAccount }}>
<Routes>
<Route path="/auth/callback/" element={<AuthCallback />} />
<Route path="/" element={<div>Home</div>} />
<Route path="/terms_of_service/" element={<div>TOS</div>} />
<Route path="/signin/" element={<div>Sign In Page</div>} />
</Routes>
</AccountContext.Provider>
</AuthContext.Provider>
</MemoryRouter>
);
return { setAuthentication, setNeedsNewPassword, setAccount };
};
describe('AuthCallback', () => {
const originalLocation = window.location;
beforeEach(() => {
localStorage.clear();
mockPost.mockReset();
mockGet.mockReset();
mockApplyAccessToken.mockReset();
assignMock.mockReset();
Object.defineProperty(window, 'location', {
configurable: true,
value: { ...originalLocation, assign: assignMock, origin: 'http://localhost' },
});
});
afterEach(() => {
Object.defineProperty(window, 'location', {
configurable: true,
value: originalLocation,
});
});
it('shows oauth error from query string', async () => {
renderCallback('?error=email_unverified');
expect(await screen.findByText(/email with this provider is not verified/i)).toBeInTheDocument();
expect(screen.getByRole('link', { name: /Back to Sign In/i })).toBeInTheDocument();
});
it('stores tokens and navigates home when TOS signed', async () => {
mockGet.mockResolvedValue({
data: {
email: 'user@example.com',
first_name: 'Jane',
last_name: 'Doe',
is_company_manager: true,
has_signed_tos: true,
has_usable_password: false,
company: { id: 1, name: 'Acme', state: 'CA', zipcode: '90001', address: '1 Main' },
},
});
const { setAuthentication } = renderCallback(
'?access=access-token&refresh=refresh-token&created=0&needs_checkout=0'
);
await waitFor(() => {
expect(localStorage.getItem('access_token')).toBe('access-token');
expect(localStorage.getItem('refresh_token')).toBe('refresh-token');
expect(mockApplyAccessToken).toHaveBeenCalledWith('access-token');
expect(setAuthentication).toHaveBeenCalledWith(true);
expect(screen.getByText('Home')).toBeInTheDocument();
});
});
it('starts Stripe checkout for new SSO accounts', async () => {
mockGet.mockResolvedValue({
data: {
email: 'new@example.com',
first_name: '',
last_name: '',
is_company_manager: true,
has_signed_tos: false,
has_usable_password: false,
company: { id: 2, name: "new@example.com's workspace", state: 'NA', zipcode: '00000', address: 'N/A' },
},
});
mockPost.mockResolvedValue({ data: { checkout_url: 'https://checkout.stripe.test/session' } });
renderCallback(
'?access=access-token&refresh=refresh-token&created=1&needs_checkout=1'
);
await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith(
'/finance/checkout/',
expect.objectContaining({
success_url: expect.stringContaining('/billing/success'),
cancel_url: expect.stringContaining('/billing/cancel'),
})
);
expect(assignMock).toHaveBeenCalledWith('https://checkout.stripe.test/session');
});
});
});
@@ -0,0 +1,211 @@
import React, { useContext, useEffect, useState } from 'react';
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
import { AxiosResponse } from 'axios';
import styled from 'styled-components';
import { applyAccessToken, axiosInstance } from '../../../axiosApi';
import { setTokens } from '../../auth/tokenStorage';
import { oauthErrorMessage } from '../../auth/sso';
import { AuthContext } from '../../contexts/AuthContext';
import { AccountContext } from '../../contexts/AccountContext';
import { Account, AccountType } from '../../data';
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
const PageContainer = styled.div`
position: relative;
width: 100vw;
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
color: #fff;
font-family: 'Inter', sans-serif;
`;
const ContentWrapper = styled.div`
flex: 1;
display: flex;
justify-content: center;
align-items: center;
z-index: 5;
padding: 2rem;
`;
const GlassCard = styled.div`
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 1rem;
padding: 3rem;
width: 100%;
max-width: 450px;
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
display: flex;
flex-direction: column;
align-items: center;
`;
const CardTitle = styled.h2`
font-size: 2rem;
margin-bottom: 1rem;
color: #fff;
text-align: center;
`;
const CardSubtitle = styled.p`
color: rgba(255, 255, 255, 0.65);
text-align: center;
margin-bottom: 1.5rem;
font-size: 0.95rem;
`;
const ErrorMessage = styled.div`
color: #ff6b6b;
margin-bottom: 1rem;
text-align: center;
background: rgba(255, 107, 107, 0.1);
padding: 0.5rem;
border-radius: 0.5rem;
width: 100%;
`;
const NavLink = styled(Link)`
color: rgba(255, 255, 255, 0.6);
margin-top: 1rem;
font-size: 0.9rem;
text-decoration: none;
&:hover {
color: #fff;
text-decoration: underline;
}
`;
function checkoutReturnUrls(): { success_url: string; cancel_url: string } {
const origin = window.location.origin;
return {
success_url: `${origin}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/billing/cancel`,
};
}
const AuthCallback = (): JSX.Element => {
const { setAuthentication, setNeedsNewPassword } = useContext(AuthContext);
const { setAccount } = useContext(AccountContext);
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [errorMessage, setErrorMessage] = useState('');
const [statusText, setStatusText] = useState('Completing sign-in…');
useEffect(() => {
let cancelled = false;
const run = async () => {
const error = searchParams.get('error');
if (error) {
setErrorMessage(
oauthErrorMessage(error, searchParams.get('error_description'))
);
setStatusText('Sign-in failed');
return;
}
const access = searchParams.get('access');
const refresh = searchParams.get('refresh');
const needsCheckout = searchParams.get('needs_checkout') === '1';
if (!access || !refresh) {
setErrorMessage('Missing sign-in tokens. Please try again.');
setStatusText('Sign-in failed');
return;
}
try {
await setTokens(access, refresh);
applyAccessToken(access);
const get_user_response: AxiosResponse<AccountType> = await axiosInstance.get(
'/user/get/'
);
if (cancelled) {
return;
}
const account = new Account({
email: get_user_response.data.email,
first_name: get_user_response.data.first_name,
last_name: get_user_response.data.last_name,
is_company_manager: get_user_response.data.is_company_manager,
has_signed_tos: get_user_response.data.has_signed_tos,
company: {
id: get_user_response.data.company?.id,
name: get_user_response.data.company?.name,
state: get_user_response.data.company?.state,
zipcode: get_user_response.data.company?.zipcode,
address: get_user_response.data.company?.address,
},
});
setAccount(account);
setAuthentication(true);
setNeedsNewPassword(get_user_response.data.has_usable_password);
if (needsCheckout) {
setStatusText('Starting checkout…');
const { success_url, cancel_url } = checkoutReturnUrls();
const checkoutResponse = await axiosInstance.post('/finance/checkout/', {
success_url,
cancel_url,
});
const checkoutUrl = checkoutResponse.data?.checkout_url;
if (!checkoutUrl) {
setErrorMessage(
'Account created, but checkout could not be started. Try signing in.'
);
setStatusText('Checkout unavailable');
return;
}
window.location.assign(checkoutUrl);
return;
}
if (account.has_signed_tos) {
navigate('/', { replace: true });
} else {
navigate('/terms_of_service/', { replace: true });
}
} catch (err) {
console.log(err);
if (!cancelled) {
setErrorMessage('Could not finish sign-in. Try again.');
setStatusText('Sign-in failed');
}
}
};
run();
return () => {
cancelled = true;
};
}, [
searchParams,
navigate,
setAccount,
setAuthentication,
setNeedsNewPassword,
]);
return (
<PageContainer>
<ParticleBackground />
<ContentWrapper>
<GlassCard>
<CardTitle>Sign In</CardTitle>
<CardSubtitle>{statusText}</CardSubtitle>
{errorMessage && <ErrorMessage>{errorMessage}</ErrorMessage>}
{errorMessage && <NavLink to="/signin/">Back to Sign In</NavLink>}
</GlassCard>
</ContentWrapper>
</PageContainer>
);
};
export default AuthCallback;
@@ -74,6 +74,33 @@ describe('SignIn', () => {
expect(await screen.findByRole('button', { name: /Don't have an account\? Sign up/i })).toBeInTheDocument();
});
it('shows SSO buttons when oauth providers configured', async () => {
mockGet.mockResolvedValue({
data: {
enable_account_registration: false,
oauth: { google: true, microsoft: true },
},
});
renderSignIn();
expect(await screen.findByRole('button', { name: 'Continue with Google' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Continue with Microsoft' })).toBeInTheDocument();
});
it('hides SSO buttons when oauth not configured', async () => {
mockGet.mockResolvedValue({
data: {
enable_account_registration: false,
oauth: { google: false, microsoft: false },
},
});
renderSignIn();
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'Continue with Google' })).not.toBeInTheDocument();
});
});
it('does not submit when required fields empty', async () => {
const user = userEvent.setup();
renderSignIn();
+12
View File
@@ -8,6 +8,7 @@ import { AccountContext } from '../../contexts/AccountContext';
import { AxiosResponse } from 'axios';
import { Account, AccountType } from '../../data';
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
import SsoButtons, { OAuthProviderFlags } from '../../components/SsoButtons/SsoButtons';
import styled from 'styled-components';
import * as Yup from 'yup';
@@ -157,6 +158,7 @@ const SignIn = (): JSX.Element => {
const navigate = useNavigate();
const [errorMessage, setErrorMessage] = useState<string>('');
const [registrationEnabled, setRegistrationEnabled] = useState(false);
const [oauthProviders, setOauthProviders] = useState<OAuthProviderFlags>({});
useEffect(() => {
let cancelled = false;
@@ -165,10 +167,15 @@ const SignIn = (): JSX.Element => {
const response = await axiosInstance.get('/public/settings/');
if (!cancelled) {
setRegistrationEnabled(Boolean(response.data?.enable_account_registration));
setOauthProviders({
google: Boolean(response.data?.oauth?.google),
microsoft: Boolean(response.data?.oauth?.microsoft),
});
}
} catch {
if (!cancelled) {
setRegistrationEnabled(false);
setOauthProviders({});
}
}
})();
@@ -252,6 +259,11 @@ const SignIn = (): JSX.Element => {
<StyledButton type="submit" disabled={formik.isSubmitting}>
Sign In
</StyledButton>
<SsoButtons
intent="login"
providers={oauthProviders}
disabled={formik.isSubmitting}
/>
</Form>
)}
</Formik>
+12
View File
@@ -8,6 +8,7 @@ import { AccountContext } from '../../contexts/AccountContext';
import { AxiosResponse } from 'axios';
import { Account, AccountType } from '../../data';
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
import SsoButtons, { OAuthProviderFlags } from '../../components/SsoButtons/SsoButtons';
import styled from 'styled-components';
import * as Yup from 'yup';
@@ -173,6 +174,7 @@ const SignUp = (): JSX.Element => {
const { setAccount } = useContext(AccountContext);
const [errorMessage, setErrorMessage] = useState<string>('');
const [registrationEnabled, setRegistrationEnabled] = useState<boolean | null>(null);
const [oauthProviders, setOauthProviders] = useState<OAuthProviderFlags>({});
useEffect(() => {
let cancelled = false;
@@ -181,10 +183,15 @@ const SignUp = (): JSX.Element => {
const response = await axiosInstance.get('/public/settings/');
if (!cancelled) {
setRegistrationEnabled(Boolean(response.data?.enable_account_registration));
setOauthProviders({
google: Boolean(response.data?.oauth?.google),
microsoft: Boolean(response.data?.oauth?.microsoft),
});
}
} catch {
if (!cancelled) {
setRegistrationEnabled(false);
setOauthProviders({});
}
}
})();
@@ -339,6 +346,11 @@ const SignUp = (): JSX.Element => {
<StyledButton type="submit" disabled={formik.isSubmitting}>
{formik.isSubmitting ? 'Creating account…' : 'Sign Up & Pay'}
</StyledButton>
<SsoButtons
intent="signup"
providers={oauthProviders}
disabled={formik.isSubmitting}
/>
</Form>
)}
</Formik>