diff --git a/apps/web/prisma/migrations/20251219093000_add_password_auth/migration.sql b/apps/web/prisma/migrations/20251219093000_add_password_auth/migration.sql
new file mode 100644
index 00000000..0c133113
--- /dev/null
+++ b/apps/web/prisma/migrations/20251219093000_add_password_auth/migration.sql
@@ -0,0 +1,22 @@
+-- AlterTable
+ALTER TABLE "User" ADD COLUMN "passwordHash" TEXT;
+
+-- CreateTable
+CREATE TABLE "PasswordResetToken" (
+ "id" TEXT NOT NULL,
+ "email" TEXT NOT NULL,
+ "token" TEXT NOT NULL,
+ "expires" TIMESTAMP(3) NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "PasswordResetToken_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "PasswordResetToken_token_key" ON "PasswordResetToken"("token");
+
+-- CreateIndex
+CREATE INDEX "PasswordResetToken_email_idx" ON "PasswordResetToken"("email");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "PasswordResetToken_email_token_key" ON "PasswordResetToken"("email", "token");
diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma
index 772d23b1..29b72753 100644
--- a/apps/web/prisma/schema.prisma
+++ b/apps/web/prisma/schema.prisma
@@ -84,6 +84,7 @@ model User {
email String? @unique
emailVerified DateTime?
image String?
+ passwordHash String? // nullable for OAuth-only users
isBetaUser Boolean @default(false)
isWaitlisted Boolean @default(false)
createdAt DateTime @default(now())
@@ -92,6 +93,17 @@ model User {
teamUsers TeamUser[]
}
+model PasswordResetToken {
+ id String @id @default(cuid())
+ email String
+ token String @unique
+ expires DateTime
+ createdAt DateTime @default(now())
+
+ @@unique([email, token])
+ @@index([email])
+}
+
enum Plan {
FREE
BASIC
diff --git a/apps/web/src/app/(dashboard)/settings/account/page.tsx b/apps/web/src/app/(dashboard)/settings/account/page.tsx
new file mode 100644
index 00000000..b0427158
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/settings/account/page.tsx
@@ -0,0 +1,499 @@
+"use client";
+
+import { useState } from "react";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { z } from "zod";
+import { Button } from "@usesend/ui/src/button";
+import { Input } from "@usesend/ui/src/input";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@usesend/ui/src/card";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@usesend/ui/src/form";
+import { Separator } from "@usesend/ui/src/separator";
+import { Skeleton } from "@usesend/ui/src/skeleton";
+import { Avatar, AvatarFallback, AvatarImage } from "@usesend/ui/src/avatar";
+import { toast } from "@usesend/ui/src/toaster";
+import { api } from "~/trpc/react";
+import { passwordSchema } from "~/server/password-utils";
+import { CheckCircle2, Github, Mail } from "lucide-react";
+
+// Schema for changing password (user already has a password)
+const changePasswordFormSchema = z
+ .object({
+ currentPassword: z.string().min(1, "Current password is required"),
+ newPassword: passwordSchema,
+ confirmPassword: z.string().min(1, "Please confirm your new password"),
+ })
+ .refine((data) => data.newPassword === data.confirmPassword, {
+ message: "Passwords do not match",
+ path: ["confirmPassword"],
+ });
+
+// Schema for setting password (OAuth-only user)
+const setPasswordFormSchema = z
+ .object({
+ newPassword: passwordSchema,
+ confirmPassword: z.string().min(1, "Please confirm your password"),
+ })
+ .refine((data) => data.newPassword === data.confirmPassword, {
+ message: "Passwords do not match",
+ path: ["confirmPassword"],
+ });
+
+type ChangePasswordFormValues = z.infer;
+type SetPasswordFormValues = z.infer;
+
+// Provider display configuration
+const providerConfig: Record<
+ string,
+ { name: string; icon: React.ReactNode; color: string }
+> = {
+ github: {
+ name: "GitHub",
+ icon: ,
+ color: "bg-gray-900 dark:bg-gray-700",
+ },
+ google: {
+ name: "Google",
+ icon: (
+
+ ),
+ color: "bg-white dark:bg-gray-200",
+ },
+};
+
+function ChangePasswordForm({ onSuccess }: { onSuccess: () => void }) {
+ const changePasswordMutation = api.user.changePassword.useMutation();
+
+ const form = useForm({
+ resolver: zodResolver(changePasswordFormSchema),
+ defaultValues: {
+ currentPassword: "",
+ newPassword: "",
+ confirmPassword: "",
+ },
+ });
+
+ async function onSubmit(values: ChangePasswordFormValues) {
+ try {
+ await changePasswordMutation.mutateAsync({
+ currentPassword: values.currentPassword,
+ newPassword: values.newPassword,
+ });
+ toast.success("Password changed successfully");
+ form.reset();
+ onSuccess();
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : "Failed to change password";
+ toast.error(message);
+ }
+ }
+
+ return (
+
+
+ );
+}
+
+function SetPasswordForm({ onSuccess }: { onSuccess: () => void }) {
+ const setPasswordMutation = api.user.setPassword.useMutation();
+ const utils = api.useUtils();
+
+ const form = useForm({
+ resolver: zodResolver(setPasswordFormSchema),
+ defaultValues: {
+ newPassword: "",
+ confirmPassword: "",
+ },
+ });
+
+ async function onSubmit(values: SetPasswordFormValues) {
+ try {
+ await setPasswordMutation.mutateAsync({
+ newPassword: values.newPassword,
+ });
+ toast.success("Password set successfully. You can now sign in with email and password.");
+ form.reset();
+ await utils.user.hasPassword.invalidate();
+ onSuccess();
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : "Failed to set password";
+ toast.error(message);
+ }
+ }
+
+ return (
+
+
+ );
+}
+
+function LinkedAccountsSection({ providers }: { providers: string[] }) {
+ if (providers.length === 0) {
+ return (
+
+ No OAuth accounts linked. You can link accounts by signing in with them.
+
+ );
+ }
+
+ return (
+
+ {providers.map((provider) => {
+ const config = providerConfig[provider.toLowerCase()];
+ return (
+
+
+
+ {config?.icon || }
+
+
+
{config?.name || provider}
+
Connected
+
+
+
+
+ );
+ })}
+
+ );
+}
+
+function ProfileSection({
+ profile,
+}: {
+ profile: { name: string | null; email: string | null; image: string | null };
+}) {
+ const initials =
+ profile.name
+ ?.split(" ")
+ .map((n) => n[0])
+ .join("")
+ .toUpperCase()
+ .slice(0, 2) || profile.email?.charAt(0).toUpperCase() || "?";
+
+ return (
+
+
+
+ {initials}
+
+
+ {profile.name && (
+
{profile.name}
+ )}
+
{profile.email}
+
+
+ );
+}
+
+export default function AccountSettingsPage() {
+ const [showPasswordForm, setShowPasswordForm] = useState(false);
+
+ const { data: profile, isLoading: profileLoading } =
+ api.user.getProfile.useQuery();
+ const { data: hasPasswordData, isLoading: hasPasswordLoading } =
+ api.user.hasPassword.useQuery();
+ const { data: linkedAccounts, isLoading: linkedAccountsLoading } =
+ api.user.getLinkedAccounts.useQuery();
+
+ const isLoading = profileLoading || hasPasswordLoading || linkedAccountsLoading;
+ const hasPassword = hasPasswordData?.hasPassword ?? false;
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Profile Info Section */}
+
+
+ Profile
+ Your account information
+
+
+ {profile && (
+
+ )}
+
+
+
+ {/* Linked Accounts Section */}
+
+
+ Linked Accounts
+
+ OAuth providers connected to your account
+
+
+
+
+
+
+
+ {/* Password Section */}
+
+
+ Password
+
+ {hasPassword
+ ? "Manage your password for email/password sign in"
+ : "Set up a password to enable email/password sign in"}
+
+
+
+ {hasPassword ? (
+
+
+
+
+
+
+
+
Password is set
+
+ You can sign in with email and password
+
+
+
+
+
+
+ {showPasswordForm && (
+ <>
+
+
setShowPasswordForm(false)}
+ />
+ >
+ )}
+
+ ) : (
+
+
+
+
+
+
+
+
No password set
+
+ Set a password to enable email/password sign in
+
+
+
+
+
+
+ {showPasswordForm && (
+ <>
+
+
setShowPasswordForm(false)}
+ />
+ >
+ )}
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(dashboard)/settings/layout.tsx b/apps/web/src/app/(dashboard)/settings/layout.tsx
index aac3fd18..22ffef6b 100644
--- a/apps/web/src/app/(dashboard)/settings/layout.tsx
+++ b/apps/web/src/app/(dashboard)/settings/layout.tsx
@@ -26,6 +26,7 @@ export default function ApiKeysPage({
) : null}
Team
+ Account
{children}
diff --git a/apps/web/src/app/api/auth/[...nextauth]/route.ts b/apps/web/src/app/api/auth/[...nextauth]/route.ts
index 0d089e71..466dbeea 100644
--- a/apps/web/src/app/api/auth/[...nextauth]/route.ts
+++ b/apps/web/src/app/api/auth/[...nextauth]/route.ts
@@ -50,8 +50,10 @@ function getClientIp(req: Request): string | null {
}
export async function POST(req: Request, ctx: any) {
+ const url = new URL(req.url);
+
+ // Rate limiting for email signin
if (env.AUTH_EMAIL_RATE_LIMIT > 0) {
- const url = new URL(req.url);
if (url.pathname.endsWith("/signin/email")) {
try {
const ip = getClientIp(req);
@@ -81,5 +83,37 @@ export async function POST(req: Request, ctx: any) {
}
}
}
+
+ // Rate limiting for credentials signin
+ if (url.pathname.endsWith("/callback/credentials")) {
+ try {
+ const ip = getClientIp(req);
+ if (!ip) {
+ logger.warn("Auth credentials rate limit skipped: missing client IP");
+ return handler(req, ctx);
+ }
+ const redis = getRedis();
+ const key = `auth-credentials-rl:${ip}`;
+ const ttl = 60;
+ const limit = 5;
+ const count = await redis.incr(key);
+ if (count === 1) await redis.expire(key, ttl);
+ if (count > limit) {
+ logger.warn({ ip }, "Auth credentials rate limit exceeded");
+ return Response.json(
+ {
+ error: {
+ code: "RATE_LIMITED",
+ message: "Too many login attempts. Please try again later.",
+ },
+ },
+ { status: 429 }
+ );
+ }
+ } catch (error) {
+ logger.error({ err: error }, "Auth credentials rate limit failed");
+ }
+ }
+
return handler(req, ctx);
}
diff --git a/apps/web/src/app/api/auth/forgot-password/route.ts b/apps/web/src/app/api/auth/forgot-password/route.ts
new file mode 100644
index 00000000..fabb5e8e
--- /dev/null
+++ b/apps/web/src/app/api/auth/forgot-password/route.ts
@@ -0,0 +1,119 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+import { randomBytes } from "crypto";
+import { db } from "~/server/db";
+import { sendPasswordResetEmail } from "~/server/mailer";
+import { getRedis } from "~/server/redis";
+import { logger } from "~/server/logger/log";
+
+const schema = z.object({
+ email: z.string().email(),
+});
+
+function getClientIp(req: Request): string | null {
+ const h = req.headers;
+ const direct =
+ h.get("x-forwarded-for") ??
+ h.get("x-real-ip") ??
+ h.get("cf-connecting-ip") ??
+ h.get("x-client-ip") ??
+ h.get("true-client-ip") ??
+ h.get("fastly-client-ip") ??
+ h.get("x-cluster-client-ip") ??
+ null;
+
+ let ip = direct?.split(",")[0]?.trim() ?? "";
+
+ if (!ip) {
+ const fwd = h.get("forwarded");
+ if (fwd) {
+ const first = fwd.split(",")[0];
+ const match = first?.match(/for=([^;]+)/i);
+ if (match && match[1]) {
+ const raw = match[1].trim().replace(/^"|"$/g, "");
+ if (raw.startsWith("[")) {
+ const end = raw.indexOf("]");
+ ip = end !== -1 ? raw.slice(1, end) : raw;
+ } else {
+ const parts = raw.split(":");
+ if (parts.length > 0 && parts[0]) {
+ ip =
+ parts.length === 2 && /^\d+(?:\.\d+){3}$/.test(parts[0])
+ ? parts[0]
+ : raw;
+ }
+ }
+ }
+ }
+ }
+
+ return ip || null;
+}
+
+export async function POST(request: Request) {
+ // Rate limiting for password reset
+ try {
+ const ip = getClientIp(request);
+ if (ip) {
+ const redis = getRedis();
+ const key = `password-reset-rl:${ip}`;
+ const ttl = 3600; // 1 hour
+ const limit = 3;
+ const count = await redis.incr(key);
+ if (count === 1) await redis.expire(key, ttl);
+ if (count > limit) {
+ logger.warn({ ip }, "Password reset rate limit exceeded");
+ // Still return success to prevent enumeration
+ return NextResponse.json({ success: true });
+ }
+ } else {
+ logger.warn("Password reset rate limit skipped: missing client IP");
+ }
+ } catch (error) {
+ logger.error({ err: error }, "Password reset rate limit failed");
+ }
+
+ try {
+ const body = await request.json();
+ const result = schema.safeParse(body);
+
+ if (!result.success) {
+ // Always return success to prevent email enumeration
+ return NextResponse.json({ success: true });
+ }
+
+ const normalizedEmail = result.data.email.toLowerCase().trim();
+
+ const user = await db.user.findUnique({
+ where: { email: normalizedEmail },
+ });
+
+ // Only send reset email if user exists AND has a password
+ if (user && user.passwordHash) {
+ // Delete any existing tokens for this email
+ await db.passwordResetToken.deleteMany({
+ where: { email: normalizedEmail },
+ });
+
+ // Create new token (expires in 1 hour)
+ const token = randomBytes(32).toString("hex");
+ const expires = new Date(Date.now() + 60 * 60 * 1000);
+
+ await db.passwordResetToken.create({
+ data: {
+ email: normalizedEmail,
+ token,
+ expires,
+ },
+ });
+
+ await sendPasswordResetEmail(normalizedEmail, token);
+ }
+
+ // Always return success to prevent email enumeration
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ console.error("Forgot password error:", error);
+ return NextResponse.json({ success: true });
+ }
+}
diff --git a/apps/web/src/app/api/auth/reset-password/route.ts b/apps/web/src/app/api/auth/reset-password/route.ts
new file mode 100644
index 00000000..b88ffb58
--- /dev/null
+++ b/apps/web/src/app/api/auth/reset-password/route.ts
@@ -0,0 +1,82 @@
+import { NextResponse } from "next/server";
+import { db } from "~/server/db";
+import { createSecureHash } from "~/server/crypto";
+import { resetPasswordSchema } from "~/server/password-utils";
+
+export async function POST(request: Request) {
+ try {
+ const body = await request.json();
+ const result = resetPasswordSchema.safeParse(body);
+
+ if (!result.success) {
+ return NextResponse.json(
+ { error: result.error.errors[0]?.message ?? "Invalid input" },
+ { status: 400 }
+ );
+ }
+
+ const { token, password } = result.data;
+
+ const resetToken = await db.passwordResetToken.findUnique({
+ where: { token },
+ });
+
+ if (!resetToken) {
+ return NextResponse.json(
+ { error: "Invalid or expired reset link" },
+ { status: 400 }
+ );
+ }
+
+ if (resetToken.expires < new Date()) {
+ // Clean up expired token
+ await db.passwordResetToken.delete({ where: { token } });
+ return NextResponse.json(
+ { error: "This reset link has expired. Please request a new one." },
+ { status: 400 }
+ );
+ }
+
+ const passwordHash = await createSecureHash(password);
+
+ await db.$transaction(async (tx) => {
+ // Verify user exists
+ const user = await tx.user.findUnique({
+ where: { email: resetToken.email },
+ });
+
+ if (!user) {
+ throw new Error("User not found");
+ }
+
+ // Update password
+ await tx.user.update({
+ where: { email: resetToken.email },
+ data: {
+ passwordHash,
+ emailVerified: new Date(),
+ },
+ });
+
+ // Delete used token
+ await tx.passwordResetToken.delete({ where: { token } });
+ });
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ console.error("Reset password error:", error);
+ const message =
+ error instanceof Error && error.message === "User not found"
+ ? "User account not found"
+ : "Something went wrong. Please try again.";
+ return NextResponse.json(
+ { error: message },
+ {
+ status:
+ error instanceof Error && error.message === "User not found"
+ ? 400
+ : 500,
+ }
+ );
+ }
+}
diff --git a/apps/web/src/app/api/auth/signup/route.ts b/apps/web/src/app/api/auth/signup/route.ts
new file mode 100644
index 00000000..39fd90d3
--- /dev/null
+++ b/apps/web/src/app/api/auth/signup/route.ts
@@ -0,0 +1,74 @@
+import { NextResponse } from "next/server";
+import { db } from "~/server/db";
+import { createSecureHash } from "~/server/crypto";
+import { signupSchema } from "~/server/password-utils";
+import { env } from "~/env";
+
+export async function POST(request: Request) {
+ try {
+ const body = await request.json();
+ const result = signupSchema.safeParse(body);
+
+ if (!result.success) {
+ return NextResponse.json(
+ { error: result.error.errors[0]?.message ?? "Invalid input" },
+ { status: 400 }
+ );
+ }
+
+ const { email, password, name } = result.data;
+ const normalizedEmail = email.toLowerCase().trim();
+
+ // Check if user exists
+ const existingUser = await db.user.findUnique({
+ where: { email: normalizedEmail },
+ });
+
+ if (existingUser) {
+ return NextResponse.json(
+ { error: "An account with this email already exists. Please sign in." },
+ { status: 400 }
+ );
+ }
+
+ // Create new user with password
+ const passwordHash = await createSecureHash(password);
+
+ // Check for pending team invites
+ const pendingInvites = await db.teamInvite.findMany({
+ where: { email: normalizedEmail },
+ });
+
+ // Determine beta/waitlist status based on environment and invites
+ const isBetaUser =
+ !env.NEXT_PUBLIC_IS_CLOUD ||
+ env.NODE_ENV === "development" ||
+ pendingInvites.length > 0;
+ const isWaitlisted =
+ env.NEXT_PUBLIC_IS_CLOUD &&
+ env.NODE_ENV !== "development" &&
+ pendingInvites.length === 0;
+
+ await db.user.create({
+ data: {
+ email: normalizedEmail,
+ name: name ?? null,
+ passwordHash,
+ isBetaUser,
+ isWaitlisted,
+ emailVerified: new Date(), // Mark as verified since they're signing up directly
+ },
+ });
+
+ return NextResponse.json({
+ success: true,
+ message: "Account created successfully. You can now sign in.",
+ });
+ } catch (error) {
+ console.error("Signup error:", error);
+ return NextResponse.json(
+ { error: "Something went wrong. Please try again." },
+ { status: 500 }
+ );
+ }
+}
diff --git a/apps/web/src/app/forgot-password/page.tsx b/apps/web/src/app/forgot-password/page.tsx
new file mode 100644
index 00000000..9b1e8b8e
--- /dev/null
+++ b/apps/web/src/app/forgot-password/page.tsx
@@ -0,0 +1,142 @@
+"use client";
+
+import { Button } from "@usesend/ui/src/button";
+import Image from "next/image";
+import { z } from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import { useState } from "react";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormMessage,
+} from "@usesend/ui/src/form";
+import { Input } from "@usesend/ui/src/input";
+import Link from "next/link";
+
+const emailSchema = z.object({
+ email: z
+ .string({ required_error: "Email is required" })
+ .email({ message: "Invalid email" }),
+});
+
+export default function ForgotPasswordPage() {
+ const [status, setStatus] = useState<"idle" | "sending" | "success">("idle");
+
+ const form = useForm>({
+ resolver: zodResolver(emailSchema),
+ defaultValues: {
+ email: "",
+ },
+ });
+
+ async function onSubmit(values: z.infer) {
+ setStatus("sending");
+ try {
+ await fetch("/api/auth/forgot-password", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email: values.email }),
+ });
+ // Always show success to prevent email enumeration
+ setStatus("success");
+ } catch {
+ // Still show success to prevent email enumeration
+ setStatus("success");
+ }
+ }
+
+ return (
+
+
+
+
+
+ Reset your password
+
+
+ Remember your password?
+
+ Sign in
+
+
+
+
+
+ {status === "success" ? (
+
+
+
+ If an account exists with that email, we've sent you a
+ password reset link. Please check your inbox.
+
+
+
+ Didn't receive the email? Check your spam folder or try again.
+
+
+
+ ) : (
+ <>
+
+ Enter your email address and we'll send you a link to reset your
+ password.
+
+
+
+ >
+ )}
+
+
+
+ );
+}
diff --git a/apps/web/src/app/login/login-page.tsx b/apps/web/src/app/login/login-page.tsx
index 46617f16..297d19f2 100644
--- a/apps/web/src/app/login/login-page.tsx
+++ b/apps/web/src/app/login/login-page.tsx
@@ -27,6 +27,7 @@ import Spinner from "@usesend/ui/src/spinner";
import Link from "next/link";
import { useTheme } from "@usesend/ui";
import { useSearchParams as useNextSearchParams } from "next/navigation";
+import { passwordLoginSchema } from "~/server/password-utils";
const emailSchema = z.object({
email: z
@@ -72,6 +73,13 @@ export default function LoginPage({
"idle" | "sending" | "success"
>("idle");
+ const [authMode, setAuthMode] = useState<"password" | "otp">("password");
+ const [loginError, setLoginError] = useState(null);
+
+ const searchParams = useNextSearchParams();
+ const inviteId = searchParams.get("inviteId");
+ const callbackUrl = typeof window !== "undefined" ? window.location.origin : "";
+
const emailForm = useForm>({
resolver: zodResolver(emailSchema),
});
@@ -80,6 +88,14 @@ export default function LoginPage({
resolver: zodResolver(otpSchema),
});
+ const passwordForm = useForm>({
+ resolver: zodResolver(passwordLoginSchema),
+ defaultValues: {
+ email: "",
+ password: "",
+ },
+ });
+
async function onEmailSubmit(values: z.infer) {
setEmailStatus("sending");
await signIn("email", {
@@ -90,18 +106,40 @@ export default function LoginPage({
}
async function onOTPSubmit(values: z.infer) {
- const { origin: callbackUrl } = window.location;
+ const { origin: baseUrl } = window.location;
const email = emailForm.getValues().email;
- console.log("email", email);
const finalCallbackUrl = inviteId
? `/join-team?inviteId=${inviteId}`
- : `${callbackUrl}/dashboard`;
+ : `${baseUrl}/dashboard`;
window.location.href = `/api/auth/callback/email?email=${encodeURIComponent(
email.toLowerCase()
)}&token=${values.otp.toLowerCase()}&callbackUrl=${encodeURIComponent(finalCallbackUrl)}`;
}
+ async function onPasswordSubmit(values: z.infer) {
+ setLoginError(null);
+ setSubmittedProvider("credentials");
+
+ const result = await signIn("credentials", {
+ email: values.email.toLowerCase(),
+ password: values.password,
+ redirect: false,
+ });
+
+ setSubmittedProvider(null);
+
+ if (result?.error) {
+ setLoginError("Invalid email or password");
+ return;
+ }
+
+ const finalCallbackUrl = inviteId
+ ? `/join-team?inviteId=${inviteId}`
+ : `${callbackUrl}/dashboard`;
+ window.location.href = finalCallbackUrl;
+ }
+
const emailProvider = providers?.find(
(provider) => provider.type === "email"
);
@@ -109,15 +147,12 @@ export default function LoginPage({
const [submittedProvider, setSubmittedProvider] =
useState | null>(null);
- const searchParams = useNextSearchParams();
- const inviteId = searchParams.get("inviteId");
-
const handleSubmit = (provider: LiteralUnion) => {
setSubmittedProvider(provider);
- const callbackUrl = inviteId
+ const redirectUrl = inviteId
? `/join-team?inviteId=${inviteId}`
: "/dashboard";
- signIn(provider, { callbackUrl });
+ signIn(provider, { callbackUrl: redirectUrl });
};
const { resolvedTheme } = useTheme();
@@ -178,7 +213,96 @@ export default function LoginPage({
- {emailStatus === "success" ? (
+
+ {/* Auth mode toggle */}
+
+
+
+
+
+ {/* Error message display */}
+ {loginError && (
+ {loginError}
+ )}
+
+ {authMode === "password" ? (
+ /* Password login form */
+
+
+ ) : emailStatus === "success" ? (
+ /* OTP verification form */
<>
We have sent an email with the OTP. Please check your inbox
@@ -238,42 +362,41 @@ export default function LoginPage({
>
) : (
- <>
-
-
- >
+ {emailStatus === "sending"
+ ? "Sending..."
+ : "Continue with email"}
+
+
+
)}
>
)}
diff --git a/apps/web/src/app/reset-password/page.tsx b/apps/web/src/app/reset-password/page.tsx
new file mode 100644
index 00000000..26826f22
--- /dev/null
+++ b/apps/web/src/app/reset-password/page.tsx
@@ -0,0 +1,239 @@
+"use client";
+
+import { Button } from "@usesend/ui/src/button";
+import Image from "next/image";
+import { z } from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import { useState, Suspense } from "react";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@usesend/ui/src/form";
+import { Input } from "@usesend/ui/src/input";
+import Link from "next/link";
+import { useSearchParams } from "next/navigation";
+import { passwordSchema } from "~/server/password-utils";
+
+const resetPasswordFormSchema = z
+ .object({
+ password: passwordSchema,
+ confirmPassword: z.string(),
+ })
+ .refine((data) => data.password === data.confirmPassword, {
+ message: "Passwords don't match",
+ path: ["confirmPassword"],
+ });
+
+function ResetPasswordForm() {
+ const searchParams = useSearchParams();
+ const token = searchParams.get("token");
+
+ const [status, setStatus] = useState<"idle" | "sending" | "success" | "error">(
+ "idle"
+ );
+ const [errorMessage, setErrorMessage] = useState("");
+
+ const form = useForm>({
+ resolver: zodResolver(resetPasswordFormSchema),
+ defaultValues: {
+ password: "",
+ confirmPassword: "",
+ },
+ });
+
+ async function onSubmit(values: z.infer) {
+ if (!token) {
+ setErrorMessage("Invalid reset link");
+ setStatus("error");
+ return;
+ }
+
+ setStatus("sending");
+ setErrorMessage("");
+
+ try {
+ const response = await fetch("/api/auth/reset-password", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token, password: values.password }),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ setErrorMessage(data.error || "Something went wrong");
+ setStatus("error");
+ return;
+ }
+
+ setStatus("success");
+ } catch {
+ setErrorMessage("Something went wrong. Please try again.");
+ setStatus("error");
+ }
+ }
+
+ if (!token) {
+ return (
+
+
+
+
+
+
+ Invalid Reset Link
+
+
+ This password reset link is invalid or has expired.
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ Create new password
+
+
+ Enter a new password for your account
+
+
+
+
+ {status === "success" ? (
+
+
+
+ Your password has been reset successfully.
+
+
+
+
+
+
+ ) : (
+ <>
+ {status === "error" && errorMessage && (
+
+ )}
+
+
+
+
+ Back to sign in
+
+
+ >
+ )}
+
+
+
+ );
+}
+
+export default function ResetPasswordPage() {
+ return (
+
+
+
+ }
+ >
+
+
+ );
+}
diff --git a/apps/web/src/app/signup/page.tsx b/apps/web/src/app/signup/page.tsx
index ba9d53ba..949323b0 100644
--- a/apps/web/src/app/signup/page.tsx
+++ b/apps/web/src/app/signup/page.tsx
@@ -1,9 +1,9 @@
import { redirect } from "next/navigation";
import { getServerAuthSession } from "~/server/auth";
-import LoginPage from "../login/login-page";
+import SignupPage from "./signup-page";
import { getProviders } from "next-auth/react";
-export default async function Login() {
+export default async function Signup() {
const session = await getServerAuthSession();
if (session) {
@@ -12,5 +12,5 @@ export default async function Login() {
const providers = await getProviders();
- return ;
+ return ;
}
diff --git a/apps/web/src/app/signup/signup-page.tsx b/apps/web/src/app/signup/signup-page.tsx
new file mode 100644
index 00000000..b813b13d
--- /dev/null
+++ b/apps/web/src/app/signup/signup-page.tsx
@@ -0,0 +1,320 @@
+"use client";
+
+import { Button } from "@usesend/ui/src/button";
+import Image from "next/image";
+import { z } from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import { useState } from "react";
+import { ClientSafeProvider, LiteralUnion, signIn } from "next-auth/react";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@usesend/ui/src/form";
+import { Input } from "@usesend/ui/src/input";
+import { BuiltInProviderType } from "next-auth/providers/index";
+import Spinner from "@usesend/ui/src/spinner";
+import Link from "next/link";
+import { useSearchParams as useNextSearchParams, useRouter } from "next/navigation";
+import { signupSchema } from "~/server/password-utils";
+
+const providerSvgs = {
+ github: (
+
+ ),
+ google: (
+
+ ),
+};
+
+// Extended schema with confirm password for client-side validation
+const signupFormSchema = signupSchema.extend({
+ confirmPassword: z.string().min(1, "Please confirm your password"),
+}).refine((data) => data.password === data.confirmPassword, {
+ message: "Passwords do not match",
+ path: ["confirmPassword"],
+});
+
+type SignupFormValues = z.infer;
+
+export default function SignupPage({
+ providers,
+}: {
+ providers?: ClientSafeProvider[];
+}) {
+ const router = useRouter();
+ const searchParams = useNextSearchParams();
+ const inviteId = searchParams.get("inviteId");
+
+ const [signupStatus, setSignupStatus] = useState<
+ "idle" | "submitting" | "success" | "error"
+ >("idle");
+ const [errorMessage, setErrorMessage] = useState("");
+ const [submittedProvider, setSubmittedProvider] =
+ useState | null>(null);
+
+ const form = useForm({
+ resolver: zodResolver(signupFormSchema),
+ defaultValues: {
+ name: "",
+ email: "",
+ password: "",
+ confirmPassword: "",
+ },
+ });
+
+ async function onSubmit(values: SignupFormValues) {
+ setSignupStatus("submitting");
+ setErrorMessage("");
+
+ try {
+ const response = await fetch("/api/auth/signup", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ email: values.email,
+ password: values.password,
+ name: values.name || undefined,
+ }),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ setSignupStatus("error");
+ setErrorMessage(data.error || "Something went wrong");
+ return;
+ }
+
+ setSignupStatus("success");
+
+ // Auto-login with credentials after successful signup
+ const signInResult = await signIn("credentials", {
+ email: values.email.toLowerCase(),
+ password: values.password,
+ redirect: false,
+ });
+
+ if (signInResult?.ok) {
+ const callbackUrl = inviteId
+ ? `/join-team?inviteId=${inviteId}`
+ : "/dashboard";
+ router.push(callbackUrl);
+ } else {
+ // If auto-login fails, redirect to login page
+ router.push("/login?message=Account created successfully. Please sign in.");
+ }
+ } catch {
+ setSignupStatus("error");
+ setErrorMessage("Something went wrong. Please try again.");
+ }
+ }
+
+ const handleOAuthSubmit = (provider: LiteralUnion) => {
+ setSubmittedProvider(provider);
+ const callbackUrl = inviteId
+ ? `/join-team?inviteId=${inviteId}`
+ : "/dashboard";
+ signIn(provider, { callbackUrl });
+ };
+
+ const oauthProviders = providers?.filter((p) => p.type !== "email" && p.type !== "credentials");
+
+ return (
+
+
+
+
+
+ Create new account
+
+
+ Already have an account?
+
+ Sign in
+
+
+
+
+
+ {/* OAuth Buttons */}
+ {oauthProviders && oauthProviders.length > 0 && (
+ <>
+ {oauthProviders.map((provider) => (
+
+ ))}
+
+ {/* Divider */}
+
+ >
+ )}
+
+ {/* Signup Form */}
+ {signupStatus === "success" ? (
+
+
+ Account created successfully!
+
+
+ Redirecting you to the dashboard...
+
+
+ ) : (
+
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/web/src/server/api/root.ts b/apps/web/src/server/api/root.ts
index b9f7f033..69ff9ef8 100644
--- a/apps/web/src/server/api/root.ts
+++ b/apps/web/src/server/api/root.ts
@@ -14,6 +14,7 @@ import { suppressionRouter } from "./routers/suppression";
import { limitsRouter } from "./routers/limits";
import { waitlistRouter } from "./routers/waitlist";
import { feedbackRouter } from "./routers/feedback";
+import { userRouter } from "./routers/user";
/**
* This is the primary router for your server.
@@ -36,6 +37,7 @@ export const appRouter = createTRPCRouter({
limits: limitsRouter,
waitlist: waitlistRouter,
feedback: feedbackRouter,
+ user: userRouter,
});
// export type definition of API
diff --git a/apps/web/src/server/api/routers/user.ts b/apps/web/src/server/api/routers/user.ts
new file mode 100644
index 00000000..785b8d15
--- /dev/null
+++ b/apps/web/src/server/api/routers/user.ts
@@ -0,0 +1,133 @@
+import { z } from "zod";
+import { TRPCError } from "@trpc/server";
+import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
+import { createSecureHash, verifySecureHash } from "~/server/crypto";
+import { passwordSchema } from "~/server/password-utils";
+
+export const userRouter = createTRPCRouter({
+ /**
+ * Check if the current user has a password set
+ */
+ hasPassword: protectedProcedure.query(async ({ ctx }) => {
+ const user = await ctx.db.user.findUnique({
+ where: { id: ctx.session.user.id },
+ select: { passwordHash: true },
+ });
+ return { hasPassword: !!user?.passwordHash };
+ }),
+
+ /**
+ * Get linked OAuth accounts for the current user
+ */
+ getLinkedAccounts: protectedProcedure.query(async ({ ctx }) => {
+ const accounts = await ctx.db.account.findMany({
+ where: { userId: ctx.session.user.id },
+ select: { provider: true },
+ });
+ return accounts.map((a) => a.provider);
+ }),
+
+ /**
+ * Change password for users who already have one
+ */
+ changePassword: protectedProcedure
+ .input(
+ z.object({
+ currentPassword: z.string().min(1, "Current password is required"),
+ newPassword: passwordSchema,
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ const user = await ctx.db.user.findUnique({
+ where: { id: ctx.session.user.id },
+ });
+
+ if (!user) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
+ }
+
+ if (!user.passwordHash) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "You don't have a password set. Use 'Set Password' instead.",
+ });
+ }
+
+ const isValid = await verifySecureHash(
+ input.currentPassword,
+ user.passwordHash
+ );
+ if (!isValid) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "Current password is incorrect",
+ });
+ }
+
+ const newPasswordHash = await createSecureHash(input.newPassword);
+
+ await ctx.db.user.update({
+ where: { id: user.id },
+ data: { passwordHash: newPasswordHash },
+ });
+
+ return { success: true };
+ }),
+
+ /**
+ * Set password for OAuth-only users
+ */
+ setPassword: protectedProcedure
+ .input(
+ z.object({
+ newPassword: passwordSchema,
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ const user = await ctx.db.user.findUnique({
+ where: { id: ctx.session.user.id },
+ });
+
+ if (!user) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
+ }
+
+ if (user.passwordHash) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "You already have a password. Use 'Change Password' instead.",
+ });
+ }
+
+ const passwordHash = await createSecureHash(input.newPassword);
+
+ await ctx.db.user.update({
+ where: { id: user.id },
+ data: { passwordHash },
+ });
+
+ return { success: true };
+ }),
+
+ /**
+ * Get current user profile info
+ */
+ getProfile: protectedProcedure.query(async ({ ctx }) => {
+ const user = await ctx.db.user.findUnique({
+ where: { id: ctx.session.user.id },
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ image: true,
+ createdAt: true,
+ },
+ });
+
+ if (!user) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
+ }
+
+ return user;
+ }),
+});
diff --git a/apps/web/src/server/auth.ts b/apps/web/src/server/auth.ts
index 1f53f4c5..ab396989 100644
--- a/apps/web/src/server/auth.ts
+++ b/apps/web/src/server/auth.ts
@@ -8,11 +8,13 @@ import { type Adapter } from "next-auth/adapters";
import GitHubProvider from "next-auth/providers/github";
import EmailProvider from "next-auth/providers/email";
import GoogleProvider from "next-auth/providers/google";
+import CredentialsProvider from "next-auth/providers/credentials";
import { Provider } from "next-auth/providers/index";
import { sendSignUpEmail } from "~/server/mailer";
import { env } from "~/env";
import { db } from "~/server/db";
+import { verifySecureHash } from "~/server/crypto";
/**
* Module augmentation for `next-auth` types. Allows us to add custom properties to the `session`
@@ -88,6 +90,48 @@ function getProviders() {
);
}
+ // Credentials provider for email/password authentication
+ providers.push(
+ CredentialsProvider({
+ id: "credentials",
+ name: "Email & Password",
+ credentials: {
+ email: { label: "Email", type: "email" },
+ password: { label: "Password", type: "password" },
+ },
+ async authorize(credentials) {
+ if (!credentials?.email || !credentials?.password) {
+ return null;
+ }
+
+ const email = (credentials.email as string).toLowerCase().trim();
+ const password = credentials.password as string;
+
+ const user = await db.user.findUnique({ where: { email } });
+
+ // Always run hash verification to prevent timing attacks
+ // Use a dummy hash when user doesn't exist to normalize response time
+ const DUMMY_HASH = "0000000000000000:0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000";
+ const hashToVerify = user?.passwordHash ?? DUMMY_HASH;
+ const isValid = await verifySecureHash(password, hashToVerify);
+
+ if (!user || !user.passwordHash || !isValid) {
+ return null;
+ }
+
+ return {
+ id: user.id,
+ email: user.email,
+ name: user.name,
+ image: user.image,
+ isBetaUser: user.isBetaUser,
+ isAdmin: user.email === env.ADMIN_EMAIL,
+ isWaitlisted: user.isWaitlisted,
+ };
+ },
+ })
+ );
+
if (providers.length === 0 && process.env.SKIP_ENV_VALIDATION !== "true") {
throw new Error("No auth providers found, need atleast one");
}
@@ -101,15 +145,28 @@ function getProviders() {
* @see https://next-auth.js.org/configuration/options
*/
export const authOptions: NextAuthOptions = {
+ session: {
+ strategy: "jwt",
+ maxAge: 30 * 24 * 60 * 60, // 30 days
+ },
callbacks: {
- session: ({ session, user }) => ({
+ jwt: async ({ token, user }) => {
+ if (user) {
+ token.id = user.id;
+ token.isBetaUser = user.isBetaUser;
+ token.isWaitlisted = user.isWaitlisted;
+ token.isAdmin = user.email === env.ADMIN_EMAIL;
+ }
+ return token;
+ },
+ session: ({ session, token }) => ({
...session,
user: {
...session.user,
- id: user.id,
- isBetaUser: user.isBetaUser,
- isAdmin: user.email === env.ADMIN_EMAIL,
- isWaitlisted: user.isWaitlisted,
+ id: token.id as number,
+ isBetaUser: token.isBetaUser as boolean,
+ isAdmin: token.isAdmin as boolean,
+ isWaitlisted: token.isWaitlisted as boolean,
},
}),
},
diff --git a/apps/web/src/server/email-templates/PasswordResetEmail.tsx b/apps/web/src/server/email-templates/PasswordResetEmail.tsx
new file mode 100644
index 00000000..6c76f325
--- /dev/null
+++ b/apps/web/src/server/email-templates/PasswordResetEmail.tsx
@@ -0,0 +1,87 @@
+import React from "react";
+import { Container, Text } from "jsx-email";
+import { render } from "jsx-email";
+import { EmailLayout } from "./components/EmailLayout";
+import { EmailHeader } from "./components/EmailHeader";
+import { EmailFooter } from "./components/EmailFooter";
+import { EmailButton } from "./components/EmailButton";
+
+interface PasswordResetEmailProps {
+ resetUrl: string;
+ logoUrl?: string;
+}
+
+export function PasswordResetEmail({
+ resetUrl,
+ logoUrl,
+}: PasswordResetEmailProps) {
+ return (
+
+
+
+
+
+ Hi there,
+
+
+
+ We received a request to reset your password for your useSend account.
+ Click the button below to create a new password:
+
+
+
+ Reset password
+
+
+
+ This link will expire in 1 hour for security reasons.
+
+
+
+ If you didn't request a password reset, you can safely ignore this
+ email. Your password will remain unchanged.
+
+
+
+
+
+ );
+}
+
+export async function renderPasswordResetEmail(
+ props: PasswordResetEmailProps
+): Promise {
+ return render();
+}
diff --git a/apps/web/src/server/email-templates/index.ts b/apps/web/src/server/email-templates/index.ts
index 02e963a7..ebf16f2b 100644
--- a/apps/web/src/server/email-templates/index.ts
+++ b/apps/web/src/server/email-templates/index.ts
@@ -8,6 +8,10 @@ export {
UsageLimitReachedEmail,
renderUsageLimitReachedEmail,
} from "./UsageLimitReachedEmail";
+export {
+ PasswordResetEmail,
+ renderPasswordResetEmail,
+} from "./PasswordResetEmail";
export * from "./components/EmailLayout";
export * from "./components/EmailHeader";
diff --git a/apps/web/src/server/mailer.ts b/apps/web/src/server/mailer.ts
index bc360e62..45699014 100644
--- a/apps/web/src/server/mailer.ts
+++ b/apps/web/src/server/mailer.ts
@@ -5,7 +5,11 @@ import { db } from "./db";
import { getDomains } from "./service/domain-service";
import { sendEmail } from "./service/email-service";
import { logger } from "./logger/log";
-import { renderOtpEmail, renderTeamInviteEmail } from "./email-templates";
+import {
+ renderOtpEmail,
+ renderTeamInviteEmail,
+ renderPasswordResetEmail,
+} from "./email-templates";
let usesend: UseSend | undefined;
@@ -69,6 +73,27 @@ export async function sendTeamInviteEmail(
await sendMail(email, subject, text, html);
}
+export async function sendPasswordResetEmail(email: string, token: string) {
+ const resetUrl = `${env.NEXTAUTH_URL}/reset-password?token=${token}`;
+
+ if (env.NODE_ENV === "development") {
+ logger.info({ email, resetUrl, token }, "Sending password reset email");
+ return;
+ }
+
+ const subject = "Reset your password";
+
+ // Use jsx-email template for beautiful HTML
+ const html = await renderPasswordResetEmail({
+ resetUrl,
+ });
+
+ // Fallback text version
+ const text = `Hey,\n\nWe received a request to reset your password for your useSend account.\n\nYou can reset your password by clicking the link below:\n${resetUrl}\n\nThis link will expire in 1 hour.\n\nIf you didn't request this, you can safely ignore this email.\n\nThanks,\nuseSend Team`;
+
+ await sendMail(email, subject, text, html);
+}
+
export async function sendSubscriptionConfirmationEmail(email: string) {
if (!env.FOUNDER_EMAIL) {
logger.error("FOUNDER_EMAIL not configured");
@@ -92,7 +117,7 @@ export async function sendMail(
) {
if (isSelfHosted()) {
logger.info("Sending email using self hosted");
- /*
+ /*
Self hosted so checking if we can send using one of the available domain
Assuming self hosted will have only one team
TODO: fix this
diff --git a/apps/web/src/server/password-utils.ts b/apps/web/src/server/password-utils.ts
new file mode 100644
index 00000000..e789e772
--- /dev/null
+++ b/apps/web/src/server/password-utils.ts
@@ -0,0 +1,66 @@
+import { z } from "zod";
+
+/**
+ * Password validation schema with strong requirements:
+ * - Minimum 8 characters
+ * - Maximum 128 characters
+ * - At least one lowercase letter
+ * - At least one uppercase letter
+ * - At least one number
+ */
+export const passwordSchema = z
+ .string()
+ .min(8, "Password must be at least 8 characters")
+ .max(128, "Password must be less than 128 characters")
+ .regex(/[a-z]/, "Password must contain at least one lowercase letter")
+ .regex(/[A-Z]/, "Password must contain at least one uppercase letter")
+ .regex(/[0-9]/, "Password must contain at least one number");
+
+/**
+ * Validates a password against the password schema
+ * @param password - The password to validate
+ * @returns SafeParseReturnType with success status and error messages
+ */
+export const validatePassword = (password: string) => {
+ return passwordSchema.safeParse(password);
+};
+
+/**
+ * Schema for signup form validation
+ */
+export const signupSchema = z.object({
+ email: z.string().email("Invalid email address"),
+ password: passwordSchema,
+ name: z.string().min(1, "Name is required").optional(),
+});
+
+/**
+ * Schema for password login form validation
+ */
+export const passwordLoginSchema = z.object({
+ email: z.string().email("Invalid email address"),
+ password: z.string().min(1, "Password is required"),
+});
+
+/**
+ * Schema for password change (when user already has a password)
+ */
+export const changePasswordSchema = z.object({
+ currentPassword: z.string().min(1, "Current password is required"),
+ newPassword: passwordSchema,
+});
+
+/**
+ * Schema for setting password (when OAuth user adds password)
+ */
+export const setPasswordSchema = z.object({
+ newPassword: passwordSchema,
+});
+
+/**
+ * Schema for password reset
+ */
+export const resetPasswordSchema = z.object({
+ token: z.string().min(1, "Reset token is required"),
+ password: passwordSchema,
+});