Add self-serve sign-up with Stripe Checkout redirect (#31).
Unit Tests / test (pull_request) Successful in 25s
Unit Tests / test (pull_request) Successful in 25s
Public /signup/ registers via backend, starts Checkout, and handles success/cancel return URLs. Sign-up link on Sign In is gated by ENABLE_ACCOUNT_REGISTRATION.
This commit is contained in:
@@ -2,6 +2,9 @@ import React, { Component, useContext } from 'react';
|
|||||||
import './App.css';
|
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 BillingSuccess from './llm-fe/pages/BillingSuccess/BillingSuccess';
|
||||||
|
import BillingCancel from './llm-fe/pages/BillingCancel/BillingCancel';
|
||||||
import PasswordReset from './llm-fe/pages/PasswordReset/PasswordReset';
|
import PasswordReset from './llm-fe/pages/PasswordReset/PasswordReset';
|
||||||
import NotFound from './llm-fe/pages/NotFound/NotFound';
|
import NotFound from './llm-fe/pages/NotFound/NotFound';
|
||||||
import { AuthContext } from './llm-fe/contexts/AuthContext';
|
import { AuthContext } from './llm-fe/contexts/AuthContext';
|
||||||
@@ -46,6 +49,9 @@ class App extends Component {
|
|||||||
<Route path='*' element={<NotFound />} />
|
<Route path='*' element={<NotFound />} />
|
||||||
|
|
||||||
<Route path={"/signin/"} Component={SignIn} />
|
<Route path={"/signin/"} Component={SignIn} />
|
||||||
|
<Route path={"/signup/"} Component={SignUp} />
|
||||||
|
<Route path={"/billing/success"} Component={BillingSuccess} />
|
||||||
|
<Route path={"/billing/cancel"} Component={BillingCancel} />
|
||||||
<Route path={"/password_reset/"} Component={PasswordReset} />
|
<Route path={"/password_reset/"} Component={PasswordReset} />
|
||||||
<Route path={"/password_reset_confirmation/"} Component={PasswordResetConfirmation} />
|
<Route path={"/password_reset_confirmation/"} Component={PasswordResetConfirmation} />
|
||||||
<Route path={'/set_password/'} Component={SetPassword} />
|
<Route path={'/set_password/'} Component={SetPassword} />
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
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;
|
||||||
|
text-align: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CardTitle = styled.h2`
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #fff;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const BodyText = styled.p`
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PrimaryButton = styled.button`
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: #fff;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const NavLink = styled(Link)`
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const BillingCancel = (): JSX.Element => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<ParticleBackground />
|
||||||
|
<ContentWrapper>
|
||||||
|
<GlassCard>
|
||||||
|
<CardTitle>Checkout canceled</CardTitle>
|
||||||
|
<BodyText>
|
||||||
|
No charge was made. Your account may already exist — sign in and open billing again, or
|
||||||
|
return to sign up to retry Stripe Checkout.
|
||||||
|
</BodyText>
|
||||||
|
<PrimaryButton type="button" onClick={() => navigate('/signup/')}>
|
||||||
|
Retry sign up
|
||||||
|
</PrimaryButton>
|
||||||
|
<NavLink to="/signin/">Back to sign in</NavLink>
|
||||||
|
</GlassCard>
|
||||||
|
</ContentWrapper>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BillingCancel;
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import React, { useContext, useEffect, useState } from 'react';
|
||||||
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
|
||||||
|
import { AuthContext } from '../../contexts/AuthContext';
|
||||||
|
|
||||||
|
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;
|
||||||
|
text-align: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CardTitle = styled.h2`
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #fff;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const BodyText = styled.p`
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PrimaryButton = styled.button`
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: #fff;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const NavLink = styled(Link)`
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const BillingSuccess = (): JSX.Element => {
|
||||||
|
const { authenticated, loading } = useContext(AuthContext);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const [sessionId] = useState(() => searchParams.get('session_id') || '');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && authenticated) {
|
||||||
|
const timer = window.setTimeout(() => navigate('/'), 2500);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [authenticated, loading, navigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<ParticleBackground />
|
||||||
|
<ContentWrapper>
|
||||||
|
<GlassCard>
|
||||||
|
<CardTitle>Payment received</CardTitle>
|
||||||
|
<BodyText>
|
||||||
|
Thanks — your subscription checkout completed
|
||||||
|
{sessionId ? ` (session ${sessionId.slice(0, 12)}…)` : ''}. You can continue into the app.
|
||||||
|
</BodyText>
|
||||||
|
{authenticated ? (
|
||||||
|
<PrimaryButton type="button" onClick={() => navigate('/')}>
|
||||||
|
Continue to app
|
||||||
|
</PrimaryButton>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<BodyText>Sign in with the account you just created to get started.</BodyText>
|
||||||
|
<PrimaryButton type="button" onClick={() => navigate('/signin/')}>
|
||||||
|
Sign in
|
||||||
|
</PrimaryButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<NavLink to="/signin/">Back to sign in</NavLink>
|
||||||
|
</GlassCard>
|
||||||
|
</ContentWrapper>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BillingSuccess;
|
||||||
@@ -51,9 +51,10 @@ describe('SignIn', () => {
|
|||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
mockPost.mockReset();
|
mockPost.mockReset();
|
||||||
mockGet.mockReset();
|
mockGet.mockReset();
|
||||||
|
mockGet.mockResolvedValue({ data: { enable_account_registration: false } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders sign-in form fields', () => {
|
it('renders sign-in form fields', async () => {
|
||||||
renderSignIn();
|
renderSignIn();
|
||||||
|
|
||||||
expect(screen.getByRole('heading', { name: 'Sign In' })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { name: 'Sign In' })).toBeInTheDocument();
|
||||||
@@ -61,6 +62,16 @@ describe('SignIn', () => {
|
|||||||
expect(screen.getByPlaceholderText('Password')).toBeInTheDocument();
|
expect(screen.getByPlaceholderText('Password')).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: 'Sign In' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: 'Sign In' })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: 'Forgot Password?' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: 'Forgot Password?' })).toBeInTheDocument();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole('button', { name: /Don't have an account/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows sign-up link when registration enabled', async () => {
|
||||||
|
mockGet.mockResolvedValue({ data: { enable_account_registration: true } });
|
||||||
|
renderSignIn();
|
||||||
|
|
||||||
|
expect(await screen.findByRole('button', { name: /Don't have an account\? Sign up/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not submit when required fields empty', async () => {
|
it('does not submit when required fields empty', async () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Form, Formik, Field } from 'formik';
|
import { Form, Formik, Field } from 'formik';
|
||||||
import React, { useContext, useState } from 'react';
|
import React, { useContext, useEffect, useState } from 'react';
|
||||||
import { applyAccessToken, axiosInstance } from '../../../axiosApi';
|
import { applyAccessToken, axiosInstance } from '../../../axiosApi';
|
||||||
import { setTokens } from '../../auth/tokenStorage';
|
import { setTokens } from '../../auth/tokenStorage';
|
||||||
import { AuthContext } from '../../contexts/AuthContext';
|
import { AuthContext } from '../../contexts/AuthContext';
|
||||||
@@ -126,6 +126,21 @@ const ForgotPasswordLink = styled.button`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const SignUpLink = styled.button`
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
export type SignInValues = {
|
export type SignInValues = {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -141,6 +156,26 @@ const SignIn = (): JSX.Element => {
|
|||||||
const { setAccount } = useContext(AccountContext);
|
const { setAccount } = useContext(AccountContext);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||||
|
const [registrationEnabled, setRegistrationEnabled] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.get('/public/settings/');
|
||||||
|
if (!cancelled) {
|
||||||
|
setRegistrationEnabled(Boolean(response.data?.enable_account_registration));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) {
|
||||||
|
setRegistrationEnabled(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSignIn = async ({ email, password }: SignInValues): Promise<void> => {
|
const handleSignIn = async ({ email, password }: SignInValues): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@@ -223,6 +258,11 @@ const SignIn = (): JSX.Element => {
|
|||||||
<ForgotPasswordLink onClick={() => navigate('/password_reset')}>
|
<ForgotPasswordLink onClick={() => navigate('/password_reset')}>
|
||||||
Forgot Password?
|
Forgot Password?
|
||||||
</ForgotPasswordLink>
|
</ForgotPasswordLink>
|
||||||
|
{registrationEnabled && (
|
||||||
|
<SignUpLink onClick={() => navigate('/signup/')}>
|
||||||
|
Don't have an account? Sign up
|
||||||
|
</SignUpLink>
|
||||||
|
)}
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
</ContentWrapper>
|
</ContentWrapper>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import SignUp from './SignUp';
|
||||||
|
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 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: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const renderSignUp = () => {
|
||||||
|
const setAuthentication = jest.fn();
|
||||||
|
const setNeedsNewPassword = jest.fn();
|
||||||
|
const setAccount = jest.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
authenticated: false,
|
||||||
|
setAuthentication,
|
||||||
|
needsNewPassword: false,
|
||||||
|
setNeedsNewPassword,
|
||||||
|
loading: false,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AccountContext.Provider value={{ account: undefined, setAccount }}>
|
||||||
|
<SignUp />
|
||||||
|
</AccountContext.Provider>
|
||||||
|
</AuthContext.Provider>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
return { setAuthentication, setNeedsNewPassword, setAccount };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('SignUp', () => {
|
||||||
|
const originalLocation = window.location;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
mockPost.mockReset();
|
||||||
|
mockGet.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 disabled message when registration flag is off', async () => {
|
||||||
|
mockGet.mockResolvedValue({ data: { enable_account_registration: false } });
|
||||||
|
renderSignUp();
|
||||||
|
|
||||||
|
expect(await screen.findByText(/not available right now/i)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByPlaceholderText('Email Address')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not submit when required fields empty', async () => {
|
||||||
|
mockGet.mockResolvedValue({ data: { enable_account_registration: true } });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderSignUp();
|
||||||
|
|
||||||
|
expect(await screen.findByPlaceholderText('Email Address')).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: /Sign Up & Pay/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPost).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers then redirects to Stripe Checkout URL', async () => {
|
||||||
|
mockGet
|
||||||
|
.mockResolvedValueOnce({ data: { enable_account_registration: true } })
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
email: 'new@example.com',
|
||||||
|
first_name: 'New',
|
||||||
|
last_name: 'User',
|
||||||
|
is_company_manager: true,
|
||||||
|
has_signed_tos: false,
|
||||||
|
has_usable_password: true,
|
||||||
|
company: { id: 1, name: 'New Co', state: 'NA', zipcode: '00000', address: 'N/A' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mockPost
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: { access: 'access-token', refresh: 'refresh-token', email: 'new@example.com' },
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
checkout_url: 'https://checkout.stripe.com/c/pay/cs_test_abc',
|
||||||
|
session_id: 'cs_test_abc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { setAuthentication } = renderSignUp();
|
||||||
|
|
||||||
|
await screen.findByPlaceholderText('Email Address');
|
||||||
|
await user.type(screen.getByPlaceholderText('Email Address'), 'new@example.com');
|
||||||
|
await user.type(screen.getByPlaceholderText('Password'), 'securepass1');
|
||||||
|
await user.type(screen.getByPlaceholderText('Confirm password'), 'securepass1');
|
||||||
|
await user.click(screen.getByRole('button', { name: /Sign Up & Pay/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/user/create/', {
|
||||||
|
email: 'new@example.com',
|
||||||
|
password: 'securepass1',
|
||||||
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
|
company_name: '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/finance/checkout/', {
|
||||||
|
success_url: 'http://localhost/billing/success?session_id={CHECKOUT_SESSION_ID}',
|
||||||
|
cancel_url: 'http://localhost/billing/cancel',
|
||||||
|
});
|
||||||
|
expect(assignMock).toHaveBeenCalledWith('https://checkout.stripe.com/c/pay/cs_test_abc');
|
||||||
|
expect(setAuthentication).toHaveBeenCalledWith(true);
|
||||||
|
expect(localStorage.getItem('access_token')).toBe('access-token');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
import { Form, Formik, Field } from 'formik';
|
||||||
|
import React, { useContext, useEffect, useState } from 'react';
|
||||||
|
import { applyAccessToken, axiosInstance } from '../../../axiosApi';
|
||||||
|
import { setTokens } from '../../auth/tokenStorage';
|
||||||
|
import { AuthContext } from '../../contexts/AuthContext';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { AccountContext } from '../../contexts/AccountContext';
|
||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
import { Account, AccountType } from '../../data';
|
||||||
|
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
|
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: 0.5rem;
|
||||||
|
color: #fff;
|
||||||
|
text-align: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CardSubtitle = styled.p`
|
||||||
|
color: rgba(255, 255, 255, 0.65);
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledInput = styled.input`
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-color: rgba(100, 149, 237, 0.5);
|
||||||
|
box-shadow: 0 0 10px rgba(100, 149, 237, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledButton = styled.button`
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: #fff;
|
||||||
|
padding: 1rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 1rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(118, 75, 162, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
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)`
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const FieldError = styled.div`
|
||||||
|
color: #ff6b6b;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-top: -1.2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export type SignUpValues = {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
confirmPassword: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
company_name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validationSchema = Yup.object().shape({
|
||||||
|
email: Yup.string().email('Invalid email').required('Required'),
|
||||||
|
password: Yup.string().min(8, 'Password must be at least 8 characters').required('Required'),
|
||||||
|
confirmPassword: Yup.string()
|
||||||
|
.oneOf([Yup.ref('password')], 'Passwords must match')
|
||||||
|
.required('Required'),
|
||||||
|
first_name: Yup.string(),
|
||||||
|
last_name: Yup.string(),
|
||||||
|
company_name: Yup.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
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 SignUp = (): JSX.Element => {
|
||||||
|
const { setAuthentication, setNeedsNewPassword } = useContext(AuthContext);
|
||||||
|
const { setAccount } = useContext(AccountContext);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||||
|
const [registrationEnabled, setRegistrationEnabled] = useState<boolean | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.get('/public/settings/');
|
||||||
|
if (!cancelled) {
|
||||||
|
setRegistrationEnabled(Boolean(response.data?.enable_account_registration));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) {
|
||||||
|
setRegistrationEnabled(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadAccount = async (): Promise<Account> => {
|
||||||
|
const get_user_response: AxiosResponse<AccountType> = await axiosInstance.get('/user/get/');
|
||||||
|
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);
|
||||||
|
return account;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSignUp = async (values: SignUpValues): Promise<void> => {
|
||||||
|
setErrorMessage('');
|
||||||
|
try {
|
||||||
|
const registerResponse = await axiosInstance.post('/user/create/', {
|
||||||
|
email: values.email,
|
||||||
|
password: values.password,
|
||||||
|
first_name: values.first_name,
|
||||||
|
last_name: values.last_name,
|
||||||
|
company_name: values.company_name,
|
||||||
|
});
|
||||||
|
|
||||||
|
await setTokens(registerResponse.data.access, registerResponse.data.refresh);
|
||||||
|
applyAccessToken(registerResponse.data.access);
|
||||||
|
await loadAccount();
|
||||||
|
|
||||||
|
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.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.assign(checkoutUrl);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.log(error);
|
||||||
|
const axiosError = error as {
|
||||||
|
response?: { status?: number; data?: { detail?: string; email?: string[] } };
|
||||||
|
};
|
||||||
|
if (axiosError.response?.status === 403) {
|
||||||
|
setErrorMessage('Account registration is currently disabled.');
|
||||||
|
} else if (axiosError.response?.data?.email?.[0]) {
|
||||||
|
setErrorMessage(axiosError.response.data.email[0]);
|
||||||
|
} else if (axiosError.response?.data?.detail) {
|
||||||
|
setErrorMessage(String(axiosError.response.data.detail));
|
||||||
|
} else {
|
||||||
|
setErrorMessage('Could not create account. Try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (registrationEnabled === null) {
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<ParticleBackground />
|
||||||
|
<ContentWrapper>
|
||||||
|
<GlassCard>
|
||||||
|
<CardTitle>Sign Up</CardTitle>
|
||||||
|
<CardSubtitle>Loading…</CardSubtitle>
|
||||||
|
</GlassCard>
|
||||||
|
</ContentWrapper>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!registrationEnabled) {
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<ParticleBackground />
|
||||||
|
<ContentWrapper>
|
||||||
|
<GlassCard>
|
||||||
|
<CardTitle>Sign Up</CardTitle>
|
||||||
|
<ErrorMessage>Self-serve registration is not available right now.</ErrorMessage>
|
||||||
|
<NavLink to="/signin/">Already have an account? Sign in</NavLink>
|
||||||
|
</GlassCard>
|
||||||
|
</ContentWrapper>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<ParticleBackground />
|
||||||
|
<ContentWrapper>
|
||||||
|
<GlassCard>
|
||||||
|
<CardTitle>Sign Up</CardTitle>
|
||||||
|
<CardSubtitle>Create an account, then complete payment on Stripe.</CardSubtitle>
|
||||||
|
{errorMessage && <ErrorMessage>{errorMessage}</ErrorMessage>}
|
||||||
|
<Formik
|
||||||
|
initialValues={{
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
|
company_name: '',
|
||||||
|
}}
|
||||||
|
onSubmit={handleSignUp}
|
||||||
|
validationSchema={validationSchema}
|
||||||
|
>
|
||||||
|
{(formik) => (
|
||||||
|
<Form style={{ width: '100%' }}>
|
||||||
|
<Field as={StyledInput} name="email" placeholder="Email Address" type="email" />
|
||||||
|
{formik.touched.email && formik.errors.email && (
|
||||||
|
<FieldError>{formik.errors.email}</FieldError>
|
||||||
|
)}
|
||||||
|
<Field as={StyledInput} name="first_name" placeholder="First name (optional)" type="text" />
|
||||||
|
<Field as={StyledInput} name="last_name" placeholder="Last name (optional)" type="text" />
|
||||||
|
<Field
|
||||||
|
as={StyledInput}
|
||||||
|
name="company_name"
|
||||||
|
placeholder="Company / workspace (optional)"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<Field as={StyledInput} name="password" placeholder="Password" type="password" />
|
||||||
|
{formik.touched.password && formik.errors.password && (
|
||||||
|
<FieldError>{formik.errors.password}</FieldError>
|
||||||
|
)}
|
||||||
|
<Field
|
||||||
|
as={StyledInput}
|
||||||
|
name="confirmPassword"
|
||||||
|
placeholder="Confirm password"
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
{formik.touched.confirmPassword && formik.errors.confirmPassword && (
|
||||||
|
<FieldError>{formik.errors.confirmPassword}</FieldError>
|
||||||
|
)}
|
||||||
|
<StyledButton type="submit" disabled={formik.isSubmitting}>
|
||||||
|
{formik.isSubmitting ? 'Creating account…' : 'Sign Up & Pay'}
|
||||||
|
</StyledButton>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
<NavLink to="/signin/">Already have an account? Sign in</NavLink>
|
||||||
|
</GlassCard>
|
||||||
|
</ContentWrapper>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SignUp;
|
||||||
Reference in New Issue
Block a user