aboutsummaryrefslogtreecommitdiffstats
path: root/packages/trpc/routers/users.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/trpc/routers/users.ts')
-rw-r--r--packages/trpc/routers/users.ts158
1 files changed, 156 insertions, 2 deletions
diff --git a/packages/trpc/routers/users.ts b/packages/trpc/routers/users.ts
index ebe7d96f..79f06057 100644
--- a/packages/trpc/routers/users.ts
+++ b/packages/trpc/routers/users.ts
@@ -11,13 +11,16 @@ import {
bookmarks,
bookmarkTags,
highlights,
+ passwordResetTokens,
tagsOnBookmarks,
users,
userSettings,
+ verificationTokens,
} from "@karakeep/db/schema";
import { deleteUserAssets } from "@karakeep/shared/assetdb";
import serverConfig from "@karakeep/shared/config";
import {
+ zResetPasswordSchema,
zSignUpSchema,
zUpdateUserSettingsSchema,
zUserSettingsSchema,
@@ -26,7 +29,7 @@ import {
} from "@karakeep/shared/types/users";
import { generatePasswordSalt, hashPassword, validatePassword } from "../auth";
-import { sendVerificationEmail, verifyEmailToken } from "../email";
+import { sendPasswordResetEmail, sendVerificationEmail } from "../email";
import {
adminProcedure,
authedProcedure,
@@ -36,6 +39,46 @@ import {
router,
} from "../index";
+export async function verifyEmailToken(
+ db: Context["db"],
+ email: string,
+ token: string,
+): Promise<boolean> {
+ const verificationToken = await db.query.verificationTokens.findFirst({
+ where: (vt, { and, eq }) =>
+ and(eq(vt.identifier, email), eq(vt.token, token)),
+ });
+
+ if (!verificationToken) {
+ return false;
+ }
+
+ if (verificationToken.expires < new Date()) {
+ // Clean up expired token
+ await db
+ .delete(verificationTokens)
+ .where(
+ and(
+ eq(verificationTokens.identifier, email),
+ eq(verificationTokens.token, token),
+ ),
+ );
+ return false;
+ }
+
+ // Clean up used token
+ await db
+ .delete(verificationTokens)
+ .where(
+ and(
+ eq(verificationTokens.identifier, email),
+ eq(verificationTokens.token, token),
+ ),
+ );
+
+ return true;
+}
+
export async function createUserRaw(
db: Context["db"],
input: {
@@ -563,7 +606,7 @@ export const usersAppRouter = router({
}),
)
.mutation(async ({ input, ctx }) => {
- const isValid = await verifyEmailToken(input.email, input.token);
+ const isValid = await verifyEmailToken(ctx.db, input.email, input.token);
if (!isValid) {
throw new TRPCError({
code: "BAD_REQUEST",
@@ -639,4 +682,115 @@ export const usersAppRouter = router({
});
}
}),
+ forgotPassword: publicProcedure
+ .use(
+ createRateLimitMiddleware({
+ name: "users.forgotPassword",
+ windowMs: 15 * 60 * 1000,
+ maxRequests: 3,
+ }),
+ )
+ .input(
+ z.object({
+ email: z.string().email(),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ if (!serverConfig.email.smtp) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Email service is not configured",
+ });
+ }
+
+ const user = await ctx.db.query.users.findFirst({
+ where: eq(users.email, input.email),
+ });
+
+ if (!user) {
+ // Don't reveal if user exists or not for security
+ return { success: true };
+ }
+
+ // Only send reset email for users with passwords (local accounts)
+ if (!user.password) {
+ return { success: true };
+ }
+
+ try {
+ await sendPasswordResetEmail(input.email, user.name, user.id);
+ return { success: true };
+ } catch (error) {
+ console.error("Failed to send password reset email:", error);
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to send password reset email",
+ });
+ }
+ }),
+ resetPassword: publicProcedure
+ .use(
+ createRateLimitMiddleware({
+ name: "users.resetPassword",
+ windowMs: 5 * 60 * 1000,
+ maxRequests: 10,
+ }),
+ )
+ .input(zResetPasswordSchema)
+ .mutation(async ({ input, ctx }) => {
+ const token = input.token;
+ const resetToken = await ctx.db.query.passwordResetTokens.findFirst({
+ where: eq(passwordResetTokens.token, token),
+ with: {
+ user: {
+ columns: {
+ id: true,
+ },
+ },
+ },
+ });
+
+ if (!resetToken) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Invalid or expired reset token",
+ });
+ }
+
+ if (resetToken.expires < new Date()) {
+ // Clean up expired token
+ await ctx.db
+ .delete(passwordResetTokens)
+ .where(eq(passwordResetTokens.token, token));
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Invalid or expired reset token",
+ });
+ }
+
+ if (!resetToken.user) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "User not found",
+ });
+ }
+
+ // Generate new password hash
+ const newSalt = generatePasswordSalt();
+ const hashedPassword = await hashPassword(input.newPassword, newSalt);
+
+ // Update user password
+ await ctx.db
+ .update(users)
+ .set({
+ password: hashedPassword,
+ salt: newSalt,
+ })
+ .where(eq(users.id, resetToken.user.id));
+
+ await ctx.db
+ .delete(passwordResetTokens)
+ .where(eq(passwordResetTokens.token, token));
+ return { success: true };
+ }),
});