chore: auth flow cleanup and verification

This commit is contained in:
2026-04-26 01:45:34 +08:00
parent f60b3b3b08
commit 668610e8fa
11 changed files with 79 additions and 86 deletions
+9 -12
View File
@@ -7,7 +7,6 @@ import {
ExternalLinkIcon, ExternalLinkIcon,
Gamepad2Icon, Gamepad2Icon,
MessageSquareIcon, MessageSquareIcon,
MonitorIcon,
SettingsIcon, SettingsIcon,
TrendingUpIcon, TrendingUpIcon,
DatabaseIcon, DatabaseIcon,
@@ -50,13 +49,11 @@ function SearchContent() {
const [results, setResults] = useState<UnifiedResult[]>([]) const [results, setResults] = useState<UnifiedResult[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const isValidQuery = query && query.length >= 2
// Handle direct navigation / browser back-forward // Handle direct navigation / browser back-forward
useEffect(() => { useEffect(() => {
if (!query || query.length < 2) { if (!isValidQuery) return
setResults([])
setError(null)
return
}
let cancelled = false let cancelled = false
@@ -85,7 +82,7 @@ function SearchContent() {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [query]) }, [isValidQuery, query])
function handleClick(result: UnifiedResult) { function handleClick(result: UnifiedResult) {
const path = result.appId const path = result.appId
@@ -114,7 +111,7 @@ function SearchContent() {
: "Enter a game name or AppID to find benchmarks, settings, and reviews."} : "Enter a game name or AppID to find benchmarks, settings, and reviews."}
</motion.p> </motion.p>
{!query && ( {!isValidQuery && (
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
@@ -127,7 +124,7 @@ function SearchContent() {
</motion.div> </motion.div>
)} )}
{query && loading && ( {isValidQuery && loading && (
<div className="flex flex-col items-center justify-center py-20 gap-4"> <div className="flex flex-col items-center justify-center py-20 gap-4">
<motion.div <motion.div
animate={{ rotate: 360 }} animate={{ rotate: 360 }}
@@ -140,7 +137,7 @@ function SearchContent() {
</div> </div>
)} )}
{query && !loading && error && ( {isValidQuery && !loading && error && (
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
@@ -150,7 +147,7 @@ function SearchContent() {
</motion.div> </motion.div>
)} )}
{query && !loading && !error && results.length === 0 && ( {isValidQuery && !loading && !error && results.length === 0 && (
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
@@ -163,7 +160,7 @@ function SearchContent() {
</motion.div> </motion.div>
)} )}
{query && !loading && !error && results.length > 0 && ( {isValidQuery && !loading && !error && results.length > 0 && (
<motion.div <motion.div
className="flex flex-col gap-3" className="flex flex-col gap-3"
initial="hidden" initial="hidden"
+1 -2
View File
@@ -5,7 +5,6 @@ import { Lock, Loader2, ArrowLeft } from "lucide-react"
import { authClient } from "@/lib/auth-client" import { authClient } from "@/lib/auth-client"
import { import {
forgotPasswordSchema, forgotPasswordSchema,
type ForgotPasswordInput,
} from "@/lib/auth/validation" } from "@/lib/auth/validation"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
@@ -51,7 +50,7 @@ export default function ForgotPasswordForm() {
Forgot your password? Forgot your password?
</h1> </h1>
<p className="text-xs text-[#ebe4f1]/50 mt-1 leading-relaxed"> <p className="text-xs text-[#ebe4f1]/50 mt-1 leading-relaxed">
Enter your email and we'll send you a verification code to Enter your email and we&apos;ll send you a verification code to
reset your password. reset your password.
</p> </p>
</div> </div>
+2 -3
View File
@@ -1,12 +1,11 @@
"use client" "use client"
import { useState, useEffect, useCallback } from "react" import { useState, useEffect } from "react"
import { Loader2 } from "lucide-react" import { Loader2 } from "lucide-react"
import { authClient } from "@/lib/auth-client" import { authClient } from "@/lib/auth-client"
import { import {
loginEmailSchema, loginEmailSchema,
loginSchema, loginSchema,
type LoginInput,
} from "@/lib/auth/validation" } from "@/lib/auth/validation"
import SocialButtons from "./social-buttons" import SocialButtons from "./social-buttons"
import Link from "next/link" import Link from "next/link"
@@ -221,7 +220,7 @@ export default function LoginForm() {
)} )}
<p className="text-center text-xs text-[#ebe4f1]/50"> <p className="text-center text-xs text-[#ebe4f1]/50">
Don't have an account?{" "} Don&apos;t have an account?{" "}
<Link <Link
href="/signup" href="/signup"
className="text-[#eb3779] hover:underline" className="text-[#eb3779] hover:underline"
+8 -13
View File
@@ -1,6 +1,6 @@
"use client" "use client"
import { useRef, useState, useCallback, useEffect } from "react" import { useRef, useCallback, useMemo } from "react"
interface OtpInputProps { interface OtpInputProps {
length?: number length?: number
@@ -17,23 +17,18 @@ export default function OtpInput({
disabled = false, disabled = false,
error, error,
}: OtpInputProps) { }: OtpInputProps) {
const [digits, setDigits] = useState<string[]>( const digits = useMemo(
value.split("").concat(Array(length).fill("")).slice(0, length), () =>
value
.split("")
.concat(Array(length).fill(""))
.slice(0, length),
[value, length],
) )
const refs = useRef<(HTMLInputElement | null)[]>([]) const refs = useRef<(HTMLInputElement | null)[]>([])
// Sync with external value
useEffect(() => {
const newDigits = value
.split("")
.concat(Array(length).fill(""))
.slice(0, length)
setDigits(newDigits)
}, [value, length])
const updateDigits = useCallback( const updateDigits = useCallback(
(newDigits: string[]) => { (newDigits: string[]) => {
setDigits(newDigits)
onChange(newDigits.join("")) onChange(newDigits.join(""))
}, },
[onChange], [onChange],
+31 -34
View File
@@ -20,14 +20,11 @@ export default function OtpVerificationStep({
const [error, setError] = useState("") const [error, setError] = useState("")
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [resendTimer, setResendTimer] = useState(300) // 5 minutes const [resendTimer, setResendTimer] = useState(300) // 5 minutes
const [canResend, setCanResend] = useState(false) const canResend = resendTimer <= 0
// Countdown timer // Countdown timer
useEffect(() => { useEffect(() => {
if (resendTimer <= 0) { if (resendTimer <= 0) return
setCanResend(true)
return
}
const interval = setInterval(() => { const interval = setInterval(() => {
setResendTimer((prev) => prev - 1) setResendTimer((prev) => prev - 1)
}, 1000) }, 1000)
@@ -40,37 +37,33 @@ export default function OtpVerificationStep({
return `${mins}:${secs.toString().padStart(2, "0")}` return `${mins}:${secs.toString().padStart(2, "0")}`
} }
const handleVerify = useCallback(async () => { const handleVerify = useCallback(
if (otp.length !== 6) return async (otpValue: string) => {
if (otpValue.length !== 6) return
setIsLoading(true) setIsLoading(true)
setError("") setError("")
const { error } = await authClient.emailOtp.verifyEmail({ const { error } = await authClient.emailOtp.verifyEmail({
email, email,
otp, otp: otpValue,
}) })
setIsLoading(false) setIsLoading(false)
if (error) { if (error) {
setError( setError(
error.code === "TOO_MANY_ATTEMPTS" error.code === "TOO_MANY_ATTEMPTS"
? "Too many attempts. Please request a new code." ? "Too many attempts. Please request a new code."
: "Invalid code. Please try again.", : "Invalid code. Please try again.",
) )
return return
} }
onSuccess() onSuccess()
}, [otp, email, onSuccess]) },
[email, onSuccess],
// Auto-submit when all digits entered )
useEffect(() => {
if (otp.length === 6) {
handleVerify()
}
}, [otp, handleVerify])
const handleResend = async () => { const handleResend = async () => {
setError("") setError("")
@@ -79,7 +72,6 @@ export default function OtpVerificationStep({
type: "email-verification", type: "email-verification",
}) })
setResendTimer(300) setResendTimer(300)
setCanResend(false)
} }
return ( return (
@@ -101,7 +93,12 @@ export default function OtpVerificationStep({
</label> </label>
<OtpInput <OtpInput
value={otp} value={otp}
onChange={setOtp} onChange={(value) => {
setOtp(value)
if (value.length === 6) {
handleVerify(value)
}
}}
disabled={isLoading} disabled={isLoading}
error={error} error={error}
/> />
@@ -126,7 +123,7 @@ export default function OtpVerificationStep({
</div> </div>
<button <button
onClick={handleVerify} onClick={() => handleVerify(otp)}
disabled={isLoading || otp.length !== 6} disabled={isLoading || otp.length !== 6}
className="w-full py-2.5 rounded-lg bg-[#eb3779] text-white text-sm font-semibold hover:bg-[#eb3779]/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2" className="w-full py-2.5 rounded-lg bg-[#eb3779] text-white text-sm font-semibold hover:bg-[#eb3779]/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
> >
+1 -1
View File
@@ -49,7 +49,7 @@ export default function PasskeySetupStep({
Set up a passkey Set up a passkey
</h1> </h1>
<p className="text-xs text-[#ebe4f1]/50 mt-1 leading-relaxed"> <p className="text-xs text-[#ebe4f1]/50 mt-1 leading-relaxed">
Sign in faster with biometrics or your device's security Sign in faster with biometrics or your device&apos;s security
key. No password needed. key. No password needed.
</p> </p>
</div> </div>
+6 -10
View File
@@ -1,6 +1,6 @@
"use client" "use client"
import { useState, useEffect, useCallback } from "react" import { useState, useEffect } from "react"
import { import {
Mail, Mail,
CheckCircle2, CheckCircle2,
@@ -10,7 +10,6 @@ import {
import { authClient } from "@/lib/auth-client" import { authClient } from "@/lib/auth-client"
import { import {
resetPasswordSchema, resetPasswordSchema,
type ResetPasswordInput,
} from "@/lib/auth/validation" } from "@/lib/auth/validation"
import OtpInput from "./otp-input" import OtpInput from "./otp-input"
import PasswordStrengthMeter from "./password-strength" import PasswordStrengthMeter from "./password-strength"
@@ -30,14 +29,11 @@ export default function ResetPasswordForm({
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [isSuccess, setIsSuccess] = useState(false) const [isSuccess, setIsSuccess] = useState(false)
const [resendTimer, setResendTimer] = useState(300) const [resendTimer, setResendTimer] = useState(300)
const [canResend, setCanResend] = useState(false) const canResend = resendTimer <= 0
// Countdown timer // Countdown timer
useEffect(() => { useEffect(() => {
if (resendTimer <= 0) { if (resendTimer <= 0) return
setCanResend(true)
return
}
const interval = setInterval(() => { const interval = setInterval(() => {
setResendTimer((prev) => prev - 1) setResendTimer((prev) => prev - 1)
}, 1000) }, 1000)
@@ -65,9 +61,10 @@ export default function ResetPasswordForm({
} }
setIsLoading(true) setIsLoading(true)
const { error } = await authClient.resetPassword({ const { error } = await authClient.emailOtp.resetPassword({
newPassword, email,
otp, otp,
password: newPassword,
}) })
setIsLoading(false) setIsLoading(false)
@@ -87,7 +84,6 @@ export default function ResetPasswordForm({
setError("") setError("")
await authClient.emailOtp.requestPasswordReset({ email }) await authClient.emailOtp.requestPasswordReset({ email })
setResendTimer(300) setResendTimer(300)
setCanResend(false)
} }
if (isSuccess) { if (isSuccess) {
+2 -2
View File
@@ -3,7 +3,7 @@
import { useState } from "react" import { useState } from "react"
import { Loader2 } from "lucide-react" import { Loader2 } from "lucide-react"
import { authClient } from "@/lib/auth-client" import { authClient } from "@/lib/auth-client"
import { signupSchema, type SignupInput } from "@/lib/auth/validation" import { signupSchema } from "@/lib/auth/validation"
import SocialButtons from "./social-buttons" import SocialButtons from "./social-buttons"
import PasswordStrengthMeter from "./password-strength" import PasswordStrengthMeter from "./password-strength"
import Link from "next/link" import Link from "next/link"
@@ -61,7 +61,7 @@ export default function SignupFormStep({ onSuccess }: SignupFormStepProps) {
Create your account Create your account
</h1> </h1>
<p className="text-xs text-[#ebe4f1]/50 mt-1"> <p className="text-xs text-[#ebe4f1]/50 mt-1">
Choose how you'd like to sign up Choose how you&apos;d like to sign up
</p> </p>
</div> </div>
+17 -7
View File
@@ -9,7 +9,7 @@ import { CircleXIcon, Gamepad2Icon, MenuIcon, XIcon } from "lucide-react"
import { routes } from "@/lib/routes" import { routes } from "@/lib/routes"
import { usePathname, useRouter, useSearchParams } from "next/navigation" import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useDebounce } from "@/lib/hooks/useDebounce" import { useDebounce } from "@/lib/hooks/useDebounce"
import { authClient } from "@/lib/auth-client" import { authClient, useSession } from "@/lib/auth-client"
import { LogOut, User } from "lucide-react" import { LogOut, User } from "lucide-react"
export default function Navbar() { export default function Navbar() {
@@ -27,13 +27,14 @@ export default function Navbar() {
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const { data: session, isPending: isSessionLoading } = const { data: session, isPending: isSessionLoading } =
authClient.useSession() useSession()
const [userMenuOpen, setUserMenuOpen] = useState(false) const [userMenuOpen, setUserMenuOpen] = useState(false)
// Sync search query with URL ?q= param // Sync search query with URL ?q= param
useEffect(() => { useEffect(() => {
const q = searchParams.get("q") || "" const q = searchParams.get("q") || ""
setSearchQuery(q) const timeout = setTimeout(() => setSearchQuery(q), 0)
return () => clearTimeout(timeout)
}, [searchParams]) }, [searchParams])
// Update URL when debounced query changes (skip if already matches) // Update URL when debounced query changes (skip if already matches)
@@ -61,13 +62,16 @@ export default function Navbar() {
sessionStorage.getItem("focusSearch") === "true" sessionStorage.getItem("focusSearch") === "true"
) { ) {
sessionStorage.removeItem("focusSearch") sessionStorage.removeItem("focusSearch")
setForceFocusStyles(true) const focusTimeout = setTimeout(() => setForceFocusStyles(true), 0)
// Focus the input after the layout animation element mounts // Focus the input after the layout animation element mounts
requestAnimationFrame(() => { requestAnimationFrame(() => {
inputRef.current?.focus() inputRef.current?.focus()
}) })
const timer = setTimeout(() => setForceFocusStyles(false), 450) const timer = setTimeout(() => setForceFocusStyles(false), 450)
return () => clearTimeout(timer) return () => {
clearTimeout(focusTimeout)
clearTimeout(timer)
}
} }
}, [isLanding, searchQuery]) }, [isLanding, searchQuery])
@@ -200,9 +204,12 @@ export default function Navbar() {
className='flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-white/[0.05] transition-colors' className='flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-white/[0.05] transition-colors'
> >
{session.user.image ? ( {session.user.image ? (
<img <Image
src={session.user.image} src={session.user.image}
alt='' alt=''
width={28}
height={28}
unoptimized
className='h-7 w-7 rounded-full' className='h-7 w-7 rounded-full'
/> />
) : ( ) : (
@@ -320,9 +327,12 @@ export default function Navbar() {
<div className='space-y-3'> <div className='space-y-3'>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
{session.user.image ? ( {session.user.image ? (
<img <Image
src={session.user.image} src={session.user.image}
alt='' alt=''
width={32}
height={32}
unoptimized
className='h-8 w-8 rounded-full' className='h-8 w-8 rounded-full'
/> />
) : ( ) : (
+1 -1
View File
@@ -53,7 +53,7 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
) )
// Fetch platform support + anti-cheat for local games // Fetch platform support + anti-cheat for local games
let platformSupportMap = new Map< const platformSupportMap = new Map<
string, string,
{ {
isSupported: boolean isSupported: boolean
+1 -1
View File
@@ -1,4 +1,4 @@
import { createAuthClient } from 'better-auth/client' import { createAuthClient } from 'better-auth/react'
import { adminClient, emailOTPClient, lastLoginMethodClient } from 'better-auth/client/plugins' import { adminClient, emailOTPClient, lastLoginMethodClient } from 'better-auth/client/plugins'
import { passkeyClient } from '@better-auth/passkey/client' import { passkeyClient } from '@better-auth/passkey/client'