From 427f9e384d58468a2b5e70c5850ef4cbe664c052 Mon Sep 17 00:00:00 2001
From: evren <158852680+evrendom@users.noreply.github.com>
Date: Tue, 7 Apr 2026 15:25:57 +0200
Subject: [PATCH 1/2] fix: add forgot-password flow to login
---
apps/api/src/auth.ts | 12 ++-
apps/api/src/email.ts | 57 ++++++++++++
apps/web/src/App.tsx | 14 +++
apps/web/src/components/auth/login-form.tsx | 42 +++++++++
.../components/auth/reset-password-form.tsx | 89 +++++++++++++++++++
5 files changed, 213 insertions(+), 1 deletion(-)
create mode 100644 apps/web/src/components/auth/reset-password-form.tsx
diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts
index b0981b46..1714f4ca 100644
--- a/apps/api/src/auth.ts
+++ b/apps/api/src/auth.ts
@@ -6,7 +6,11 @@ import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { bearer, deviceAuthorization, organization } from "better-auth/plugins";
import type { ResendConfig } from "./email.js";
-import { sendOrganizationInvitationEmail, syncSignupContact } from "./email.js";
+import {
+ sendOrganizationInvitationEmail,
+ sendPasswordResetEmail,
+ syncSignupContact,
+} from "./email.js";
import { captureApiProductAnalyticsEvent } from "./lib/product-analytics.js";
import { fetchGitHubHandle, notifySignup } from "./slack.js";
@@ -92,6 +96,12 @@ export function createAuth(db: object, config: AuthConfig) {
},
emailAndPassword: {
enabled: true,
+ sendResetPassword: async ({ user, url }) => {
+ void sendPasswordResetEmail(resend, {
+ email: user.email,
+ resetUrl: url,
+ });
+ },
},
socialProviders: config.socialProviders,
plugins: [
diff --git a/apps/api/src/email.ts b/apps/api/src/email.ts
index 4af90ba3..5300555f 100644
--- a/apps/api/src/email.ts
+++ b/apps/api/src/email.ts
@@ -24,6 +24,12 @@ export interface InvitationEmailContent {
text: string;
}
+export interface PasswordResetEmailContent {
+ subject: string;
+ html: string;
+ text: string;
+}
+
export function getResendConfigWarnings(config: ResendConfig): string[] {
if (!config.apiKey || config.fromEmail) {
return [];
@@ -91,6 +97,27 @@ export function buildInvitationEmailContent(
};
}
+export function buildPasswordResetEmailContent(
+ resetUrl: string,
+): PasswordResetEmailContent {
+ const safeUrl = normalizeText(resetUrl);
+
+ return {
+ subject: "Reset your Rudel password",
+ html: `
+
We received a request to reset your Rudel password.
+
+
+ Reset Password
+
+
+Or copy this link into your browser:
+${escapeHtml(safeUrl)}
+`,
+ text: `We received a request to reset your Rudel password.\n\nReset your password here: ${safeUrl}`,
+ };
+}
+
export async function syncSignupContact(
config: ResendConfig,
user: { email: string; name: string },
@@ -146,3 +173,33 @@ export async function sendOrganizationInvitationEmail(
});
}
}
+
+export async function sendPasswordResetEmail(
+ config: ResendConfig,
+ data: { email: string; resetUrl: string },
+): Promise {
+ if (!config.apiKey || !config.fromEmail) {
+ return;
+ }
+
+ const message = buildPasswordResetEmailContent(data.resetUrl);
+
+ try {
+ const resend = new Resend(config.apiKey);
+ await resend.emails.send({
+ from: config.fromEmail,
+ to: data.email,
+ subject: message.subject,
+ html: message.html,
+ text: message.text,
+ });
+ logger.info("Password reset email sent to {email}", {
+ email: data.email,
+ });
+ } catch (err) {
+ logger.error("Failed to send password reset email to {email}: {error}", {
+ email: data.email,
+ error: err,
+ });
+ }
+}
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 68422012..7cbc042f 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -2,6 +2,7 @@ import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { LoginForm } from "./components/auth/login-form";
+import { ResetPasswordForm } from "./components/auth/reset-password-form";
import { SignupForm } from "./components/auth/signup-form";
import { Button } from "./components/ui/button";
import { useAnalyticsTracking } from "./hooks/useDashboardAnalytics";
@@ -30,6 +31,10 @@ import { SessionsListPage } from "./pages/dashboard/SessionsListPage";
type Page = "login" | "signup";
+function isResetPasswordPath() {
+ return window.location.pathname === "/reset-password";
+}
+
function getDeviceUserCode(): string | null {
const params = new URLSearchParams(window.location.search);
return params.get("user_code");
@@ -219,6 +224,15 @@ function App() {
);
}
+ if (!session && isResetPasswordPath()) {
+ return (
+
+

+
(window.location.href = "/")} />
+
+ );
+ }
+
if (!session) {
return (
diff --git a/apps/web/src/components/auth/login-form.tsx b/apps/web/src/components/auth/login-form.tsx
index aa9b4897..a7d3428e 100644
--- a/apps/web/src/components/auth/login-form.tsx
+++ b/apps/web/src/components/auth/login-form.tsx
@@ -39,13 +39,16 @@ export function LoginForm({
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
+ const [infoMessage, setInfoMessage] = useState("");
const [loading, setLoading] = useState(false);
+ const [requestingPasswordReset, setRequestingPasswordReset] = useState(false);
const { trackAuthenticationAction } = useAnalyticsTracking({
pageName: "login",
});
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
+ setInfoMessage("");
trackAuthenticationAction({
actionName: "sign_in",
sourceComponent: "login_form",
@@ -60,6 +63,32 @@ export function LoginForm({
}
}
+ async function handleRequestPasswordReset() {
+ if (!email) {
+ setError("Enter your email address first to reset your password.");
+ setInfoMessage("");
+ return;
+ }
+
+ setError("");
+ setInfoMessage("");
+ setRequestingPasswordReset(true);
+ const { error } = await authClient.requestPasswordReset({
+ email,
+ redirectTo: `${window.location.origin}/reset-password`,
+ });
+ setRequestingPasswordReset(false);
+
+ if (error) {
+ setError(error.message ?? "Could not send password reset email");
+ return;
+ }
+
+ setInfoMessage(
+ "If an account exists for that email, we sent a password reset link.",
+ );
+ }
+
async function handleSocialSignIn(provider: "google" | "github") {
setError("");
trackAuthenticationAction({
@@ -108,9 +137,22 @@ export function LoginForm({
/>
{error && {error}
}
+ {infoMessage && (
+ {infoMessage}
+ )}
+
diff --git a/apps/web/src/components/auth/reset-password-form.tsx b/apps/web/src/components/auth/reset-password-form.tsx
new file mode 100644
index 00000000..0099cb0d
--- /dev/null
+++ b/apps/web/src/components/auth/reset-password-form.tsx
@@ -0,0 +1,89 @@
+import { useState } from "react";
+import { authClient } from "../../lib/auth-client";
+import { Button } from "../ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "../ui/card";
+import { Input } from "../ui/input";
+import { Label } from "../ui/label";
+
+export function ResetPasswordForm({
+ onBackToLogin,
+}: {
+ onBackToLogin: () => void;
+}) {
+ const token = new URLSearchParams(window.location.search).get("token");
+ const invalidToken = new URLSearchParams(window.location.search).get("error");
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState(
+ invalidToken ? "This reset link is invalid or expired." : "",
+ );
+ const [successMessage, setSuccessMessage] = useState("");
+ const [loading, setLoading] = useState(false);
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ if (!token) {
+ setError("This reset link is invalid or expired.");
+ return;
+ }
+ setError("");
+ setSuccessMessage("");
+ setLoading(true);
+ const { error } = await authClient.resetPassword({
+ newPassword: password,
+ token,
+ });
+ setLoading(false);
+
+ if (error) {
+ setError(error.message ?? "Password reset failed");
+ return;
+ }
+
+ setSuccessMessage("Your password has been reset. You can now sign in.");
+ setPassword("");
+ }
+
+ return (
+
+
+ Reset password
+ Enter your new password
+
+
+
+
+
+
+
+ );
+}
From 97f28d469377bec562e5b5f1cb215d4c6addf36f Mon Sep 17 00:00:00 2001
From: evren <158852680+evrendom@users.noreply.github.com>
Date: Tue, 7 Apr 2026 15:32:48 +0200
Subject: [PATCH 2/2] fix: satisfy forgot-password lint
---
apps/web/src/components/auth/login-form.tsx | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/apps/web/src/components/auth/login-form.tsx b/apps/web/src/components/auth/login-form.tsx
index a7d3428e..a0ea7c86 100644
--- a/apps/web/src/components/auth/login-form.tsx
+++ b/apps/web/src/components/auth/login-form.tsx
@@ -151,7 +151,9 @@ export function LoginForm({
disabled={requestingPasswordReset}
className="text-left text-sm underline underline-offset-4 hover:text-primary disabled:cursor-not-allowed disabled:opacity-50"
>
- {requestingPasswordReset ? "Sending reset link..." : "Forgot password?"}
+ {requestingPasswordReset
+ ? "Sending reset link..."
+ : "Forgot password?"}