mirror of
https://github.com/wasp-lang/open-saas.git
synced 2025-09-29 13:04:13 +02:00
🎹 add email login and ts stuff
This commit is contained in:
@@ -3,11 +3,10 @@
|
||||
<img src='src/client/static/gptsaastemplate.png' width='700px'/>
|
||||
|
||||
## Running it locally
|
||||
Before you being, install [Wasp](https://wasp-lang.dev) by running `curl -sSL https://get.wasp-lang.dev/installer.sh | sh` in your terminal.
|
||||
|
||||
1. First clone this repo.
|
||||
2. Create a `.env.server` file in the root of the project
|
||||
3. Copy the `env.server.example` file contents to `.env.server` and fill in your API keys
|
||||
1. Make sure you have the latest version of [Wasp](https://wasp-lang.dev) installed by running `curl -sSL https://get.wasp-lang.dev/installer.sh | sh` in your terminal.
|
||||
2. Run `wasp new <project-name> -t saas` to create a new app using this template.
|
||||
3. Rename the `env.server.example` file to `.env.server` and fill in your API keys
|
||||
4. Make sure you have a Database connected and running. Here are two quick options:
|
||||
- run `wasp start db` if you have Docker installed (if not, on MacOS run `brew install docker-machine docker`). This will start a Postgres database for you. No need to do anything else! 🤯
|
||||
- or provision a Postgres database on [Railway](https://railway.app), go to settings and copy the `connection url`. Past it as `DATABASE_URL=<your-postgres-connection-url>` into your `env.server` file.
|
||||
|
72
main.wasp
72
main.wasp
@@ -15,10 +15,26 @@ app SaaSTemplate {
|
||||
userEntity: User,
|
||||
externalAuthEntity: SocialLogin,
|
||||
methods: {
|
||||
email: {
|
||||
fromField: {
|
||||
name: "SaaS App",
|
||||
// make sure this address is the same you registered your SendGrid or MailGun account with!
|
||||
email: "vince@wasp-lang.dev"
|
||||
},
|
||||
emailVerification: {
|
||||
clientRoute: EmailVerificationRoute,
|
||||
getEmailContentFn: import { getVerificationEmailContent } from "@server/auth/email.js",
|
||||
},
|
||||
passwordReset: {
|
||||
clientRoute: PasswordResetRoute,
|
||||
getEmailContentFn: import { getPasswordResetEmailContent } from "@server/auth/email.js",
|
||||
},
|
||||
},
|
||||
google: { // Guide for setting up Auth via Google https://wasp-lang.dev/docs/integrations/google
|
||||
getUserFieldsFn: import { getUserFields } from "@server/auth/google.js",
|
||||
configFn: import { config } from "@server/auth/google.js",
|
||||
},
|
||||
// gitHub: {} // Guide for setting up Auth via Github https://wasp-lang.dev/docs/integrations/github
|
||||
},
|
||||
onAuthFailedRedirectTo: "/",
|
||||
},
|
||||
@@ -31,10 +47,15 @@ app SaaSTemplate {
|
||||
emailSender: {
|
||||
provider: SendGrid,
|
||||
defaultFrom: {
|
||||
name: "MySaaSApp",
|
||||
// make sure this address is the same you registered your SendGrid account with!
|
||||
email: "email@mysaasapp.com"
|
||||
name: "SaaS App",
|
||||
// make sure this address is the same you registered your SendGrid or MailGun account with!
|
||||
email: "vince@wasp-lang.dev"
|
||||
},
|
||||
// defaultFrom: {
|
||||
// name: "MySaaSApp",
|
||||
// // make sure this address is the same you registered your SendGrid or MailGun account with!
|
||||
// email: "email@mysaasapp.com"
|
||||
// },
|
||||
},
|
||||
dependencies: [
|
||||
("@headlessui/react", "1.7.13"),
|
||||
@@ -55,15 +76,19 @@ app SaaSTemplate {
|
||||
*/
|
||||
|
||||
entity User {=psl
|
||||
id Int @id @default(autoincrement())
|
||||
email String @unique
|
||||
stripeId String?
|
||||
checkoutSessionId String?
|
||||
hasPaid Boolean @default(false)
|
||||
sendEmail Boolean @default(false)
|
||||
datePaid DateTime?
|
||||
credits Int @default(3)
|
||||
relatedObject RelatedObject[]
|
||||
id Int @id @default(autoincrement())
|
||||
email String? @unique
|
||||
password String?
|
||||
isEmailVerified Boolean @default(false)
|
||||
emailVerificationSentAt DateTime?
|
||||
passwordResetSentAt DateTime?
|
||||
stripeId String?
|
||||
checkoutSessionId String?
|
||||
hasPaid Boolean @default(false)
|
||||
sendEmail Boolean @default(false)
|
||||
datePaid DateTime?
|
||||
credits Int @default(3)
|
||||
relatedObject RelatedObject[]
|
||||
externalAuthAssociations SocialLogin[]
|
||||
psl=}
|
||||
|
||||
@@ -99,7 +124,27 @@ page MainPage {
|
||||
|
||||
route LoginRoute { path: "/login", to: LoginPage }
|
||||
page LoginPage {
|
||||
component: import Login from "@client/Login"
|
||||
component: import Login from "@client/auth/LoginPage"
|
||||
}
|
||||
|
||||
route SignupRoute { path: "/signup", to: SignupPage }
|
||||
page SignupPage {
|
||||
component: import { Signup } from "@client/auth/SignupPage"
|
||||
}
|
||||
|
||||
route RequestPasswordResetRoute { path: "/request-password-reset", to: RequestPasswordResetPage }
|
||||
page RequestPasswordResetPage {
|
||||
component: import { RequestPasswordReset } from "@client/auth/RequestPasswordReset",
|
||||
}
|
||||
|
||||
route PasswordResetRoute { path: "/password-reset", to: PasswordResetPage }
|
||||
page PasswordResetPage {
|
||||
component: import { PasswordReset } from "@client/auth/PasswordReset",
|
||||
}
|
||||
|
||||
route EmailVerificationRoute { path: "/email-verification", to: EmailVerificationPage }
|
||||
page EmailVerificationPage {
|
||||
component: import { EmailVerification } from "@client/auth/EmailVerification",
|
||||
}
|
||||
|
||||
route GptRoute { path: "/gpt", to: GptPage }
|
||||
@@ -159,6 +204,7 @@ query getRelatedObjects {
|
||||
|
||||
/*
|
||||
* 📡 These are custom Wasp API Endpoints. Use them for callbacks, webhooks, etc.
|
||||
* https://wasp-lang.dev/docs/language/features#apis
|
||||
*/
|
||||
|
||||
api stripeWebhook {
|
||||
|
6
migrations/20230417122144_email_password/migration.sql
Normal file
6
migrations/20230417122144_email_password/migration.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "emailVerificationSentAt" TIMESTAMP(3),
|
||||
ADD COLUMN "isEmailVerified" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "password" TEXT,
|
||||
ADD COLUMN "passwordResetSentAt" TIMESTAMP(3),
|
||||
ALTER COLUMN "email" DROP NOT NULL;
|
@@ -72,7 +72,7 @@ function BuyMoreButton({ isLoading, setIsLoading }: { isLoading: boolean, setIsL
|
||||
const handleClick = async () => {
|
||||
setIsLoading(true);
|
||||
const stripeResults = await stripePayment();
|
||||
if (stripeResults) {
|
||||
if (stripeResults?.sessionUrl) {
|
||||
window.open(stripeResults.sessionUrl, '_self');
|
||||
}
|
||||
setIsLoading(false);
|
||||
|
@@ -8,7 +8,6 @@ export default function CheckoutPage({ user }: { user: User }) {
|
||||
const history = useHistory();
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
function delayedRedirect() {
|
||||
return setTimeout(() => {
|
||||
history.push('/account');
|
||||
@@ -33,19 +32,23 @@ export default function CheckoutPage({ user }: { user: User }) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>
|
||||
{hasPaid === 'paid'
|
||||
? '🥳 Payment Successful!'
|
||||
: hasPaid === 'canceled'
|
||||
? '😢 Payment Canceled'
|
||||
: hasPaid === 'error' && '🙄 Payment Error'}
|
||||
</h1>
|
||||
{hasPaid !== 'loading' && (
|
||||
<span className='text-center'>
|
||||
You are being redirected to your account page... <br />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
<div className='flex min-h-full flex-col justify-center mt-10 sm:px-6 lg:px-8'>
|
||||
<div className='sm:mx-auto sm:w-full sm:max-w-md'>
|
||||
<div className='bg-white py-8 px-4 shadow-xl ring-1 ring-gray-900/10 sm:rounded-lg sm:px-10'>
|
||||
<h1>
|
||||
{hasPaid === 'paid'
|
||||
? '🥳 Payment Successful!'
|
||||
: hasPaid === 'canceled'
|
||||
? '😢 Payment Canceled'
|
||||
: hasPaid === 'error' && '🙄 Payment Error'}
|
||||
</h1>
|
||||
{hasPaid !== 'loading' && (
|
||||
<span className='text-center'>
|
||||
You are being redirected to your account page... <br />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -17,7 +17,7 @@ export default function GptPage() {
|
||||
const { data: user } = useAuth();
|
||||
|
||||
const onSubmit = async ({ instructions, command, temperature }: any) => {
|
||||
console.log('user, ', !!user)
|
||||
console.log('user, ', !!user);
|
||||
if (!user) {
|
||||
alert('You must be logged in to use this feature.');
|
||||
return;
|
||||
@@ -25,7 +25,7 @@ export default function GptPage() {
|
||||
try {
|
||||
const response = (await generateGptResponse({ instructions, command, temperature })) as RelatedObject;
|
||||
if (response) {
|
||||
setResponse(response.content)
|
||||
setResponse(response.content);
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Something went wrong. Please try again.');
|
||||
@@ -41,97 +41,103 @@ export default function GptPage() {
|
||||
} = useForm();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className='space-y-6 mt-10 sm:w-[90%] md:w-[50%] mx-auto border-b border-gray-900/10 px-6 pb-12'>
|
||||
<div className='col-span-full'>
|
||||
<label htmlFor='instructions' className='block text-sm font-medium leading-6 text-gray-900'>
|
||||
Instructions -- How should GPT behave?
|
||||
</label>
|
||||
<div className='mt-2'>
|
||||
<textarea
|
||||
id='instructions'
|
||||
placeholder='You are a career advice assistant. You are given a prompt and you must respond with of career advice and 10 actionable items.'
|
||||
rows={3}
|
||||
className='block w-full rounded-md border-0 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:py-1.5 sm:text-sm sm:leading-6'
|
||||
defaultValue={''}
|
||||
{...register('instructions', {
|
||||
required: 'This is required',
|
||||
minLength: {
|
||||
value: 5,
|
||||
message: 'Minimum length should be 5',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<span className='text-sm text-red-500'>{formErrors.instructions && formErrors.instructions.message}</span>
|
||||
</div>
|
||||
<div className='col-span-full'>
|
||||
<label htmlFor='command' className='block text-sm font-medium leading-6 text-gray-900'>
|
||||
Command -- What should GPT do?
|
||||
</label>
|
||||
<div className='mt-2'>
|
||||
<textarea
|
||||
id='command'
|
||||
placeholder='How should I prepare for opening my own speciatly-coffee shop?'
|
||||
rows={3}
|
||||
className='block w-full rounded-md border-0 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:py-1.5 sm:text-sm sm:leading-6'
|
||||
defaultValue={''}
|
||||
{...register('command', {
|
||||
required: 'This is required',
|
||||
minLength: {
|
||||
value: 5,
|
||||
message: 'Minimum length should be 5',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<span className='text-sm text-red-500'>{formErrors.command && formErrors.command.message}</span>
|
||||
</div>
|
||||
<div className='mt-10 px-6'>
|
||||
<div className='overflow-hidden bg-white ring-1 ring-gray-900/10 shadow-lg sm:rounded-lg lg:m-8'>
|
||||
<div className='m-4 py-4 sm:px-6 lg:px-8'>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className='space-y-6 sm:w-[90%] md:w-[60%] mx-auto border-b border-gray-900/10 px-6 pb-12'>
|
||||
<div className='col-span-full'>
|
||||
<label htmlFor='instructions' className='block text-sm font-medium leading-6 text-gray-900'>
|
||||
Instructions -- How should GPT behave?
|
||||
</label>
|
||||
<div className='mt-2'>
|
||||
<textarea
|
||||
id='instructions'
|
||||
placeholder='You are a career advice assistant. You are given a prompt and you must respond with of career advice and 10 actionable items.'
|
||||
rows={3}
|
||||
className='block w-full rounded-md border-0 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:py-1.5 sm:text-sm sm:leading-6'
|
||||
defaultValue={''}
|
||||
{...register('instructions', {
|
||||
required: 'This is required',
|
||||
minLength: {
|
||||
value: 5,
|
||||
message: 'Minimum length should be 5',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<span className='text-sm text-red-500'>
|
||||
{formErrors.instructions && formErrors.instructions.message}
|
||||
</span>
|
||||
</div>
|
||||
<div className='col-span-full'>
|
||||
<label htmlFor='command' className='block text-sm font-medium leading-6 text-gray-900'>
|
||||
Command -- What should GPT do?
|
||||
</label>
|
||||
<div className='mt-2'>
|
||||
<textarea
|
||||
id='command'
|
||||
placeholder='How should I prepare for opening my own speciatly-coffee shop?'
|
||||
rows={3}
|
||||
className='block w-full rounded-md border-0 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:py-1.5 sm:text-sm sm:leading-6'
|
||||
defaultValue={''}
|
||||
{...register('command', {
|
||||
required: 'This is required',
|
||||
minLength: {
|
||||
value: 5,
|
||||
message: 'Minimum length should be 5',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<span className='text-sm text-red-500'>{formErrors.command && formErrors.command.message}</span>
|
||||
</div>
|
||||
|
||||
<div className='h-10 '>
|
||||
<label htmlFor='temperature' className='w-full text-gray-700 text-sm font-semibold'>
|
||||
Temperature Input -- Controls How Random GPT's Output is
|
||||
</label>
|
||||
<div className='w-32 mt-2'>
|
||||
<div className='flex flex-row h-10 w-full rounded-lg relative rounded-md border-0 ring-1 ring-inset ring-gray-300 bg-transparent mt-1'>
|
||||
<input
|
||||
type='number'
|
||||
className='outline-none focus:outline-none border-0 rounded-md ring-1 ring-inset ring-gray-300 text-center w-full font-semibold text-md hover:text-black focus:text-black md:text-basecursor-default flex items-center text-gray-700 outline-none'
|
||||
value={temperature}
|
||||
min='0'
|
||||
max='2'
|
||||
step='0.1'
|
||||
{...register('temperature', {
|
||||
onChange: (e) => {
|
||||
console.log(e.target.value);
|
||||
setTemperature(Number(e.target.value));
|
||||
},
|
||||
required: 'This is required',
|
||||
})}
|
||||
></input>
|
||||
<div className='h-10 '>
|
||||
<label htmlFor='temperature' className='w-full text-gray-700 text-sm font-semibold'>
|
||||
Temperature Input -- Controls How Random GPT's Output is
|
||||
</label>
|
||||
<div className='w-32 mt-2'>
|
||||
<div className='flex flex-row h-10 w-full rounded-lg relative rounded-md border-0 ring-1 ring-inset ring-gray-300 bg-transparent mt-1'>
|
||||
<input
|
||||
type='number'
|
||||
className='outline-none focus:outline-none border-0 rounded-md ring-1 ring-inset ring-gray-300 text-center w-full font-semibold text-md hover:text-black focus:text-black md:text-basecursor-default flex items-center text-gray-700 outline-none'
|
||||
value={temperature}
|
||||
min='0'
|
||||
max='2'
|
||||
step='0.1'
|
||||
{...register('temperature', {
|
||||
onChange: (e) => {
|
||||
console.log(e.target.value);
|
||||
setTemperature(Number(e.target.value));
|
||||
},
|
||||
required: 'This is required',
|
||||
})}
|
||||
></input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-6 flex justify-end gap-x-6 sm:w-[90%] md:w-[50%] mx-auto'>
|
||||
<button
|
||||
type='submit'
|
||||
<div className='mt-6 flex justify-end gap-x-6 sm:w-[90%] md:w-[50%] mx-auto'>
|
||||
<button
|
||||
type='submit'
|
||||
className={`${
|
||||
isSubmitting && 'animate-puls'
|
||||
} rounded-md bg-yellow-500 py-2 px-3 text-sm font-semibold text-white shadow-sm hover:bg-yellow-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600`}
|
||||
>
|
||||
{!isSubmitting ? 'Submit' : 'Loading...'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div
|
||||
className={`${
|
||||
isSubmitting && 'animate-puls'
|
||||
} rounded-md bg-yellow-500 py-2 px-3 text-sm font-semibold text-white shadow-sm hover:bg-yellow-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600`}
|
||||
isSubmitting && 'animate-pulse'
|
||||
} mt-2 mx-6 flex justify-center rounded-lg border border-dashed border-gray-900/25 mt-10 sm:w-[90%] md:w-[50%] mx-auto mt-12 px-6 py-10`}
|
||||
>
|
||||
{!isSubmitting ? 'Submit' : 'Loading...'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div
|
||||
className={`${
|
||||
isSubmitting && 'animate-pulse'
|
||||
} mt-2 mx-6 flex justify-center rounded-lg border border-dashed border-gray-900/25 mt-10 sm:w-[90%] md:w-[50%] mx-auto mt-12 px-6 py-10`}
|
||||
>
|
||||
<div className='space-y-2 text-center'>
|
||||
<p className='text-sm text-gray-500'>{response ? response : 'GPT Response will load here'}</p>
|
||||
<div className='space-y-2 text-center'>
|
||||
<p className='text-sm text-gray-500'>{response ? response : 'GPT Response will load here'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -1,43 +0,0 @@
|
||||
import { signInUrl } from '@wasp/auth/helpers/Google';
|
||||
import { AiOutlineGoogle } from 'react-icons/ai';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useEffect } from 'react';
|
||||
import useAuth from '@wasp/auth/useAuth';
|
||||
|
||||
export default function Login() {
|
||||
const history = useHistory();
|
||||
|
||||
const { data: user } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
history.push('/');
|
||||
}
|
||||
}, [user, history]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8'>
|
||||
<div className='sm:mx-auto sm:w-full sm:max-w-md'>
|
||||
<h2 className='mt-6 text-center text-3xl font-bold tracking-tight text-gray-900'>Sign in to your account</h2>
|
||||
</div>
|
||||
|
||||
<div className='mt-8 sm:mx-auto sm:w-full sm:max-w-md'>
|
||||
<div className='bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10'>
|
||||
<div className='mt-6'>
|
||||
<div>
|
||||
<a
|
||||
href={signInUrl}
|
||||
className='inline-flex w-full justify-center items-center rounded-md bg-white py-2 px-4 text-gray-500 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:outline-offset-0'
|
||||
>
|
||||
<AiOutlineGoogle className='h-5 w-5 mr-2' />
|
||||
<span >Sign in with Google</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
@@ -2,4 +2,11 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* rest of content below */
|
||||
|
||||
a {
|
||||
color: rgb(79 70 229 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: rgb(99 102 241 / var(--tw-text-opacity));
|
||||
}
|
||||
|
@@ -39,7 +39,7 @@ export default function MainPage() {
|
||||
<span className='text-sm font-semibold leading-6 text-gray-900'>Made with Wasp {' = }'}</span>
|
||||
<a
|
||||
href='https://wasp-lang.dev/docs'
|
||||
className='rounded-md bg-yellow-500 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-yellow-6 00 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500'
|
||||
className='rounded-md bg-yellow-500 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-yellow-400 hover:text-black/70 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500'
|
||||
>
|
||||
Read the Wasp Docs
|
||||
</a>
|
||||
|
@@ -29,7 +29,7 @@ export default function PricingPage() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await stripePayment();
|
||||
if (response) {
|
||||
if (response?.sessionUrl) {
|
||||
window.open(response.sessionUrl, '_self');
|
||||
}
|
||||
} catch (e) {
|
||||
|
15
src/client/auth/EmailVerification.tsx
Normal file
15
src/client/auth/EmailVerification.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { VerifyEmailForm } from '@wasp/auth/forms/VerifyEmail';
|
||||
import { AuthWrapper } from './authWrapper';
|
||||
|
||||
export function EmailVerification() {
|
||||
return (
|
||||
<AuthWrapper>
|
||||
<VerifyEmailForm />
|
||||
<br />
|
||||
<span className='text-sm font-medium text-gray-900'>
|
||||
If everything is okay, <Link to='/login' className='underline'>go to login</Link>
|
||||
</span>
|
||||
</AuthWrapper>
|
||||
);
|
||||
}
|
40
src/client/auth/LoginPage.tsx
Normal file
40
src/client/auth/LoginPage.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { LoginForm } from '@wasp/auth/forms/Login';
|
||||
import { AuthWrapper } from './authWrapper';
|
||||
import useAuth from '@wasp/auth/useAuth';
|
||||
|
||||
export default function Login() {
|
||||
const history = useHistory();
|
||||
|
||||
const { data: user } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
history.push('/');
|
||||
}
|
||||
}, [user, history]);
|
||||
|
||||
return (
|
||||
<AuthWrapper>
|
||||
<LoginForm />
|
||||
<br />
|
||||
<span className='text-sm font-medium text-gray-900'>
|
||||
Don't have an account yet?{' '}
|
||||
<Link to='/signup' className='underline'>
|
||||
go to signup
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
<br />
|
||||
<span className='text-sm font-medium text-gray-900'>
|
||||
Forgot your password?{' '}
|
||||
<Link to='/request-password-reset' className='underline'>
|
||||
reset it
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
</AuthWrapper>
|
||||
);
|
||||
}
|
15
src/client/auth/PasswordReset.tsx
Normal file
15
src/client/auth/PasswordReset.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ResetPasswordForm } from '@wasp/auth/forms/ResetPassword';
|
||||
import { AuthWrapper } from './authWrapper';
|
||||
|
||||
export function PasswordReset() {
|
||||
return (
|
||||
<AuthWrapper>
|
||||
<ResetPasswordForm />
|
||||
<br />
|
||||
<span className='text-sm font-medium text-gray-900'>
|
||||
If everything is okay, <Link to='/login'>go to login</Link>
|
||||
</span>
|
||||
</AuthWrapper>
|
||||
);
|
||||
}
|
10
src/client/auth/RequestPasswordReset.tsx
Normal file
10
src/client/auth/RequestPasswordReset.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ForgotPasswordForm } from '@wasp/auth/forms/ForgotPassword';
|
||||
import { AuthWrapper } from './authWrapper';
|
||||
|
||||
export function RequestPasswordReset() {
|
||||
return (
|
||||
<AuthWrapper>
|
||||
<ForgotPasswordForm />
|
||||
</AuthWrapper>
|
||||
);
|
||||
}
|
20
src/client/auth/SignupPage.tsx
Normal file
20
src/client/auth/SignupPage.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { SignupForm } from '@wasp/auth/forms/Signup';
|
||||
import { AuthWrapper } from './authWrapper';
|
||||
|
||||
export function Signup() {
|
||||
return (
|
||||
<AuthWrapper>
|
||||
<SignupForm />
|
||||
<br />
|
||||
<span className='text-sm font-medium text-gray-900'>
|
||||
I already have an account (
|
||||
<Link to='/login' className='underline'>
|
||||
go to login
|
||||
</Link>
|
||||
).
|
||||
</span>
|
||||
<br />
|
||||
</AuthWrapper>
|
||||
);
|
||||
}
|
15
src/client/auth/authWrapper.tsx
Normal file
15
src/client/auth/authWrapper.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export function AuthWrapper({children} : {children: ReactNode }) {
|
||||
return (
|
||||
<div className='flex min-h-full flex-col justify-center mt-10 sm:px-6 lg:px-8'>
|
||||
<div className='sm:mx-auto sm:w-full sm:max-w-md'>
|
||||
<div className='bg-white py-8 px-4 shadow-xl ring-1 ring-gray-900/10 sm:rounded-lg sm:px-10'>
|
||||
<div className='-mt-8'>
|
||||
{ children }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -12,18 +12,18 @@ const stripe = new Stripe(process.env.STRIPE_KEY!, {
|
||||
// WASP_WEB_CLIENT_URL will be set up by Wasp when deploying to production: https://wasp-lang.dev/docs/deploying
|
||||
const DOMAIN = process.env.WASP_WEB_CLIENT_URL || 'http://localhost:3000';
|
||||
|
||||
export const stripePayment: StripePayment<string, StripePaymentResult> = async (_args, context) => {
|
||||
export const stripePayment: StripePayment<void, StripePaymentResult> = async (_args, context) => {
|
||||
if (!context.user) {
|
||||
throw new HttpError(401);
|
||||
}
|
||||
let customer: Stripe.Customer;
|
||||
const stripeCustomers = await stripe.customers.list({
|
||||
email: context.user.email,
|
||||
email: context.user.email!,
|
||||
});
|
||||
if (!stripeCustomers.data.length) {
|
||||
console.log('creating customer');
|
||||
customer = await stripe.customers.create({
|
||||
email: context.user.email,
|
||||
email: context.user.email!,
|
||||
});
|
||||
} else {
|
||||
console.log('using existing customer');
|
||||
|
19
src/server/auth/email.ts
Normal file
19
src/server/auth/email.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { GetVerificationEmailContentFn, GetPasswordResetEmailContentFn } from '@wasp/types';
|
||||
|
||||
export const getVerificationEmailContent: GetVerificationEmailContentFn = ({ verificationLink }) => ({
|
||||
subject: 'Verify your email',
|
||||
text: `Click the link below to verify your email: ${verificationLink}`,
|
||||
html: `
|
||||
<p>Click the link below to verify your email</p>
|
||||
<a href="${verificationLink}">Verify email</a>
|
||||
`,
|
||||
});
|
||||
|
||||
export const getPasswordResetEmailContent: GetPasswordResetEmailContentFn = ({ passwordResetLink }) => ({
|
||||
subject: 'Password reset',
|
||||
text: `Click the link below to reset your password: ${passwordResetLink}`,
|
||||
html: `
|
||||
<p>Click the link below to reset your password</p>
|
||||
<a href="${passwordResetLink}">Reset password</a>
|
||||
`,
|
||||
});
|
@@ -1,4 +1,5 @@
|
||||
import { User, Prisma } from '@prisma/client';
|
||||
import { User } from '@wasp/entities'
|
||||
import { Prisma } from '@prisma/client'
|
||||
|
||||
export type Context = {
|
||||
user: User;
|
||||
|
@@ -94,7 +94,7 @@ export const stripeWebhook: StripeWebhook = async (request, response, context) =
|
||||
if (subscription.cancel_at_period_end) {
|
||||
console.log('Subscription canceled at period end');
|
||||
|
||||
const customerEmail = await context.entities.User.findFirst({
|
||||
const customer = await context.entities.User.findFirst({
|
||||
where: {
|
||||
stripeId: userStripeId,
|
||||
},
|
||||
@@ -103,9 +103,9 @@ export const stripeWebhook: StripeWebhook = async (request, response, context) =
|
||||
},
|
||||
});
|
||||
|
||||
if (customerEmail) {
|
||||
if (customer?.email) {
|
||||
await emailSender.send({
|
||||
to: customerEmail.email,
|
||||
to: customer.email,
|
||||
subject: 'We hate to see you go :(',
|
||||
text: 'We hate to see you go. Here is a sweet offer...',
|
||||
html: 'We hate to see you go. Here is a sweet offer...',
|
||||
|
@@ -28,7 +28,7 @@ export async function checkAndQueueEmails(_args: unknown, context: Context) {
|
||||
const currentDate = new Date();
|
||||
const twoWeeksFromNow = new Date(currentDate.getTime() + 14 * 24 * 60 * 60 * 1000);
|
||||
|
||||
console.log('Starting CRON JOB: \n\nSending expiration notices...');
|
||||
console.log('Starting CRON JOB: \n\nSending notices...');
|
||||
|
||||
const users = await context.entities.User.findMany({
|
||||
where: {
|
||||
@@ -39,10 +39,10 @@ export async function checkAndQueueEmails(_args: unknown, context: Context) {
|
||||
},
|
||||
}) as User[];
|
||||
|
||||
console.log('Sending expiration notices to users: ', users.length);
|
||||
console.log('Sending notices to users: ', users.length);
|
||||
|
||||
if (users.length === 0) {
|
||||
console.log('No users to send expiration notices to.');
|
||||
console.log('No users to send notices to.');
|
||||
return;
|
||||
}
|
||||
await Promise.allSettled(
|
||||
@@ -52,7 +52,7 @@ export async function checkAndQueueEmails(_args: unknown, context: Context) {
|
||||
emailToSend.to = user.email;
|
||||
await emailSender.send(emailToSend);
|
||||
} catch (error) {
|
||||
console.error('Error sending expiration notice to user: ', user.id, error);
|
||||
console.error('Error sending notice to user: ', user.id, error);
|
||||
}
|
||||
}
|
||||
})
|
||||
|
Reference in New Issue
Block a user