Add self-serve sign-up with Stripe Checkout redirect (#31).
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:
2026-07-27 06:17:42 -05:00
parent a4a28ef13e
commit c089823124
7 changed files with 785 additions and 2 deletions
+6
View File
@@ -2,6 +2,9 @@ import React, { Component, useContext } from 'react';
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 BillingSuccess from './llm-fe/pages/BillingSuccess/BillingSuccess';
import BillingCancel from './llm-fe/pages/BillingCancel/BillingCancel';
import PasswordReset from './llm-fe/pages/PasswordReset/PasswordReset';
import NotFound from './llm-fe/pages/NotFound/NotFound';
import { AuthContext } from './llm-fe/contexts/AuthContext';
@@ -46,6 +49,9 @@ class App extends Component {
<Route path='*' element={<NotFound />} />
<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_confirmation/"} Component={PasswordResetConfirmation} />
<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;
+12 -1
View File
@@ -51,9 +51,10 @@ describe('SignIn', () => {
localStorage.clear();
mockPost.mockReset();
mockGet.mockReset();
mockGet.mockResolvedValue({ data: { enable_account_registration: false } });
});
it('renders sign-in form fields', () => {
it('renders sign-in form fields', async () => {
renderSignIn();
expect(screen.getByRole('heading', { name: 'Sign In' })).toBeInTheDocument();
@@ -61,6 +62,16 @@ describe('SignIn', () => {
expect(screen.getByPlaceholderText('Password')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Sign In' })).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 () => {
+41 -1
View File
@@ -1,5 +1,5 @@
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 { setTokens } from '../../auth/tokenStorage';
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 = {
email: string;
password: string;
@@ -141,6 +156,26 @@ const SignIn = (): JSX.Element => {
const { setAccount } = useContext(AccountContext);
const navigate = useNavigate();
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> => {
try {
@@ -223,6 +258,11 @@ const SignIn = (): JSX.Element => {
<ForgotPasswordLink onClick={() => navigate('/password_reset')}>
Forgot Password?
</ForgotPasswordLink>
{registrationEnabled && (
<SignUpLink onClick={() => navigate('/signup/')}>
Don't have an account? Sign up
</SignUpLink>
)}
</GlassCard>
</ContentWrapper>
</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');
});
});
});
+352
View File
@@ -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;