Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add email service and reset password flow #49

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,7 @@ POSTGRES_URL=postgresql://***
STRIPE_SECRET_KEY=sk_test_***
STRIPE_WEBHOOK_SECRET=whsec_***
BASE_URL=http://localhost:3000
AUTH_SECRET=***
AUTH_SECRET=***
RESEND_API_KEY=re_***
RESEND_AUTHORIZED_EMAIL=[email protected]
NEXT_PUBLIC_SITE_URL=http://localhost:3000
55 changes: 33 additions & 22 deletions app/(dashboard)/dashboard/invite-team.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,55 +35,66 @@ export function InviteTeamMember() {
<CardTitle>Invite Team Member</CardTitle>
</CardHeader>
<CardContent>
<form action={inviteAction} className="space-y-4">
<form action={inviteAction} className='space-y-4'>
<div>
<Label htmlFor="email">Email</Label>
<Label htmlFor='firstName'>First Name</Label>
<Input
id="email"
name="email"
type="email"
placeholder="Enter email"
id='firstName'
name='firstName'
type='text'
placeholder='Enter first name'
required
disabled={!isOwner}
/>
</div>
<div>
<Label htmlFor='email'>Email</Label>
<Input
id='email'
name='email'
type='email'
placeholder='Enter email'
required
disabled={!isOwner}
/>
</div>
<div>
<Label>Role</Label>
<RadioGroup
defaultValue="member"
name="role"
className="flex space-x-4"
defaultValue='member'
name='role'
className='flex space-x-4'
disabled={!isOwner}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="member" id="member" />
<Label htmlFor="member">Member</Label>
<div className='flex items-center space-x-2'>
<RadioGroupItem value='member' id='member' />
<Label htmlFor='member'>Member</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="owner" id="owner" />
<Label htmlFor="owner">Owner</Label>
<div className='flex items-center space-x-2'>
<RadioGroupItem value='owner' id='owner' />
<Label htmlFor='owner'>Owner</Label>
</div>
</RadioGroup>
</div>
{inviteState?.error && (
<p className="text-red-500">{inviteState.error}</p>
<p className='text-red-500'>{inviteState.error}</p>
)}
{inviteState?.success && (
<p className="text-green-500">{inviteState.success}</p>
<p className='text-green-500'>{inviteState.success}</p>
)}
<Button
type="submit"
className="bg-orange-500 hover:bg-orange-600 text-white"
type='submit'
className='bg-orange-500 hover:bg-orange-600 text-white'
disabled={isInvitePending || !isOwner}
>
{isInvitePending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Inviting...
</>
) : (
<>
<PlusCircle className="mr-2 h-4 w-4" />
<PlusCircle className='mr-2 h-4 w-4' />
Invite Member
</>
)}
Expand All @@ -92,7 +103,7 @@ export function InviteTeamMember() {
</CardContent>
{!isOwner && (
<CardFooter>
<p className="text-sm text-muted-foreground">
<p className='text-sm text-muted-foreground'>
You must be a team owner to invite new members.
</p>
</CardFooter>
Expand Down
192 changes: 180 additions & 12 deletions app/(login)/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -165,7 +177,7 @@ export const signUp = validatedAction(signUpSchema, async (data, formData) => {
} else {
// Create a new team if there's no invitation
const newTeam: NewTeam = {
name: `${email}'s Team`,
name: `${email.split('@')[0]}'s Team`,
};

[createdTeam] = await db.insert(teams).values(newTeam).returning();
Expand Down Expand Up @@ -357,12 +369,13 @@ export const removeTeamMember = validatedActionWithUser(
const inviteTeamMemberSchema = z.object({
email: z.string().email('Invalid email address'),
role: z.enum(['member', 'owner']),
firstName: z.string().min(1, 'First name is required').max(50),
});

export const inviteTeamMember = validatedActionWithUser(
inviteTeamMemberSchema,
async (data, _, user) => {
const { email, role } = data;
const { email, role, firstName } = data;
const userWithTeam = await getUserWithTeam(user.id);

if (!userWithTeam?.teamId) {
Expand Down Expand Up @@ -400,23 +413,178 @@ 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,
user.id,
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,
firstName || email.split('@')[0],
user.name || user.email.split('@')[0],
user.email,
team[0].name,
newInvitation[0].id.toString(),
newInvitation[0].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);

const userWithTeam = await getUserWithTeam(user[0].id);

await logActivity(
userWithTeam?.teamId,
user[0].id,
ActivityType.FORGOT_PASSWORD
);

const successMessage =
'If an account 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();

if (!passwordResetToken) {
return {
error: errorMessage,
};
}

const emailResponse = await sendResetPasswordEmail(
email,
user[0].name || 'Friend',
passwordResetToken.token
);

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;

const passwordResetToken = await db
.select()
.from(oneTimeTokens)
.where(
and(
eq(oneTimeTokens.token, tokenHash),
eq(oneTimeTokens.type, OneTimeTokenType.RESET_PASSWORD)
)
)
.limit(1);

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.' };
}

const user = await db
.select()
.from(users)
.where(eq(users.id, passwordResetToken[0].userId))
.limit(1);

if (user.length === 0) {
return { error: 'User not found.' };
}

const newPasswordHash = await hashPassword(password);
const userWithTeam = await getUserWithTeam(user[0].id);

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.' };
}
);
5 changes: 5 additions & 0 deletions app/(login)/forgot-password/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Login } from '../login';

export default function ForgotPasswordPage() {
return <Login mode='forgotPassword' />;
}
Loading