Add Google/Microsoft SSO buttons and OAuth callback flow (#24) (#41)
Unit Tests / test (push) Successful in 12s
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:
@@ -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
|
this frontend (unlike Django apps under `~/Documents/secrets/`). Change the
|
||||||
committed `.env.*` files if API domains change, then redeploy.
|
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`,
|
Optional local overrides (gitignored): `.env.local`, `.env.development.local`,
|
||||||
`.env.production.local`, `.env.mobile.local`.
|
`.env.production.local`, `.env.mobile.local`.
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import './App.css';
|
|||||||
import { Routes, Route, Outlet, Navigate } from 'react-router-dom';
|
import { Routes, Route, Outlet, Navigate } from 'react-router-dom';
|
||||||
import SignIn from './llm-fe/pages/SignIn/SignIn';
|
import SignIn from './llm-fe/pages/SignIn/SignIn';
|
||||||
import SignUp from './llm-fe/pages/SignUp/SignUp';
|
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 BillingSuccess from './llm-fe/pages/BillingSuccess/BillingSuccess';
|
||||||
import BillingCancel from './llm-fe/pages/BillingCancel/BillingCancel';
|
import BillingCancel from './llm-fe/pages/BillingCancel/BillingCancel';
|
||||||
import PasswordReset from './llm-fe/pages/PasswordReset/PasswordReset';
|
import PasswordReset from './llm-fe/pages/PasswordReset/PasswordReset';
|
||||||
@@ -50,6 +51,7 @@ class App extends Component {
|
|||||||
|
|
||||||
<Route path={"/signin/"} Component={SignIn} />
|
<Route path={"/signin/"} Component={SignIn} />
|
||||||
<Route path={"/signup/"} Component={SignUp} />
|
<Route path={"/signup/"} Component={SignUp} />
|
||||||
|
<Route path={"/auth/callback/"} Component={AuthCallback} />
|
||||||
<Route path={"/billing/success"} Component={BillingSuccess} />
|
<Route path={"/billing/success"} Component={BillingSuccess} />
|
||||||
<Route path={"/billing/cancel"} Component={BillingCancel} />
|
<Route path={"/billing/cancel"} Component={BillingCancel} />
|
||||||
<Route path={"/password_reset/"} Component={PasswordReset} />
|
<Route path={"/password_reset/"} Component={PasswordReset} />
|
||||||
|
|||||||
@@ -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();
|
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 () => {
|
it('does not submit when required fields empty', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderSignIn();
|
renderSignIn();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { AccountContext } from '../../contexts/AccountContext';
|
|||||||
import { AxiosResponse } from 'axios';
|
import { AxiosResponse } from 'axios';
|
||||||
import { Account, AccountType } from '../../data';
|
import { Account, AccountType } from '../../data';
|
||||||
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
|
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
|
||||||
|
import SsoButtons, { OAuthProviderFlags } from '../../components/SsoButtons/SsoButtons';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
@@ -157,6 +158,7 @@ const SignIn = (): JSX.Element => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||||
const [registrationEnabled, setRegistrationEnabled] = useState(false);
|
const [registrationEnabled, setRegistrationEnabled] = useState(false);
|
||||||
|
const [oauthProviders, setOauthProviders] = useState<OAuthProviderFlags>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -165,10 +167,15 @@ const SignIn = (): JSX.Element => {
|
|||||||
const response = await axiosInstance.get('/public/settings/');
|
const response = await axiosInstance.get('/public/settings/');
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setRegistrationEnabled(Boolean(response.data?.enable_account_registration));
|
setRegistrationEnabled(Boolean(response.data?.enable_account_registration));
|
||||||
|
setOauthProviders({
|
||||||
|
google: Boolean(response.data?.oauth?.google),
|
||||||
|
microsoft: Boolean(response.data?.oauth?.microsoft),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setRegistrationEnabled(false);
|
setRegistrationEnabled(false);
|
||||||
|
setOauthProviders({});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -252,6 +259,11 @@ const SignIn = (): JSX.Element => {
|
|||||||
<StyledButton type="submit" disabled={formik.isSubmitting}>
|
<StyledButton type="submit" disabled={formik.isSubmitting}>
|
||||||
Sign In
|
Sign In
|
||||||
</StyledButton>
|
</StyledButton>
|
||||||
|
<SsoButtons
|
||||||
|
intent="login"
|
||||||
|
providers={oauthProviders}
|
||||||
|
disabled={formik.isSubmitting}
|
||||||
|
/>
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
)}
|
||||||
</Formik>
|
</Formik>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { AccountContext } from '../../contexts/AccountContext';
|
|||||||
import { AxiosResponse } from 'axios';
|
import { AxiosResponse } from 'axios';
|
||||||
import { Account, AccountType } from '../../data';
|
import { Account, AccountType } from '../../data';
|
||||||
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
|
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
|
||||||
|
import SsoButtons, { OAuthProviderFlags } from '../../components/SsoButtons/SsoButtons';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
@@ -173,6 +174,7 @@ const SignUp = (): JSX.Element => {
|
|||||||
const { setAccount } = useContext(AccountContext);
|
const { setAccount } = useContext(AccountContext);
|
||||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||||
const [registrationEnabled, setRegistrationEnabled] = useState<boolean | null>(null);
|
const [registrationEnabled, setRegistrationEnabled] = useState<boolean | null>(null);
|
||||||
|
const [oauthProviders, setOauthProviders] = useState<OAuthProviderFlags>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -181,10 +183,15 @@ const SignUp = (): JSX.Element => {
|
|||||||
const response = await axiosInstance.get('/public/settings/');
|
const response = await axiosInstance.get('/public/settings/');
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setRegistrationEnabled(Boolean(response.data?.enable_account_registration));
|
setRegistrationEnabled(Boolean(response.data?.enable_account_registration));
|
||||||
|
setOauthProviders({
|
||||||
|
google: Boolean(response.data?.oauth?.google),
|
||||||
|
microsoft: Boolean(response.data?.oauth?.microsoft),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setRegistrationEnabled(false);
|
setRegistrationEnabled(false);
|
||||||
|
setOauthProviders({});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -339,6 +346,11 @@ const SignUp = (): JSX.Element => {
|
|||||||
<StyledButton type="submit" disabled={formik.isSubmitting}>
|
<StyledButton type="submit" disabled={formik.isSubmitting}>
|
||||||
{formik.isSubmitting ? 'Creating account…' : 'Sign Up & Pay'}
|
{formik.isSubmitting ? 'Creating account…' : 'Sign Up & Pay'}
|
||||||
</StyledButton>
|
</StyledButton>
|
||||||
|
<SsoButtons
|
||||||
|
intent="signup"
|
||||||
|
providers={oauthProviders}
|
||||||
|
disabled={formik.isSubmitting}
|
||||||
|
/>
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
)}
|
||||||
</Formik>
|
</Formik>
|
||||||
|
|||||||
Reference in New Issue
Block a user