feat: replace Google OAuth with email/password authentication

Replace Google OAuth provider with email/password authentication to reduce
friction for MVP development and vibe coding workflows.

Changes:
- Remove Google OAuth configuration from auth.ts
- Add emailAndPassword provider with enabled: true
- Add email verification with sendOnSignUp: true
- Add password reset functionality
- Log verification and reset URLs to terminal (no email integration yet)

New auth pages (src/app/(auth)/):
- /login - Sign in page
- /register - Sign up page
- /forgot-password - Password reset request
- /reset-password - Password reset completion

New components (src/components/auth/):
- sign-up-form.tsx - Registration form
- forgot-password-form.tsx - Password reset request form
- reset-password-form.tsx - Password reset form

Updated components:
- sign-in-button.tsx - Now email/password form instead of Google button
- user-profile.tsx - Shows Sign in/Sign up buttons when logged out

Bug fixes:
- Fix React render error in profile page by wrapping router.push in useEffect

Config updates:
- Remove GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET from env.example
- Update CLAUDE.md documentation to reflect email/password auth
- Add requestPasswordReset, resetPassword, sendVerificationEmail to auth-client exports

All changes applied to both main project and create-agentic-app template.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Auto
2025-12-26 05:50:43 +02:00
parent 5cd66b245e
commit f29d296816
45 changed files with 2231 additions and 91 deletions

View File

