Skip to content
Merged
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
12 changes: 11 additions & 1 deletion apps/api/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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: [
Expand Down
57 changes: 57 additions & 0 deletions apps/api/src/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];
Expand Down Expand Up @@ -91,6 +97,27 @@ export function buildInvitationEmailContent(
};
}

export function buildPasswordResetEmailContent(
resetUrl: string,
): PasswordResetEmailContent {
const safeUrl = normalizeText(resetUrl);

return {
subject: "Reset your Rudel password",
html: `
<p>We received a request to reset your Rudel password.</p>
<p>
<a href="${escapeHtml(safeUrl)}" style="display:inline-block;padding:12px 24px;background:#000;color:#fff;text-decoration:none;border-radius:6px;font-weight:600;">
Reset Password
</a>
</p>
<p>Or copy this link into your browser:</p>
<p>${escapeHtml(safeUrl)}</p>
`,
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 },
Expand Down Expand Up @@ -146,3 +173,33 @@ export async function sendOrganizationInvitationEmail(
});
}
}

export async function sendPasswordResetEmail(
config: ResendConfig,
data: { email: string; resetUrl: string },
): Promise<void> {
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,
});
}
}
14 changes: 14 additions & 0 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -219,6 +224,15 @@ function App() {
);
}

if (!session && isResetPasswordPath()) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-6">
<img src={logoSrc} alt="Rudel" className="h-10 w-10" />
<ResetPasswordForm onBackToLogin={() => (window.location.href = "/")} />
</div>
);
}

if (!session) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-6">
Expand Down
44 changes: 44 additions & 0 deletions apps/web/src/components/auth/login-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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({
Expand Down Expand Up @@ -108,9 +137,24 @@ export function LoginForm({
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
{infoMessage && (
<p className="text-sm text-muted-foreground">{infoMessage}</p>
)}
<Button type="submit" disabled={loading}>
{loading ? "Signing in..." : "Sign in"}
</Button>
<button
type="button"
onClick={() => {
void handleRequestPasswordReset();
}}
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?"}
</button>
</form>

<div className="flex items-center gap-2">
Expand Down
89 changes: 89 additions & 0 deletions apps/web/src/components/auth/reset-password-form.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle className="text-2xl">Reset password</CardTitle>
<CardDescription>Enter your new password</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="new-password">New password</Label>
<Input
id="new-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
{successMessage && (
<p className="text-sm text-muted-foreground">{successMessage}</p>
)}
<Button type="submit" disabled={loading || !token}>
{loading ? "Resetting password..." : "Reset password"}
</Button>
</form>

<button
type="button"
onClick={onBackToLogin}
className="text-left text-sm underline underline-offset-4 hover:text-primary"
>
Back to sign in
</button>
</CardContent>
</Card>
);
}
Loading