From 3dab20cbc17c91a46b5dba4404a5f889b27ab8ee Mon Sep 17 00:00:00 2001 From: jonkurtis Date: Thu, 19 Sep 2024 20:54:21 -0400 Subject: [PATCH 1/4] add emails, resend and react-email --- .env.example | 3 +- emails/invite.tsx | 138 ++++++++++++++++++++++++++++++++++++++ emails/reset-password.tsx | 98 +++++++++++++++++++++++++++ lib/resend.ts | 3 + package.json | 11 ++- 5 files changed, 251 insertions(+), 2 deletions(-) create mode 100644 emails/invite.tsx create mode 100644 emails/reset-password.tsx create mode 100644 lib/resend.ts diff --git a/.env.example b/.env.example index aa45b7c727..7ef845dc6b 100644 --- a/.env.example +++ b/.env.example @@ -4,4 +4,5 @@ POSTGRES_URL=postgresql://*** STRIPE_SECRET_KEY=sk_test_*** STRIPE_WEBHOOK_SECRET=whsec_*** BASE_URL=http://localhost:3000 -AUTH_SECRET=*** \ No newline at end of file +AUTH_SECRET=*** +RESEND_API_KEY=re_*** \ No newline at end of file diff --git a/emails/invite.tsx b/emails/invite.tsx new file mode 100644 index 0000000000..c9daf28b13 --- /dev/null +++ b/emails/invite.tsx @@ -0,0 +1,138 @@ +import { + Body, + Button, + Container, + Column, + Head, + Heading, + Hr, + Html, + Img, + Link, + Preview, + Row, + Section, + Text, + Tailwind, +} from "@react-email/components"; +import { CircleIcon } from "lucide-react"; +import * as React from "react"; + +interface InviteUserEmailProps { + username?: string; + userImage?: string; + invitedByUsername?: string; + invitedByEmail?: string; + teamName?: string; + teamImage?: string; + inviteLink?: string; + inviteFromIp?: string; + inviteFromLocation?: string; +} + +export const InviteUserEmail = ({ + username = "ACME user", + userImage = "https://demo.react.email/static/vercel-user.png", + invitedByUsername = "ACME", + invitedByEmail = "acme@acme.com", + teamName = "ACME Team", + teamImage = "https://demo.react.email/static/vercel-team.png", + inviteLink = "https://vercel.com/teams/invite/foo", + inviteFromIp = "204.13.186.218", + inviteFromLocation = "São Paulo, Brazil", +}: InviteUserEmailProps) => { + const previewText = `Join ${invitedByUsername} on Vercel`; + + return ( + + + {previewText} + + + +
+ + + + ACME + + +
+ + Join {teamName} on Vercel + + + Hello {username}, + + + {invitedByUsername} ( + + {invitedByEmail} + + ) has invited you to the {teamName} team on{" "} + Vercel. + +
+ + + + + + invited you to + + + + + +
+
+ +
+ + or copy and paste this URL into your browser:{" "} + + {inviteLink} + + +
+ + This invitation was intended for{" "} + {username}. This invite was + sent from {inviteFromIp}{" "} + located in{" "} + {inviteFromLocation}. If you + were not expecting this invitation, you can ignore this email. If + you are concerned about your account's safety, please reply to + this email to get in touch with us. + +
+ +
+ + ); +}; + +export default InviteUserEmail; diff --git a/emails/reset-password.tsx b/emails/reset-password.tsx new file mode 100644 index 0000000000..6a89d42fbf --- /dev/null +++ b/emails/reset-password.tsx @@ -0,0 +1,98 @@ +import { + Body, + Button, + Container, + Column, + Head, + Heading, + Hr, + Html, + Img, + Link, + Preview, + Row, + Section, + Text, + Tailwind, +} from "@react-email/components"; +import * as React from "react"; +import { CircleIcon } from "lucide-react"; +interface ResetPasswordEmailProps { + username?: string; + userImage?: string; + userEmail?: string; + resetPasswordLink?: string; + resetFromIp?: string; + resetFromLocation?: string; +} + +export const ResetPasswordEmail = ({ + username = "alanturing", + userImage = "https://demo.react.email/static/vercel-user.png", + userEmail = "alan.turing@example.com", + resetPasswordLink = "https://vercel.com/teams/invite/foo", + resetFromIp = "204.13.186.218", + resetFromLocation = "São Paulo, Brazil", +}: ResetPasswordEmailProps) => { + const previewText = `Reset your password on ACME`; + + return ( + + + {previewText} + + + +
+ + + + ACME + + +
+ + Password Reset + + + Hello {username}, + + + Someone has requested a password reset for your ACME account. + Click the button below to reset your password. + +
+ +
+ + or copy and paste this URL into your browser:{" "} + + {resetPasswordLink} + + +
+ + This password reset email was intended for{" "} + {username}. This email was + sent from {resetFromIp}{" "} + located in {resetFromLocation} + . If you were not expecting this password reset email, you can + ignore this email. To keep your account secure, please{" "} + do not forward this email to anyone. + +
+ +
+ + ); +}; + +export default ResetPasswordEmail; diff --git a/lib/resend.ts b/lib/resend.ts new file mode 100644 index 0000000000..4b9d870d84 --- /dev/null +++ b/lib/resend.ts @@ -0,0 +1,3 @@ +import { Resend } from 'resend'; + +export const resend = new Resend(process.env.RESEND_API_KEY); diff --git a/package.json b/package.json index 47f2c436bb..02a76524e9 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "db:seed": "npx tsx lib/db/seed.ts", "db:generate": "drizzle-kit generate", "db:migrate": "npx tsx lib/db/migrate.ts", - "db:studio": "drizzle-kit studio" + "db:studio": "drizzle-kit studio", + "email": "email dev --port 3001" }, "dependencies": { "@radix-ui/react-avatar": "^1.1.0", @@ -19,6 +20,9 @@ "@radix-ui/react-scroll-area": "^1.1.0", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", + "@react-email/components": "0.0.25", + "@react-email/render": "^1.0.1", + "@react-email/tailwind": "^0.1.0", "@types/bcryptjs": "^2.4.6", "@types/node": "^22.5.5", "@types/react": "^18.3.7", @@ -37,6 +41,8 @@ "postgres": "^3.4.4", "react": "19.0.0-rc-7771d3a7-20240827", "react-dom": "19.0.0-rc-7771d3a7-20240827", + "resend": "^4.0.0", + "responsive-react-email": "^0.0.5", "server-only": "^0.0.1", "stripe": "^16.12.0", "tailwind-merge": "^2.5.2", @@ -45,5 +51,8 @@ "tailwindcss-react-aria-components": "1.1.5", "typescript": "^5.6.2", "zod": "^3.23.8" + }, + "devDependencies": { + "react-email": "3.0.1" } } From d8bb4f72f179d8b41b1202d05e8813bbb9c9b2f7 Mon Sep 17 00:00:00 2001 From: jonkurtis Date: Sat, 21 Sep 2024 00:28:25 -0400 Subject: [PATCH 2/4] one_time_token migrations, password reset and email-service --- .env.example | 3 +- app/(login)/actions.ts | 187 +++++++- app/(login)/forgot-password/page.tsx | 5 + app/(login)/login.tsx | 197 +++++--- app/(login)/reset-password/page.tsx | 5 + emails/invite.tsx | 78 +--- emails/reset-password.tsx | 33 +- lib/auth/session.ts | 5 + lib/db/migrations/0001_clumsy_energizer.sql | 17 + lib/db/migrations/0003_broad_blacklash.sql | 17 + lib/db/migrations/meta/0000_snapshot.json | 62 +-- lib/db/migrations/meta/0001_snapshot.json | 474 ++++++++++++++++++++ lib/db/migrations/meta/_journal.json | 7 + lib/db/schema.ts | 36 ++ lib/email/email-service.ts | 64 +++ lib/{ => email}/resend.ts | 0 16 files changed, 989 insertions(+), 201 deletions(-) create mode 100644 app/(login)/forgot-password/page.tsx create mode 100644 app/(login)/reset-password/page.tsx create mode 100644 lib/db/migrations/0001_clumsy_energizer.sql create mode 100644 lib/db/migrations/0003_broad_blacklash.sql create mode 100644 lib/db/migrations/meta/0001_snapshot.json create mode 100644 lib/email/email-service.ts rename lib/{ => email}/resend.ts (100%) diff --git a/.env.example b/.env.example index 7ef845dc6b..c42f1e2d55 100644 --- a/.env.example +++ b/.env.example @@ -5,4 +5,5 @@ STRIPE_SECRET_KEY=sk_test_*** STRIPE_WEBHOOK_SECRET=whsec_*** BASE_URL=http://localhost:3000 AUTH_SECRET=*** -RESEND_API_KEY=re_*** \ No newline at end of file +RESEND_API_KEY=re_*** +RESEND_AUTHORIZED_EMAIL=hello@acme.com \ No newline at end of file diff --git a/app/(login)/actions.ts b/app/(login)/actions.ts index 6063c5675c..46c44f96fd 100644 --- a/app/(login)/actions.ts +++ b/app/(login)/actions.ts @@ -9,14 +9,22 @@ import { teams, teamMembers, activityLogs, + oneTimeTokens, type NewUser, type NewTeam, type NewTeamMember, type NewActivityLog, + type NewOneTimeToken, ActivityType, invitations, + OneTimeTokenType, } from '@/lib/db/schema'; -import { comparePasswords, hashPassword, setSession } from '@/lib/auth/session'; +import { + comparePasswords, + hashPassword, + setSession, + generateRandomToken, +} from '@/lib/auth/session'; import { redirect } from 'next/navigation'; import { cookies } from 'next/headers'; import { createCheckoutSession } from '@/lib/payments/stripe'; @@ -25,6 +33,10 @@ import { validatedAction, validatedActionWithUser, } from '@/lib/auth/middleware'; +import { + sendResetPasswordEmail, + sendInvitationEmail, +} from '@/lib/email/email-service'; async function logActivity( teamId: number | null | undefined, @@ -414,9 +426,178 @@ export const inviteTeamMember = validatedActionWithUser( ActivityType.INVITE_TEAM_MEMBER ); - // TODO: Send invitation email and include ?inviteId={id} to sign-up URL - // await sendInvitationEmail(email, userWithTeam.team.name, role) + const team = await db + .select() + .from(teams) + .where(eq(teams.id, userWithTeam.teamId)) + .limit(1); + + await sendInvitationEmail( + email, + user.name || user.email, + team[0].name, + role + ); return { success: 'Invitation sent successfully' }; } ); + +const forgotPasswordSchema = z.object({ + email: z.string().email(), +}); + +export const forgotPassword = validatedAction( + forgotPasswordSchema, + async (data) => { + const { email } = data; + + const user = await db + .select() + .from(users) + .where(eq(users.email, email)) + .limit(1); + + console.log('user', user); + + const userWithTeam = await getUserWithTeam(user[0].id); + + console.log('userWithTeam', userWithTeam); + + await logActivity( + userWithTeam?.teamId, + user[0].id, + ActivityType.FORGOT_PASSWORD + ); + + console.log('userWithTeam?.teamId', userWithTeam?.teamId); + + const successMessage = + 'If an account with that email exists, a password reset email will be sent.'; + + const errorMessage = + 'Failed to send password reset email. Please try again.'; + + if (user.length === 0) { + return { + success: successMessage, + }; + } + + const resetToken = await generateRandomToken(); + const resetTokenHash = await hashPassword(resetToken); + + const newPasswordResetToken: NewOneTimeToken = { + userId: user[0].id, + token: resetTokenHash, + type: OneTimeTokenType.RESET_PASSWORD, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), // 1 hour + }; + const [passwordResetToken] = await db + .insert(oneTimeTokens) + .values(newPasswordResetToken) + .returning(); + + console.log('passwordResetToken', passwordResetToken); + + if (!passwordResetToken) { + return { + error: errorMessage, + }; + } + + const emailResponse = await sendResetPasswordEmail( + email, + user[0].name || 'Friend', + passwordResetToken.token + ); + + console.log('emailResponse', emailResponse); + + if (emailResponse.error) { + return { + error: errorMessage, + }; + } + + return { success: successMessage }; + } +); + +const resetPasswordSchema = z.object({ + token: z.string(), + password: z.string().min(8).max(100), +}); + +export const resetPassword = validatedAction( + resetPasswordSchema, + async (data) => { + const { token, password } = data; + // const tokenHash = await hashPassword(token); + const tokenHash = token; + + console.log('tokenHash', tokenHash); + + const passwordResetToken = await db + .select() + .from(oneTimeTokens) + .where( + and( + eq(oneTimeTokens.token, tokenHash), + eq(oneTimeTokens.type, OneTimeTokenType.RESET_PASSWORD) + ) + ) + .limit(1); + + console.log('passwordResetToken', passwordResetToken); + + if (passwordResetToken.length === 0) { + return { error: 'Invalid or expired password reset token.' }; + } + // Check if the token is expired + if (passwordResetToken[0].expiresAt < new Date()) { + return { error: 'Password reset token has expired.' }; + } + + console.log('passwordResetToken[0].userId', passwordResetToken[0].userId); + + const user = await db + .select() + .from(users) + .where(eq(users.id, passwordResetToken[0].userId)) + .limit(1); + + console.log('user', user); + + if (user.length === 0) { + return { error: 'User not found.' }; + } + + const newPasswordHash = await hashPassword(password); + const userWithTeam = await getUserWithTeam(user[0].id); + + console.log('userWithTeam', userWithTeam); + + await Promise.all([ + db + .update(users) + .set({ passwordHash: newPasswordHash }) + .where(eq(users.id, user[0].id)), + db + .delete(oneTimeTokens) + .where( + and( + eq(oneTimeTokens.id, passwordResetToken[0].id), + eq(oneTimeTokens.userId, user[0].id) + ) + ), + logActivity( + userWithTeam?.teamId, + user[0].id, + ActivityType.UPDATE_PASSWORD + ), + ]); + + return { success: 'Password reset successfully.' }; + } +); diff --git a/app/(login)/forgot-password/page.tsx b/app/(login)/forgot-password/page.tsx new file mode 100644 index 0000000000..173413b142 --- /dev/null +++ b/app/(login)/forgot-password/page.tsx @@ -0,0 +1,5 @@ +import { Login } from '../login'; + +export default function ForgotPasswordPage() { + return ; +} diff --git a/app/(login)/login.tsx b/app/(login)/login.tsx index 2a2461a9a4..9debbc9fe3 100644 --- a/app/(login)/login.tsx +++ b/app/(login)/login.tsx @@ -7,130 +7,183 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { CircleIcon, Loader2 } from 'lucide-react'; -import { signIn, signUp } from './actions'; +import { signIn, signUp, forgotPassword, resetPassword } from './actions'; import { ActionState } from '@/lib/auth/middleware'; -export function Login({ mode = 'signin' }: { mode?: 'signin' | 'signup' }) { +const actionMap = { + signin: signIn, + signup: signUp, + forgotPassword: forgotPassword, + resetPassword: resetPassword, +}; + +const defaultAction = signIn; // Default action if mode is not found + +// ... existing imports ... + +const MODES = { + signin: { + title: 'Sign in to your account', + buttonText: 'Sign in', + altLink: { text: 'Create an account', href: '/sign-up' }, + altPrompt: 'New to our platform?', + }, + signup: { + title: 'Create your account', + buttonText: 'Sign up', + altLink: { text: 'Sign in to existing account', href: '/sign-in' }, + altPrompt: 'Already have an account?', + }, + forgotPassword: { + title: 'Forgot Password', + buttonText: 'Send reset link', + altLink: { text: 'Sign in', href: '/sign-in' }, + altPrompt: 'Remembered your password?', + }, + resetPassword: { + title: 'Reset Password', + buttonText: 'Reset password', + altLink: { text: 'Forgot password?', href: '/forgot-password' }, + altPrompt: 'Need to reset your password?', + }, +}; + +export function Login({ + mode = 'signin', +}: { + mode?: 'signin' | 'signup' | 'forgotPassword' | 'resetPassword'; +}) { const searchParams = useSearchParams(); const redirect = searchParams.get('redirect'); const priceId = searchParams.get('priceId'); const inviteId = searchParams.get('inviteId'); + const token = searchParams.get('token'); const [state, formAction, pending] = useActionState( - mode === 'signin' ? signIn : signUp, + actionMap[mode] || defaultAction, { error: '' } ); + const { title, buttonText, altLink, altPrompt } = MODES[mode]; + return ( -
-
-
- +
+
+
+
-

- {mode === 'signin' - ? 'Sign in to your account' - : 'Create your account'} +

+ {title}

-
-
- - - +
+ + + + + {mode === 'resetPassword' && ( + + )} +
-
+
-
- -
- + {(mode === 'signin' || + mode === 'signup' || + mode === 'resetPassword') && ( +
+ +
+ +
+ {mode === 'signin' && ( +
+ + Forgot your password? + +
+ )}
-
+ )} {state?.error && ( -
{state.error}
+
{state.error}
)}
-
-
-
-
+
+
+
+
-
- - {mode === 'signin' - ? 'New to our platform?' - : 'Already have an account?'} - +
+ {altPrompt}
-
+
- {mode === 'signin' - ? 'Create an account' - : 'Sign in to existing account'} + {altLink.text}
diff --git a/app/(login)/reset-password/page.tsx b/app/(login)/reset-password/page.tsx new file mode 100644 index 0000000000..acc0967502 --- /dev/null +++ b/app/(login)/reset-password/page.tsx @@ -0,0 +1,5 @@ +import { Login } from '../login'; + +export default function ResetPasswordPage() { + return ; +} diff --git a/emails/invite.tsx b/emails/invite.tsx index c9daf28b13..6175aec4c9 100644 --- a/emails/invite.tsx +++ b/emails/invite.tsx @@ -2,44 +2,35 @@ import { Body, Button, Container, - Column, Head, Heading, Hr, Html, - Img, Link, Preview, - Row, Section, Text, Tailwind, -} from "@react-email/components"; -import { CircleIcon } from "lucide-react"; -import * as React from "react"; +} from '@react-email/components'; +import { CircleIcon } from 'lucide-react'; +import * as React from 'react'; interface InviteUserEmailProps { - username?: string; - userImage?: string; + firstName?: string; invitedByUsername?: string; invitedByEmail?: string; teamName?: string; - teamImage?: string; inviteLink?: string; - inviteFromIp?: string; - inviteFromLocation?: string; + role?: string; } export const InviteUserEmail = ({ - username = "ACME user", - userImage = "https://demo.react.email/static/vercel-user.png", - invitedByUsername = "ACME", - invitedByEmail = "acme@acme.com", - teamName = "ACME Team", - teamImage = "https://demo.react.email/static/vercel-team.png", - inviteLink = "https://vercel.com/teams/invite/foo", - inviteFromIp = "204.13.186.218", - inviteFromLocation = "São Paulo, Brazil", + firstName = 'John', + invitedByUsername = 'ACME', + invitedByEmail = 'acme@acme.com', + teamName = 'ACME Team', + inviteLink = 'https://vercel.com/teams/invite/foo', + role = 'member', }: InviteUserEmailProps) => { const previewText = `Join ${invitedByUsername} on Vercel`; @@ -62,7 +53,7 @@ export const InviteUserEmail = ({ Join {teamName} on Vercel - Hello {username}, + Hello {firstName}, {invitedByUsername} ( @@ -72,37 +63,9 @@ export const InviteUserEmail = ({ > {invitedByEmail} - ) has invited you to the {teamName} team on{" "} + ) has invited you to the {teamName} team on{' '} Vercel. -
- - - - - - invited you to - - - - - -
- or copy and paste this URL into your browser:{" "} + or copy and paste this URL into your browser:{' '} {inviteLink}
- This invitation was intended for{" "} - {username}. This invite was - sent from {inviteFromIp}{" "} - located in{" "} - {inviteFromLocation}. If you - were not expecting this invitation, you can ignore this email. If - you are concerned about your account's safety, please reply to - this email to get in touch with us. + This invitation was intended for{' '} + {firstName}. This invite was + sent by {invitedByUsername}. + If you were not expecting this invitation, you can ignore this + email. diff --git a/emails/reset-password.tsx b/emails/reset-password.tsx index 6a89d42fbf..a4a073180f 100644 --- a/emails/reset-password.tsx +++ b/emails/reset-password.tsx @@ -14,25 +14,19 @@ import { Section, Text, Tailwind, -} from "@react-email/components"; -import * as React from "react"; -import { CircleIcon } from "lucide-react"; +} from '@react-email/components'; +import * as React from 'react'; +import { CircleIcon } from 'lucide-react'; interface ResetPasswordEmailProps { username?: string; - userImage?: string; - userEmail?: string; + email?: string; resetPasswordLink?: string; - resetFromIp?: string; - resetFromLocation?: string; } export const ResetPasswordEmail = ({ - username = "alanturing", - userImage = "https://demo.react.email/static/vercel-user.png", - userEmail = "alan.turing@example.com", - resetPasswordLink = "https://vercel.com/teams/invite/foo", - resetFromIp = "204.13.186.218", - resetFromLocation = "São Paulo, Brazil", + username = 'alanturing', + email = 'alanturing@acme.com', + resetPasswordLink = 'https://vercel.com/teams/invite/foo', }: ResetPasswordEmailProps) => { const previewText = `Reset your password on ACME`; @@ -70,7 +64,7 @@ export const ResetPasswordEmail = ({ - or copy and paste this URL into your browser:{" "} + or copy and paste this URL into your browser:{' '}
- This password reset email was intended for{" "} - {username}. This email was - sent from {resetFromIp}{" "} - located in {resetFromLocation} - . If you were not expecting this password reset email, you can - ignore this email. To keep your account secure, please{" "} + This password reset email was intended for{' '} + {username} and sent to{' '} + {email}. If you were not + expecting this password reset email, you can ignore this email. To + keep your account secure, please{' '} do not forward this email to anyone. diff --git a/lib/auth/session.ts b/lib/auth/session.ts index ba9f6f45c9..f284b2e189 100644 --- a/lib/auth/session.ts +++ b/lib/auth/session.ts @@ -57,3 +57,8 @@ export async function setSession(user: NewUser) { sameSite: 'lax', }); } + +export async function generateRandomToken() { + const array = new Uint32Array(1); + return crypto.getRandomValues(array)[0].toString(36); +} diff --git a/lib/db/migrations/0001_clumsy_energizer.sql b/lib/db/migrations/0001_clumsy_energizer.sql new file mode 100644 index 0000000000..32a29ceefa --- /dev/null +++ b/lib/db/migrations/0001_clumsy_energizer.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS "one_time_tokens" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" integer NOT NULL, + "token" varchar(255) NOT NULL, + "type" varchar(50) NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "expires_at" timestamp NOT NULL, + CONSTRAINT "one_time_tokens_token_unique" UNIQUE("token") +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "one_time_tokens" ADD CONSTRAINT "one_time_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "token_idx" ON "one_time_tokens" USING btree ("token"); \ No newline at end of file diff --git a/lib/db/migrations/0003_broad_blacklash.sql b/lib/db/migrations/0003_broad_blacklash.sql new file mode 100644 index 0000000000..32a29ceefa --- /dev/null +++ b/lib/db/migrations/0003_broad_blacklash.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS "one_time_tokens" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" integer NOT NULL, + "token" varchar(255) NOT NULL, + "type" varchar(50) NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "expires_at" timestamp NOT NULL, + CONSTRAINT "one_time_tokens_token_unique" UNIQUE("token") +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "one_time_tokens" ADD CONSTRAINT "one_time_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "token_idx" ON "one_time_tokens" USING btree ("token"); \ No newline at end of file diff --git a/lib/db/migrations/meta/0000_snapshot.json b/lib/db/migrations/meta/0000_snapshot.json index 622eb97794..49751057e3 100644 --- a/lib/db/migrations/meta/0000_snapshot.json +++ b/lib/db/migrations/meta/0000_snapshot.json @@ -52,12 +52,8 @@ "name": "activity_logs_team_id_teams_id_fk", "tableFrom": "activity_logs", "tableTo": "teams", - "columnsFrom": [ - "team_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["team_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" }, @@ -65,12 +61,8 @@ "name": "activity_logs_user_id_users_id_fk", "tableFrom": "activity_logs", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -133,12 +125,8 @@ "name": "invitations_team_id_teams_id_fk", "tableFrom": "invitations", "tableTo": "teams", - "columnsFrom": [ - "team_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["team_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" }, @@ -146,12 +134,8 @@ "name": "invitations_invited_by_users_id_fk", "tableFrom": "invitations", "tableTo": "users", - "columnsFrom": [ - "invited_by" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -201,12 +185,8 @@ "name": "team_members_user_id_users_id_fk", "tableFrom": "team_members", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" }, @@ -214,12 +194,8 @@ "name": "team_members_team_id_teams_id_fk", "tableFrom": "team_members", "tableTo": "teams", - "columnsFrom": [ - "team_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["team_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -295,16 +271,12 @@ "teams_stripe_customer_id_unique": { "name": "teams_stripe_customer_id_unique", "nullsNotDistinct": false, - "columns": [ - "stripe_customer_id" - ] + "columns": ["stripe_customer_id"] }, "teams_stripe_subscription_id_unique": { "name": "teams_stripe_subscription_id_unique", "nullsNotDistinct": false, - "columns": [ - "stripe_subscription_id" - ] + "columns": ["stripe_subscription_id"] } } }, @@ -371,9 +343,7 @@ "users_email_unique": { "name": "users_email_unique", "nullsNotDistinct": false, - "columns": [ - "email" - ] + "columns": ["email"] } } } @@ -386,4 +356,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/lib/db/migrations/meta/0001_snapshot.json b/lib/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000000..0b74cf359b --- /dev/null +++ b/lib/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,474 @@ +{ + "id": "44fd284b-faed-4897-9af5-2a6a95c7a705", + "prevId": "261fd993-fb2c-43e7-89d6-cd58786c5f58", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.activity_logs": { + "name": "activity_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "activity_logs_team_id_teams_id_fk": { + "name": "activity_logs_team_id_teams_id_fk", + "tableFrom": "activity_logs", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "activity_logs_user_id_users_id_fk": { + "name": "activity_logs_user_id_users_id_fk", + "tableFrom": "activity_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.invitations": { + "name": "invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "invited_at": { + "name": "invited_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + } + }, + "indexes": {}, + "foreignKeys": { + "invitations_team_id_teams_id_fk": { + "name": "invitations_team_id_teams_id_fk", + "tableFrom": "invitations", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invitations_invited_by_users_id_fk": { + "name": "invitations_invited_by_users_id_fk", + "tableFrom": "invitations", + "tableTo": "users", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.one_time_tokens": { + "name": "one_time_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "token_idx": { + "name": "token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "one_time_tokens_user_id_users_id_fk": { + "name": "one_time_tokens_user_id_users_id_fk", + "tableFrom": "one_time_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "one_time_tokens_token_unique": { + "name": "one_time_tokens_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + } + }, + "public.team_members": { + "name": "team_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_members_user_id_users_id_fk": { + "name": "team_members_user_id_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.teams": { + "name": "teams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_product_id": { + "name": "stripe_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_name": { + "name": "plan_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "subscription_status": { + "name": "subscription_status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_stripe_customer_id_unique": { + "name": "teams_stripe_customer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_customer_id" + ] + }, + "teams_stripe_subscription_id_unique": { + "name": "teams_stripe_subscription_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_subscription_id" + ] + } + } + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/lib/db/migrations/meta/_journal.json b/lib/db/migrations/meta/_journal.json index fd44474175..788a9694c9 100644 --- a/lib/db/migrations/meta/_journal.json +++ b/lib/db/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1726443359662, "tag": "0000_soft_the_anarchist", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1726892321461, + "tag": "0001_clumsy_energizer", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 1d047ce661..1708334aa3 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -5,6 +5,7 @@ import { text, timestamp, integer, + uniqueIndex, } from 'drizzle-orm/pg-core'; import { relations } from 'drizzle-orm'; @@ -68,6 +69,25 @@ export const invitations = pgTable('invitations', { status: varchar('status', { length: 20 }).notNull().default('pending'), }); +export const oneTimeTokens = pgTable( + 'one_time_tokens', + { + id: serial('id').primaryKey(), + userId: integer('user_id') + .notNull() + .references(() => users.id), + token: varchar('token', { length: 255 }).notNull().unique(), + type: varchar('type', { length: 50 }).notNull(), + createdAt: timestamp('created_at').notNull().defaultNow(), + expiresAt: timestamp('expires_at').notNull(), + }, + (table) => { + return { + tokenIdx: uniqueIndex('token_idx').on(table.token), + }; + } +); + export const teamsRelations = relations(teams, ({ many }) => ({ teamMembers: many(teamMembers), activityLogs: many(activityLogs), @@ -112,6 +132,13 @@ export const activityLogsRelations = relations(activityLogs, ({ one }) => ({ }), })); +export const oneTimeTokensRelations = relations(oneTimeTokens, ({ one }) => ({ + user: one(users, { + fields: [oneTimeTokens.userId], + references: [users.id], + }), +})); + export type User = typeof users.$inferSelect; export type NewUser = typeof users.$inferInsert; export type Team = typeof teams.$inferSelect; @@ -127,6 +154,8 @@ export type TeamDataWithMembers = Team & { user: Pick; })[]; }; +export type OneTimeToken = typeof oneTimeTokens.$inferSelect; +export type NewOneTimeToken = typeof oneTimeTokens.$inferInsert; export enum ActivityType { SIGN_UP = 'SIGN_UP', @@ -139,4 +168,11 @@ export enum ActivityType { REMOVE_TEAM_MEMBER = 'REMOVE_TEAM_MEMBER', INVITE_TEAM_MEMBER = 'INVITE_TEAM_MEMBER', ACCEPT_INVITATION = 'ACCEPT_INVITATION', + FORGOT_PASSWORD = 'FORGOT_PASSWORD', + RESET_PASSWORD = 'RESET_PASSWORD', +} + +export enum OneTimeTokenType { + SIGN_IN = 'SIGN_IN', + RESET_PASSWORD = 'RESET_PASSWORD', } diff --git a/lib/email/email-service.ts b/lib/email/email-service.ts new file mode 100644 index 0000000000..e01bd6ea5f --- /dev/null +++ b/lib/email/email-service.ts @@ -0,0 +1,64 @@ +import { resend } from './resend'; +import { InviteUserEmail } from '@/emails/invite'; +import { ResetPasswordEmail } from '@/emails/reset-password'; + +export async function sendInvitationEmail( + to: string, + invitedByUsername: string, + teamName: string, + role: string +) { + const subject = `${invitedByUsername} has invited you to join ${teamName} on ACME`; + + if (!process.env.RESEND_AUTHORIZED_EMAIL) { + return { error: 'RESEND_AUTHORIZED_EMAIL is not set' }; + } + + const { data, error } = await resend.emails.send({ + from: process.env.RESEND_AUTHORIZED_EMAIL, + to, + subject, + react: InviteUserEmail({ + firstName: 'John', + invitedByUsername: 'ACME', + invitedByEmail: 'acme@acme.com', + teamName: 'ACME Team', + inviteLink: 'https://vercel.com/teams/invite/foo', + }), + }); + + if (error) { + return { error: error.message }; + } + + return { data }; +} + +export async function sendResetPasswordEmail( + to: string, + username: string, + token: string +) { + const subject = `Reset your password on ACME`; + + if (!process.env.RESEND_AUTHORIZED_EMAIL) { + return { error: 'RESEND_AUTHORIZED_EMAIL is not set' }; + } + + const { data, error } = await resend.emails.send({ + from: process.env.RESEND_AUTHORIZED_EMAIL, + to, + subject, + react: ResetPasswordEmail({ + username, + email: to, + resetPasswordLink: `http://localhost:3000/reset-password?token=${token}`, + }), + }); + + if (error) { + return { error: error.message }; + } + + return { data }; +} diff --git a/lib/resend.ts b/lib/email/resend.ts similarity index 100% rename from lib/resend.ts rename to lib/email/resend.ts From 61fa629667f73acc66bd85b4be48092a037c07a0 Mon Sep 17 00:00:00 2001 From: jonkurtis Date: Mon, 23 Sep 2024 23:19:35 -0400 Subject: [PATCH 3/4] error/success message and invites work --- .env.example | 3 ++- app/(login)/actions.ts | 44 ++++++++++++---------------------- app/(login)/login.tsx | 49 +++++++++++++++++++++----------------- emails/invite.tsx | 11 +++++---- lib/email/email-service.ts | 14 +++++++---- lib/utils.ts | 24 +++++++++++++++++++ 6 files changed, 83 insertions(+), 62 deletions(-) diff --git a/.env.example b/.env.example index c42f1e2d55..40e0bad729 100644 --- a/.env.example +++ b/.env.example @@ -6,4 +6,5 @@ STRIPE_WEBHOOK_SECRET=whsec_*** BASE_URL=http://localhost:3000 AUTH_SECRET=*** RESEND_API_KEY=re_*** -RESEND_AUTHORIZED_EMAIL=hello@acme.com \ No newline at end of file +RESEND_AUTHORIZED_EMAIL=hello@acme.com +NEXT_PUBLIC_SITE_URL=http://localhost:3000 \ No newline at end of file diff --git a/app/(login)/actions.ts b/app/(login)/actions.ts index 46c44f96fd..1317f8e65e 100644 --- a/app/(login)/actions.ts +++ b/app/(login)/actions.ts @@ -412,13 +412,16 @@ export const inviteTeamMember = validatedActionWithUser( } // Create a new invitation - await db.insert(invitations).values({ - teamId: userWithTeam.teamId, - email, - role, - invitedBy: user.id, - status: 'pending', - }); + const newInvitation = await db + .insert(invitations) + .values({ + teamId: userWithTeam.teamId, + email, + role, + invitedBy: user.id, + status: 'pending', + }) + .returning(); await logActivity( userWithTeam.teamId, @@ -435,8 +438,11 @@ export const inviteTeamMember = validatedActionWithUser( await sendInvitationEmail( email, user.name || user.email, + user.name || user.email, + user.email, team[0].name, - role + newInvitation[0].id.toString(), + newInvitation[0].role ); return { success: 'Invitation sent successfully' }; @@ -458,22 +464,16 @@ export const forgotPassword = validatedAction( .where(eq(users.email, email)) .limit(1); - console.log('user', user); - const userWithTeam = await getUserWithTeam(user[0].id); - console.log('userWithTeam', userWithTeam); - await logActivity( userWithTeam?.teamId, user[0].id, ActivityType.FORGOT_PASSWORD ); - console.log('userWithTeam?.teamId', userWithTeam?.teamId); - const successMessage = - 'If an account with that email exists, a password reset email will be sent.'; + 'If an account exists, a password reset email will be sent.'; const errorMessage = 'Failed to send password reset email. Please try again.'; @@ -498,8 +498,6 @@ export const forgotPassword = validatedAction( .values(newPasswordResetToken) .returning(); - console.log('passwordResetToken', passwordResetToken); - if (!passwordResetToken) { return { error: errorMessage, @@ -512,8 +510,6 @@ export const forgotPassword = validatedAction( passwordResetToken.token ); - console.log('emailResponse', emailResponse); - if (emailResponse.error) { return { error: errorMessage, @@ -536,8 +532,6 @@ export const resetPassword = validatedAction( // const tokenHash = await hashPassword(token); const tokenHash = token; - console.log('tokenHash', tokenHash); - const passwordResetToken = await db .select() .from(oneTimeTokens) @@ -549,8 +543,6 @@ export const resetPassword = validatedAction( ) .limit(1); - console.log('passwordResetToken', passwordResetToken); - if (passwordResetToken.length === 0) { return { error: 'Invalid or expired password reset token.' }; } @@ -559,16 +551,12 @@ export const resetPassword = validatedAction( return { error: 'Password reset token has expired.' }; } - console.log('passwordResetToken[0].userId', passwordResetToken[0].userId); - const user = await db .select() .from(users) .where(eq(users.id, passwordResetToken[0].userId)) .limit(1); - console.log('user', user); - if (user.length === 0) { return { error: 'User not found.' }; } @@ -576,8 +564,6 @@ export const resetPassword = validatedAction( const newPasswordHash = await hashPassword(password); const userWithTeam = await getUserWithTeam(user[0].id); - console.log('userWithTeam', userWithTeam); - await Promise.all([ db .update(users) diff --git a/app/(login)/login.tsx b/app/(login)/login.tsx index 9debbc9fe3..3310a72242 100644 --- a/app/(login)/login.tsx +++ b/app/(login)/login.tsx @@ -43,8 +43,8 @@ const MODES = { resetPassword: { title: 'Reset Password', buttonText: 'Reset password', - altLink: { text: 'Forgot password?', href: '/forgot-password' }, - altPrompt: 'Need to reset your password?', + altLink: { text: 'Sign in', href: '/sign-in' }, + altPrompt: 'Remembered your password?', }, }; @@ -84,27 +84,28 @@ export function Login({ {mode === 'resetPassword' && ( )} - -
- -
- + {mode !== 'resetPassword' && ( +
+ +
+ +
-
+ )} {(mode === 'signin' || mode === 'signup' || @@ -131,6 +132,7 @@ export function Login({ placeholder='Enter your password' />
+ {mode === 'signin' && (
{state.error}
)} + {state?.success && ( +
{state.success}
+ )}
{mode === 'signin' && ( -
+
- {teamName} + You're Invited @@ -65,7 +65,7 @@ export const InviteUserEmail = ({ {invitedByEmail} ) has invited you to the {teamName} team on{' '} - Vercel. + ACME.