@@ -0,0 +1,35 @@
import { headers } from "next/headers"
import { redirect } from "next/navigation"
import { ForgotPasswordForm } from "@/components/auth/forgot-password-form"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { auth } from "@/lib/auth"
export default async function ForgotPasswordPage() {
const session = await auth.api.getSession({ headers: await headers() })
if (session) {
redirect("/dashboard")
}
return (
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle>Forgot password</CardTitle>
<CardDescription>
Enter your email address and we&apos;ll send you a reset link
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center">
<ForgotPasswordForm />
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,7 @@
export default function AuthLayout({
children,
}: {
children: React.ReactNode
}) {
return <>{children}</>
}

View File

@@ -0,0 +1,44 @@
import { headers } from "next/headers"
import { redirect } from "next/navigation"
import { SignInButton } from "@/components/auth/sign-in-button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { auth } from "@/lib/auth"
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ reset?: string }>
}) {
const session = await auth.api.getSession({ headers: await headers() })
if (session) {
redirect("/dashboard")
}
const { reset } = await searchParams
return (
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle>Welcome back</CardTitle>
<CardDescription>Sign in to your account</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center">
{reset === "success" && (
<p className="mb-4 text-sm text-green-600 dark:text-green-400">
Password reset successfully. Please sign in with your new password.
</p>
)}
<SignInButton />
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,33 @@
import { headers } from "next/headers"
import { redirect } from "next/navigation"
import { SignUpForm } from "@/components/auth/sign-up-form"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { auth } from "@/lib/auth"
export default async function RegisterPage() {
const session = await auth.api.getSession({ headers: await headers() })
if (session) {
redirect("/dashboard")
}
return (
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle>Create an account</CardTitle>
<CardDescription>Get started with your new account</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center">
<SignUpForm />
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,36 @@
import { Suspense } from "react"
import { headers } from "next/headers"
import { redirect } from "next/navigation"
import { ResetPasswordForm } from "@/components/auth/reset-password-form"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { auth } from "@/lib/auth"
export default async function ResetPasswordPage() {
const session = await auth.api.getSession({ headers: await headers() })
if (session) {
redirect("/dashboard")
}
return (
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle>Reset password</CardTitle>
<CardDescription>Enter your new password below</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center">
<Suspense fallback={<div>Loading...</div>}>
<ResetPasswordForm />
</Suspense>
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Mail, Calendar, User, Shield, ArrowLeft, Lock, Smartphone } from "lucide-react";
import { toast } from "sonner";
@@ -33,7 +33,13 @@ export default function ProfilePage() {
const [securityOpen, setSecurityOpen] = useState(false);
const [emailPrefsOpen, setEmailPrefsOpen] = useState(false);
if (isPending) {
useEffect(() => {
if (!isPending && !session) {
router.push("/");
}
}, [isPending, session, router]);
if (isPending || !session) {
return (
<div className="flex items-center justify-center min-h-screen">
<div>Loading...</div>
@@ -41,11 +47,6 @@ export default function ProfilePage() {
);
}
if (!session) {
router.push("/");
return null;
}
const user = session.user;
const createdDate = user.createdAt
? new Date(user.createdAt).toLocaleDateString("en-US", {

View File

@@ -0,0 +1,83 @@
"use client"
import { useState } from "react"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { requestPasswordReset } from "@/lib/auth-client"
export function ForgotPasswordForm() {
const [email, setEmail] = useState("")
const [error, setError] = useState("")
const [success, setSuccess] = useState(false)
const [isPending, setIsPending] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
setIsPending(true)
try {
const result = await requestPasswordReset({
email,
redirectTo: "/reset-password",
})
if (result.error) {
setError(result.error.message || "Failed to send reset email")
} else {
setSuccess(true)
}
} catch {
setError("An unexpected error occurred")
} finally {
setIsPending(false)
}
}
if (success) {
return (
<div className="space-y-4 w-full max-w-sm text-center">
<p className="text-sm text-muted-foreground">
If an account exists with that email, a password reset link has been sent.
Check your terminal for the reset URL.
</p>
<Link href="/login">
<Button variant="outline" className="w-full">
Back to sign in
</Button>
</Link>
</div>
)
}
return (
<form onSubmit={handleSubmit} className="space-y-4 w-full max-w-sm">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isPending}
/>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<Button type="submit" className="w-full" disabled={isPending}>
{isPending ? "Sending..." : "Send reset link"}
</Button>
<div className="text-center text-sm text-muted-foreground">
Remember your password?{" "}
<Link href="/login" className="text-primary hover:underline">
Sign in
</Link>
</div>
</form>
)
}

View File

@@ -0,0 +1,107 @@
"use client"
import { useState } from "react"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { resetPassword } from "@/lib/auth-client"
export function ResetPasswordForm() {
const router = useRouter()
const searchParams = useSearchParams()
const token = searchParams.get("token")
const error = searchParams.get("error")
const [password, setPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [formError, setFormError] = useState("")
const [isPending, setIsPending] = useState(false)
if (error === "invalid_token" || !token) {
return (
<div className="space-y-4 w-full max-w-sm text-center">
<p className="text-sm text-destructive">
{error === "invalid_token"
? "This password reset link is invalid or has expired."
: "No reset token provided."}
</p>
<Link href="/forgot-password">
<Button variant="outline" className="w-full">
Request a new link
</Button>
</Link>
</div>
)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setFormError("")
if (password !== confirmPassword) {
setFormError("Passwords do not match")
return
}
if (password.length < 8) {
setFormError("Password must be at least 8 characters")
return
}
setIsPending(true)
try {
const result = await resetPassword({
newPassword: password,
token,
})
if (result.error) {
setFormError(result.error.message || "Failed to reset password")
} else {
router.push("/login?reset=success")
}
} catch {
setFormError("An unexpected error occurred")
} finally {
setIsPending(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4 w-full max-w-sm">
<div className="space-y-2">
<Label htmlFor="password">New Password</Label>
<Input
id="password"
type="password"
placeholder="Enter new password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isPending}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm New Password</Label>
<Input
id="confirmPassword"
type="password"
placeholder="Confirm new password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
disabled={isPending}
/>
</div>
{formError && (
<p className="text-sm text-destructive">{formError}</p>
)}
<Button type="submit" className="w-full" disabled={isPending}>
{isPending ? "Resetting..." : "Reset password"}
</Button>
</form>
)
}

View File

@@ -1,29 +1,97 @@
"use client";
"use client"
import { Button } from "@/components/ui/button";
import { signIn, useSession } from "@/lib/auth-client";
import { useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { signIn, useSession } from "@/lib/auth-client"
export function SignInButton() {
const { data: session, isPending } = useSession();
const { data: session, isPending: sessionPending } = useSession()
const router = useRouter()
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [error, setError] = useState("")
const [isPending, setIsPending] = useState(false)
if (isPending) {
return <Button disabled>Loading...</Button>;
if (sessionPending) {
return <Button disabled>Loading...</Button>
}
if (session) {
return null;
return null
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
setIsPending(true)
try {
const result = await signIn.email({
email,
password,
callbackURL: "/dashboard",
})
if (result.error) {
setError(result.error.message || "Failed to sign in")
} else {
router.push("/dashboard")
router.refresh()
}
} catch {
setError("An unexpected error occurred")
} finally {
setIsPending(false)
}
}
return (
<Button
onClick={async () => {
await signIn.social({
provider: "google",
callbackURL: "/dashboard",
});
}}
>
Sign in
</Button>
);
<form onSubmit={handleSubmit} className="space-y-4 w-full max-w-sm">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isPending}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="Your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isPending}
/>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<Button type="submit" className="w-full" disabled={isPending}>
{isPending ? "Signing in..." : "Sign in"}
</Button>
<div className="text-center text-sm text-muted-foreground">
<Link href="/forgot-password" className="hover:underline">
Forgot password?
</Link>
</div>
<div className="text-center text-sm text-muted-foreground">
Don&apos;t have an account?{" "}
<Link href="/register" className="text-primary hover:underline">
Sign up
</Link>
</div>
</form>
)
}

View File

@@ -0,0 +1,121 @@
"use client"
import { useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { signUp } from "@/lib/auth-client"
export function SignUpForm() {
const router = useRouter()
const [name, setName] = useState("")
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [error, setError] = useState("")
const [isPending, setIsPending] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
if (password !== confirmPassword) {
setError("Passwords do not match")
return
}
if (password.length < 8) {
setError("Password must be at least 8 characters")
return
}
setIsPending(true)
try {
const result = await signUp.email({
name,
email,
password,
callbackURL: "/dashboard",
})
if (result.error) {
setError(result.error.message || "Failed to create account")
} else {
router.push("/dashboard")
router.refresh()
}
} catch {
setError("An unexpected error occurred")
} finally {
setIsPending(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4 w-full max-w-sm">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
type="text"
placeholder="Your name"
value={name}
onChange={(e) => setName(e.target.value)}
required
disabled={isPending}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isPending}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="Create a password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isPending}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
id="confirmPassword"
type="password"
placeholder="Confirm your password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
disabled={isPending}
/>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<Button type="submit" className="w-full" disabled={isPending}>
{isPending ? "Creating account..." : "Create account"}
</Button>
<div className="text-center text-sm text-muted-foreground">
Already have an account?{" "}
<Link href="/login" className="text-primary hover:underline">
Sign in
</Link>
</div>
</form>
)
}

View File

@@ -4,6 +4,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { User, LogOut } from "lucide-react";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
@@ -13,7 +14,6 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useSession, signOut } from "@/lib/auth-client";
import { SignInButton } from "./sign-in-button";
export function UserProfile() {
const { data: session, isPending } = useSession();
@@ -25,8 +25,15 @@ export function UserProfile() {
if (!session) {
return (
<div className="flex flex-col items-center gap-4 p-6">
<SignInButton />
<div className="flex items-center gap-2">
<Link href="/login">
<Button variant="ghost" size="sm">
Sign in
</Button>
</Link>
<Link href="/register">
<Button size="sm">Sign up</Button>
</Link>
</div>
);
}

View File

@@ -10,4 +10,7 @@ export const {
signUp,
useSession,
getSession,
requestPasswordReset,
resetPassword,
sendVerificationEmail,
} = authClient

View File

@@ -6,10 +6,20 @@ export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
}),
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID as string,
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
emailAndPassword: {
enabled: true,
sendResetPassword: async ({ user, url }) => {
// Log password reset URL to terminal (no email integration yet)
// eslint-disable-next-line no-console
console.log(`\n${"=".repeat(60)}\nPASSWORD RESET REQUEST\nUser: ${user.email}\nReset URL: ${url}\n${"=".repeat(60)}\n`)
},
},
emailVerification: {
sendOnSignUp: true,
sendVerificationEmail: async ({ user, url }) => {
// Log verification URL to terminal (no email integration yet)
// eslint-disable-next-line no-console
console.log(`\n${"=".repeat(60)}\nEMAIL VERIFICATION\nUser: ${user.email}\nVerification URL: ${url}\n${"=".repeat(60)}\n`)
},
},
})