Merge pull request #326 from MODSetter/dev

fix(ui): shows relevant messages and toasts for auth/login
This commit is contained in:
Rohan Verma 2025-09-18 17:53:44 -07:00 committed by GitHub
commit 8bbc8dba4e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 604 additions and 42 deletions

View file

@ -157,7 +157,7 @@ async def create_documents_file_upload(
@router.get("/documents/", response_model=list[DocumentRead]) @router.get("/documents/", response_model=list[DocumentRead])
async def read_documents( async def read_documents(
skip: int = 0, skip: int = 0,
limit: int = 3000, limit: int = 300,
search_space_id: int | None = None, search_space_id: int | None = None,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), user: User = Depends(current_active_user),

View file

@ -1,12 +1,16 @@
"use client"; "use client";
import { AnimatePresence, motion } from "framer-motion";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "sonner";
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
export function LocalLoginForm() { export function LocalLoginForm() {
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState<string | null>(null);
const [errorTitle, setErrorTitle] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [authType, setAuthType] = useState<string | null>(null); const [authType, setAuthType] = useState<string | null>(null);
const router = useRouter(); const router = useRouter();
@ -19,7 +23,11 @@ export function LocalLoginForm() {
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setIsLoading(true); setIsLoading(true);
setError(""); setError(null); // Clear any previous errors
setErrorTitle(null);
// Show loading toast
const loadingToast = toast.loading("Signing you in...");
try { try {
// Create form data for the API request // Create form data for the API request
@ -42,13 +50,53 @@ export function LocalLoginForm() {
const data = await response.json(); const data = await response.json();
if (!response.ok) { if (!response.ok) {
throw new Error(data.detail || "Failed to login"); throw new Error(data.detail || `HTTP ${response.status}`);
} }
router.push(`/auth/callback?token=${data.access_token}`); // Success toast
toast.success("Login successful!", {
id: loadingToast,
description: "Redirecting to dashboard...",
duration: 2000,
});
// Small delay to show success message
setTimeout(() => {
router.push(`/auth/callback?token=${data.access_token}`);
}, 500);
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : "An error occurred during login"; // Use auth-errors utility to get proper error details
setError(errorMessage); let errorCode = "UNKNOWN_ERROR";
if (err instanceof Error) {
errorCode = err.message;
} else if (isNetworkError(err)) {
errorCode = "NETWORK_ERROR";
}
// Get detailed error information from auth-errors utility
const errorDetails = getAuthErrorDetails(errorCode);
// Set persistent error display
setErrorTitle(errorDetails.title);
setError(errorDetails.description);
// Show error toast with conditional retry action
const toastOptions: any = {
id: loadingToast,
description: errorDetails.description,
duration: 6000,
};
// Add retry action if the error is retryable
if (shouldRetry(errorCode)) {
toastOptions.action = {
label: "Retry",
onClick: () => handleSubmit(e),
};
}
toast.error(errorDetails.title, toastOptions);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -57,11 +105,67 @@ export function LocalLoginForm() {
return ( return (
<div className="w-full max-w-md"> <div className="w-full max-w-md">
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{error && ( {/* Error Display */}
<div className="rounded-md bg-red-50 p-4 text-sm text-red-500 dark:bg-red-900/20 dark:text-red-200"> <AnimatePresence>
{error} {error && errorTitle && (
</div> <motion.div
)} initial={{ opacity: 0, y: -10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10, scale: 0.95 }}
transition={{ duration: 0.3 }}
className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-900 shadow-sm dark:border-red-900/30 dark:bg-red-900/20 dark:text-red-200"
>
<div className="flex items-start gap-3">
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="flex-shrink-0 mt-0.5 text-red-500 dark:text-red-400"
>
<title>Error Icon</title>
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold mb-1">{errorTitle}</p>
<p className="text-sm text-red-700 dark:text-red-300">{error}</p>
</div>
<button
onClick={() => {
setError(null);
setErrorTitle(null);
}}
className="flex-shrink-0 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200 transition-colors"
aria-label="Dismiss error"
type="button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<title>Close</title>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</motion.div>
)}
</AnimatePresence>
<div> <div>
<label <label
@ -76,7 +180,12 @@ export function LocalLoginForm() {
required required
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white" className={`mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${
error
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
}`}
disabled={isLoading}
/> />
</div> </div>
@ -93,14 +202,19 @@ export function LocalLoginForm() {
required required
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white" className={`mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${
error
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
}`}
disabled={isLoading}
/> />
</div> </div>
<button <button
type="submit" type="submit"
disabled={isLoading} disabled={isLoading}
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" className="w-full rounded-md bg-blue-600 px-4 py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
> >
{isLoading ? "Signing in..." : "Sign in"} {isLoading ? "Signing in..." : "Sign in"}
</button> </button>

View file

@ -1,23 +1,82 @@
"use client"; "use client";
import { AnimatePresence, motion } from "framer-motion";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { Suspense, useEffect, useState } from "react"; import { Suspense, useEffect, useState } from "react";
import { toast } from "sonner";
import { Logo } from "@/components/Logo"; import { Logo } from "@/components/Logo";
import { getAuthErrorDetails, shouldRetry } from "@/lib/auth-errors";
import { AmbientBackground } from "./AmbientBackground"; import { AmbientBackground } from "./AmbientBackground";
import { GoogleLoginButton } from "./GoogleLoginButton"; import { GoogleLoginButton } from "./GoogleLoginButton";
import { LocalLoginForm } from "./LocalLoginForm"; import { LocalLoginForm } from "./LocalLoginForm";
function LoginContent() { function LoginContent() {
const [authType, setAuthType] = useState<string | null>(null); const [authType, setAuthType] = useState<string | null>(null);
const [registrationSuccess, setRegistrationSuccess] = useState(false);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [urlError, setUrlError] = useState<{ title: string; message: string } | null>(null);
const searchParams = useSearchParams(); const searchParams = useSearchParams();
useEffect(() => { useEffect(() => {
// Check if the user was redirected from registration // Check for various URL parameters that might indicate success or error states
if (searchParams.get("registered") === "true") { const registered = searchParams.get("registered");
setRegistrationSuccess(true); const error = searchParams.get("error");
const message = searchParams.get("message");
const logout = searchParams.get("logout");
// Show registration success message
if (registered === "true") {
toast.success("Registration successful!", {
description: "You can now sign in with your credentials",
duration: 5000,
});
}
// Show logout confirmation
if (logout === "true") {
toast.success("Logged out successfully", {
description: "You have been securely logged out",
duration: 3000,
});
}
// Show error messages from OAuth or other flows using auth-errors utility
if (error) {
// Use the auth-errors utility to get proper error details
const errorDetails = getAuthErrorDetails(error);
// If we have a custom message from URL params, use it as description
const errorDescription = message ? decodeURIComponent(message) : errorDetails.description;
// Set persistent error display
setUrlError({
title: errorDetails.title,
message: errorDescription,
});
// Show toast with conditional retry action
const toastOptions: any = {
description: errorDescription,
duration: 6000,
};
// Add retry action if the error is retryable
if (shouldRetry(error)) {
toastOptions.action = {
label: "Retry",
onClick: () => window.location.reload(),
};
}
toast.error(errorDetails.title, toastOptions);
}
// Show general messages
if (message && !error && !registered && !logout) {
toast.info("Notice", {
description: decodeURIComponent(message),
duration: 4000,
});
} }
// Get the auth type from environment variables // Get the auth type from environment variables
@ -54,11 +113,64 @@ function LoginContent() {
Sign In Sign In
</h1> </h1>
{registrationSuccess && ( {/* URL Error Display */}
<div className="mb-4 w-full rounded-md bg-green-50 p-4 text-sm text-green-500 dark:bg-green-900/20 dark:text-green-200"> <AnimatePresence>
Registration successful! You can now sign in with your credentials. {urlError && (
</div> <motion.div
)} initial={{ opacity: 0, y: -10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10, scale: 0.95 }}
transition={{ duration: 0.3 }}
className="mb-6 w-full max-w-md rounded-lg border border-red-200 bg-red-50 p-4 text-red-900 shadow-sm dark:border-red-900/30 dark:bg-red-900/20 dark:text-red-200"
>
<div className="flex items-start gap-3">
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="flex-shrink-0 mt-0.5 text-red-500 dark:text-red-400"
>
<title>Error Icon</title>
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold mb-1">{urlError.title}</p>
<p className="text-sm text-red-700 dark:text-red-300">{urlError.message}</p>
</div>
<button
type="button"
onClick={() => setUrlError(null)}
className="flex-shrink-0 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200 transition-colors"
aria-label="Dismiss error"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<title>Close</title>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</motion.div>
)}
</AnimatePresence>
<LocalLoginForm /> <LocalLoginForm />
</div> </div>

View file

@ -1,16 +1,20 @@
"use client"; "use client";
import { AnimatePresence, motion } from "framer-motion";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Logo } from "@/components/Logo"; import { Logo } from "@/components/Logo";
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
import { AmbientBackground } from "../login/AmbientBackground"; import { AmbientBackground } from "../login/AmbientBackground";
export default function RegisterPage() { export default function RegisterPage() {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState<string | null>(null);
const [errorTitle, setErrorTitle] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const router = useRouter(); const router = useRouter();
@ -28,11 +32,20 @@ export default function RegisterPage() {
// Form validation // Form validation
if (password !== confirmPassword) { if (password !== confirmPassword) {
setError("Passwords do not match"); setError("Passwords do not match");
setErrorTitle("Password Mismatch");
toast.error("Password Mismatch", {
description: "The passwords you entered do not match",
duration: 4000,
});
return; return;
} }
setIsLoading(true); setIsLoading(true);
setError(""); setError(null); // Clear any previous errors
setErrorTitle(null);
// Show loading toast
const loadingToast = toast.loading("Creating your account...");
try { try {
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/register`, { const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/register`, {
@ -52,15 +65,53 @@ export default function RegisterPage() {
const data = await response.json(); const data = await response.json();
if (!response.ok) { if (!response.ok) {
throw new Error(data.detail || "Registration failed"); throw new Error(data.detail || `HTTP ${response.status}`);
} }
// Redirect to login page after successful registration // Success toast
router.push("/login?registered=true"); toast.success("Account created successfully!", {
} catch (err: unknown) { id: loadingToast,
const errorMessage = description: "Redirecting to login page...",
err instanceof Error ? err.message : "An error occurred during registration"; duration: 2000,
setError(errorMessage); });
// Small delay to show success message
setTimeout(() => {
router.push("/login?registered=true");
}, 500);
} catch (err) {
// Use auth-errors utility to get proper error details
let errorCode = "UNKNOWN_ERROR";
if (err instanceof Error) {
errorCode = err.message;
} else if (isNetworkError(err)) {
errorCode = "NETWORK_ERROR";
}
// Get detailed error information from auth-errors utility
const errorDetails = getAuthErrorDetails(errorCode);
// Set persistent error display
setErrorTitle(errorDetails.title);
setError(errorDetails.description);
// Show error toast with conditional retry action
const toastOptions: any = {
id: loadingToast,
description: errorDetails.description,
duration: 6000,
};
// Add retry action if the error is retryable
if (shouldRetry(errorCode)) {
toastOptions.action = {
label: "Retry",
onClick: () => handleSubmit(e),
};
}
toast.error(errorDetails.title, toastOptions);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -77,11 +128,67 @@ export default function RegisterPage() {
<div className="w-full max-w-md"> <div className="w-full max-w-md">
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{error && ( {/* Enhanced Error Display */}
<div className="rounded-md bg-red-50 p-4 text-sm text-red-500 dark:bg-red-900/20 dark:text-red-200"> <AnimatePresence>
{error} {error && errorTitle && (
</div> <motion.div
)} initial={{ opacity: 0, y: -10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10, scale: 0.95 }}
transition={{ duration: 0.3 }}
className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-900 shadow-sm dark:border-red-900/30 dark:bg-red-900/20 dark:text-red-200"
>
<div className="flex items-start gap-3">
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="flex-shrink-0 mt-0.5 text-red-500 dark:text-red-400"
>
<title>Error Icon</title>
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold mb-1">{errorTitle}</p>
<p className="text-sm text-red-700 dark:text-red-300">{error}</p>
</div>
<button
onClick={() => {
setError(null);
setErrorTitle(null);
}}
className="flex-shrink-0 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200 transition-colors"
aria-label="Dismiss error"
type="button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<title>Close</title>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</motion.div>
)}
</AnimatePresence>
<div> <div>
<label <label
@ -96,7 +203,12 @@ export default function RegisterPage() {
required required
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white" className={`mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${
error
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
}`}
disabled={isLoading}
/> />
</div> </div>
@ -113,7 +225,12 @@ export default function RegisterPage() {
required required
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white" className={`mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${
error
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
}`}
disabled={isLoading}
/> />
</div> </div>
@ -130,14 +247,19 @@ export default function RegisterPage() {
required required
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white" className={`mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${
error
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
}`}
disabled={isLoading}
/> />
</div> </div>
<button <button
type="submit" type="submit"
disabled={isLoading} disabled={isLoading}
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" className="w-full rounded-md bg-blue-600 px-4 py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
> >
{isLoading ? "Creating account..." : "Register"} {isLoading ? "Creating account..." : "Register"}
</button> </button>

View file

@ -0,0 +1,214 @@
/**
* Authentication error messages and handling utilities
*/
interface AuthErrorMapping {
[key: string]: {
title: string;
description?: string;
};
}
const AUTH_ERROR_MESSAGES: AuthErrorMapping = {
// Common HTTP errors
"401": {
title: "Invalid credentials",
description: "Please check your email and password",
},
"403": {
title: "Access denied",
description: "Your account may be suspended or restricted",
},
"404": {
title: "Account not found",
description: "No account exists with this email address",
},
"409": {
title: "Account conflict",
description: "An account with this email already exists",
},
"429": {
title: "Too many attempts",
description: "Please wait before trying again",
},
"500": {
title: "Server error",
description: "Something went wrong on our end. Please try again",
},
"503": {
title: "Service unavailable",
description: "Login service is temporarily down",
},
// FastAPI specific errors
LOGIN_BAD_CREDENTIALS: {
title: "Invalid credentials",
description: "The email or password you entered is incorrect",
},
LOGIN_USER_NOT_VERIFIED: {
title: "Account not verified",
description: "Please verify your email address before signing in",
},
USER_INACTIVE: {
title: "Account inactive",
description: "Your account has been deactivated. Contact support for assistance",
},
REGISTER_USER_ALREADY_EXISTS: {
title: "Account already exists",
description: "An account with this email address already exists",
},
REGISTER_INVALID_PASSWORD: {
title: "Invalid password",
description: "Password must meet security requirements",
},
// OAuth errors
access_denied: {
title: "Access denied",
description: "You denied access or cancelled the login process",
},
invalid_request: {
title: "Invalid request",
description: "The login request was malformed",
},
unauthorized_client: {
title: "Authentication failed",
description: "The application is not authorized to perform this action",
},
unsupported_response_type: {
title: "Login method not supported",
description: "This login method is not currently available",
},
invalid_scope: {
title: "Invalid permissions",
description: "The requested permissions are not valid",
},
server_error: {
title: "Server error",
description: "An error occurred on the authentication server",
},
temporarily_unavailable: {
title: "Service unavailable",
description: "Login is temporarily unavailable. Please try again later",
},
// Network errors
NETWORK_ERROR: {
title: "Connection failed",
description: "Please check your internet connection and try again",
},
TIMEOUT: {
title: "Request timeout",
description: "The login request took too long. Please try again",
},
// Generic fallbacks
UNKNOWN_ERROR: {
title: "Login failed",
description: "An unexpected error occurred. Please try again",
},
};
/**
* Get a user-friendly error message for authentication errors
* @param errorCode - The error code or message from the API
* @param returnTitle - Whether to return just the title or full description
* @returns Formatted error message
*/
export function getAuthErrorMessage(errorCode: string, returnTitle: boolean = false): string {
if (!errorCode) {
const fallback = AUTH_ERROR_MESSAGES.UNKNOWN_ERROR;
return returnTitle ? fallback.title : fallback.description || fallback.title;
}
// Clean up the error code
const cleanErrorCode = errorCode.trim().toUpperCase();
// Try exact match first
let errorInfo = AUTH_ERROR_MESSAGES[cleanErrorCode] || AUTH_ERROR_MESSAGES[errorCode];
// Try partial matches for HTTP status codes
if (!errorInfo) {
const statusCodeMatch = errorCode.match(/(\d{3})/);
if (statusCodeMatch) {
errorInfo = AUTH_ERROR_MESSAGES[statusCodeMatch[1]];
}
}
// Try partial matches for common error patterns
if (!errorInfo) {
const patterns = [
{ pattern: /credential|password|email/i, code: "LOGIN_BAD_CREDENTIALS" },
{ pattern: /verify|verification/i, code: "LOGIN_USER_NOT_VERIFIED" },
{ pattern: /inactive|disabled|suspended/i, code: "USER_INACTIVE" },
{ pattern: /exists|duplicate/i, code: "REGISTER_USER_ALREADY_EXISTS" },
{ pattern: /network|connection/i, code: "NETWORK_ERROR" },
{ pattern: /timeout/i, code: "TIMEOUT" },
{ pattern: /rate|limit|many/i, code: "429" },
];
for (const { pattern, code } of patterns) {
if (pattern.test(errorCode)) {
errorInfo = AUTH_ERROR_MESSAGES[code];
break;
}
}
}
// Fallback to unknown error
if (!errorInfo) {
errorInfo = AUTH_ERROR_MESSAGES.UNKNOWN_ERROR;
}
return returnTitle ? errorInfo.title : errorInfo.description || errorInfo.title;
}
/**
* Get both title and description for an error
* @param errorCode - The error code or message from the API
* @returns Object with title and description
*/
export function getAuthErrorDetails(errorCode: string): { title: string; description: string } {
const title = getAuthErrorMessage(errorCode, true);
const description = getAuthErrorMessage(errorCode, false);
return { title, description };
}
/**
* Check if an error is a network-related error
* @param error - The error object or message
* @returns True if it's a network error
*/
export function isNetworkError(error: unknown): boolean {
if (error instanceof TypeError && error.message.includes("fetch")) {
return true;
}
if (typeof error === "string") {
return /network|connection|fetch|cors/i.test(error);
}
return false;
}
/**
* Check if an error should trigger a retry action
* @param errorCode - The error code or message
* @returns True if retry is recommended
*/
export function shouldRetry(errorCode: string): boolean {
const retryableCodes = [
"500",
"503",
"429",
"NETWORK_ERROR",
"TIMEOUT",
"server_error",
"temporarily_unavailable",
];
return retryableCodes.some(
(code) => errorCode.includes(code) || errorCode.toUpperCase().includes(code)
);
}