From c089823124ff03b72ca368bb8cdbcfa8343fc864 Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Mon, 27 Jul 2026 06:17:42 -0500 Subject: [PATCH] Add self-serve sign-up with Stripe Checkout redirect (#31). 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. --- llm-fe/src/App.tsx | 6 + .../pages/BillingCancel/BillingCancel.tsx | 104 ++++++ .../pages/BillingSuccess/BillingSuccess.tsx | 124 ++++++ .../src/llm-fe/pages/SignIn/SignIn.test.tsx | 13 +- llm-fe/src/llm-fe/pages/SignIn/SignIn.tsx | 42 ++- .../src/llm-fe/pages/SignUp/SignUp.test.tsx | 146 ++++++++ llm-fe/src/llm-fe/pages/SignUp/SignUp.tsx | 352 ++++++++++++++++++ 7 files changed, 785 insertions(+), 2 deletions(-) create mode 100644 llm-fe/src/llm-fe/pages/BillingCancel/BillingCancel.tsx create mode 100644 llm-fe/src/llm-fe/pages/BillingSuccess/BillingSuccess.tsx create mode 100644 llm-fe/src/llm-fe/pages/SignUp/SignUp.test.tsx create mode 100644 llm-fe/src/llm-fe/pages/SignUp/SignUp.tsx diff --git a/llm-fe/src/App.tsx b/llm-fe/src/App.tsx index a2e1b38..e6ca686 100644 --- a/llm-fe/src/App.tsx +++ b/llm-fe/src/App.tsx @@ -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 { } /> + + + diff --git a/llm-fe/src/llm-fe/pages/BillingCancel/BillingCancel.tsx b/llm-fe/src/llm-fe/pages/BillingCancel/BillingCancel.tsx new file mode 100644 index 0000000..2d51f2d --- /dev/null +++ b/llm-fe/src/llm-fe/pages/BillingCancel/BillingCancel.tsx @@ -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 ( + + + + + Checkout canceled + + No charge was made. Your account may already exist — sign in and open billing again, or + return to sign up to retry Stripe Checkout. + + navigate('/signup/')}> + Retry sign up + + Back to sign in + + + + ); +}; + +export default BillingCancel; diff --git a/llm-fe/src/llm-fe/pages/BillingSuccess/BillingSuccess.tsx b/llm-fe/src/llm-fe/pages/BillingSuccess/BillingSuccess.tsx new file mode 100644 index 0000000..b07ea20 --- /dev/null +++ b/llm-fe/src/llm-fe/pages/BillingSuccess/BillingSuccess.tsx @@ -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 ( + + + + + Payment received + + Thanks — your subscription checkout completed + {sessionId ? ` (session ${sessionId.slice(0, 12)}…)` : ''}. You can continue into the app. + + {authenticated ? ( + navigate('/')}> + Continue to app + + ) : ( + <> + Sign in with the account you just created to get started. + navigate('/signin/')}> + Sign in + + + )} + Back to sign in + + + + ); +}; + +export default BillingSuccess; diff --git a/llm-fe/src/llm-fe/pages/SignIn/SignIn.test.tsx b/llm-fe/src/llm-fe/pages/SignIn/SignIn.test.tsx index dd4c2fd..9416664 100644 --- a/llm-fe/src/llm-fe/pages/SignIn/SignIn.test.tsx +++ b/llm-fe/src/llm-fe/pages/SignIn/SignIn.test.tsx @@ -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 () => { diff --git a/llm-fe/src/llm-fe/pages/SignIn/SignIn.tsx b/llm-fe/src/llm-fe/pages/SignIn/SignIn.tsx index f3d22bc..533e917 100644 --- a/llm-fe/src/llm-fe/pages/SignIn/SignIn.tsx +++ b/llm-fe/src/llm-fe/pages/SignIn/SignIn.tsx @@ -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(''); + 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 => { try { @@ -223,6 +258,11 @@ const SignIn = (): JSX.Element => { navigate('/password_reset')}> Forgot Password? + {registrationEnabled && ( + navigate('/signup/')}> + Don't have an account? Sign up + + )} diff --git a/llm-fe/src/llm-fe/pages/SignUp/SignUp.test.tsx b/llm-fe/src/llm-fe/pages/SignUp/SignUp.test.tsx new file mode 100644 index 0000000..6e780f4 --- /dev/null +++ b/llm-fe/src/llm-fe/pages/SignUp/SignUp.test.tsx @@ -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 } }, + }, + applyAccessToken: jest.fn(), +})); + +const renderSignUp = () => { + const setAuthentication = jest.fn(); + const setNeedsNewPassword = jest.fn(); + const setAccount = jest.fn(); + + render( + + + + + + + + ); + + 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'); + }); + }); +}); diff --git a/llm-fe/src/llm-fe/pages/SignUp/SignUp.tsx b/llm-fe/src/llm-fe/pages/SignUp/SignUp.tsx new file mode 100644 index 0000000..8ed5665 --- /dev/null +++ b/llm-fe/src/llm-fe/pages/SignUp/SignUp.tsx @@ -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(''); + const [registrationEnabled, setRegistrationEnabled] = useState(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 => { + const get_user_response: AxiosResponse = 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 => { + 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 ( + + + + + Sign Up + Loading… + + + + ); + } + + if (!registrationEnabled) { + return ( + + + + + Sign Up + Self-serve registration is not available right now. + Already have an account? Sign in + + + + ); + } + + return ( + + + + + Sign Up + Create an account, then complete payment on Stripe. + {errorMessage && {errorMessage}} + + {(formik) => ( +
+ + {formik.touched.email && formik.errors.email && ( + {formik.errors.email} + )} + + + + + {formik.touched.password && formik.errors.password && ( + {formik.errors.password} + )} + + {formik.touched.confirmPassword && formik.errors.confirmPassword && ( + {formik.errors.confirmPassword} + )} + + {formik.isSubmitting ? 'Creating account…' : 'Sign Up & Pay'} + + + )} +
+ Already have an account? Sign in +
+
+
+ ); +}; + +export default SignUp;