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 b9f28e56d..1cfec056a 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,6 +14,8 @@ import { import { AnimatePresence, motion, type Variants } from "motion/react"; import { useRouter, useSearchParams } from "next/navigation"; import { useEffect, useState } from "react"; +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"; @@ -48,9 +51,6 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { useAtom, useAtomValue } from "jotai"; -import { activeSearchSpaceChatsAtom } from "@/atoms/chats/chat-queries.atom"; -import { deleteChatMutationAtom } from "@/atoms/chats/chat-mutations.atom"; export interface Chat { created_at: string; diff --git a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx index ee2922130..213868314 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx @@ -7,6 +7,9 @@ import { useParams, usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import type React from "react"; import { useEffect, useMemo, useState } from "react"; +import { activeChatIdAtom } from "@/atoms/chats/chat-querie.atoms"; +import { activeChathatUIAtom } from "@/atoms/chats/ui.atoms"; +import { activeSearchSpaceIdAtom } from "@/atoms/seach-spaces/seach-space-queries.atom"; import { ChatPanelContainer } from "@/components/chat/ChatPanel/ChatPanelContainer"; import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"; @@ -17,9 +20,6 @@ import { Separator } from "@/components/ui/separator"; import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { useLLMPreferences } from "@/hooks/use-llm-configs"; import { cn } from "@/lib/utils"; -import { activeChatIdAtom } from "@/atoms/chats/chat-queries.atom"; -import { chatUIAtom } from "@/atoms/chats/chat-uis.atom"; -import { activeSearchSpaceIdAtom } from "@/atoms/seach-spaces/seach-space-queries.atom"; export function DashboardClientLayout({ children, @@ -37,7 +37,7 @@ export function DashboardClientLayout({ const pathname = usePathname(); const searchSpaceIdNum = Number(searchSpaceId); const { search_space_id, chat_id } = useParams(); - const [chatUIState, setChatUIState] = useAtom(chatUIAtom); + const [chatUIState, setChatUIState] = useAtom(activeChathatUIAtom); const activeChatId = useAtomValue(activeChatIdAtom); const setActiveSearchSpaceIdState = useSetAtom(activeSearchSpaceIdAtom); const setActiveChatIdState = useSetAtom(activeChatIdAtom); diff --git a/surfsense_web/atoms/auth/auth-mutation.atoms.ts b/surfsense_web/atoms/auth/auth-mutation.atoms.ts new file mode 100644 index 000000000..6bf4ac948 --- /dev/null +++ b/surfsense_web/atoms/auth/auth-mutation.atoms.ts @@ -0,0 +1,22 @@ +import { atomWithMutation } from "jotai-tanstack-query"; +import type { LoginRequest, RegisterRequest } from "@/contracts/types/auth.types"; +import { authApiService } from "@/lib/apis/auth-api.service"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; + +export const registerMutationAtom = atomWithMutation(() => { + return { + mutationKey: cacheKeys.auth.user, + mutationFn: async (request: RegisterRequest) => { + return authApiService.register(request); + }, + }; +}); + +export const loginMutationAtom = atomWithMutation(() => { + return { + mutationKey: cacheKeys.auth.user, + mutationFn: async (request: LoginRequest) => { + return authApiService.login(request); + }, + }; +}); diff --git a/surfsense_web/atoms/chats/chat-mutations.atom.ts b/surfsense_web/atoms/chats/chat-mutation.atoms.ts similarity index 93% rename from surfsense_web/atoms/chats/chat-mutations.atom.ts rename to surfsense_web/atoms/chats/chat-mutation.atoms.ts index fbb30a01e..a6dd1c9dc 100644 --- a/surfsense_web/atoms/chats/chat-mutations.atom.ts +++ b/surfsense_web/atoms/chats/chat-mutation.atoms.ts @@ -1,10 +1,10 @@ import { atomWithMutation } from "jotai-tanstack-query"; -import { deleteChat } from "@/lib/apis/chats.api"; -import { activeSearchSpaceIdAtom } from "../seach-spaces/seach-space-queries.atom"; -import { queryClient } from "@/lib/query-client/client"; -import { cacheKeys } from "@/lib/query-client/cache-keys"; import { toast } from "sonner"; -import { Chat } from "@/app/dashboard/[search_space_id]/chats/chats-client"; +import type { Chat } from "@/app/dashboard/[search_space_id]/chats/chats-client"; +import { deleteChat } from "@/lib/apis/chats.api"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; +import { queryClient } from "@/lib/query-client/client"; +import { activeSearchSpaceIdAtom } from "../seach-spaces/seach-space-queries.atom"; export const deleteChatMutationAtom = atomWithMutation((get) => { const searchSpaceId = get(activeSearchSpaceIdAtom); diff --git a/surfsense_web/atoms/chats/chat-queries.atom.ts b/surfsense_web/atoms/chats/chat-querie.atoms.ts similarity index 100% rename from surfsense_web/atoms/chats/chat-queries.atom.ts rename to surfsense_web/atoms/chats/chat-querie.atoms.ts index 2463b65ef..3603bf9bb 100644 --- a/surfsense_web/atoms/chats/chat-queries.atom.ts +++ b/surfsense_web/atoms/chats/chat-querie.atoms.ts @@ -2,10 +2,10 @@ import { atom } from "jotai"; import { atomWithQuery } from "jotai-tanstack-query"; import type { ChatDetails } from "@/app/dashboard/[search_space_id]/chats/chats-client"; import type { PodcastItem } from "@/app/dashboard/[search_space_id]/podcasts/podcasts-client"; +import { activeSearchSpaceIdAtom } from "@/atoms/seach-spaces/seach-space-queries.atom"; import { fetchChatDetails, fetchChatsBySearchSpace } from "@/lib/apis/chats.api"; import { getPodcastByChatId } from "@/lib/apis/podcasts.api"; import { cacheKeys } from "@/lib/query-client/cache-keys"; -import { activeSearchSpaceIdAtom } from "@/atoms/seach-spaces/seach-space-queries.atom"; type ActiveChatState = { chatId: string | null; diff --git a/surfsense_web/atoms/chats/chat-uis.atom.ts b/surfsense_web/atoms/chats/chat-uis.atom.ts deleted file mode 100644 index 3b7e6794b..000000000 --- a/surfsense_web/atoms/chats/chat-uis.atom.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { atom } from "jotai"; - -type ChatUIState = { - isChatPannelOpen: boolean; -}; - -export const chatUIAtom = atom({ - isChatPannelOpen: false, -}); diff --git a/surfsense_web/atoms/chats/ui.atoms.ts b/surfsense_web/atoms/chats/ui.atoms.ts new file mode 100644 index 000000000..4d2b64186 --- /dev/null +++ b/surfsense_web/atoms/chats/ui.atoms.ts @@ -0,0 +1,9 @@ +import { atom } from "jotai"; + +type ActiveChathatUIState = { + isChatPannelOpen: boolean; +}; + +export const activeChathatUIAtom = atom({ + isChatPannelOpen: false, +}); diff --git a/surfsense_web/components/announcement-banner.tsx b/surfsense_web/components/announcement-banner.tsx index cbc48b444..537aa6da7 100644 --- a/surfsense_web/components/announcement-banner.tsx +++ b/surfsense_web/components/announcement-banner.tsx @@ -2,8 +2,8 @@ import { useAtom } from "jotai"; import { ExternalLink, Info, X } from "lucide-react"; -import { Button } from "@/components/ui/button"; import { announcementDismissedAtom } from "@/atoms/announcement.atom"; +import { Button } from "@/components/ui/button"; export function AnnouncementBanner() { const [isDismissed, setIsDismissed] = useAtom(announcementDismissedAtom); diff --git a/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx b/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx index b2dc67ba8..3edd00400 100644 --- a/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx +++ b/surfsense_web/components/chat/ChatPanel/ChatPanelContainer.tsx @@ -2,10 +2,10 @@ import { useAtom, useAtomValue } from "jotai"; import { LoaderIcon, PanelRight, TriangleAlert } from "lucide-react"; import { toast } from "sonner"; +import { activeChatAtom, activeChatIdAtom } from "@/atoms/chats/chat-querie.atoms"; +import { activeChathatUIAtom } from "@/atoms/chats/ui.atoms"; import { generatePodcast } from "@/lib/apis/podcasts.api"; import { cn } from "@/lib/utils"; -import { activeChatAtom, activeChatIdAtom } from "@/atoms/chats/chat-queries.atom"; -import { chatUIAtom } from "@/atoms/chats/chat-uis.atom"; import { ChatPanelView } from "./ChatPanelView"; export interface GeneratePodcastRequest { @@ -24,7 +24,7 @@ export function ChatPanelContainer() { } = useAtomValue(activeChatAtom); const activeChatIdState = useAtomValue(activeChatIdAtom); const authToken = localStorage.getItem("surfsense_bearer_token"); - const { isChatPannelOpen } = useAtomValue(chatUIAtom); + const { isChatPannelOpen } = useAtomValue(activeChathatUIAtom); const handleGeneratePodcast = async (request: GeneratePodcastRequest) => { try { diff --git a/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx b/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx index 5b490b16c..d0e7c47e8 100644 --- a/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx +++ b/surfsense_web/components/chat/ChatPanel/ChatPanelView.tsx @@ -4,9 +4,9 @@ import { useAtom, useAtomValue } from "jotai"; import { AlertCircle, Play, RefreshCw, Sparkles } from "lucide-react"; import { motion } from "motion/react"; import { useCallback } from "react"; +import { activeChatAtom } from "@/atoms/chats/chat-querie.atoms"; +import { activeChathatUIAtom } from "@/atoms/chats/ui.atoms"; import { cn } from "@/lib/utils"; -import { activeChatAtom } from "@/atoms/chats/chat-queries.atom"; -import { chatUIAtom } from "@/atoms/chats/chat-uis.atom"; import { getPodcastStalenessMessage, isPodcastStale } from "../PodcastUtils"; import type { GeneratePodcastRequest } from "./ChatPanelContainer"; import { ConfigModal } from "./ConfigModal"; @@ -17,7 +17,7 @@ interface ChatPanelViewProps { } export function ChatPanelView(props: ChatPanelViewProps) { - const [chatUIState, setChatUIState] = useAtom(chatUIAtom); + const [chatUIState, setChatUIState] = useAtom(activeChathatUIAtom); const { data: activeChatState } = useAtomValue(activeChatAtom); const { isChatPannelOpen } = chatUIState; @@ -40,6 +40,7 @@ export function ChatPanelView(props: ChatPanelViewProps) { }); }, [chatDetails, generatePodcast]); + // biome-ignore-start lint/a11y/useSemanticElements: using div for custom layout — will convert later return (
@@ -202,4 +203,5 @@ export function ChatPanelView(props: ChatPanelViewProps) { ) : null}
); + // biome-ignore-end lint/a11y/useSemanticElements : using div for custom layout — will convert later } diff --git a/surfsense_web/components/chat/ChatPanel/ConfigModal.tsx b/surfsense_web/components/chat/ChatPanel/ConfigModal.tsx index c7ac58e9b..09a49a2b1 100644 --- a/surfsense_web/components/chat/ChatPanel/ConfigModal.tsx +++ b/surfsense_web/components/chat/ChatPanel/ConfigModal.tsx @@ -3,8 +3,8 @@ import { useAtomValue } from "jotai"; import { Pencil } from "lucide-react"; import { useCallback, useContext, useState } from "react"; +import { activeChatAtom } from "@/atoms/chats/chat-querie.atoms"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { activeChatAtom } from "@/atoms/chats/chat-queries.atom"; import type { GeneratePodcastRequest } from "./ChatPanelContainer"; interface ConfigModalProps { diff --git a/surfsense_web/contracts/types/auth.types.ts b/surfsense_web/contracts/types/auth.types.ts index 80feb52f7..059f18df8 100644 --- a/surfsense_web/contracts/types/auth.types.ts +++ b/surfsense_web/contracts/types/auth.types.ts @@ -1,29 +1,27 @@ import { z } from "zod"; export const loginRequest = z.object({ - email: z.string().email(), - password: z.string().min(3), - grant_type: z.string().optional(), + email: z.string().email("Invalid email address"), + password: z.string().min(3, "Password must be at least 3 characters"), + grant_type: z.string().optional(), }); export const loginResponse = z.object({ - access_token: z.string(), - token_type: z.string(), + access_token: z.string(), + token_type: z.string(), }); export const registerRequest = loginRequest.omit({ grant_type: true }).extend({ - is_active: z.boolean().optional(), - is_superuser: z.boolean().optional(), - is_verified: z.boolean().optional(), + is_active: z.boolean().optional(), + is_superuser: z.boolean().optional(), + is_verified: z.boolean().optional(), }); -export const registerResponse = registerRequest - .omit({ password: true }) - .extend({ - id: z.number(), - pages_limit: z.number(), - pages_used: z.number(), - }); +export const registerResponse = registerRequest.omit({ password: true }).extend({ + id: z.string(), + pages_limit: z.number(), + pages_used: z.number(), +}); export type LoginRequest = z.infer; export type LoginResponse = z.infer; diff --git a/surfsense_web/contracts/types/chat.types.ts b/surfsense_web/contracts/types/chat.types.ts index 7a8767940..cff4a0ae2 100644 --- a/surfsense_web/contracts/types/chat.types.ts +++ b/surfsense_web/contracts/types/chat.types.ts @@ -1,5 +1,5 @@ +import type { Message } from "@ai-sdk/react"; import { z } from "zod"; -import { type Message } from "@ai-sdk/react"; import { paginationQueryParams } from "."; export const chatTypeEnum = z.enum(["QNA"]); diff --git a/surfsense_web/lib/apis/auth-api.service.ts b/surfsense_web/lib/apis/auth-api.service.ts index a9eea9be7..0689487ba 100644 --- a/surfsense_web/lib/apis/auth-api.service.ts +++ b/surfsense_web/lib/apis/auth-api.service.ts @@ -1,11 +1,12 @@ import { + type LoginRequest, loginRequest, - LoginRequest, loginResponse, + type RegisterRequest, registerRequest, - RegisterRequest, registerResponse, } from "@/contracts/types/auth.types"; +import { ValidationError } from "../error"; import { baseApiService } from "./base-api.service"; export class AuthApiService { @@ -14,7 +15,11 @@ export class AuthApiService { const parsedRequest = loginRequest.safeParse(request); if (!parsedRequest.success) { - throw new Error(`Invalid request: ${parsedRequest.error.message}`); + console.error("Invalid request:", parsedRequest.error); + + // Format a user frendly error message + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`, undefined, "VALLIDATION_ERROR"); } return baseApiService.post(`/auth/jwt/login`, parsedRequest.data, loginResponse, { @@ -27,9 +32,15 @@ export class AuthApiService { const parsedRequest = registerRequest.safeParse(request); if (!parsedRequest.success) { - throw new Error(`Invalid request: ${parsedRequest.error.message}`); + console.error("Invalid request:", parsedRequest.error); + + // Format a user frendly error message + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); } return baseApiService.post(`/auth/register`, parsedRequest.data, registerResponse); }; } + +export const authApiService = new AuthApiService(); diff --git a/surfsense_web/lib/apis/base-api.service.ts b/surfsense_web/lib/apis/base-api.service.ts index 777da0094..0cd410618 100644 --- a/surfsense_web/lib/apis/base-api.service.ts +++ b/surfsense_web/lib/apis/base-api.service.ts @@ -1,5 +1,11 @@ -import z from "zod"; -import { AppError, AuthenticationError, AuthorizationError, ValidationError } from "../error"; +import type z from "zod"; +import { + AppError, + AuthenticationError, + AuthorizationError, + NotFoundError, + ValidationError, +} from "../error"; export type RequestOptions = { method: "GET" | "POST" | "PUT" | "DELETE"; @@ -14,6 +20,8 @@ export class BaseApiService { bearerToken: string; baseUrl: string; + noAuthEndpoints: string[] = ["/auth/jwt/login", "/auth/register"]; + constructor(bearerToken: string, baseUrl: string) { this.bearerToken = bearerToken; this.baseUrl = baseUrl; @@ -29,84 +37,123 @@ export class BaseApiService { responseSchema?: z.ZodSchema, options?: RequestOptions ): Promise { - const defaultOptions: RequestOptions = { - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${this.bearerToken}`, - }, - method: "GET", - }; + try { + const defaultOptions: RequestOptions = { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.bearerToken || ""}`, + }, + method: "GET", + }; - const mergedOptions: RequestOptions = { - ...defaultOptions, - ...(options ?? {}), - headers: { - ...defaultOptions.headers, - ...(options?.headers ?? {}), - }, - }; + const mergedOptions: RequestOptions = { + ...defaultOptions, + ...(options ?? {}), + headers: { + ...defaultOptions.headers, + ...(options?.headers ?? {}), + }, + }; - let requestBody; + // biome-ignore lint/suspicious: Unknown + let requestBody; - // Serialize body - if (body) { - if (mergedOptions.headers?.["Content-Type"].toLocaleLowerCase() === "application/json") { - requestBody = JSON.stringify(body); + // Serialize body + if (body) { + if (mergedOptions.headers?.["Content-Type"].toLocaleLowerCase() === "application/json") { + requestBody = JSON.stringify(body); + } + + if ( + mergedOptions.headers?.["Content-Type"].toLocaleLowerCase() === + "application/x-www-form-urlencoded" + ) { + requestBody = new URLSearchParams(body); + } + + mergedOptions.body = requestBody; } - if ( - mergedOptions.headers?.["Content-Type"].toLocaleLowerCase() === - "application/x-www-form-urlencoded" - ) { - requestBody = new URLSearchParams(body); + if (!this.baseUrl) { + throw new AppError("Base URL is not set."); } - mergedOptions.body = requestBody; - } - - if (!this.baseUrl) { - throw new AppError("Base URL is not set."); - } - - if (!this.bearerToken) { - throw new AuthenticationError("You are not authenticated. Please login again."); - } - - const fullUrl = new URL(url, this.baseUrl).toString(); - - const response = await fetch(fullUrl, mergedOptions); - - if (!response.ok) { - if (response.status === 401) { + if (!this.bearerToken && !this.noAuthEndpoints.includes(url)) { throw new AuthenticationError("You are not authenticated. Please login again."); } - if (response.status === 403) { - throw new AuthorizationError("You don't have permission to access this resource."); + const fullUrl = new URL(url, this.baseUrl).toString(); + + const response = await fetch(fullUrl, mergedOptions); + + if (!response.ok) { + // biome-ignore lint/suspicious: Unknown + let data; + + try { + data = await response.json(); + } catch (error) { + console.error("Failed to parse response as JSON:", error); + + throw new AppError("Something went wrong", response.status, response.statusText); + } + + // for fastapi errors response + if ("detail" in data) { + throw new AppError(data.detail, response.status, response.statusText); + } + + switch (response.status) { + case 401: + throw new AuthenticationError( + "You are not authenticated. Please login again.", + response.status, + response.statusText + ); + case 403: + throw new AuthorizationError( + "You don't have permission to access this resource.", + response.status, + response.statusText + ); + case 404: + throw new NotFoundError("Resource not found", response.status, response.statusText); + // Add more cases as needed + default: + throw new AppError("Something went wrong", response.status, response.statusText); + } } - throw new AppError(`API Error: ${response.statusText}`); - } + // biome-ignore lint/suspicious: Unknown + let data; - let data; + try { + data = await response.json(); + } catch (error) { + console.error("Failed to parse response as JSON:", error); - try { - data = await response.json(); - } catch (error) { - throw new AppError(`Failed to parse response as JSON: ${error}`); - } + throw new AppError("Something went wrong", response.status, response.statusText); + } + + if (!responseSchema) { + return data; + } + + const parsedData = responseSchema.safeParse(data); + + if (!parsedData.success) { + /** The request was successful, but the response data does not match the expected schema. + * This is a client side error, and should be fixed by updating the responseSchema to keep things typed. + * This error should not be shown to the user , it is for dev only. + */ + console.error("Invalid API response schema:", parsedData.error); + } - if (!responseSchema) { return data; + } catch (error) { + console.error("Request failed:", error); + throw error; } - - const parsedData = responseSchema.safeParse(data); - - if (!parsedData.success) { - throw new ValidationError(`Invalid response: ${parsedData.error.message}`); - } - - return parsedData.data; } async get( diff --git a/surfsense_web/lib/apis/chats-api.service.api.ts b/surfsense_web/lib/apis/chats-api.service.api.ts index 4019d35ea..ae7aa9837 100644 --- a/surfsense_web/lib/apis/chats-api.service.api.ts +++ b/surfsense_web/lib/apis/chats-api.service.api.ts @@ -1,21 +1,21 @@ -import { ResearchMode } from "@/components/chat/types"; import { Message } from "@ai-sdk/react"; +import { z } from "zod"; +import { ResearchMode } from "@/components/chat/types"; import { + type CreateChatRequest, chatDetails, chatSummary, createChatRequest, - CreateChatRequest, + type DeleteChatRequest, deleteChatRequest, - DeleteChatRequest, - getChatDetailsRequest, - GetChatDetailsRequest, - getChatsBySearchSpaceRequest, - GetChatsBySearchSpaceRequest, deleteChatResponse, - UpdateChatRequest, + type GetChatDetailsRequest, + type GetChatsBySearchSpaceRequest, + getChatDetailsRequest, + getChatsBySearchSpaceRequest, + type UpdateChatRequest, updateChatRequest, } from "@/contracts/types/chat.types"; -import { z } from "zod"; import { baseApiService } from "./base-api.service"; export class ChatApiService { diff --git a/surfsense_web/lib/apis/chats.api.ts b/surfsense_web/lib/apis/chats.api.ts index c5e90ecd5..fc98585d3 100644 --- a/surfsense_web/lib/apis/chats.api.ts +++ b/surfsense_web/lib/apis/chats.api.ts @@ -1,6 +1,6 @@ +import type { Message } from "@ai-sdk/react"; import type { Chat, ChatDetails } from "@/app/dashboard/[search_space_id]/chats/chats-client"; -import { ResearchMode } from "@/components/chat/types"; -import { Message } from "@ai-sdk/react"; +import type { ResearchMode } from "@/components/chat/types"; export const fetchChatDetails = async ( chatId: string, diff --git a/surfsense_web/lib/apis/documents.api.ts b/surfsense_web/lib/apis/documents.api.ts index 7ef306a4c..02975d2fd 100644 --- a/surfsense_web/lib/apis/documents.api.ts +++ b/surfsense_web/lib/apis/documents.api.ts @@ -1,5 +1,5 @@ -import { DocumentWithChunks } from "@/hooks/use-document-by-chunk"; -import { DocumentTypeCount } from "@/hooks/use-document-types"; +import type { DocumentWithChunks } from "@/hooks/use-document-by-chunk"; +import type { DocumentTypeCount } from "@/hooks/use-document-types"; import { normalizeListResponse } from "../pagination"; export const uploadDocument = async (formData: FormData, authToken: string) => { diff --git a/surfsense_web/lib/apis/llm-configs.api.ts b/surfsense_web/lib/apis/llm-configs.api.ts index 2f9608d2f..53860b4dd 100644 --- a/surfsense_web/lib/apis/llm-configs.api.ts +++ b/surfsense_web/lib/apis/llm-configs.api.ts @@ -1,4 +1,4 @@ -import { CreateLLMConfig, LLMConfig, UpdateLLMConfig } from "@/hooks/use-llm-configs"; +import type { CreateLLMConfig, LLMConfig, UpdateLLMConfig } from "@/hooks/use-llm-configs"; export const fetchLLMConfigs = async (searchSpaceId: number, authToken: string) => { const response = await fetch( diff --git a/surfsense_web/lib/apis/search-source-connectors.api.ts b/surfsense_web/lib/apis/search-source-connectors.api.ts index c84d0be20..98ac31284 100644 --- a/surfsense_web/lib/apis/search-source-connectors.api.ts +++ b/surfsense_web/lib/apis/search-source-connectors.api.ts @@ -1,4 +1,4 @@ -import { Connector, CreateConnectorRequest } from "@/hooks/use-connectors"; +import type { Connector, CreateConnectorRequest } from "@/hooks/use-connectors"; export const createConnector = async ( data: CreateConnectorRequest, diff --git a/surfsense_web/lib/error.ts b/surfsense_web/lib/error.ts index 4e8ab0e9d..c8d8283c8 100644 --- a/surfsense_web/lib/error.ts +++ b/surfsense_web/lib/error.ts @@ -1,30 +1,40 @@ export class AppError extends Error { - constructor(message: string) { + status?: number; + statusText?: string; + constructor(message: string, status?: number, statusText?: string) { super(message); - this.name = this.constructor.name; + this.name = this.constructor.name; // User friendly + this.status = status; + this.statusText = statusText; // Dev friendly } } export class NetworkError extends AppError { - constructor(message: string) { - super(message); + constructor(message: string, status?: number, statusText?: string) { + super(message, status, statusText); } } export class ValidationError extends AppError { - constructor(message: string) { - super(message); + constructor(message: string, status?: number, statusText?: string) { + super(message, status, statusText); } } export class AuthenticationError extends AppError { - constructor(message: string) { - super(message); + constructor(message: string, status?: number, statusText?: string) { + super(message, status, statusText); } } export class AuthorizationError extends AppError { - constructor(message: string) { - super(message); + constructor(message: string, status?: number, statusText?: string) { + super(message, status, statusText); + } +} + +export class NotFoundError extends AppError { + constructor(message: string, status?: number, statusText?: string) { + super(message, status, statusText); } } diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts index 6b9b2df04..cb3a26f62 100644 --- a/surfsense_web/lib/query-client/cache-keys.ts +++ b/surfsense_web/lib/query-client/cache-keys.ts @@ -3,4 +3,7 @@ export const cacheKeys = { chats: (searchSpaceId: string) => ["active-search-space", "chats", searchSpaceId] as const, activeChat: (chatId: string) => ["active-search-space", "active-chat", chatId] as const, }, + auth: { + user: ["auth", "user"] as const, + }, }; diff --git a/surfsense_web/lib/utils.ts b/surfsense_web/lib/utils.ts index e8b097d15..18e665cfb 100644 --- a/surfsense_web/lib/utils.ts +++ b/surfsense_web/lib/utils.ts @@ -1,4 +1,4 @@ -import { Message } from "@ai-sdk/react"; +import type { Message } from "@ai-sdk/react"; import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge";