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:
@@ -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} />
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user