diff --git a/apps/web/app/(org)/login/form.tsx b/apps/web/app/(org)/login/form.tsx index 4f4588f19c..6e3890a2d3 100644 --- a/apps/web/app/(org)/login/form.tsx +++ b/apps/web/app/(org)/login/form.tsx @@ -10,7 +10,7 @@ import { import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { AnimatePresence, motion } from "framer-motion"; import Cookies from "js-cookie"; -import { LucideArrowUpRight } from "lucide-react"; +import { KeyRound, LucideArrowUpRight } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; @@ -40,6 +40,8 @@ export function LoginForm() { const [lastEmailSentTime, setLastEmailSentTime] = useState( null, ); + const [password, setPassword] = useState(""); + const [showCredientialLogin,setShowCredientialLogin]=useState(false); const theme = Cookies.get("theme") || "light"; useEffect(() => { @@ -248,6 +250,60 @@ export function LoginForm() { e.preventDefault(); if (!email) return; + if (showCredientialLogin) { + if (!email || !password) { + toast.error("Please enter email and password"); + return; + } + + try { + setLoading(true); + trackEvent("auth_started", { method: "password", is_signup: false }); + + const res = await signIn("credentials", { + email, + password, + redirect: false, + ...(next && next.length > 0 ? { callbackUrl: next } : {}), + }); + + setLoading(false); + + if (res?.ok && !res?.error) { + trackEvent("auth_success", { method: "password", is_signup: false }); + router.push(next || "/dashboard"); + return; + } + // Handle specific known errors first + if (res?.error?.toLowerCase().includes("verify")) { + toast.error("Please verify your email before logging in."); + const resend = await fetch("/api/auth/resend", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }); + + const data = await resend.json(); + if (!data?.status) throw new Error("Something went wrong, try other login methods."); + + router.push(`/verify-otp?email=${encodeURIComponent(email)}&type=credentials`); + return; + } + + if (res?.error) { + toast.error(res.error); + return; + } + + } catch (error) { + console.error("Credential login error:", error); + toast.error("Unexpected error, please try again later."); + } finally { + setLoading(false); + } + return; + } + // Check if we're rate limited on the client side if (lastEmailSentTime) { const timeSinceLastRequest = @@ -309,15 +365,28 @@ export function LoginForm() { }} className="flex flex-col space-y-3" > - + {showCredientialLogin ? ( + + ) : ( + + )} )} @@ -388,6 +457,91 @@ const LoginWithSSO = ({ ); }; +const LoginWithEmailAndPassword = ({ + email, + emailSent, + setEmail, + loading, + password, + setPassword, + setShowCredientialLogin +}: { + email: string; + emailSent: boolean; + setEmail: (email: string) => void; + password: string, + setPassword: (password: string) => void; + loading: boolean; + setShowCredientialLogin : (show : boolean) => void + +}) => { + return ( + + + { + setEmail(e.target.value); + }} + /> + { + setPassword(e.target.value); + }} + /> + } + > + Login with email + + setShowCredientialLogin(false)} + disabled={loading || emailSent} + > + + Login with OTP + + + Don't have an account?{" "} + + Sign up here + + + + + ); +}; + const NormalLogin = ({ setShowOrgInput, email, @@ -396,6 +550,7 @@ const NormalLogin = ({ loading, oauthError, handleGoogleSignIn, + setShowCredientialLogin }: { setShowOrgInput: (show: boolean) => void; email: string; @@ -404,6 +559,7 @@ const NormalLogin = ({ loading: boolean; oauthError: boolean; handleGoogleSignIn: () => void; + setShowCredientialLogin : (show : boolean) => void }) => { const publicEnv = usePublicEnv(); @@ -455,7 +611,17 @@ const NormalLogin = ({ Sign up here - + setShowCredientialLogin(true)} + disabled={loading || emailSent} + > + + Login with Password + {(publicEnv.googleAuthAvailable || publicEnv.workosAuthAvailable) && ( <>
diff --git a/apps/web/app/(org)/signup/form.tsx b/apps/web/app/(org)/signup/form.tsx index 10e9dede14..b54596c38d 100644 --- a/apps/web/app/(org)/signup/form.tsx +++ b/apps/web/app/(org)/signup/form.tsx @@ -10,7 +10,7 @@ import { import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { AnimatePresence, motion } from "framer-motion"; import Cookies from "js-cookie"; -import { LucideArrowUpRight } from "lucide-react"; +import { KeyRound, LucideArrowUpRight } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; @@ -40,6 +40,8 @@ export function SignupForm() { const [lastEmailSentTime, setLastEmailSentTime] = useState( null, ); + const [password, setPassword] = useState(""); + const [showCredientialLogin,setShowCredientialLogin]=useState(false); const theme = Cookies.get("theme") || "light"; useEffect(() => { @@ -248,6 +250,52 @@ export function SignupForm() { e.preventDefault(); if (!email) return; + if (showCredientialLogin) { + if (!email || !password) { + toast.error("Please enter email and password"); + return; + } + + if (password.length < 8) { + toast.error("Password must be at least 8 characters long"); + return; + } + try { + setLoading(true); + trackEvent("auth_started", { method: "password", is_signup: true }); + + const response = await fetch("/api/auth/signup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + + const data = await response.json(); + setLoading(false); + + if (!response.ok) { + toast.error(data.message || "Something went wrong during signup. Please try again."); + return; + } + + toast.success("Verification code sent to your email!"); + trackEvent("auth_email_sent", { email_domain: email.split("@")[1] }); + + const params = new URLSearchParams({ + email, + type: "credentials", + ...(next && { next }), + }); + router.push(`/verify-otp?${params.toString()}`); + } catch (error) { + console.error("Credential signup error:", error); + toast.error("Something went wrong during signup. Try again?"); + } finally { + setLoading(false); + } + return; + } + // Check if we're rate limited on the client side if (lastEmailSentTime) { const timeSinceLastRequest = @@ -309,15 +357,28 @@ export function SignupForm() { }} className="flex flex-col space-y-3" > - + {showCredientialLogin ? ( + + ) : ( + + )} )} @@ -402,6 +463,80 @@ const SignupWithSSO = ({ ); }; + +const SignUpWithEmailAndPassword = ({ + email, + emailSent, + setEmail, + loading, + password, + setPassword, + setShowCredientialLogin +}: { + email: string; + emailSent: boolean; + setEmail: (email: string) => void; + password: string, + setPassword: (password: string) => void; + loading: boolean; + setShowCredientialLogin: (show: boolean) => void + +}) => { + return ( + + + { + setEmail(e.target.value); + }} + /> + { + setPassword(e.target.value); + }} + /> + } + > + Signup with email + + setShowCredientialLogin(false)} + disabled={loading || emailSent} + > + + Signup with OTP + + + + ); +}; + const NormalSignup = ({ setShowOrgInput, email, @@ -410,6 +545,7 @@ const NormalSignup = ({ loading, oauthError, handleGoogleSignIn, + setShowCredientialLogin }: { setShowOrgInput: (show: boolean) => void; email: string; @@ -418,6 +554,7 @@ const NormalSignup = ({ loading: boolean; oauthError: boolean; handleGoogleSignIn: () => void; + setShowCredientialLogin: (show: boolean) => void; }) => { const publicEnv = usePublicEnv(); @@ -446,6 +583,17 @@ const NormalSignup = ({ > Sign up with email + setShowCredientialLogin(true)} + disabled={loading || emailSent} + > + + Sign up with Password + {(publicEnv.googleAuthAvailable || publicEnv.workosAuthAvailable) && ( <> diff --git a/apps/web/app/(org)/verify-otp/form.tsx b/apps/web/app/(org)/verify-otp/form.tsx index a7161c3466..25b4b385a7 100644 --- a/apps/web/app/(org)/verify-otp/form.tsx +++ b/apps/web/app/(org)/verify-otp/form.tsx @@ -15,10 +15,12 @@ export function VerifyOTPForm({ email, next, lastSent, + type }: { email: string; next?: string; lastSent?: string; + type?: "email" | "credentials"; }) { const [code, setCode] = useState(["", "", "", "", "", ""]); const [lastResendTime, setLastResendTime] = useState( @@ -75,6 +77,35 @@ export function VerifyOTPForm({ const otpCode = code.join(""); if (otpCode.length !== 6) throw "Please enter a complete 6-digit code"; + if (type === "credentials") { + const res = await fetch("/api/auth/verify-otp", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, otp: otpCode }), + }); + + const data = await res.json().catch(() => ({})); + + if (!res.ok || !data?.status) { + throw "Invalid or expired OTP. Please try again."; + } + if (!data?.authToken) { + throw "Authentication token not received. Please try again."; + } + + // Use the temporary auth token to sign in via credentials provider + const result = await signIn("credentials", { + email, + otp_token: data.authToken, + redirect: false, + }); + + if (result?.error) { + throw "Login failed after verification. Please try logging in again."; + } + return; + } + // shoutout https://github.com/buoyad/Tally/pull/14 const res = await fetch( `/api/auth/callback/email?email=${encodeURIComponent(email)}&token=${encodeURIComponent(otpCode)}&callbackUrl=${encodeURIComponent("/login-success")}`, @@ -114,6 +145,21 @@ export function VerifyOTPForm({ } } + if (type === "credentials") { + const res = await fetch("/api/auth/resend", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }); + + const data = await res.json(); + if (!data?.status) { + throw data?.message || "OTP resend failed."; + } + setLastResendTime(Date.now()); + return data; + } + const result = await signIn("email", { email, redirect: false, diff --git a/apps/web/app/(org)/verify-otp/page.tsx b/apps/web/app/(org)/verify-otp/page.tsx index a31859b07e..828f07ebbf 100644 --- a/apps/web/app/(org)/verify-otp/page.tsx +++ b/apps/web/app/(org)/verify-otp/page.tsx @@ -8,7 +8,7 @@ export const metadata = { }; export default async function VerifyOTPPage(props: { - searchParams: Promise<{ email?: string; next?: string; lastSent?: string }>; + searchParams: Promise<{ email?: string; next?: string; lastSent?: string ; type?: string }>; }) { const searchParams = await props.searchParams; const user = await getCurrentUser(); @@ -20,6 +20,7 @@ export default async function VerifyOTPPage(props: { if (!searchParams.email) { redirect("/login"); } + const verifyType = searchParams.type === "credentials" ? "credentials" : "email"; return (
@@ -28,6 +29,7 @@ export default async function VerifyOTPPage(props: { email={searchParams.email} next={searchParams.next} lastSent={searchParams.lastSent} + type={verifyType} />
diff --git a/packages/database/auth/auth-options.ts b/packages/database/auth/auth-options.ts index 8f39e2575e..7e7166f251 100644 --- a/packages/database/auth/auth-options.ts +++ b/packages/database/auth/auth-options.ts @@ -1,7 +1,7 @@ import crypto from "node:crypto"; import { serverEnv } from "@cap/env"; import { Organisation, User } from "@cap/web-domain"; -import { eq } from "drizzle-orm"; +import {eq, and} from "drizzle-orm"; import type { NextAuthOptions } from "next-auth"; import { getServerSession as _getServerSession } from "next-auth"; import type { Adapter } from "next-auth/adapters"; @@ -9,13 +9,15 @@ import EmailProvider from "next-auth/providers/email"; import GoogleProvider from "next-auth/providers/google"; import type { Provider } from "next-auth/providers/index"; import WorkOSProvider from "next-auth/providers/workos"; +import CredentialsProvider from "next-auth/providers/credentials"; import { dub } from "../dub.ts"; import { sendEmail } from "../emails/config.ts"; import { nanoId } from "../helpers.ts"; import { db } from "../index.ts"; -import { organizationMembers, organizations, users } from "../schema.ts"; +import {organizationMembers, organizations, users, verificationTokens} from "../schema.ts"; import { isEmailAllowedForSignup } from "./domain-utils.ts"; import { DrizzleAdapter } from "./drizzle-adapter.ts"; +import { verifyPassword } from "@cap/database/crypto"; export const maxDuration = 120; @@ -105,6 +107,99 @@ export const authOptions = (): NextAuthOptions => { } }, }), + CredentialsProvider({ + name: "Credentials", + credentials: { + email: { label: "Email", type: "text", placeholder: "you@domain.com" }, + password: { label: "Password", type: "password" }, + otp_token: { label: "OTP Token", type: "text" }, + }, + async authorize(credentials) { + try { + if (!credentials?.email) { + throw new Error("Missing email"); + } + + const [user] = await db() + .select() + .from(users) + .where(eq(users.email, credentials.email)) + .limit(1); + + if (!user) throw new Error("We couldn’t find your account. Try signing up!"); + + // If otp_token is provided, verify it instead of password + // This is used for completing signup after OTP verification + if (credentials.otp_token) { + const authTokenIdentifier = `auth-token:${credentials.email}`; + const [tokenRecord] = await db() + .select() + .from(verificationTokens) + .where( + and( + eq( + verificationTokens.identifier, + authTokenIdentifier, + ), + eq(verificationTokens.token, credentials.otp_token), + ), + ) + .limit(1); + + if (!tokenRecord) { + throw new Error("Invalid or expired authentication token"); + } + + if (new Date(tokenRecord.expires) < new Date()) { + throw new Error("Authentication token expired"); + } + + // Require email verification + if (!user.emailVerified) { + throw new Error("Please verify your email before logging in."); + } + + // Delete the one-time token after use + await db() + .delete(verificationTokens) + .where(eq(verificationTokens.identifier, authTokenIdentifier)); + + return { + id: user.id, + name: user.name, + email: user.email, + image: user.image, + }; + } + + // Normal password authentication + if (!credentials?.password || user.password === null) { + throw new Error("Invalid email or password"); + } + // Require email verification before login + //navigation to verify-otp and sending otp handled at client side + if (!user.emailVerified) { + throw new Error("Please verify your email before logging in."); + } + + const isValid = await verifyPassword( + user.password, + credentials.password, + ); + if (!isValid) throw new Error("Invalid email or password"); + + return { + id: user.id, + name: user.name, + email: user.email, + image: user.image, + }; + } catch (err) { + console.error("Credential authorize error:", err); + throw err; + } + }, + }), ]; return _providers; diff --git a/packages/database/auth/drizzle-adapter.ts b/packages/database/auth/drizzle-adapter.ts index 7edaca2ea4..1dbbd23e19 100644 --- a/packages/database/auth/drizzle-adapter.ts +++ b/packages/database/auth/drizzle-adapter.ts @@ -19,6 +19,7 @@ export function DrizzleAdapter(db: MySql2Database): Adapter { return { async createUser(userData: any) { const userId = User.UserId.make(nanoId()); + const hashedPassword = userData.password; await db.transaction(async (tx) => { const [pendingInvite] = await tx .select({ id: organizationInvites.id }) @@ -37,6 +38,7 @@ export function DrizzleAdapter(db: MySql2Database): Adapter { emailVerified: userData.emailVerified, name: userData.name, image: userData.image, + password:hashedPassword, activeOrganizationId: Organisation.OrganisationId.make(""), }); diff --git a/packages/database/schema.ts b/packages/database/schema.ts index 14a2e9685e..0260a2149f 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -66,6 +66,7 @@ export const users = mysqlTable( lastName: varchar("lastName", { length: 255 }), email: varchar("email", { length: 255 }).notNull(), emailVerified: timestamp("emailVerified"), + password: encryptedTextNullable("password"), image: varchar("image", { length: 255 }).$type(), stripeCustomerId: varchar("stripeCustomerId", { length: 255 }), stripeSubscriptionId: varchar("stripeSubscriptionId", {