Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 29 additions & 5 deletions app/api/auth/magic-link/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { loginRateLimit } from '@/lib/utils/rate-limiter'

export const maxDuration = 45
const MAGIC_LINK_COOLDOWN_SECONDS = 60

function isValidEmail(email: string) {
const atIndex = email.lastIndexOf('@')
Expand Down Expand Up @@ -39,20 +40,31 @@ function normalizeSupabaseAuthError(error: { message?: string; status?: number }
) {
return {
status: 429,
message: 'A magic link was requested recently. Please wait 60 seconds before requesting another one.',
message: `A magic link was requested recently. Please wait ${MAGIC_LINK_COOLDOWN_SECONDS} seconds before requesting another one.`,
retryAfter: MAGIC_LINK_COOLDOWN_SECONDS,
}
}

if (lowerMessage.includes('invalid') && lowerMessage.includes('email')) {
return {
status: 400,
message: 'Please enter a valid email address.',
retryAfter: 0,
}
}

if (lowerMessage.includes('redirect')) {
return {
status: 500,
message: 'Magic link redirect is not configured correctly. Please contact support.',
retryAfter: 0,
}
}

return {
status: 502,
message: 'Unable to send magic link right now. Please try again in a moment.',
status: 503,
message: 'Email delivery is temporarily unavailable. Please try again in a moment.',
retryAfter: MAGIC_LINK_COOLDOWN_SECONDS,
}
}

Expand All @@ -66,6 +78,10 @@ export async function POST(request: NextRequest) {
return NextResponse.json(
{
error: 'Too many magic link requests. Please try again later.',
retryAfter: Math.max(
MAGIC_LINK_COOLDOWN_SECONDS,
Math.ceil(((rateLimitResult.lockedUntil || rateLimitResult.resetTime) - Date.now()) / 1000)
),
lockedUntil: rateLimitResult.lockedUntil,
resetTime: rateLimitResult.resetTime,
},
Expand Down Expand Up @@ -118,8 +134,16 @@ export async function POST(request: NextRequest) {
durationMs: Date.now() - startedAt,
})
return NextResponse.json(
{ error: normalizedError.message },
{ status: normalizedError.status }
{
error: normalizedError.message,
retryAfter: normalizedError.retryAfter,
},
{
status: normalizedError.status,
headers: normalizedError.retryAfter
? { 'Retry-After': String(normalizedError.retryAfter) }
: undefined,
}
)
}

Expand Down
25 changes: 24 additions & 1 deletion app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const loginHighlights = [
]

const workspaceItems = ["Project planning", "Collaborator review", "Security settings"]
const MAGIC_LINK_COOLDOWN_SECONDS = 60

