diff --git a/README.md b/README.md index 6ab55b8..3f36ac6 100644 --- a/README.md +++ b/README.md @@ -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//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`. diff --git a/llm-fe/src/App.tsx b/llm-fe/src/App.tsx index e6ca686..559ed9f 100644 --- a/llm-fe/src/App.tsx +++ b/llm-fe/src/App.tsx @@ -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 { + diff --git a/llm-fe/src/llm-fe/auth/sso.js b/llm-fe/src/llm-fe/auth/sso.js new file mode 100644 index 0000000..165f3d9 --- /dev/null +++ b/llm-fe/src/llm-fe/auth/sso.js @@ -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.'; +} diff --git a/llm-fe/src/llm-fe/components/SsoButtons/SsoButtons.tsx b/llm-fe/src/llm-fe/components/SsoButtons/SsoButtons.tsx new file mode 100644 index 0000000..2917a94 --- /dev/null +++ b/llm-fe/src/llm-fe/components/SsoButtons/SsoButtons.tsx @@ -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 ( + <> + or continue with + {google && ( + startOAuth('google', intent)} + > + Continue with Google + + )} + {microsoft && ( + startOAuth('microsoft', intent)} + > + Continue with Microsoft + + )} + + ); +}; + +export default SsoButtons; diff --git a/llm-fe/src/llm-fe/pages/AuthCallback/AuthCallback.test.tsx b/llm-fe/src/llm-fe/pages/AuthCallback/AuthCallback.test.tsx new file mode 100644 index 0000000..032563b --- /dev/null +++ b/llm-fe/src/llm-fe/pages/AuthCallback/AuthCallback.test.tsx @@ -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 } }, + }, + applyAccessToken: (...args: unknown[]) => mockApplyAccessToken(...args), +})); + +const renderCallback = (query: string) => { + const setAuthentication = jest.fn(); + const setNeedsNewPassword = jest.fn(); + const setAccount = jest.fn(); + + render( + + + + + } /> + Home} /> + TOS} /> + Sign In Page} /> + + + + + ); + + 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'); + }); + }); +}); diff --git a/llm-fe/src/llm-fe/pages/AuthCallback/AuthCallback.tsx b/llm-fe/src/llm-fe/pages/AuthCallback/AuthCallback.tsx new file mode 100644 index 0000000..22e942f --- /dev/null +++ b/llm-fe/src/llm-fe/pages/AuthCallback/AuthCallback.tsx @@ -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 = 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 ( + + + + + Sign In + {statusText} + {errorMessage && {errorMessage}} + {errorMessage && Back to Sign In} + + + + ); +}; + +export default AuthCallback; diff --git a/llm-fe/src/llm-fe/pages/SignIn/SignIn.test.tsx b/llm-fe/src/llm-fe/pages/SignIn/SignIn.test.tsx index 9416664..a077c0b 100644 --- a/llm-fe/src/llm-fe/pages/SignIn/SignIn.test.tsx +++ b/llm-fe/src/llm-fe/pages/SignIn/SignIn.test.tsx @@ -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(); diff --git a/llm-fe/src/llm-fe/pages/SignIn/SignIn.tsx b/llm-fe/src/llm-fe/pages/SignIn/SignIn.tsx index 1d2c394..7401241 100644 --- a/llm-fe/src/llm-fe/pages/SignIn/SignIn.tsx +++ b/llm-fe/src/llm-fe/pages/SignIn/SignIn.tsx @@ -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(''); const [registrationEnabled, setRegistrationEnabled] = useState(false); + const [oauthProviders, setOauthProviders] = useState({}); 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 => { Sign In + )} diff --git a/llm-fe/src/llm-fe/pages/SignUp/SignUp.tsx b/llm-fe/src/llm-fe/pages/SignUp/SignUp.tsx index 8ed5665..f6b58ca 100644 --- a/llm-fe/src/llm-fe/pages/SignUp/SignUp.tsx +++ b/llm-fe/src/llm-fe/pages/SignUp/SignUp.tsx @@ -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(''); const [registrationEnabled, setRegistrationEnabled] = useState(null); + const [oauthProviders, setOauthProviders] = useState({}); 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 => { {formik.isSubmitting ? 'Creating account…' : 'Sign Up & Pay'} + )}