diff --git a/surfsense_backend/app/routes/documents_routes.py b/surfsense_backend/app/routes/documents_routes.py index b75c56663..3ea1e8e61 100644 --- a/surfsense_backend/app/routes/documents_routes.py +++ b/surfsense_backend/app/routes/documents_routes.py @@ -157,7 +157,7 @@ async def create_documents_file_upload( @router.get("/documents/", response_model=list[DocumentRead]) async def read_documents( skip: int = 0, - limit: int = 3000, + limit: int = 300, search_space_id: int | None = None, session: AsyncSession = Depends(get_async_session), user: User = Depends(current_active_user), diff --git a/surfsense_web/app/login/LocalLoginForm.tsx b/surfsense_web/app/login/LocalLoginForm.tsx index 7cc50b28a..b464bea98 100644 --- a/surfsense_web/app/login/LocalLoginForm.tsx +++ b/surfsense_web/app/login/LocalLoginForm.tsx @@ -1,12 +1,16 @@ "use client"; +import { AnimatePresence, motion } from "framer-motion"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors"; export function LocalLoginForm() { const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); - const [error, setError] = useState(""); + const [error, setError] = useState(null); + const [errorTitle, setErrorTitle] = useState(null); const [isLoading, setIsLoading] = useState(false); const [authType, setAuthType] = useState(null); const router = useRouter(); @@ -19,7 +23,11 @@ export function LocalLoginForm() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setIsLoading(true); - setError(""); + setError(null); // Clear any previous errors + setErrorTitle(null); + + // Show loading toast + const loadingToast = toast.loading("Signing you in..."); try { // Create form data for the API request @@ -42,13 +50,53 @@ export function LocalLoginForm() { const data = await response.json(); 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) { - const errorMessage = err instanceof Error ? err.message : "An error occurred during login"; - setError(errorMessage); + // 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 { setIsLoading(false); } @@ -57,11 +105,67 @@ export function LocalLoginForm() { return (
- {error && ( -
- {error} -
- )} + {/* Error Display */} + + {error && errorTitle && ( + +
+ + Error Icon + + + + +
+

{errorTitle}

+

{error}

+
+ +
+
+ )} +
@@ -93,14 +202,19 @@ export function LocalLoginForm() { required value={password} 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} />
diff --git a/surfsense_web/app/login/page.tsx b/surfsense_web/app/login/page.tsx index 1957c22bf..c9e9d9090 100644 --- a/surfsense_web/app/login/page.tsx +++ b/surfsense_web/app/login/page.tsx @@ -1,23 +1,82 @@ "use client"; +import { AnimatePresence, motion } from "framer-motion"; import { Loader2 } from "lucide-react"; import { useSearchParams } from "next/navigation"; import { Suspense, useEffect, useState } from "react"; +import { toast } from "sonner"; import { Logo } from "@/components/Logo"; +import { getAuthErrorDetails, shouldRetry } from "@/lib/auth-errors"; import { AmbientBackground } from "./AmbientBackground"; import { GoogleLoginButton } from "./GoogleLoginButton"; import { LocalLoginForm } from "./LocalLoginForm"; function LoginContent() { const [authType, setAuthType] = useState(null); - const [registrationSuccess, setRegistrationSuccess] = useState(false); const [isLoading, setIsLoading] = useState(true); + const [urlError, setUrlError] = useState<{ title: string; message: string } | null>(null); const searchParams = useSearchParams(); useEffect(() => { - // Check if the user was redirected from registration - if (searchParams.get("registered") === "true") { - setRegistrationSuccess(true); + // Check for various URL parameters that might indicate success or error states + const registered = searchParams.get("registered"); + 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 @@ -54,11 +113,64 @@ function LoginContent() { Sign In - {registrationSuccess && ( -
- Registration successful! You can now sign in with your credentials. -
- )} + {/* URL Error Display */} + + {urlError && ( + +
+ + Error Icon + + + + +
+

{urlError.title}

+

{urlError.message}

+
+ +
+
+ )} +
diff --git a/surfsense_web/app/register/page.tsx b/surfsense_web/app/register/page.tsx index 1a89dbb4e..f046e5cff 100644 --- a/surfsense_web/app/register/page.tsx +++ b/surfsense_web/app/register/page.tsx @@ -1,16 +1,20 @@ "use client"; +import { AnimatePresence, motion } from "framer-motion"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; +import { toast } from "sonner"; import { Logo } from "@/components/Logo"; +import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors"; import { AmbientBackground } from "../login/AmbientBackground"; export default function RegisterPage() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); - const [error, setError] = useState(""); + const [error, setError] = useState(null); + const [errorTitle, setErrorTitle] = useState(null); const [isLoading, setIsLoading] = useState(false); const router = useRouter(); @@ -28,11 +32,20 @@ export default function RegisterPage() { // Form validation if (password !== confirmPassword) { setError("Passwords do not match"); + setErrorTitle("Password Mismatch"); + toast.error("Password Mismatch", { + description: "The passwords you entered do not match", + duration: 4000, + }); return; } setIsLoading(true); - setError(""); + setError(null); // Clear any previous errors + setErrorTitle(null); + + // Show loading toast + const loadingToast = toast.loading("Creating your account..."); try { 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(); 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 - router.push("/login?registered=true"); - } catch (err: unknown) { - const errorMessage = - err instanceof Error ? err.message : "An error occurred during registration"; - setError(errorMessage); + // Success toast + toast.success("Account created successfully!", { + id: loadingToast, + description: "Redirecting to login page...", + duration: 2000, + }); + + // 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 { setIsLoading(false); } @@ -77,11 +128,67 @@ export default function RegisterPage() {
- {error && ( -
- {error} -
- )} + {/* Enhanced Error Display */} + + {error && errorTitle && ( + +
+ + Error Icon + + + + +
+

{errorTitle}

+

{error}

+
+ +
+
+ )} +
@@ -113,7 +225,12 @@ export default function RegisterPage() { required value={password} 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} />
@@ -130,14 +247,19 @@ export default function RegisterPage() { required value={confirmPassword} 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} /> diff --git a/surfsense_web/lib/auth-errors.ts b/surfsense_web/lib/auth-errors.ts new file mode 100644 index 000000000..ce50c9c99 --- /dev/null +++ b/surfsense_web/lib/auth-errors.ts @@ -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) + ); +}