function AuthBrand() {
return (
Expand All @@ -56,6 +57,7 @@ function LoginPageContent() {
const [success, setSuccess] = useState("")
const [isLoading, setIsLoading] = useState(false)
const [isCheckingAuth, setIsCheckingAuth] = useState(true)
const [cooldownSeconds, setCooldownSeconds] = useState(0)
const [t, setT] = useState(getTranslations("en"))

const checkAuth = useCallback(async () => {
Expand All @@ -80,11 +82,26 @@ function LoginPageContent() {
void checkAuth()
}, [checkAuth])

useEffect(() => {
if (cooldownSeconds <= 0) return

const timer = window.setInterval(() => {
setCooldownSeconds((current) => Math.max(0, current - 1))
}, 1000)

return () => window.clearInterval(timer)
}, [cooldownSeconds])

const handleLogin = async (event: React.FormEvent) => {
event.preventDefault()
setError("")
setSuccess("")

if (cooldownSeconds > 0) {
setError(`Please wait ${cooldownSeconds}s before requesting another magic link.`)
return
}

if (!email) {
setError(`${t.auth?.email || "Email"} is required`)
return
Expand All @@ -102,8 +119,12 @@ function LoginPageContent() {
const result = await signInWithOtp(email, rememberMe)

if (result.success) {
setCooldownSeconds(MAGIC_LINK_COOLDOWN_SECONDS)
setSuccess(result.message || "Check your email for the magic link to sign in.")
} else {
if (result.retryAfter) {
setCooldownSeconds(result.retryAfter)
}
setError(result.error || "Login failed")
}
} catch (err: unknown) {
Expand Down Expand Up @@ -256,14 +277,16 @@ function LoginPageContent() {

<Button
type="submit"
disabled={isLoading || !!success}
disabled={isLoading || !!success || cooldownSeconds > 0}
className="h-12 w-full rounded-lg text-sm font-semibold"
>
{isLoading ? (
<>
<ArrowPathIcon className="h-5 w-5 animate-spin" />
Sending...
</>
) : cooldownSeconds > 0 ? (
<>Wait {cooldownSeconds}s</>
) : (
<>
Send Magic Link
Expand Down
25 changes: 24 additions & 1 deletion app/signup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const signupHighlights = [
]

const previewItems = ["Plan", "Build", "Review"]
const MAGIC_LINK_COOLDOWN_SECONDS = 60

function AuthBrand() {
return (
Expand All @@ -60,6 +61,7 @@ function SignUpPageContent() {
const [isLoading, setIsLoading] = useState(false)
const [isCheckingAuth, setIsCheckingAuth] = useState(true)
const [usePassword, setUsePassword] = useState(false)
const [cooldownSeconds, setCooldownSeconds] = useState(0)
const [t, setT] = useState(getTranslations("en"))

const checkAuth = useCallback(async () => {
Expand All @@ -84,11 +86,26 @@ function SignUpPageContent() {
void checkAuth()
}, [checkAuth])

useEffect(() => {
if (cooldownSeconds <= 0) return

const timer = window.setInterval(() => {
setCooldownSeconds((current) => Math.max(0, current - 1))
}, 1000)

return () => window.clearInterval(timer)
}, [cooldownSeconds])

const handleSignUp = async (event: React.FormEvent) => {
event.preventDefault()
setError("")
setSuccess("")

if (!usePassword && cooldownSeconds > 0) {
setError(`Please wait ${cooldownSeconds}s before requesting another magic link.`)
return
}

if (!email) {
setError("Email is required")
return
Expand Down Expand Up @@ -130,8 +147,12 @@ function SignUpPageContent() {
const result = await signInWithOtp(email, true)

if (result.success) {
setCooldownSeconds(MAGIC_LINK_COOLDOWN_SECONDS)
setSuccess(result.message || "Check your email for the magic link to complete registration.")
} else {
if (result.retryAfter) {
setCooldownSeconds(result.retryAfter)
}
setError(result.error || "Sign up failed")
}
}
Expand Down Expand Up @@ -359,14 +380,16 @@ function SignUpPageContent() {

<Button
type="submit"
disabled={isLoading || !!success}
disabled={isLoading || !!success || (!usePassword && cooldownSeconds > 0)}
className="h-12 w-full rounded-lg text-sm font-semibold"
>
{isLoading ? (
<>
<ArrowPathIcon className="h-5 w-5 animate-spin" />
{usePassword ? "Creating..." : "Sending..."}
</>
) : !usePassword && cooldownSeconds > 0 ? (
<>Wait {cooldownSeconds}s</>
) : (
<>
{usePassword ? "Create Account" : "Send Magic Link"}
Expand Down
4 changes: 3 additions & 1 deletion lib/features/auth/auth-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ export async function signIn(
export async function signInWithOtp(
email: string,
rememberMe = true,
): Promise<{ success: boolean; error?: string; message?: string }> {
): Promise<{ success: boolean; error?: string; message?: string; retryAfter?: number }> {
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), 45000)

Expand All @@ -257,12 +257,14 @@ export async function signInWithOtp(
success?: boolean
error?: string
message?: string
retryAfter?: number
} | null

if (!response.ok || !result?.success) {
return {
success: false,
error: result?.error || 'Failed to send login link',
retryAfter: typeof result?.retryAfter === 'number' ? result.retryAfter : undefined,
}
}

Expand Down
Loading