🎹 add email login and ts stuff

This commit is contained in:
vincanger
2023-04-17 16:39:04 +02:00
parent 2bc1afdae1
commit 4a82d1a49f
21 changed files with 337 additions and 178 deletions

View File

@@ -3,11 +3,10 @@
<img src='src/client/static/gptsaastemplate.png' width='700px'/> <img src='src/client/static/gptsaastemplate.png' width='700px'/>
## Running it locally ## 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. 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. Create a `.env.server` file in the root of the project 2. Run `wasp new <project-name> -t saas` to create a new app using this template.
3. Copy the `env.server.example` file contents to `.env.server` and fill in your API keys 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: 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! 🤯 - 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. - 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.

View File

@@ -15,10 +15,26 @@ app SaaSTemplate {
userEntity: User, userEntity: User,
externalAuthEntity: SocialLogin, externalAuthEntity: SocialLogin,
methods: { 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 google: { // Guide for setting up Auth via Google https://wasp-lang.dev/docs/integrations/google
getUserFieldsFn: import { getUserFields } from "@server/auth/google.js", getUserFieldsFn: import { getUserFields } from "@server/auth/google.js",
configFn: import { config } 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: "/", onAuthFailedRedirectTo: "/",
}, },
@@ -31,10 +47,15 @@ app SaaSTemplate {
emailSender: { emailSender: {
provider: SendGrid, provider: SendGrid,
defaultFrom: { defaultFrom: {
name: "MySaaSApp", name: "SaaS App",
// make sure this address is the same you registered your SendGrid account with! // make sure this address is the same you registered your SendGrid or MailGun account with!
email: "email@mysaasapp.com" 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: [ dependencies: [
("@headlessui/react", "1.7.13"), ("@headlessui/react", "1.7.13"),
@@ -56,7 +77,11 @@ app SaaSTemplate {
entity User {=psl entity User {=psl
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
email String @unique email String? @unique
password String?
isEmailVerified Boolean @default(false)
emailVerificationSentAt DateTime?
passwordResetSentAt DateTime?
stripeId String? stripeId String?
checkoutSessionId String? checkoutSessionId String?
hasPaid Boolean @default(false) hasPaid Boolean @default(false)
@@ -99,7 +124,27 @@ page MainPage {
route LoginRoute { path: "/login", to: LoginPage } route LoginRoute { path: "/login", to: LoginPage }
page 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 } route GptRoute { path: "/gpt", to: GptPage }
@@ -159,6 +204,7 @@ query getRelatedObjects {
/* /*
* 📡 These are custom Wasp API Endpoints. Use them for callbacks, webhooks, etc. * 📡 These are custom Wasp API Endpoints. Use them for callbacks, webhooks, etc.
* https://wasp-lang.dev/docs/language/features#apis
*/ */
api stripeWebhook { api stripeWebhook {

View 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;

View File

@@ -72,7 +72,7 @@ function BuyMoreButton({ isLoading, setIsLoading }: { isLoading: boolean, setIsL
const handleClick = async () => { const handleClick = async () => {
setIsLoading(true); setIsLoading(true);
const stripeResults = await stripePayment(); const stripeResults = await stripePayment();
if (stripeResults) { if (stripeResults?.sessionUrl) {
window.open(stripeResults.sessionUrl, '_self'); window.open(stripeResults.sessionUrl, '_self');
} }
setIsLoading(false); setIsLoading(false);

View File

@@ -8,7 +8,6 @@ export default function CheckoutPage({ user }: { user: User }) {
const history = useHistory(); const history = useHistory();
useEffect(() => { useEffect(() => {
function delayedRedirect() { function delayedRedirect() {
return setTimeout(() => { return setTimeout(() => {
history.push('/account'); history.push('/account');
@@ -33,7 +32,9 @@ export default function CheckoutPage({ user }: { user: User }) {
}, []); }, []);
return ( 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'>
<h1> <h1>
{hasPaid === 'paid' {hasPaid === 'paid'
? '🥳 Payment Successful!' ? '🥳 Payment Successful!'
@@ -46,6 +47,8 @@ export default function CheckoutPage({ user }: { user: User }) {
You are being redirected to your account page... <br /> You are being redirected to your account page... <br />
</span> </span>
)} )}
</> </div>
</div>
</div>
); );
} }

View File

@@ -17,7 +17,7 @@ export default function GptPage() {
const { data: user } = useAuth(); const { data: user } = useAuth();
const onSubmit = async ({ instructions, command, temperature }: any) => { const onSubmit = async ({ instructions, command, temperature }: any) => {
console.log('user, ', !!user) console.log('user, ', !!user);
if (!user) { if (!user) {
alert('You must be logged in to use this feature.'); alert('You must be logged in to use this feature.');
return; return;
@@ -25,7 +25,7 @@ export default function GptPage() {
try { try {
const response = (await generateGptResponse({ instructions, command, temperature })) as RelatedObject; const response = (await generateGptResponse({ instructions, command, temperature })) as RelatedObject;
if (response) { if (response) {
setResponse(response.content) setResponse(response.content);
} }
} catch (e) { } catch (e) {
alert('Something went wrong. Please try again.'); alert('Something went wrong. Please try again.');
@@ -41,9 +41,11 @@ export default function GptPage() {
} = useForm(); } = useForm();
return ( return (
<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)}> <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='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'> <div className='col-span-full'>
<label htmlFor='instructions' className='block text-sm font-medium leading-6 text-gray-900'> <label htmlFor='instructions' className='block text-sm font-medium leading-6 text-gray-900'>
Instructions -- How should GPT behave? Instructions -- How should GPT behave?
@@ -64,7 +66,9 @@ export default function GptPage() {
})} })}
/> />
</div> </div>
<span className='text-sm text-red-500'>{formErrors.instructions && formErrors.instructions.message}</span> <span className='text-sm text-red-500'>
{formErrors.instructions && formErrors.instructions.message}
</span>
</div> </div>
<div className='col-span-full'> <div className='col-span-full'>
<label htmlFor='command' className='block text-sm font-medium leading-6 text-gray-900'> <label htmlFor='command' className='block text-sm font-medium leading-6 text-gray-900'>
@@ -135,5 +139,7 @@ export default function GptPage() {
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
); );
} }

View File

@@ -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>
</>
);
}

View File

@@ -2,4 +2,11 @@
@tailwind components; @tailwind components;
@tailwind utilities; @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));
}

View File

@@ -39,7 +39,7 @@ export default function MainPage() {
<span className='text-sm font-semibold leading-6 text-gray-900'>Made with Wasp &nbsp; {' = }'}</span> <span className='text-sm font-semibold leading-6 text-gray-900'>Made with Wasp &nbsp; {' = }'}</span>
<a <a
href='https://wasp-lang.dev/docs' 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 Read the Wasp Docs
</a> </a>

View File

@@ -29,7 +29,7 @@ export default function PricingPage() {
setIsLoading(true); setIsLoading(true);
try { try {
const response = await stripePayment(); const response = await stripePayment();
if (response) { if (response?.sessionUrl) {
window.open(response.sessionUrl, '_self'); window.open(response.sessionUrl, '_self');
} }
} catch (e) { } catch (e) {

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,10 @@
import { ForgotPasswordForm } from '@wasp/auth/forms/ForgotPassword';
import { AuthWrapper } from './authWrapper';
export function RequestPasswordReset() {
return (
<AuthWrapper>
<ForgotPasswordForm />
</AuthWrapper>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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 // 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'; 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) { if (!context.user) {
throw new HttpError(401); throw new HttpError(401);
} }
let customer: Stripe.Customer; let customer: Stripe.Customer;
const stripeCustomers = await stripe.customers.list({ const stripeCustomers = await stripe.customers.list({
email: context.user.email, email: context.user.email!,
}); });
if (!stripeCustomers.data.length) { if (!stripeCustomers.data.length) {
console.log('creating customer'); console.log('creating customer');
customer = await stripe.customers.create({ customer = await stripe.customers.create({
email: context.user.email, email: context.user.email!,
}); });
} else { } else {
console.log('using existing customer'); console.log('using existing customer');

19
src/server/auth/email.ts Normal file
View 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>
`,
});

View File

@@ -1,4 +1,5 @@
import { User, Prisma } from '@prisma/client'; import { User } from '@wasp/entities'
import { Prisma } from '@prisma/client'
export type Context = { export type Context = {
user: User; user: User;

View File

@@ -94,7 +94,7 @@ export const stripeWebhook: StripeWebhook = async (request, response, context) =
if (subscription.cancel_at_period_end) { if (subscription.cancel_at_period_end) {
console.log('Subscription canceled 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: { where: {
stripeId: userStripeId, stripeId: userStripeId,
}, },
@@ -103,9 +103,9 @@ export const stripeWebhook: StripeWebhook = async (request, response, context) =
}, },
}); });
if (customerEmail) { if (customer?.email) {
await emailSender.send({ await emailSender.send({
to: customerEmail.email, to: customer.email,
subject: 'We hate to see you go :(', subject: 'We hate to see you go :(',
text: 'We hate to see you go. Here is a sweet offer...', text: 'We hate to see you go. Here is a sweet offer...',
html: 'We hate to see you go. Here is a sweet offer...', html: 'We hate to see you go. Here is a sweet offer...',

View File

@@ -28,7 +28,7 @@ export async function checkAndQueueEmails(_args: unknown, context: Context) {
const currentDate = new Date(); const currentDate = new Date();
const twoWeeksFromNow = new Date(currentDate.getTime() + 14 * 24 * 60 * 60 * 1000); 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({ const users = await context.entities.User.findMany({
where: { where: {
@@ -39,10 +39,10 @@ export async function checkAndQueueEmails(_args: unknown, context: Context) {
}, },
}) as User[]; }) as User[];
console.log('Sending expiration notices to users: ', users.length); console.log('Sending notices to users: ', users.length);
if (users.length === 0) { if (users.length === 0) {
console.log('No users to send expiration notices to.'); console.log('No users to send notices to.');
return; return;
} }
await Promise.allSettled( await Promise.allSettled(
@@ -52,7 +52,7 @@ export async function checkAndQueueEmails(_args: unknown, context: Context) {
emailToSend.to = user.email; emailToSend.to = user.email;
await emailSender.send(emailToSend); await emailSender.send(emailToSend);
} catch (error) { } catch (error) {
console.error('Error sending expiration notice to user: ', user.id, error); console.error('Error sending notice to user: ', user.id, error);
} }
} }
}) })