diff --git a/surfsense_web/app/(home)/login/LocalLoginForm.tsx b/surfsense_web/app/(home)/login/LocalLoginForm.tsx index cd5f43b48..0157c9faf 100644 --- a/surfsense_web/app/(home)/login/LocalLoginForm.tsx +++ b/surfsense_web/app/(home)/login/LocalLoginForm.tsx @@ -1,4 +1,5 @@ "use client"; +import { useAtom } from "jotai"; import { Eye, EyeOff } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import Link from "next/link"; @@ -6,7 +7,9 @@ import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; import { toast } from "sonner"; +import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms"; import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors"; +import { ValidationError } from "@/lib/error"; export function LocalLoginForm() { const t = useTranslations("auth"); @@ -14,11 +17,16 @@ export function LocalLoginForm() { const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); - const [error, setError] = useState(null); - const [errorTitle, setErrorTitle] = useState(null); - const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState<{ + title: string | null; + message: string | null; + }>({ + title: null, + message: null, + }); const [authType, setAuthType] = useState(null); const router = useRouter(); + const [{ mutateAsync: login, isPending: isLoggingIn }] = useAtom(loginMutationAtom); useEffect(() => { // Get the auth type from environment variables @@ -27,36 +35,17 @@ export function LocalLoginForm() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - setIsLoading(true); - setError(null); // Clear any previous errors - setErrorTitle(null); + setError({ title: null, message: null }); // Clear any previous errors // Show loading toast const loadingToast = toast.loading(tCommon("loading")); try { - // Create form data for the API request - const formData = new URLSearchParams(); - formData.append("username", username); - formData.append("password", password); - formData.append("grant_type", "password"); - - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/jwt/login`, - { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: formData.toString(), - } - ); - - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.detail || `HTTP ${response.status}`); - } + const data = await login({ + username, + password, + grant_type: "password", + }); // Success toast toast.success(t("login_success"), { @@ -70,6 +59,16 @@ export function LocalLoginForm() { router.push(`/auth/callback?token=${data.access_token}`); }, 500); } catch (err) { + if (err instanceof ValidationError) { + setError({ title: err.name, message: err.message }); + toast.error(err.name, { + id: loadingToast, + description: err.message, + duration: 6000, + }); + return; + } + // Use auth-errors utility to get proper error details let errorCode = "UNKNOWN_ERROR"; @@ -83,8 +82,10 @@ export function LocalLoginForm() { const errorDetails = getAuthErrorDetails(errorCode); // Set persistent error display - setErrorTitle(errorDetails.title); - setError(errorDetails.description); + setError({ + title: errorDetails.title, + message: errorDetails.description, + }); // Show error toast with conditional retry action const toastOptions: any = { @@ -102,8 +103,6 @@ export function LocalLoginForm() { } toast.error(errorDetails.title, toastOptions); - } finally { - setIsLoading(false); } }; @@ -112,7 +111,7 @@ export function LocalLoginForm() {
{/* Error Display */} - {error && errorTitle && ( + {error && error.title && (
-

{errorTitle}

-

{error}

+

{error.title}

+

{error.message}

diff --git a/surfsense_web/app/(home)/register/page.tsx b/surfsense_web/app/(home)/register/page.tsx index 9e3b42e2a..c535832be 100644 --- a/surfsense_web/app/(home)/register/page.tsx +++ b/surfsense_web/app/(home)/register/page.tsx @@ -1,13 +1,16 @@ "use client"; +import { useAtom } from "jotai"; import { AnimatePresence, motion } from "motion/react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; import { toast } from "sonner"; +import { registerMutationAtom } from "@/atoms/auth/auth-mutation.atoms"; import { Logo } from "@/components/Logo"; import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors"; +import { AppError, ValidationError } from "@/lib/error"; import { AmbientBackground } from "../login/AmbientBackground"; export default function RegisterPage() { @@ -16,10 +19,15 @@ export default function RegisterPage() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); - const [error, setError] = useState(null); - const [errorTitle, setErrorTitle] = useState(null); - const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState<{ + title: string | null; + message: string | null; + }>({ + title: null, + message: null, + }); const router = useRouter(); + const [{ mutateAsync: register, isPending: isRegistering }] = useAtom(registerMutationAtom); // Check authentication type and redirect if not LOCAL useEffect(() => { @@ -34,8 +42,7 @@ export default function RegisterPage() { // Form validation if (password !== confirmPassword) { - setError(t("passwords_no_match")); - setErrorTitle(t("password_mismatch")); + setError({ title: t("password_mismatch"), message: t("passwords_no_match_desc") }); toast.error(t("password_mismatch"), { description: t("passwords_no_match_desc"), duration: 4000, @@ -43,48 +50,20 @@ export default function RegisterPage() { return; } - setIsLoading(true); - setError(null); // Clear any previous errors - setErrorTitle(null); + setError({ title: null, message: null }); // Clear any previous errors // Show loading toast const loadingToast = toast.loading(t("creating_account")); try { - const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/register`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - email, - password, - is_active: true, - is_superuser: false, - is_verified: false, - }), + await register({ + email, + password, + is_active: true, + is_superuser: false, + is_verified: false, }); - const data = await response.json(); - - if (!response.ok && response.status === 403) { - const friendlyMessage = - "Registrations are currently closed. If you need access, contact your administrator."; - setErrorTitle("Registration is disabled"); - setError(friendlyMessage); - toast.error("Registration is disabled", { - id: loadingToast, - description: friendlyMessage, - duration: 6000, - }); - setIsLoading(false); - return; - } - - if (!response.ok) { - throw new Error(data.detail || `HTTP ${response.status}`); - } - // Success toast toast.success(t("register_success"), { id: loadingToast, @@ -97,6 +76,34 @@ export default function RegisterPage() { router.push("/login?registered=true"); }, 500); } catch (err) { + if (err instanceof AppError) { + switch (err.status) { + case 403: { + const friendlyMessage = + "Registrations are currently closed. If you need access, contact your administrator."; + setError({ title: "Registration is disabled", message: friendlyMessage }); + toast.error("Registration is disabled", { + id: loadingToast, + description: friendlyMessage, + duration: 6000, + }); + return; + } + default: + break; + } + + if (err instanceof ValidationError) { + setError({ title: err.name, message: err.message }); + toast.error(err.name, { + id: loadingToast, + description: err.message, + duration: 6000, + }); + return; + } + } + // Use auth-errors utility to get proper error details let errorCode = "UNKNOWN_ERROR"; @@ -110,8 +117,7 @@ export default function RegisterPage() { const errorDetails = getAuthErrorDetails(errorCode); // Set persistent error display - setErrorTitle(errorDetails.title); - setError(errorDetails.description); + setError({ title: errorDetails.title, message: errorDetails.description }); // Show error toast with conditional retry action const toastOptions: any = { @@ -129,8 +135,6 @@ export default function RegisterPage() { } toast.error(errorDetails.title, toastOptions); - } finally { - setIsLoading(false); } }; @@ -147,7 +151,7 @@ export default function RegisterPage() {
{/* Enhanced Error Display */} - {error && errorTitle && ( + {error && error.title && (
-

{errorTitle}

-

{error}

+

{error.title}

+

{error.message}

diff --git a/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx b/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx index 9517431b4..64661620d 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx @@ -1,6 +1,7 @@ "use client"; import { format } from "date-fns"; +import { useAtom, useAtomValue } from "jotai"; import { Calendar, ExternalLink, @@ -13,7 +14,8 @@ import { import { AnimatePresence, motion, type Variants } from "motion/react"; import { useRouter, useSearchParams } from "next/navigation"; import { useEffect, useState } from "react"; -import { toast } from "sonner"; +import { deleteChatMutationAtom } from "@/atoms/chats/chat-mutation.atoms"; +import { activeSearchSpaceChatsAtom } from "@/atoms/chats/chat-querie.atoms"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; @@ -49,19 +51,18 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { cn } from "@/lib/utils"; export interface Chat { created_at: string; id: number; - type: "DOCUMENT" | "CHAT"; + type: "QNA"; title: string; search_space_id: number; state_version: number; } export interface ChatDetails { - type: "DOCUMENT" | "CHAT"; + type: "QNA"; title: string; initial_connectors: string[]; messages: any[]; @@ -91,18 +92,24 @@ const MotionCard = motion(Card); export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) { const router = useRouter(); - const [chats, setChats] = useState([]); const [filteredChats, setFilteredChats] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [currentPage, setCurrentPage] = useState(1); const [totalPages, setTotalPages] = useState(1); const [selectedType, setSelectedType] = useState("all"); const [sortOrder, setSortOrder] = useState("newest"); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [chatToDelete, setChatToDelete] = useState<{ id: number; title: string } | null>(null); - const [isDeleting, setIsDeleting] = useState(false); + const [chatToDelete, setChatToDelete] = useState<{ + id: number; + title: string; + } | null>(null); + const { + isFetching: isFetchingChats, + data: chats, + error: fetchError, + } = useAtomValue(activeSearchSpaceChatsAtom); + const [{ isPending: isDeletingChat, mutateAsync: deleteChat, error: deleteError }] = + useAtom(deleteChatMutationAtom); const chatsPerPage = 9; const searchParams = useSearchParams(); @@ -118,58 +125,9 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) } }, [searchParams]); - // Fetch chats from API - useEffect(() => { - const fetchChats = async () => { - try { - setIsLoading(true); - - // Get token from localStorage - const token = localStorage.getItem("surfsense_bearer_token"); - - if (!token) { - setError("Authentication token not found. Please log in again."); - setIsLoading(false); - return; - } - - // Fetch all chats for this search space - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats?search_space_id=${searchSpaceId}`, - { - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - cache: "no-store", - } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => null); - throw new Error(`Failed to fetch chats: ${response.status} ${errorData?.error || ""}`); - } - - const data: Chat[] = await response.json(); - setChats(data); - setFilteredChats(data); - setError(null); - } catch (error) { - console.error("Error fetching chats:", error); - setError(error instanceof Error ? error.message : "Unknown error occurred"); - setChats([]); - setFilteredChats([]); - } finally { - setIsLoading(false); - } - }; - - fetchChats(); - }, [searchSpaceId]); - // Filter and sort chats based on search query, type, and sort order useEffect(() => { - let result = [...chats]; + let result = [...(chats || [])]; // Filter by search term if (searchQuery) { @@ -203,49 +161,19 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) const handleDeleteChat = async () => { if (!chatToDelete) return; - setIsDeleting(true); - try { - const token = localStorage.getItem("surfsense_bearer_token"); - if (!token) { - setIsDeleting(false); - return; - } + await deleteChat(chatToDelete.id); - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/${chatToDelete.id}`, - { - method: "DELETE", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - } - ); - - if (!response.ok) { - throw new Error(`Failed to delete chat: ${response.statusText}`); - } - - // Close dialog and refresh chats - setDeleteDialogOpen(false); - setChatToDelete(null); - - // Update local state by removing the deleted chat - setChats((prevChats) => prevChats.filter((chat) => chat.id !== chatToDelete.id)); - } catch (error) { - console.error("Error deleting chat:", error); - } finally { - setIsDeleting(false); - } + setDeleteDialogOpen(false); + setChatToDelete(null); }; // Calculate pagination - const indexOfLastChat = currentPage * chatsPerPage; - const indexOfFirstChat = indexOfLastChat - chatsPerPage; + const indexOfLastChat = currentPage * chatsPerPage; // Index of last chat in the current page + const indexOfFirstChat = indexOfLastChat - chatsPerPage; // Index of first chat in the current page const currentChats = filteredChats.slice(indexOfFirstChat, indexOfLastChat); // Get unique chat types for filter dropdown - const chatTypes = ["all", ...Array.from(new Set(chats.map((chat) => chat.type)))]; + const chatTypes = chats ? ["all", ...Array.from(new Set(chats.map((chat) => chat.type)))] : []; return ( {/* Status Messages */} - {isLoading && ( + {isFetchingChats && (
@@ -316,14 +244,14 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps)
)} - {error && !isLoading && ( + {fetchError && !isFetchingChats && (

Error loading chats

-

{error}

+

{fetchError.message}

)} - {!isLoading && !error && filteredChats.length === 0 && ( + {!isFetchingChats && !fetchError && filteredChats.length === 0 && (

No chats found

@@ -336,7 +264,7 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) )} {/* Chat Grid */} - {!isLoading && !error && filteredChats.length > 0 && ( + {!isFetchingChats && !fetchError && filteredChats.length > 0 && (
{currentChats.map((chat, index) => ( @@ -422,7 +350,7 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) )} {/* Pagination */} - {!isLoading && !error && totalPages > 1 && ( + {!isFetchingChats && !fetchError && totalPages > 1 && ( @@ -504,17 +432,17 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps)