-
+
+
+
-
-
- {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}
+
+
+
+
+
+ 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"
}
}