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 ( +
+ Rudel + (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 + + +
+
+ + setPassword(e.target.value)} + required + /> +
+ {error &&

{error}

} + {successMessage && ( +

{successMessage}

+ )} + +
+ + +
+
+ ); +} 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?"}