diff --git a/.env.example b/.env.example index aa45b7c727..40e0bad729 100644 --- a/.env.example +++ b/.env.example @@ -4,4 +4,7 @@ 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_*** +RESEND_AUTHORIZED_EMAIL=hello@acme.com +NEXT_PUBLIC_SITE_URL=http://localhost:3000 \ No newline at end of file diff --git a/app/(dashboard)/dashboard/invite-team.tsx b/app/(dashboard)/dashboard/invite-team.tsx index 4ab9bf18c3..92f85d5aa1 100644 --- a/app/(dashboard)/dashboard/invite-team.tsx +++ b/app/(dashboard)/dashboard/invite-team.tsx @@ -36,16 +36,27 @@ export function InviteTeamMember() { Invite Team Member -
+
+
+
+ + @@ -53,9 +64,9 @@ export function InviteTeamMember() {
@@ -69,24 +80,24 @@ export function InviteTeamMember() {
{inviteState?.error && ( -

{inviteState.error}

+

{inviteState.error}

)} {inviteState?.success && ( -

{inviteState.success}

+

{inviteState.success}

)}
-
-
-
-
+
+
+
+
-
- - {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 new file mode 100644 index 0000000000..67efe40ecd --- /dev/null +++ b/emails/invite.tsx @@ -0,0 +1,99 @@ +import { + Body, + Button, + Container, + Head, + Heading, + Hr, + Html, + Link, + Preview, + Section, + Text, + Tailwind, +} from '@react-email/components'; +import { CircleIcon } from 'lucide-react'; +import * as React from 'react'; +import { getURL } from '@/lib/utils'; +interface InviteUserEmailProps { + firstName?: string; + invitedByUsername?: string; + invitedByEmail?: string; + teamName?: string; + inviteId?: string; + role?: string; +} + +export const InviteUserEmail = ({ + firstName = 'John', + invitedByUsername = 'ACME', + invitedByEmail = 'acme@acme.com', + teamName = 'ACME Team', + inviteId = undefined, + role = 'member', +}: InviteUserEmailProps) => { + const previewText = `Join ${invitedByUsername} on Vercel`; + const inviteLink = `${getURL()}/sign-up?inviteId=${inviteId}`; + + return ( + + + {previewText} + + + +
+ + + + You're Invited + + +
+ + Join {teamName} on ACME + + + Hello {firstName}, + + + {invitedByUsername} ( + + {invitedByEmail} + + ) has invited you to the {teamName} team on{' '} + ACME. + +
+ +
+ + or copy and paste this URL into your browser:{' '} + + {inviteLink} + + +
+ + This invitation was intended for{' '} + {firstName}. This invite was + sent by {invitedByUsername}. + If you were not expecting this invitation, you can ignore this + email. + +
+ +
+ + ); +}; + +export default InviteUserEmail; diff --git a/emails/reset-password.tsx b/emails/reset-password.tsx new file mode 100644 index 0000000000..cf5b1b4b03 --- /dev/null +++ b/emails/reset-password.tsx @@ -0,0 +1,91 @@ +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; + email: string; + resetPasswordLink?: string; +} + +export const ResetPasswordEmail = ({ + username = 'Friend', + email, + resetPasswordLink, +}: 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} 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. + +
+ +
+ + ); +}; + +export default ResetPasswordEmail; diff --git a/lib/auth/session.ts b/lib/auth/session.ts index 0c474c70aa..c925168e90 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..f75a1b2134 --- /dev/null +++ b/lib/email/email-service.ts @@ -0,0 +1,68 @@ +import { resend } from './resend'; +import { InviteUserEmail } from '@/emails/invite'; +import { ResetPasswordEmail } from '@/emails/reset-password'; + +export async function sendInvitationEmail( + to: string, + firstName: string, + invitedByUsername: string, + invitedByEmail: string, + teamName: string, + inviteId: 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: firstName, + invitedByUsername: invitedByUsername, + invitedByEmail: invitedByEmail, + teamName: teamName, + inviteId: inviteId, + role: role, + }), + }); + + 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/email/resend.ts b/lib/email/resend.ts new file mode 100644 index 0000000000..4b9d870d84 --- /dev/null +++ b/lib/email/resend.ts @@ -0,0 +1,3 @@ +import { Resend } from 'resend'; + +export const resend = new Resend(process.env.RESEND_API_KEY); diff --git a/lib/utils.ts b/lib/utils.ts index bd0c391ddd..ff8f264a8c 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -4,3 +4,27 @@ import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +export const getURL = (path: string = '') => { + // Check if NEXT_PUBLIC_SITE_URL is set and non-empty. Set this to your site URL in production env. + let url = + process?.env?.NEXT_PUBLIC_SITE_URL && + process.env.NEXT_PUBLIC_SITE_URL.trim() !== '' + ? process.env.NEXT_PUBLIC_SITE_URL + : // If not set, check for NEXT_PUBLIC_VERCEL_URL, which is automatically set by Vercel. + process?.env?.NEXT_PUBLIC_VERCEL_URL && + process.env.NEXT_PUBLIC_VERCEL_URL.trim() !== '' + ? process.env.NEXT_PUBLIC_VERCEL_URL + : // If neither is set, default to localhost for local development. + 'http://localhost:3000/'; + + // Trim the URL and remove trailing slash if exists. + url = url.replace(/\/+$/, ''); + // Make sure to include `https://` when not localhost. + url = url.includes('http') ? url : `https://${url}`; + // Ensure path starts without a slash to avoid double slashes in the final URL. + path = path.replace(/^\/+/, ''); + + // Concatenate the URL and the path. + return path ? `${url}/${path}` : url; +}; diff --git a/package.json b/package.json index 0f45347267..908907bb5b 100644 --- a/package.json +++ b/package.json @@ -9,16 +9,21 @@ "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:studio": "drizzle-kit studio" + "email": "email dev --port 3001" }, "dependencies": { "@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-dropdown-menu": "^2.1.6", + "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-radio-group": "^1.2.3", "@radix-ui/react-scroll-area": "^1.2.3", "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slot": "^1.1.2", - "@tailwindcss/postcss": "4.1.1", + "@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.14.0", "@types/react": "19.1.0", "@types/react-dom": "19.1.1", @@ -26,6 +31,8 @@ "bcryptjs": "^3.0.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "resend": "^4.0.0", + "responsive-react-email": "^0.0.5", "dotenv": "^16.4.7", "drizzle-kit": "^0.30.6", "drizzle-orm": "^0.41.0", @@ -41,8 +48,11 @@ "tailwind-merge": "^3.1.0", "tailwindcss": "4.1.1", "tailwindcss-animate": "^1.0.7", + "zod": "^3.24.2" + }, + "devDependencies": { + "react-email": "3.0.1" "tailwindcss-react-aria-components": "2.0.0", "typescript": "^5.8.2", - "zod": "^3.24.2" } }