mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-06 20:15:17 +02:00
Merge remote-tracking branch 'upstream/main' into feat/bookstack-connector
This commit is contained in:
commit
e238fab638
110 changed files with 10076 additions and 1671 deletions
40
surfsense_web/app/api/convert-to-blocknote/route.ts
Normal file
40
surfsense_web/app/api/convert-to-blocknote/route.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { ServerBlockNoteEditor } from "@blocknote/server-util";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { markdown } = await request.json();
|
||||
|
||||
if (!markdown || typeof markdown !== "string") {
|
||||
return NextResponse.json({ error: "Markdown string is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Log raw markdown input before conversion
|
||||
// console.log(`\n${"=".repeat(80)}`);
|
||||
// console.log("RAW MARKDOWN INPUT (BEFORE CONVERSION):");
|
||||
// console.log("=".repeat(80));
|
||||
// console.log(markdown);
|
||||
// console.log(`${"=".repeat(80)}\n`);
|
||||
|
||||
// Create server-side editor instance
|
||||
const editor = ServerBlockNoteEditor.create();
|
||||
|
||||
// Convert markdown directly to BlockNote blocks
|
||||
const blocks = await editor.tryParseMarkdownToBlocks(markdown);
|
||||
|
||||
if (!blocks || blocks.length === 0) {
|
||||
throw new Error("Markdown parsing returned no blocks");
|
||||
}
|
||||
|
||||
return NextResponse.json({ blocknote_document: blocks });
|
||||
} catch (error: any) {
|
||||
console.error("Failed to convert markdown to BlockNote:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Failed to convert markdown to BlockNote blocks",
|
||||
details: error.message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
28
surfsense_web/app/api/convert-to-markdown/route.ts
Normal file
28
surfsense_web/app/api/convert-to-markdown/route.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { ServerBlockNoteEditor } from "@blocknote/server-util";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { blocknote_document } = await request.json();
|
||||
|
||||
if (!blocknote_document || !Array.isArray(blocknote_document)) {
|
||||
return NextResponse.json({ error: "BlockNote document array is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Create server-side editor instance
|
||||
const editor = ServerBlockNoteEditor.create();
|
||||
|
||||
// Convert BlockNote blocks to markdown
|
||||
const markdown = await editor.blocksToMarkdownLossy(blocknote_document);
|
||||
|
||||
return NextResponse.json({
|
||||
markdown,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to convert BlockNote to markdown:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to convert BlockNote blocks to markdown" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|||
import { Separator } from "@/components/ui/separator";
|
||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { useLLMPreferences } from "@/hooks/use-llm-configs";
|
||||
import { useUserAccess } from "@/hooks/use-rbac";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function DashboardClientLayout({
|
||||
|
|
@ -60,11 +61,15 @@ export function DashboardClientLayout({
|
|||
}, [activeChatId, isChatPannelOpen]);
|
||||
|
||||
const { loading, error, isOnboardingComplete } = useLLMPreferences(searchSpaceIdNum);
|
||||
const { access, loading: accessLoading } = useUserAccess(searchSpaceIdNum);
|
||||
const [hasCheckedOnboarding, setHasCheckedOnboarding] = useState(false);
|
||||
|
||||
// Skip onboarding check if we're already on the onboarding page
|
||||
const isOnboardingPage = pathname?.includes("/onboard");
|
||||
|
||||
// Only owners should see onboarding - invited members use existing config
|
||||
const isOwner = access?.is_owner ?? false;
|
||||
|
||||
// Translate navigation items
|
||||
const tNavMenu = useTranslations("nav_menu");
|
||||
const translatedNavMain = useMemo(() => {
|
||||
|
|
@ -102,11 +107,13 @@ export function DashboardClientLayout({
|
|||
return;
|
||||
}
|
||||
|
||||
// Only check once after preferences have loaded
|
||||
if (!loading && !hasCheckedOnboarding) {
|
||||
// Wait for both preferences and access data to load
|
||||
if (!loading && !accessLoading && !hasCheckedOnboarding) {
|
||||
const onboardingComplete = isOnboardingComplete();
|
||||
|
||||
if (!onboardingComplete) {
|
||||
// Only redirect to onboarding if user is the owner and onboarding is not complete
|
||||
// Invited members (non-owners) should skip onboarding and use existing config
|
||||
if (!onboardingComplete && isOwner) {
|
||||
router.push(`/dashboard/${searchSpaceId}/onboard`);
|
||||
}
|
||||
|
||||
|
|
@ -114,8 +121,10 @@ export function DashboardClientLayout({
|
|||
}
|
||||
}, [
|
||||
loading,
|
||||
accessLoading,
|
||||
isOnboardingComplete,
|
||||
isOnboardingPage,
|
||||
isOwner,
|
||||
router,
|
||||
searchSpaceId,
|
||||
hasCheckedOnboarding,
|
||||
|
|
@ -145,7 +154,7 @@ export function DashboardClientLayout({
|
|||
}, [chat_id, search_space_id]);
|
||||
|
||||
// Show loading screen while checking onboarding status (only on first load)
|
||||
if (!hasCheckedOnboarding && loading && !isOnboardingPage) {
|
||||
if (!hasCheckedOnboarding && (loading || accessLoading) && !isOnboardingPage) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
|
||||
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
type SearchSourceConnector,
|
||||
useSearchSourceConnectors,
|
||||
} from "@/hooks/use-search-source-connectors";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
||||
export default function AirtableConnectorPage() {
|
||||
const router = useRouter();
|
||||
|
|
@ -46,14 +47,9 @@ export default function AirtableConnectorPage() {
|
|||
const handleConnectAirtable = async () => {
|
||||
setIsConnecting(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/auth/airtable/connector/add/?space_id=${searchSpaceId}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
}
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import { EnumConnectorName } from "@/contracts/enums/connector";
|
|||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
// Assuming useSearchSourceConnectors hook exists and works similarly
|
||||
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
|
||||
import { authenticatedFetch, redirectToLogin } from "@/lib/auth-utils";
|
||||
|
||||
// Define the form schema with Zod for GitHub PAT entry step
|
||||
const githubPatFormSchema = z.object({
|
||||
|
|
@ -101,19 +102,11 @@ export default function GithubConnectorPage() {
|
|||
setConnectorName(values.name); // Store the name
|
||||
setValidatedPat(values.github_pat); // Store the PAT temporarily
|
||||
try {
|
||||
const token = localStorage.getItem("surfsense_bearer_token");
|
||||
if (!token) {
|
||||
throw new Error("No authentication token found");
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/github/repositories`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ github_pat: values.github_pat }),
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
type SearchSourceConnector,
|
||||
useSearchSourceConnectors,
|
||||
} from "@/hooks/use-search-source-connectors";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
||||
export default function GoogleCalendarConnectorPage() {
|
||||
const router = useRouter();
|
||||
|
|
@ -51,14 +52,9 @@ export default function GoogleCalendarConnectorPage() {
|
|||
try {
|
||||
setIsConnecting(true);
|
||||
// Call backend to initiate authorization flow
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/auth/google/calendar/connector/add/?space_id=${searchSpaceId}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
}
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
type SearchSourceConnector,
|
||||
useSearchSourceConnectors,
|
||||
} from "@/hooks/use-search-source-connectors";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
||||
export default function GoogleGmailConnectorPage() {
|
||||
const router = useRouter();
|
||||
|
|
@ -50,14 +51,9 @@ export default function GoogleGmailConnectorPage() {
|
|||
try {
|
||||
setIsConnecting(true);
|
||||
// Call backend to initiate authorization flow
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/auth/google/gmail/connector/add/?space_id=${searchSpaceId}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
}
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
|
|
@ -309,6 +309,7 @@ export function DocumentsTableShell({
|
|||
refreshDocuments={async () => {
|
||||
await onRefresh();
|
||||
}}
|
||||
searchSpaceId={searchSpaceId as string}
|
||||
/>
|
||||
</TableCell>
|
||||
</motion.tr>
|
||||
|
|
@ -340,6 +341,7 @@ export function DocumentsTableShell({
|
|||
refreshDocuments={async () => {
|
||||
await onRefresh();
|
||||
}}
|
||||
searchSpaceId={searchSpaceId as string}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
import { FileText, Pencil, Trash2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { JsonMetadataViewer } from "@/components/json-metadata-viewer";
|
||||
|
|
@ -12,29 +14,26 @@ import {
|
|||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type { Document } from "./types";
|
||||
|
||||
export function RowActions({
|
||||
document,
|
||||
deleteDocument,
|
||||
refreshDocuments,
|
||||
searchSpaceId,
|
||||
}: {
|
||||
document: Document;
|
||||
deleteDocument: (id: number) => Promise<boolean>;
|
||||
refreshDocuments: () => Promise<void>;
|
||||
searchSpaceId: string;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [isMetadataOpen, setIsMetadataOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true);
|
||||
|
|
@ -48,62 +47,114 @@ export function RowActions({
|
|||
toast.error("Failed to delete document");
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setIsOpen(false);
|
||||
setIsDeleteOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(`/dashboard/${searchSpaceId}/editor/${document.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<JsonMetadataViewer
|
||||
title={document.title}
|
||||
metadata={document.document_metadata}
|
||||
trigger={
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||
View Metadata
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
setIsOpen(true);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleDelete();
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? "Deleting..." : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{/* Edit Button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 17 }}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-muted/80"
|
||||
onClick={handleEdit}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<span className="sr-only">Edit Document</span>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>Edit Document</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* View Metadata Button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 17 }}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-muted/80"
|
||||
onClick={() => setIsMetadataOpen(true)}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="sr-only">View Metadata</span>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>View Metadata</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<JsonMetadataViewer
|
||||
title={document.title}
|
||||
metadata={document.document_metadata}
|
||||
open={isMetadataOpen}
|
||||
onOpenChange={setIsMetadataOpen}
|
||||
/>
|
||||
|
||||
{/* Delete Button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 17 }}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => setIsDeleteOpen(true)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="sr-only">Delete</span>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>Delete</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleDelete();
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isDeleting ? "Deleting..." : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,256 @@
|
|||
"use client";
|
||||
|
||||
import { AlertCircle, FileText, Loader2, Save, X } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { BlockNoteEditor } from "@/components/DynamicBlockNoteEditor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
||||
|
||||
interface EditorContent {
|
||||
document_id: number;
|
||||
title: string;
|
||||
blocknote_document: any;
|
||||
last_edited_at: string | null;
|
||||
}
|
||||
|
||||
export default function EditorPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const documentId = params.documentId as string;
|
||||
|
||||
const [document, setDocument] = useState<EditorContent | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editorContent, setEditorContent] = useState<any>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
|
||||
// Fetch document content - DIRECT CALL TO FASTAPI
|
||||
useEffect(() => {
|
||||
async function fetchDocument() {
|
||||
const token = getBearerToken();
|
||||
if (!token) {
|
||||
console.error("No auth token found");
|
||||
// Redirect to login with current path saved
|
||||
redirectToLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${params.search_space_id}/documents/${documentId}/editor-content`,
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Failed to fetch document" }));
|
||||
throw new Error(errorData.detail || "Failed to fetch document");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Check if blocknote_document exists
|
||||
if (!data.blocknote_document) {
|
||||
setError(
|
||||
"This document does not have BlockNote content. Please re-upload the document to enable editing."
|
||||
);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setDocument(data);
|
||||
setEditorContent(data.blocknote_document);
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
console.error("Error fetching document:", error);
|
||||
setError(
|
||||
error instanceof Error ? error.message : "Failed to fetch document. Please try again."
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (documentId) {
|
||||
fetchDocument();
|
||||
}
|
||||
}, [documentId, params.search_space_id]);
|
||||
|
||||
// Track changes to mark as unsaved
|
||||
useEffect(() => {
|
||||
if (editorContent && document) {
|
||||
setHasUnsavedChanges(true);
|
||||
}
|
||||
}, [editorContent, document]);
|
||||
|
||||
// TODO: Maybe add Auto-save every 30 seconds - DIRECT CALL TO FASTAPI
|
||||
|
||||
// Save and exit - DIRECT CALL TO FASTAPI
|
||||
const handleSave = async () => {
|
||||
const token = getBearerToken();
|
||||
if (!token) {
|
||||
toast.error("Please login to save");
|
||||
redirectToLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editorContent) {
|
||||
toast.error("No content to save");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
// Save blocknote_document and trigger reindexing in background
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${params.search_space_id}/documents/${documentId}/save`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ blocknote_document: editorContent }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Failed to save document" }));
|
||||
throw new Error(errorData.detail || "Failed to save document");
|
||||
}
|
||||
|
||||
setHasUnsavedChanges(false);
|
||||
toast.success("Document saved! Reindexing in background...");
|
||||
|
||||
// Small delay before redirect to show success message
|
||||
setTimeout(() => {
|
||||
router.push(`/dashboard/${params.search_space_id}/documents`);
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
console.error("Error saving document:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to save document. Please try again."
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (hasUnsavedChanges) {
|
||||
if (confirm("You have unsaved changes. Are you sure you want to leave?")) {
|
||||
router.back();
|
||||
}
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px] p-6">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 className="h-12 w-12 text-primary animate-spin mb-4" />
|
||||
<p className="text-muted-foreground">Loading editor...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px] p-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="w-full max-w-md"
|
||||
>
|
||||
<Card className="border-destructive/50">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
<CardTitle className="text-destructive">Error</CardTitle>
|
||||
</div>
|
||||
<CardDescription>{error}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={() => router.back()} variant="outline" className="w-full">
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Go Back
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!document) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px] p-6">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<FileText className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">Document not found</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex flex-col h-full w-full"
|
||||
>
|
||||
{/* Toolbar */}
|
||||
<div className="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-4 border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-6">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<FileText className="h-5 w-5 text-muted-foreground shrink-0" />
|
||||
<div className="flex flex-col min-w-0">
|
||||
<h1 className="text-lg font-semibold truncate">{document.title}</h1>
|
||||
{hasUnsavedChanges && <p className="text-xs text-muted-foreground">Unsaved changes</p>}
|
||||
</div>
|
||||
</div>
|
||||
<Separator orientation="vertical" className="h-6" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleCancel} disabled={saving} className="gap-2">
|
||||
<X className="h-4 w-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving} className="gap-2">
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4" />
|
||||
Save & Exit
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor Container */}
|
||||
<div className="flex-1 overflow-hidden relative">
|
||||
<div className="h-full w-full overflow-auto p-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<BlockNoteEditor initialContent={editorContent} onChange={setEditorContent} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
@ -52,6 +52,12 @@ export default function DashboardLayout({
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Team",
|
||||
url: `/dashboard/${search_space_id}/team`,
|
||||
icon: "Users",
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
url: `/dashboard/${search_space_id}/settings`,
|
||||
|
|
|
|||
|
|
@ -1126,7 +1126,7 @@ function LogRowActions({ row, t }: { row: Row<Log>; t: (key: string) => string }
|
|||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteLog(log.id);
|
||||
toast.success(t("log_deleted_success"));
|
||||
// toast.success(t("log_deleted_success"));
|
||||
await refreshLogs();
|
||||
} catch (error) {
|
||||
console.error("Error deleting log:", error);
|
||||
|
|
|
|||
|
|
@ -1,20 +1,19 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowLeft, ArrowRight, Bot, CheckCircle, MessageSquare, Sparkles } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { FileText, MessageSquare, UserPlus, Users } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { CompletionStep } from "@/components/onboard/completion-step";
|
||||
import { SetupLLMStep } from "@/components/onboard/setup-llm-step";
|
||||
import { SetupPromptStep } from "@/components/onboard/setup-prompt-step";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { OnboardActionCard } from "@/components/onboard/onboard-action-card";
|
||||
import { OnboardAdvancedSettings } from "@/components/onboard/onboard-advanced-settings";
|
||||
import { OnboardHeader } from "@/components/onboard/onboard-header";
|
||||
import { OnboardLLMSetup } from "@/components/onboard/onboard-llm-setup";
|
||||
import { OnboardLoading } from "@/components/onboard/onboard-loading";
|
||||
import { OnboardStats } from "@/components/onboard/onboard-stats";
|
||||
import { useGlobalLLMConfigs, useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs";
|
||||
|
||||
const TOTAL_STEPS = 3;
|
||||
import { getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
||||
|
||||
const OnboardPage = () => {
|
||||
const t = useTranslations("onboard");
|
||||
|
|
@ -28,10 +27,17 @@ const OnboardPage = () => {
|
|||
preferences,
|
||||
loading: preferencesLoading,
|
||||
isOnboardingComplete,
|
||||
updatePreferences,
|
||||
refreshPreferences,
|
||||
} = useLLMPreferences(searchSpaceId);
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [hasUserProgressed, setHasUserProgressed] = useState(false);
|
||||
|
||||
const [isAutoConfiguring, setIsAutoConfiguring] = useState(false);
|
||||
const [autoConfigComplete, setAutoConfigComplete] = useState(false);
|
||||
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
|
||||
const [showPromptSettings, setShowPromptSettings] = useState(false);
|
||||
|
||||
// Track if we've already attempted auto-configuration
|
||||
const hasAttemptedAutoConfig = useRef(false);
|
||||
|
||||
// Track if onboarding was complete on initial mount
|
||||
const wasCompleteOnMount = useRef<boolean | null>(null);
|
||||
|
|
@ -39,12 +45,13 @@ const OnboardPage = () => {
|
|||
|
||||
// Check if user is authenticated
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem("surfsense_bearer_token");
|
||||
const token = getBearerToken();
|
||||
if (!token) {
|
||||
router.push("/login");
|
||||
// Save current path and redirect to login
|
||||
redirectToLogin();
|
||||
return;
|
||||
}
|
||||
}, [router]);
|
||||
}, []);
|
||||
|
||||
// Capture onboarding state on first load
|
||||
useEffect(() => {
|
||||
|
|
@ -59,231 +66,215 @@ const OnboardPage = () => {
|
|||
}
|
||||
}, [preferencesLoading, configsLoading, globalConfigsLoading, isOnboardingComplete]);
|
||||
|
||||
// Track if user has progressed beyond step 1
|
||||
// Redirect to dashboard if onboarding was already complete
|
||||
useEffect(() => {
|
||||
if (currentStep > 1) {
|
||||
setHasUserProgressed(true);
|
||||
}
|
||||
}, [currentStep]);
|
||||
|
||||
// Redirect to dashboard if onboarding was already complete on mount (not during this session)
|
||||
useEffect(() => {
|
||||
// Only redirect if:
|
||||
// 1. Onboarding was complete when page loaded
|
||||
// 2. User hasn't progressed past step 1
|
||||
// 3. All data is loaded
|
||||
if (
|
||||
wasCompleteOnMount.current === true &&
|
||||
!hasUserProgressed &&
|
||||
!preferencesLoading &&
|
||||
!configsLoading &&
|
||||
!globalConfigsLoading
|
||||
) {
|
||||
// Small delay to ensure the check is stable on initial load
|
||||
const timer = setTimeout(() => {
|
||||
router.push(`/dashboard/${searchSpaceId}`);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [
|
||||
hasUserProgressed,
|
||||
preferencesLoading,
|
||||
configsLoading,
|
||||
globalConfigsLoading,
|
||||
router,
|
||||
searchSpaceId,
|
||||
]);
|
||||
}, [preferencesLoading, configsLoading, globalConfigsLoading, router, searchSpaceId]);
|
||||
|
||||
const progress = (currentStep / TOTAL_STEPS) * 100;
|
||||
|
||||
const stepTitles = [t("setup_llm_configuration"), "Configure AI Responses", t("setup_complete")];
|
||||
|
||||
const stepDescriptions = [
|
||||
t("configure_providers_and_assign_roles"),
|
||||
"Customize how the AI responds to your queries (Optional)",
|
||||
t("all_set"),
|
||||
];
|
||||
|
||||
// User can proceed to step 2 if all roles are assigned
|
||||
const canProceedToStep2 =
|
||||
!preferencesLoading &&
|
||||
preferences.long_context_llm_id &&
|
||||
preferences.fast_llm_id &&
|
||||
preferences.strategic_llm_id;
|
||||
|
||||
// User can always proceed from step 2 to step 3 (prompt config is optional)
|
||||
const canProceedToStep3 = true;
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentStep < TOTAL_STEPS) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
// Auto-configure LLM roles if global configs are available
|
||||
const autoConfigureLLMs = useCallback(async () => {
|
||||
if (hasAttemptedAutoConfig.current) return;
|
||||
if (globalConfigs.length === 0) return;
|
||||
if (isOnboardingComplete()) {
|
||||
setAutoConfigComplete(true);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep(currentStep - 1);
|
||||
hasAttemptedAutoConfig.current = true;
|
||||
setIsAutoConfiguring(true);
|
||||
|
||||
try {
|
||||
const allConfigs = [...globalConfigs, ...llmConfigs];
|
||||
|
||||
if (allConfigs.length === 0) {
|
||||
setIsAutoConfiguring(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use first available config for all roles
|
||||
const defaultConfigId = allConfigs[0].id;
|
||||
|
||||
const newPreferences = {
|
||||
long_context_llm_id: defaultConfigId,
|
||||
fast_llm_id: defaultConfigId,
|
||||
strategic_llm_id: defaultConfigId,
|
||||
};
|
||||
|
||||
const success = await updatePreferences(newPreferences);
|
||||
|
||||
if (success) {
|
||||
await refreshPreferences();
|
||||
setAutoConfigComplete(true);
|
||||
toast.success("AI models configured automatically!", {
|
||||
description: "You can customize these in advanced settings.",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Auto-configuration failed:", error);
|
||||
} finally {
|
||||
setIsAutoConfiguring(false);
|
||||
}
|
||||
};
|
||||
}, [globalConfigs, llmConfigs, isOnboardingComplete, updatePreferences, refreshPreferences]);
|
||||
|
||||
if (configsLoading || preferencesLoading || globalConfigsLoading) {
|
||||
// Trigger auto-configuration once data is loaded
|
||||
useEffect(() => {
|
||||
if (!configsLoading && !globalConfigsLoading && !preferencesLoading) {
|
||||
autoConfigureLLMs();
|
||||
}
|
||||
}, [configsLoading, globalConfigsLoading, preferencesLoading, autoConfigureLLMs]);
|
||||
|
||||
const allConfigs = [...globalConfigs, ...llmConfigs];
|
||||
const isReady = autoConfigComplete || isOnboardingComplete();
|
||||
|
||||
// Loading state
|
||||
if (configsLoading || preferencesLoading || globalConfigsLoading || isAutoConfiguring) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Bot className="h-12 w-12 text-primary animate-pulse mb-4" />
|
||||
<p className="text-sm text-muted-foreground">{t("loading_config")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<OnboardLoading
|
||||
title={isAutoConfiguring ? "Setting up your AI assistant..." : t("loading_config")}
|
||||
subtitle={
|
||||
isAutoConfiguring
|
||||
? "Auto-configuring optimal settings for you"
|
||||
: "Please wait while we load your configuration"
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// No configs available - show LLM setup
|
||||
if (allConfigs.length === 0) {
|
||||
return (
|
||||
<OnboardLLMSetup
|
||||
searchSpaceId={searchSpaceId}
|
||||
title={t("welcome_title")}
|
||||
configTitle={t("setup_llm_configuration")}
|
||||
configDescription={t("configure_providers_and_assign_roles")}
|
||||
onConfigCreated={refreshConfigs}
|
||||
onConfigDeleted={refreshConfigs}
|
||||
onPreferencesUpdated={refreshPreferences}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Main onboarding view
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-background via-background to-muted/20 flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="w-full max-w-4xl"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<Logo className="w-12 h-12 mr-3 rounded-full" />
|
||||
<h1 className="text-3xl font-bold">{t("welcome_title")}</h1>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-lg">{t("welcome_subtitle")}</p>
|
||||
</div>
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="flex items-center justify-center min-h-screen p-4 md:p-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="w-full max-w-5xl"
|
||||
>
|
||||
{/* Header */}
|
||||
<OnboardHeader
|
||||
title={t("welcome_title")}
|
||||
subtitle={
|
||||
isReady ? "You're all set! Choose what you'd like to do next." : t("welcome_subtitle")
|
||||
}
|
||||
isReady={isReady}
|
||||
/>
|
||||
|
||||
{/* Progress */}
|
||||
<Card className="mb-8 bg-background/60 backdrop-blur-sm">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="text-sm font-medium">
|
||||
{t("step_of", { current: currentStep, total: TOTAL_STEPS })}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("percent_complete", { percent: Math.round(progress) })}
|
||||
</div>
|
||||
</div>
|
||||
<Progress value={progress} className="mb-4" />
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{Array.from({ length: TOTAL_STEPS }, (_, i) => {
|
||||
const stepNum = i + 1;
|
||||
const isCompleted = stepNum < currentStep;
|
||||
const isCurrent = stepNum === currentStep;
|
||||
{/* Quick Stats */}
|
||||
<OnboardStats
|
||||
globalConfigsCount={globalConfigs.length}
|
||||
userConfigsCount={llmConfigs.length}
|
||||
/>
|
||||
|
||||
return (
|
||||
<div key={stepNum} className="flex items-center space-x-2">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||
isCompleted
|
||||
? "bg-primary text-primary-foreground"
|
||||
: isCurrent
|
||||
? "bg-primary/20 text-primary border-2 border-primary"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{isCompleted ? <CheckCircle className="w-4 h-4" /> : stepNum}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={`text-sm font-medium truncate ${
|
||||
isCurrent ? "text-foreground" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{stepTitles[i]}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Action Cards */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10"
|
||||
>
|
||||
<OnboardActionCard
|
||||
title="Manage Team"
|
||||
description="Invite team members and collaborate on your search space"
|
||||
icon={Users}
|
||||
features={[
|
||||
"Invite team members",
|
||||
"Assign roles & permissions",
|
||||
"Collaborate together",
|
||||
]}
|
||||
buttonText="Manage Team"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/team`)}
|
||||
colorScheme="emerald"
|
||||
delay={0.7}
|
||||
/>
|
||||
|
||||
{/* Step Content */}
|
||||
<Card className="min-h-[500px] bg-background/60 backdrop-blur-sm">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl flex items-center justify-center gap-2">
|
||||
{currentStep === 1 && <Sparkles className="w-6 h-6" />}
|
||||
{currentStep === 2 && <MessageSquare className="w-6 h-6" />}
|
||||
{currentStep === 3 && <CheckCircle className="w-6 h-6" />}
|
||||
{stepTitles[currentStep - 1]}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
{stepDescriptions[currentStep - 1]}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentStep}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{currentStep === 1 && (
|
||||
<SetupLLMStep
|
||||
searchSpaceId={searchSpaceId}
|
||||
onConfigCreated={refreshConfigs}
|
||||
onConfigDeleted={refreshConfigs}
|
||||
onPreferencesUpdated={refreshPreferences}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 2 && (
|
||||
<SetupPromptStep searchSpaceId={searchSpaceId} onComplete={handleNext} />
|
||||
)}
|
||||
{currentStep === 3 && <CompletionStep searchSpaceId={searchSpaceId} />}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<OnboardActionCard
|
||||
title="Add Sources"
|
||||
description="Connect your data sources to start building your knowledge base"
|
||||
icon={FileText}
|
||||
features={[
|
||||
"Connect documents and files",
|
||||
"Import from various sources",
|
||||
"Build your knowledge base",
|
||||
]}
|
||||
buttonText="Add Sources"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/sources/add`)}
|
||||
colorScheme="blue"
|
||||
delay={0.8}
|
||||
/>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between mt-8">
|
||||
{currentStep === 1 ? (
|
||||
<>
|
||||
<div />
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={!canProceedToStep2}
|
||||
className="flex items-center gap-2"
|
||||
<OnboardActionCard
|
||||
title="Start Chatting"
|
||||
description="Jump right into the AI researcher and start asking questions"
|
||||
icon={MessageSquare}
|
||||
features={[
|
||||
"AI-powered conversations",
|
||||
"Research and explore topics",
|
||||
"Get instant insights",
|
||||
]}
|
||||
buttonText="Start Chatting"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/researcher`)}
|
||||
colorScheme="violet"
|
||||
delay={0.9}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Advanced Settings */}
|
||||
<OnboardAdvancedSettings
|
||||
searchSpaceId={searchSpaceId}
|
||||
showLLMSettings={showAdvancedSettings}
|
||||
setShowLLMSettings={setShowAdvancedSettings}
|
||||
showPromptSettings={showPromptSettings}
|
||||
setShowPromptSettings={setShowPromptSettings}
|
||||
onConfigCreated={refreshConfigs}
|
||||
onConfigDeleted={refreshConfigs}
|
||||
onPreferencesUpdated={refreshPreferences}
|
||||
/>
|
||||
|
||||
{/* Footer */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 1.1 }}
|
||||
className="text-center mt-10 text-muted-foreground text-sm"
|
||||
>
|
||||
<p>
|
||||
You can always adjust these settings later in{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/settings`)}
|
||||
className="text-primary hover:underline underline-offset-2 transition-colors"
|
||||
>
|
||||
{t("next")}
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : currentStep === 2 ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handlePrevious}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t("previous")}
|
||||
</Button>
|
||||
{/* Next button is handled by SetupPromptStep component */}
|
||||
<div />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handlePrevious}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t("previous")}
|
||||
</Button>
|
||||
<div />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
Settings
|
||||
</button>
|
||||
</p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -17,13 +17,14 @@ export default function ResearcherPage() {
|
|||
const { search_space_id } = useParams();
|
||||
const router = useRouter();
|
||||
const hasSetInitialConnectors = useRef(false);
|
||||
const hasInitiatedResponse = useRef<string | null>(null);
|
||||
const activeChatId = useAtomValue(activeChatIdAtom);
|
||||
const { data: activeChatState, isFetching: isChatLoading } = useAtomValue(activeChatAtom);
|
||||
const { mutateAsync: createChat } = useAtomValue(createChatMutationAtom);
|
||||
const { mutateAsync: updateChat } = useAtomValue(updateChatMutationAtom);
|
||||
const isNewChat = !activeChatId;
|
||||
|
||||
// Reset the flag when chat ID changes
|
||||
// Reset the flag when chat ID changes (but not hasInitiatedResponse - we need to remember if we already initiated)
|
||||
useEffect(() => {
|
||||
hasSetInitialConnectors.current = false;
|
||||
}, [activeChatId]);
|
||||
|
|
@ -167,10 +168,14 @@ export default function ResearcherPage() {
|
|||
if (chatData.messages && Array.isArray(chatData.messages)) {
|
||||
if (chatData.messages.length === 1 && chatData.messages[0].role === "user") {
|
||||
// Single user message - append to trigger LLM response
|
||||
handler.append({
|
||||
role: "user",
|
||||
content: chatData.messages[0].content,
|
||||
});
|
||||
// Only if we haven't already initiated for this chat and handler doesn't have messages yet
|
||||
if (hasInitiatedResponse.current !== activeChatId && handler.messages.length === 0) {
|
||||
hasInitiatedResponse.current = activeChatId;
|
||||
handler.append({
|
||||
role: "user",
|
||||
content: chatData.messages[0].content,
|
||||
});
|
||||
}
|
||||
} else if (chatData.messages.length > 1) {
|
||||
// Multiple messages - set them all
|
||||
handler.setMessages(chatData.messages);
|
||||
|
|
|
|||
1325
surfsense_web/app/dashboard/[search_space_id]/team/page.tsx
Normal file
1325
surfsense_web/app/dashboard/[search_space_id]/team/page.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,28 +1,28 @@
|
|||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AnnouncementBanner } from "@/components/announcement-banner";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
const router = useRouter();
|
||||
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user is authenticated
|
||||
const token = localStorage.getItem("surfsense_bearer_token");
|
||||
const token = getBearerToken();
|
||||
if (!token) {
|
||||
router.push("/login");
|
||||
// Save current path and redirect to login
|
||||
redirectToLogin();
|
||||
return;
|
||||
}
|
||||
setIsCheckingAuth(false);
|
||||
}, [router]);
|
||||
}, []);
|
||||
|
||||
// Show loading screen while checking authentication
|
||||
if (isCheckingAuth) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { AlertCircle, Loader2, Plus, Search, Trash2 } from "lucide-react";
|
||||
import { AlertCircle, Loader2, Plus, Search, Trash2, UserCheck, Users } from "lucide-react";
|
||||
import { motion, type Variants } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
|
@ -22,6 +22,7 @@ import {
|
|||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
|
|
@ -35,6 +36,7 @@ import { Spotlight } from "@/components/ui/spotlight";
|
|||
import { Tilt } from "@/components/ui/tilt";
|
||||
import { useUser } from "@/hooks";
|
||||
import { useSearchSpaces } from "@/hooks/use-search-spaces";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
||||
/**
|
||||
* Formats a date string into a readable format
|
||||
|
|
@ -172,14 +174,9 @@ const DashboardPage = () => {
|
|||
const handleDeleteSearchSpace = async (id: number) => {
|
||||
// Send DELETE request to the API
|
||||
try {
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${id}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
}
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -308,16 +305,30 @@ const DashboardPage = () => {
|
|||
>
|
||||
<div className="flex flex-1 flex-col justify-between p-1">
|
||||
<div>
|
||||
<h3 className="font-medium text-lg">{space.name}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-lg">{space.name}</h3>
|
||||
{!space.is_owner && (
|
||||
<Badge variant="secondary" className="text-xs font-normal">
|
||||
{t("shared")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{space.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 text-xs text-muted-foreground">
|
||||
{/* <span>{space.title}</span> */}
|
||||
<div className="mt-4 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t("created")} {formatDate(space.created_at)}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{space.is_owner ? (
|
||||
<UserCheck className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>{space.member_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -4,19 +4,21 @@ import { motion } from "motion/react";
|
|||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { SearchSpaceForm } from "@/components/search-space-form";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
||||
export default function SearchSpacesPage() {
|
||||
const router = useRouter();
|
||||
const handleCreateSearchSpace = async (data: { name: string; description: string }) => {
|
||||
const handleCreateSearchSpace = async (data: { name: string; description?: string }) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: data.name,
|
||||
description: data.description || "",
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -31,7 +33,8 @@ export default function SearchSpacesPage() {
|
|||
description: `"${data.name}" has been created.`,
|
||||
});
|
||||
|
||||
router.push(`/dashboard`);
|
||||
// Redirect to the newly created search space's onboarding
|
||||
router.push(`/dashboard/${result.id}/onboard`);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
|
|
@ -63,8 +63,8 @@
|
|||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.396 0.141 25.723);
|
||||
--destructive-foreground: oklch(0.637 0.237 25.331);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(0.269 0 0);
|
||||
--input: oklch(0.269 0 0);
|
||||
--ring: oklch(0.439 0 0);
|
||||
|
|
|
|||
340
surfsense_web/app/invite/[invite_code]/page.tsx
Normal file
340
surfsense_web/app/invite/[invite_code]/page.tsx
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Loader2,
|
||||
LogIn,
|
||||
Shield,
|
||||
Sparkles,
|
||||
Users,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { use, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { useInviteInfo } from "@/hooks/use-rbac";
|
||||
import { getBearerToken } from "@/lib/auth-utils";
|
||||
|
||||
export default function InviteAcceptPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const inviteCode = params.invite_code as string;
|
||||
|
||||
const { inviteInfo, loading, acceptInvite } = useInviteInfo(inviteCode);
|
||||
const [accepting, setAccepting] = useState(false);
|
||||
const [accepted, setAccepted] = useState(false);
|
||||
const [acceptedData, setAcceptedData] = useState<{
|
||||
search_space_id: number;
|
||||
search_space_name: string;
|
||||
role_name: string;
|
||||
} | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoggedIn, setIsLoggedIn] = useState<boolean | null>(null);
|
||||
|
||||
// Check if user is logged in
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const token = getBearerToken();
|
||||
setIsLoggedIn(!!token);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAccept = async () => {
|
||||
setAccepting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await acceptInvite();
|
||||
if (result) {
|
||||
setAccepted(true);
|
||||
setAcceptedData(result);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to accept invite");
|
||||
} finally {
|
||||
setAccepting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoginRedirect = () => {
|
||||
// Store the invite code to redirect back after login
|
||||
localStorage.setItem("pending_invite_code", inviteCode);
|
||||
// Save the current invite page URL so we can return after authentication
|
||||
localStorage.setItem("surfsense_redirect_path", `/invite/${inviteCode}`);
|
||||
// Redirect to login (we manually set the path above since invite pages need special handling)
|
||||
window.location.href = "/login";
|
||||
};
|
||||
|
||||
// Check for pending invite after login
|
||||
useEffect(() => {
|
||||
if (isLoggedIn && typeof window !== "undefined") {
|
||||
const pendingInvite = localStorage.getItem("pending_invite_code");
|
||||
if (pendingInvite === inviteCode) {
|
||||
localStorage.removeItem("pending_invite_code");
|
||||
// Auto-accept the invite after redirect
|
||||
handleAccept();
|
||||
}
|
||||
}
|
||||
}, [isLoggedIn, inviteCode]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-background via-background to-primary/5">
|
||||
{/* Background decoration */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute -top-1/2 -right-1/2 w-full h-full bg-gradient-to-bl from-primary/10 via-transparent to-transparent rounded-full blur-3xl" />
|
||||
<div className="absolute -bottom-1/2 -left-1/2 w-full h-full bg-gradient-to-tr from-violet-500/10 via-transparent to-transparent rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ duration: 0.5, ease: "easeOut" }}
|
||||
className="w-full max-w-md relative z-10"
|
||||
>
|
||||
<Card className="border-none shadow-2xl bg-card/80 backdrop-blur-xl">
|
||||
{loading || isLoggedIn === null ? (
|
||||
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||
>
|
||||
<Loader2 className="h-12 w-12 text-primary" />
|
||||
</motion.div>
|
||||
<p className="mt-4 text-muted-foreground">Loading invite details...</p>
|
||||
</CardContent>
|
||||
) : accepted && acceptedData ? (
|
||||
<>
|
||||
<CardHeader className="text-center pb-4">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 200, damping: 15 }}
|
||||
className="mx-auto mb-4 h-20 w-20 rounded-full bg-gradient-to-br from-emerald-500/20 to-emerald-500/5 flex items-center justify-center ring-4 ring-emerald-500/20"
|
||||
>
|
||||
<CheckCircle2 className="h-10 w-10 text-emerald-500" />
|
||||
</motion.div>
|
||||
<CardTitle className="text-2xl">Welcome to the team!</CardTitle>
|
||||
<CardDescription>
|
||||
You've successfully joined {acceptedData.search_space_name}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="bg-muted/50 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Users className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{acceptedData.search_space_name}</p>
|
||||
<p className="text-sm text-muted-foreground">Search Space</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-violet-500/10 flex items-center justify-center">
|
||||
<Shield className="h-5 w-5 text-violet-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{acceptedData.role_name}</p>
|
||||
<p className="text-sm text-muted-foreground">Your Role</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
className="w-full gap-2"
|
||||
onClick={() => router.push(`/dashboard/${acceptedData.search_space_id}`)}
|
||||
>
|
||||
Go to Search Space
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</>
|
||||
) : !inviteInfo?.is_valid ? (
|
||||
<>
|
||||
<CardHeader className="text-center pb-4">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 200, damping: 15 }}
|
||||
className="mx-auto mb-4 h-20 w-20 rounded-full bg-gradient-to-br from-destructive/20 to-destructive/5 flex items-center justify-center ring-4 ring-destructive/20"
|
||||
>
|
||||
<XCircle className="h-10 w-10 text-destructive" />
|
||||
</motion.div>
|
||||
<CardTitle className="text-2xl">Invalid Invite</CardTitle>
|
||||
<CardDescription>
|
||||
{inviteInfo?.message || "This invite link is no longer valid"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The invite may have expired, reached its maximum uses, or been revoked by the
|
||||
owner.
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => router.push("/dashboard")}
|
||||
>
|
||||
Go to Dashboard
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</>
|
||||
) : !isLoggedIn ? (
|
||||
<>
|
||||
<CardHeader className="text-center pb-4">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 200, damping: 15 }}
|
||||
className="mx-auto mb-4 h-20 w-20 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center ring-4 ring-primary/20"
|
||||
>
|
||||
<Sparkles className="h-10 w-10 text-primary" />
|
||||
</motion.div>
|
||||
<CardTitle className="text-2xl">You're Invited!</CardTitle>
|
||||
<CardDescription>
|
||||
Sign in to join {inviteInfo?.search_space_name || "this search space"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="bg-muted/50 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Users className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{inviteInfo?.search_space_name}</p>
|
||||
<p className="text-sm text-muted-foreground">Search Space</p>
|
||||
</div>
|
||||
</div>
|
||||
{inviteInfo?.role_name && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-violet-500/10 flex items-center justify-center">
|
||||
<Shield className="h-5 w-5 text-violet-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{inviteInfo.role_name}</p>
|
||||
<p className="text-sm text-muted-foreground">Role you'll receive</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button className="w-full gap-2" onClick={handleLoginRedirect}>
|
||||
<LogIn className="h-4 w-4" />
|
||||
Sign in to Accept
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CardHeader className="text-center pb-4">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 200, damping: 15 }}
|
||||
className="mx-auto mb-4 h-20 w-20 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center ring-4 ring-primary/20"
|
||||
>
|
||||
<Sparkles className="h-10 w-10 text-primary" />
|
||||
</motion.div>
|
||||
<CardTitle className="text-2xl">You're Invited!</CardTitle>
|
||||
<CardDescription>
|
||||
Accept this invite to join {inviteInfo?.search_space_name || "this search space"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="bg-muted/50 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Users className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{inviteInfo?.search_space_name}</p>
|
||||
<p className="text-sm text-muted-foreground">Search Space</p>
|
||||
</div>
|
||||
</div>
|
||||
{inviteInfo?.role_name && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-violet-500/10 flex items-center justify-center">
|
||||
<Shield className="h-5 w-5 text-violet-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{inviteInfo.role_name}</p>
|
||||
<p className="text-sm text-muted-foreground">Role you'll receive</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex items-center gap-2 p-3 bg-destructive/10 text-destructive rounded-lg text-sm"
|
||||
>
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => router.push("/dashboard")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="flex-1 gap-2" onClick={handleAccept} disabled={accepting}>
|
||||
{accepting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Accepting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Accept Invite
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Branding */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="mt-6 text-center"
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Image src="/icon-128.png" alt="SurfSense" width={24} height={24} className="rounded" />
|
||||
<span className="text-sm font-medium">SurfSense</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import type {
|
|||
UpdateChatRequest,
|
||||
} from "@/contracts/types/chat.types";
|
||||
import { chatsApiService } from "@/lib/apis/chats-api.service";
|
||||
import { getBearerToken } from "@/lib/auth-utils";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { queryClient } from "@/lib/query-client/client";
|
||||
import { activeSearchSpaceIdAtom } from "../seach-spaces/seach-space-queries.atom";
|
||||
|
|
@ -14,7 +15,7 @@ import { globalChatsQueryParamsAtom } from "./ui.atoms";
|
|||
|
||||
export const deleteChatMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||
const authToken = localStorage.getItem("surfsense_bearer_token");
|
||||
const authToken = getBearerToken();
|
||||
const chatsQueryParams = get(globalChatsQueryParamsAtom);
|
||||
|
||||
return {
|
||||
|
|
@ -38,7 +39,7 @@ export const deleteChatMutationAtom = atomWithMutation((get) => {
|
|||
|
||||
export const createChatMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||
const authToken = localStorage.getItem("surfsense_bearer_token");
|
||||
const authToken = getBearerToken();
|
||||
const chatsQueryParams = get(globalChatsQueryParamsAtom);
|
||||
|
||||
return {
|
||||
|
|
@ -58,7 +59,7 @@ export const createChatMutationAtom = atomWithMutation((get) => {
|
|||
|
||||
export const updateChatMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||
const authToken = localStorage.getItem("surfsense_bearer_token");
|
||||
const authToken = getBearerToken();
|
||||
const chatsQueryParams = get(globalChatsQueryParamsAtom);
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -2,12 +2,13 @@ import { atomWithQuery } from "jotai-tanstack-query";
|
|||
import { activeSearchSpaceIdAtom } from "@/atoms/seach-spaces/seach-space-queries.atom";
|
||||
import { chatsApiService } from "@/lib/apis/chats-api.service";
|
||||
import { podcastsApiService } from "@/lib/apis/podcasts-api.service";
|
||||
import { getBearerToken } from "@/lib/auth-utils";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { activeChatIdAtom, globalChatsQueryParamsAtom } from "./ui.atoms";
|
||||
|
||||
export const activeChatAtom = atomWithQuery((get) => {
|
||||
const activeChatId = get(activeChatIdAtom);
|
||||
const authToken = localStorage.getItem("surfsense_bearer_token");
|
||||
const authToken = getBearerToken();
|
||||
|
||||
return {
|
||||
queryKey: cacheKeys.chats.activeChat(activeChatId ?? ""),
|
||||
|
|
@ -32,7 +33,7 @@ export const activeChatAtom = atomWithQuery((get) => {
|
|||
|
||||
export const chatsAtom = atomWithQuery((get) => {
|
||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||
const authToken = localStorage.getItem("surfsense_bearer_token");
|
||||
const authToken = getBearerToken();
|
||||
const queryParams = get(globalChatsQueryParamsAtom);
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -7,13 +7,14 @@ import type {
|
|||
Podcast,
|
||||
} from "@/contracts/types/podcast.types";
|
||||
import { podcastsApiService } from "@/lib/apis/podcasts-api.service";
|
||||
import { getBearerToken } from "@/lib/auth-utils";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { queryClient } from "@/lib/query-client/client";
|
||||
import { globalPodcastsQueryParamsAtom } from "./ui.atoms";
|
||||
|
||||
export const deletePodcastMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||
const authToken = localStorage.getItem("surfsense_bearer_token");
|
||||
const authToken = getBearerToken();
|
||||
const podcastsQueryParams = get(globalPodcastsQueryParamsAtom);
|
||||
|
||||
return {
|
||||
|
|
@ -37,7 +38,7 @@ export const deletePodcastMutationAtom = atomWithMutation((get) => {
|
|||
|
||||
export const generatePodcastMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||
const authToken = localStorage.getItem("surfsense_bearer_token");
|
||||
const authToken = getBearerToken();
|
||||
const podcastsQueryParams = get(globalPodcastsQueryParamsAtom);
|
||||
|
||||
return {
|
||||
|
|
|
|||
68
surfsense_web/components/BlockNoteEditor.tsx
Normal file
68
surfsense_web/components/BlockNoteEditor.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import "@blocknote/core/fonts/inter.css";
|
||||
import "@blocknote/mantine/style.css";
|
||||
import { BlockNoteView } from "@blocknote/mantine";
|
||||
import { useCreateBlockNote } from "@blocknote/react";
|
||||
|
||||
interface BlockNoteEditorProps {
|
||||
initialContent?: any;
|
||||
onChange?: (content: any) => void;
|
||||
}
|
||||
|
||||
export default function BlockNoteEditor({ initialContent, onChange }: BlockNoteEditorProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
// Track the initial content to prevent re-initialization
|
||||
const initialContentRef = useRef<any>(null);
|
||||
const isInitializedRef = useRef(false);
|
||||
|
||||
// Creates a new editor instance - only use initialContent on first render
|
||||
const editor = useCreateBlockNote({
|
||||
initialContent: initialContentRef.current === null ? initialContent || undefined : undefined,
|
||||
});
|
||||
|
||||
// Store initial content on first render only
|
||||
useEffect(() => {
|
||||
if (initialContent && initialContentRef.current === null) {
|
||||
initialContentRef.current = initialContent;
|
||||
isInitializedRef.current = true;
|
||||
}
|
||||
}, [initialContent]);
|
||||
|
||||
// Call onChange when document changes (but don't update from props)
|
||||
useEffect(() => {
|
||||
if (!onChange || !editor || !isInitializedRef.current) return;
|
||||
|
||||
const handleChange = () => {
|
||||
onChange(editor.document);
|
||||
};
|
||||
|
||||
// Subscribe to document changes
|
||||
const unsubscribe = editor.onChange(handleChange);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [editor, onChange]);
|
||||
|
||||
// Determine theme for BlockNote with custom dark mode background
|
||||
const blockNoteTheme = useMemo(() => {
|
||||
if (resolvedTheme === "dark") {
|
||||
// Custom dark theme - only override editor background, let BlockNote handle the rest
|
||||
return {
|
||||
colors: {
|
||||
editor: {
|
||||
background: "#0A0A0A", // Custom dark background
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return "light" as const;
|
||||
}, [resolvedTheme]);
|
||||
|
||||
// Renders the editor instance
|
||||
return <BlockNoteView editor={editor} theme={blockNoteTheme} />;
|
||||
}
|
||||
6
surfsense_web/components/DynamicBlockNoteEditor.tsx
Normal file
6
surfsense_web/components/DynamicBlockNoteEditor.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
// Dynamically import BlockNote editor with SSR disabled
|
||||
export const BlockNoteEditor = dynamic(() => import("./BlockNoteEditor"), { ssr: false });
|
||||
|
|
@ -2,22 +2,25 @@
|
|||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { getAndClearRedirectPath, setBearerToken } from "@/lib/auth-utils";
|
||||
|
||||
interface TokenHandlerProps {
|
||||
redirectPath?: string; // Path to redirect after storing token
|
||||
redirectPath?: string; // Default path to redirect after storing token (if no saved path)
|
||||
tokenParamName?: string; // Name of the URL parameter containing the token
|
||||
storageKey?: string; // Key to use when storing in localStorage
|
||||
storageKey?: string; // Key to use when storing in localStorage (kept for backwards compatibility)
|
||||
}
|
||||
|
||||
/**
|
||||
* Client component that extracts a token from URL parameters and stores it in localStorage
|
||||
* After storing the token, it redirects the user back to the page they were on before
|
||||
* being redirected to login (if available), or to the default redirectPath.
|
||||
*
|
||||
* @param redirectPath - Path to redirect after storing token (default: '/')
|
||||
* @param redirectPath - Default path to redirect after storing token (default: '/dashboard')
|
||||
* @param tokenParamName - Name of the URL parameter containing the token (default: 'token')
|
||||
* @param storageKey - Key to use when storing in localStorage (default: 'auth_token')
|
||||
* @param storageKey - Key to use when storing in localStorage (default: 'surfsense_bearer_token')
|
||||
*/
|
||||
const TokenHandler = ({
|
||||
redirectPath = "/",
|
||||
redirectPath = "/dashboard",
|
||||
tokenParamName = "token",
|
||||
storageKey = "surfsense_bearer_token",
|
||||
}: TokenHandlerProps) => {
|
||||
|
|
@ -33,14 +36,22 @@ const TokenHandler = ({
|
|||
|
||||
if (token) {
|
||||
try {
|
||||
// Store token in localStorage
|
||||
// Store token in localStorage using both methods for compatibility
|
||||
localStorage.setItem(storageKey, token);
|
||||
// console.log(`Token stored in localStorage with key: ${storageKey}`);
|
||||
setBearerToken(token);
|
||||
|
||||
// Redirect to specified path
|
||||
router.push(redirectPath);
|
||||
// Check if there's a saved redirect path from before the auth flow
|
||||
const savedRedirectPath = getAndClearRedirectPath();
|
||||
|
||||
// Use the saved path if available, otherwise use the default redirectPath
|
||||
const finalRedirectPath = savedRedirectPath || redirectPath;
|
||||
|
||||
// Redirect to the appropriate path
|
||||
router.push(finalRedirectPath);
|
||||
} catch (error) {
|
||||
console.error("Error storing token in localStorage:", error);
|
||||
// Even if there's an error, try to redirect to the default path
|
||||
router.push(redirectPath);
|
||||
}
|
||||
}
|
||||
}, [searchParams, tokenParamName, storageKey, redirectPath, router]);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { ChatInput } from "@llamaindex/chat-ui";
|
||||
import { Brain, Check, FolderOpen, Minus, Plus, Zap } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Brain, Check, FolderOpen, Minus, Plus, PlusCircle, Zap } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import React, { Suspense, useCallback, useState } from "react";
|
||||
import { DocumentsDataTable } from "@/components/chat/DocumentsDataTable";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
|
@ -115,6 +115,7 @@ const ConnectorSelector = React.memo(
|
|||
selectedConnectors?: string[];
|
||||
}) => {
|
||||
const { search_space_id } = useParams();
|
||||
const router = useRouter();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
// Fetch immediately (not lazy) so the button can show the correct count
|
||||
|
|
@ -247,9 +248,19 @@ const ConnectorSelector = React.memo(
|
|||
<Brain className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h4 className="text-sm font-medium mb-1">No sources found</h4>
|
||||
<p className="text-xs text-muted-foreground max-w-xs">
|
||||
<p className="text-xs text-muted-foreground max-w-xs mb-4">
|
||||
Add documents or configure search connectors for this search space
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
router.push(`/dashboard/${search_space_id}/sources/add`);
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<PlusCircle className="h-4 w-4" />
|
||||
Add Sources
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { useAtomValue } from "jotai";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import React, { useEffect } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { activeChatAtom } from "@/atoms/chats/chat-query.atoms";
|
||||
import {
|
||||
Breadcrumb,
|
||||
|
|
@ -14,6 +14,7 @@ import {
|
|||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { useSearchSpace } from "@/hooks/use-search-space";
|
||||
import { authenticatedFetch, getBearerToken } from "@/lib/auth-utils";
|
||||
|
||||
interface BreadcrumbItemInterface {
|
||||
label: string;
|
||||
|
|
@ -34,6 +35,36 @@ export function DashboardBreadcrumb() {
|
|||
autoFetch: !!searchSpaceId,
|
||||
});
|
||||
|
||||
// State to store document title for editor breadcrumb
|
||||
const [documentTitle, setDocumentTitle] = useState<string | null>(null);
|
||||
|
||||
// Fetch document title when on editor page
|
||||
useEffect(() => {
|
||||
if (segments[2] === "editor" && segments[3] && searchSpaceId) {
|
||||
const documentId = segments[3];
|
||||
const token = getBearerToken();
|
||||
|
||||
if (token) {
|
||||
authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`,
|
||||
{ method: "GET" }
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.title) {
|
||||
setDocumentTitle(data.title);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// If fetch fails, just use the document ID
|
||||
setDocumentTitle(null);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setDocumentTitle(null);
|
||||
}
|
||||
}, [segments, searchSpaceId]);
|
||||
|
||||
// Parse the pathname to create breadcrumb items
|
||||
const generateBreadcrumbs = (path: string): BreadcrumbItemInterface[] => {
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
|
|
@ -66,6 +97,7 @@ export function DashboardBreadcrumb() {
|
|||
logs: t("logs"),
|
||||
chats: t("chats"),
|
||||
settings: t("settings"),
|
||||
editor: t("editor"),
|
||||
};
|
||||
|
||||
sectionLabel = sectionLabels[section] || sectionLabel;
|
||||
|
|
@ -73,7 +105,21 @@ export function DashboardBreadcrumb() {
|
|||
// Handle sub-sections
|
||||
if (segments[3]) {
|
||||
const subSection = segments[3];
|
||||
let subSectionLabel = subSection.charAt(0).toUpperCase() + subSection.slice(1);
|
||||
|
||||
// Handle editor sub-sections (document ID)
|
||||
if (section === "editor") {
|
||||
const documentLabel = documentTitle || subSection;
|
||||
breadcrumbs.push({
|
||||
label: t("documents"),
|
||||
href: `/dashboard/${segments[1]}/documents`,
|
||||
});
|
||||
breadcrumbs.push({
|
||||
label: sectionLabel,
|
||||
href: `/dashboard/${segments[1]}/documents`,
|
||||
});
|
||||
breadcrumbs.push({ label: documentLabel });
|
||||
return breadcrumbs;
|
||||
}
|
||||
|
||||
// Handle sources sub-sections
|
||||
if (section === "sources") {
|
||||
|
|
@ -81,7 +127,7 @@ export function DashboardBreadcrumb() {
|
|||
add: "Add Sources",
|
||||
};
|
||||
|
||||
const sourceLabel = sourceLabels[subSection] || subSectionLabel;
|
||||
const sourceLabel = sourceLabels[subSection] || subSection;
|
||||
breadcrumbs.push({
|
||||
label: "Sources",
|
||||
href: `/dashboard/${segments[1]}/sources`,
|
||||
|
|
@ -98,7 +144,7 @@ export function DashboardBreadcrumb() {
|
|||
webpage: t("add_webpages"),
|
||||
};
|
||||
|
||||
const documentLabel = documentLabels[subSection] || subSectionLabel;
|
||||
const documentLabel = documentLabels[subSection] || subSection;
|
||||
breadcrumbs.push({
|
||||
label: t("documents"),
|
||||
href: `/dashboard/${segments[1]}/documents`,
|
||||
|
|
@ -160,7 +206,7 @@ export function DashboardBreadcrumb() {
|
|||
manage: t("manage_connectors"),
|
||||
};
|
||||
|
||||
const connectorLabel = connectorLabels[subSection] || subSectionLabel;
|
||||
const connectorLabel = connectorLabels[subSection] || subSection;
|
||||
breadcrumbs.push({
|
||||
label: t("connectors"),
|
||||
href: `/dashboard/${segments[1]}/connectors`,
|
||||
|
|
@ -170,6 +216,7 @@ export function DashboardBreadcrumb() {
|
|||
}
|
||||
|
||||
// Handle other sub-sections
|
||||
let subSectionLabel = subSection.charAt(0).toUpperCase() + subSection.slice(1);
|
||||
const subSectionLabels: Record<string, string> = {
|
||||
upload: t("upload_documents"),
|
||||
youtube: t("add_youtube"),
|
||||
|
|
|
|||
|
|
@ -15,9 +15,17 @@ interface JsonMetadataViewerProps {
|
|||
title: string;
|
||||
metadata: any;
|
||||
trigger?: React.ReactNode;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function JsonMetadataViewer({ title, metadata, trigger }: JsonMetadataViewerProps) {
|
||||
export function JsonMetadataViewer({
|
||||
title,
|
||||
metadata,
|
||||
trigger,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: JsonMetadataViewerProps) {
|
||||
// Ensure metadata is a valid object
|
||||
const jsonData = React.useMemo(() => {
|
||||
if (!metadata) return {};
|
||||
|
|
@ -35,6 +43,23 @@ export function JsonMetadataViewer({ title, metadata, trigger }: JsonMetadataVie
|
|||
}
|
||||
}, [metadata]);
|
||||
|
||||
// Controlled mode: when open and onOpenChange are provided
|
||||
if (open !== undefined && onOpenChange !== undefined) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title} - Metadata</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="mt-4 p-4 bg-muted/30 rounded-md">
|
||||
<JsonView data={jsonData} style={defaultStyles} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// Uncontrolled mode: when using trigger
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
|
|
|
|||
|
|
@ -1,161 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
ArrowRight,
|
||||
Bot,
|
||||
Brain,
|
||||
CheckCircle,
|
||||
FileText,
|
||||
MessageSquare,
|
||||
Sparkles,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useGlobalLLMConfigs, useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs";
|
||||
|
||||
interface CompletionStepProps {
|
||||
searchSpaceId: number;
|
||||
}
|
||||
|
||||
export function CompletionStep({ searchSpaceId }: CompletionStepProps) {
|
||||
const router = useRouter();
|
||||
const { llmConfigs } = useLLMConfigs(searchSpaceId);
|
||||
const { globalConfigs } = useGlobalLLMConfigs();
|
||||
const { preferences } = useLLMPreferences(searchSpaceId);
|
||||
|
||||
// Combine global and user-specific configs
|
||||
const allConfigs = [...globalConfigs, ...llmConfigs];
|
||||
|
||||
const assignedConfigs = {
|
||||
long_context: allConfigs.find((c) => c.id === preferences.long_context_llm_id),
|
||||
fast: allConfigs.find((c) => c.id === preferences.fast_llm_id),
|
||||
strategic: allConfigs.find((c) => c.id === preferences.strategic_llm_id),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Next Steps - What would you like to do? */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="text-center">
|
||||
<h3 className="text-xl font-semibold mb-2">What would you like to do next?</h3>
|
||||
<p className="text-muted-foreground">Choose an option to continue</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Add Sources Card */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.7 }}
|
||||
>
|
||||
<Card className="h-full border-2 hover:border-primary/50 transition-all hover:shadow-lg cursor-pointer group">
|
||||
<CardHeader>
|
||||
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-950 rounded-lg flex items-center justify-center mb-3 group-hover:scale-110 transition-transform">
|
||||
<FileText className="w-6 h-6 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<CardTitle className="text-lg">Add Sources</CardTitle>
|
||||
<CardDescription>
|
||||
Connect your data sources to start building your knowledge base
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
<span>Connect documents and files</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
<span>Import from various sources</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
<span>Build your knowledge base</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full group-hover:bg-primary/90"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/sources/add`)}
|
||||
>
|
||||
Add Sources
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Start Chatting Card */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.8 }}
|
||||
>
|
||||
<Card className="h-full border-2 hover:border-primary/50 transition-all hover:shadow-lg cursor-pointer group">
|
||||
<CardHeader>
|
||||
<div className="w-12 h-12 bg-purple-100 dark:bg-purple-950 rounded-lg flex items-center justify-center mb-3 group-hover:scale-110 transition-transform">
|
||||
<MessageSquare className="w-6 h-6 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<CardTitle className="text-lg">Start Chatting</CardTitle>
|
||||
<CardDescription>
|
||||
Jump right into the AI researcher and start asking questions
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
<span>AI-powered conversations</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
<span>Research and explore topics</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
<span>Get instant insights</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full group-hover:bg-primary/90"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/researcher`)}
|
||||
>
|
||||
Start Chatting
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.9 }}
|
||||
className="flex flex-wrap justify-center gap-2 pt-4"
|
||||
>
|
||||
<Badge variant="secondary">
|
||||
✓ {allConfigs.length} LLM provider{allConfigs.length > 1 ? "s" : ""} available
|
||||
</Badge>
|
||||
{globalConfigs.length > 0 && (
|
||||
<Badge variant="secondary">✓ {globalConfigs.length} Global config(s)</Badge>
|
||||
)}
|
||||
{llmConfigs.length > 0 && (
|
||||
<Badge variant="secondary">✓ {llmConfigs.length} Custom config(s)</Badge>
|
||||
)}
|
||||
<Badge variant="secondary">✓ All roles assigned</Badge>
|
||||
<Badge variant="secondary">✓ Ready to use</Badge>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
surfsense_web/components/onboard/index.ts
Normal file
8
surfsense_web/components/onboard/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export { OnboardActionCard } from "./onboard-action-card";
|
||||
export { OnboardAdvancedSettings } from "./onboard-advanced-settings";
|
||||
export { OnboardHeader } from "./onboard-header";
|
||||
export { OnboardLLMSetup } from "./onboard-llm-setup";
|
||||
export { OnboardLoading } from "./onboard-loading";
|
||||
export { OnboardStats } from "./onboard-stats";
|
||||
export { SetupLLMStep } from "./setup-llm-step";
|
||||
export { SetupPromptStep } from "./setup-prompt-step";
|
||||
114
surfsense_web/components/onboard/onboard-action-card.tsx
Normal file
114
surfsense_web/components/onboard/onboard-action-card.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowRight, CheckCircle, type LucideIcon } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface OnboardActionCardProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
features: string[];
|
||||
buttonText: string;
|
||||
onClick: () => void;
|
||||
colorScheme: "emerald" | "blue" | "violet";
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
const colorSchemes = {
|
||||
emerald: {
|
||||
iconBg: "bg-emerald-500/10 dark:bg-emerald-500/20",
|
||||
iconRing: "ring-emerald-500/20 dark:ring-emerald-500/30",
|
||||
iconColor: "text-emerald-600 dark:text-emerald-400",
|
||||
checkColor: "text-emerald-500",
|
||||
buttonBg: "bg-emerald-600 hover:bg-emerald-500",
|
||||
hoverBorder: "hover:border-emerald-500/50",
|
||||
},
|
||||
blue: {
|
||||
iconBg: "bg-blue-500/10 dark:bg-blue-500/20",
|
||||
iconRing: "ring-blue-500/20 dark:ring-blue-500/30",
|
||||
iconColor: "text-blue-600 dark:text-blue-400",
|
||||
checkColor: "text-blue-500",
|
||||
buttonBg: "bg-blue-600 hover:bg-blue-500",
|
||||
hoverBorder: "hover:border-blue-500/50",
|
||||
},
|
||||
violet: {
|
||||
iconBg: "bg-violet-500/10 dark:bg-violet-500/20",
|
||||
iconRing: "ring-violet-500/20 dark:ring-violet-500/30",
|
||||
iconColor: "text-violet-600 dark:text-violet-400",
|
||||
checkColor: "text-violet-500",
|
||||
buttonBg: "bg-violet-600 hover:bg-violet-500",
|
||||
hoverBorder: "hover:border-violet-500/50",
|
||||
},
|
||||
};
|
||||
|
||||
export function OnboardActionCard({
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
features,
|
||||
buttonText,
|
||||
onClick,
|
||||
colorScheme,
|
||||
delay = 0,
|
||||
}: OnboardActionCardProps) {
|
||||
const colors = colorSchemes[colorScheme];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay, type: "spring", stiffness: 200 }}
|
||||
whileHover={{ y: -6, transition: { duration: 0.2 } }}
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
"h-full cursor-pointer group relative overflow-hidden transition-all duration-300",
|
||||
"border bg-card hover:shadow-lg",
|
||||
colors.hoverBorder
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardHeader className="relative pb-4">
|
||||
<motion.div
|
||||
className={cn(
|
||||
"w-14 h-14 rounded-2xl flex items-center justify-center mb-4 ring-1 transition-all duration-300",
|
||||
colors.iconBg,
|
||||
colors.iconRing,
|
||||
"group-hover:scale-110"
|
||||
)}
|
||||
whileHover={{ rotate: [0, -5, 5, 0] }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Icon className={cn("w-7 h-7", colors.iconColor)} />
|
||||
</motion.div>
|
||||
<CardTitle className="text-xl">{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="relative space-y-4">
|
||||
<div className="space-y-2.5 text-sm text-muted-foreground">
|
||||
{features.map((feature, index) => (
|
||||
<div key={index} className="flex items-center gap-2.5">
|
||||
<CheckCircle className={cn("w-4 h-4", colors.checkColor)} />
|
||||
<span>{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className={cn(
|
||||
"w-full text-white border-0 transition-all duration-300",
|
||||
colors.buttonBg
|
||||
)}
|
||||
>
|
||||
{buttonText}
|
||||
<ArrowRight className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
144
surfsense_web/components/onboard/onboard-advanced-settings.tsx
Normal file
144
surfsense_web/components/onboard/onboard-advanced-settings.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronDown, MessageSquare, Settings2 } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { SetupLLMStep } from "@/components/onboard/setup-llm-step";
|
||||
import { SetupPromptStep } from "@/components/onboard/setup-prompt-step";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface OnboardAdvancedSettingsProps {
|
||||
searchSpaceId: number;
|
||||
showLLMSettings: boolean;
|
||||
setShowLLMSettings: (show: boolean) => void;
|
||||
showPromptSettings: boolean;
|
||||
setShowPromptSettings: (show: boolean) => void;
|
||||
onConfigCreated: () => void;
|
||||
onConfigDeleted: () => void;
|
||||
onPreferencesUpdated: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function OnboardAdvancedSettings({
|
||||
searchSpaceId,
|
||||
showLLMSettings,
|
||||
setShowLLMSettings,
|
||||
showPromptSettings,
|
||||
setShowPromptSettings,
|
||||
onConfigCreated,
|
||||
onConfigDeleted,
|
||||
onPreferencesUpdated,
|
||||
}: OnboardAdvancedSettingsProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 1 }}
|
||||
className="space-y-4"
|
||||
>
|
||||
{/* LLM Configuration */}
|
||||
<Collapsible open={showLLMSettings} onOpenChange={setShowLLMSettings}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Card className="hover:bg-muted/50 transition-colors cursor-pointer">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-xl bg-fuchsia-500/10 dark:bg-fuchsia-500/20 border border-fuchsia-500/20">
|
||||
<Settings2 className="w-5 h-5 text-fuchsia-600 dark:text-fuchsia-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">LLM Configuration</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Customize AI models and role assignments
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<motion.div
|
||||
animate={{ rotate: showLLMSettings ? 180 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<ChevronDown className="w-5 h-5 text-muted-foreground" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
<AnimatePresence>
|
||||
{showLLMSettings && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Card className="mt-2">
|
||||
<CardContent className="pt-6">
|
||||
<SetupLLMStep
|
||||
searchSpaceId={searchSpaceId}
|
||||
onConfigCreated={onConfigCreated}
|
||||
onConfigDeleted={onConfigDeleted}
|
||||
onPreferencesUpdated={onPreferencesUpdated}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Prompt Configuration */}
|
||||
<Collapsible open={showPromptSettings} onOpenChange={setShowPromptSettings}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Card className="hover:bg-muted/50 transition-colors cursor-pointer">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-xl bg-cyan-500/10 dark:bg-cyan-500/20 border border-cyan-500/20">
|
||||
<MessageSquare className="w-5 h-5 text-cyan-600 dark:text-cyan-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">AI Response Settings</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure citations and custom instructions (Optional)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<motion.div
|
||||
animate={{ rotate: showPromptSettings ? 180 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<ChevronDown className="w-5 h-5 text-muted-foreground" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
<AnimatePresence>
|
||||
{showPromptSettings && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Card className="mt-2">
|
||||
<CardContent className="pt-6">
|
||||
<SetupPromptStep
|
||||
searchSpaceId={searchSpaceId}
|
||||
onComplete={() => setShowPromptSettings(false)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
56
surfsense_web/components/onboard/onboard-header.tsx
Normal file
56
surfsense_web/components/onboard/onboard-header.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"use client";
|
||||
|
||||
import { CheckCircle } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface OnboardHeaderProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
isReady?: boolean;
|
||||
}
|
||||
|
||||
export function OnboardHeader({ title, subtitle, isReady }: OnboardHeaderProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
className="text-center mb-10"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 200, delay: 0.2 }}
|
||||
className="inline-flex items-center justify-center mb-6"
|
||||
>
|
||||
<Logo className="w-20 h-20 rounded-2xl shadow-lg" />
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="space-y-2"
|
||||
>
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-foreground">{title}</h1>
|
||||
<p className="text-muted-foreground text-lg md:text-xl max-w-2xl mx-auto">{subtitle}</p>
|
||||
</motion.div>
|
||||
|
||||
{isReady && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.4, type: "spring" }}
|
||||
className="mt-4"
|
||||
>
|
||||
<Badge className="px-4 py-2 text-sm bg-emerald-500/10 border-emerald-500/30 text-emerald-600 dark:text-emerald-400">
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
AI Configuration Complete
|
||||
</Badge>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
93
surfsense_web/components/onboard/onboard-llm-setup.tsx
Normal file
93
surfsense_web/components/onboard/onboard-llm-setup.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"use client";
|
||||
|
||||
import { Bot } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { SetupLLMStep } from "@/components/onboard/setup-llm-step";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
interface OnboardLLMSetupProps {
|
||||
searchSpaceId: number;
|
||||
title: string;
|
||||
configTitle: string;
|
||||
configDescription: string;
|
||||
onConfigCreated: () => void;
|
||||
onConfigDeleted: () => void;
|
||||
onPreferencesUpdated: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function OnboardLLMSetup({
|
||||
searchSpaceId,
|
||||
title,
|
||||
configTitle,
|
||||
configDescription,
|
||||
onConfigCreated,
|
||||
onConfigDeleted,
|
||||
onPreferencesUpdated,
|
||||
}: OnboardLLMSetupProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="w-full max-w-4xl"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 200, delay: 0.1 }}
|
||||
className="inline-flex items-center justify-center mb-6"
|
||||
>
|
||||
<Logo className="w-16 h-16 rounded-2xl shadow-lg" />
|
||||
</motion.div>
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="text-4xl font-bold text-foreground mb-3"
|
||||
>
|
||||
{title}
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="text-muted-foreground text-lg"
|
||||
>
|
||||
Configure your AI model to get started
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
{/* LLM Setup Card */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
>
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader className="text-center border-b pb-6">
|
||||
<div className="flex items-center justify-center gap-3 mb-2">
|
||||
<div className="p-2 rounded-xl bg-primary/10 border border-primary/20">
|
||||
<Bot className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">{configTitle}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>{configDescription}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<SetupLLMStep
|
||||
searchSpaceId={searchSpaceId}
|
||||
onConfigCreated={onConfigCreated}
|
||||
onConfigDeleted={onConfigDeleted}
|
||||
onPreferencesUpdated={onPreferencesUpdated}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
surfsense_web/components/onboard/onboard-loading.tsx
Normal file
47
surfsense_web/components/onboard/onboard-loading.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"use client";
|
||||
|
||||
import { Wand2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
interface OnboardLoadingProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
export function OnboardLoading({ title, subtitle }: OnboardLoadingProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-center"
|
||||
>
|
||||
<div className="relative mb-8 flex justify-center">
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
|
||||
>
|
||||
<Wand2 className="w-16 h-16 text-primary" />
|
||||
</motion.div>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-foreground mb-2">{title}</h2>
|
||||
<p className="text-muted-foreground">{subtitle}</p>
|
||||
<div className="mt-6 flex justify-center gap-1.5">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="w-2 h-2 rounded-full bg-primary"
|
||||
animate={{ scale: [1, 1.5, 1], opacity: [0.5, 1, 0.5] }}
|
||||
transition={{
|
||||
duration: 1,
|
||||
repeat: Infinity,
|
||||
delay: i * 0.2,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
surfsense_web/components/onboard/onboard-stats.tsx
Normal file
38
surfsense_web/components/onboard/onboard-stats.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"use client";
|
||||
|
||||
import { Bot, Brain, Sparkles } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface OnboardStatsProps {
|
||||
globalConfigsCount: number;
|
||||
userConfigsCount: number;
|
||||
}
|
||||
|
||||
export function OnboardStats({ globalConfigsCount, userConfigsCount }: OnboardStatsProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="flex flex-wrap justify-center gap-3 mb-10"
|
||||
>
|
||||
{globalConfigsCount > 0 && (
|
||||
<Badge variant="secondary" className="px-3 py-1.5">
|
||||
<Sparkles className="w-3 h-3 mr-1.5 text-violet-500" />
|
||||
{globalConfigsCount} Global Model{globalConfigsCount > 1 ? "s" : ""}
|
||||
</Badge>
|
||||
)}
|
||||
{userConfigsCount > 0 && (
|
||||
<Badge variant="secondary" className="px-3 py-1.5">
|
||||
<Bot className="w-3 h-3 mr-1.5 text-blue-500" />
|
||||
{userConfigsCount} Custom Config{userConfigsCount > 1 ? "s" : ""}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="secondary" className="px-3 py-1.5">
|
||||
<Brain className="w-3 h-3 mr-1.5 text-fuchsia-500" />
|
||||
All Roles Assigned
|
||||
</Badge>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import { Switch } from "@/components/ui/switch";
|
|||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { type CommunityPrompt, useCommunityPrompts } from "@/hooks/use-community-prompts";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
||||
interface SetupPromptStepProps {
|
||||
searchSpaceId: number;
|
||||
|
|
@ -74,14 +75,11 @@ export function SetupPromptStep({ searchSpaceId, onComplete }: SetupPromptStepPr
|
|||
|
||||
// Only send update if there's something to update
|
||||
if (Object.keys(payload).length > 0) {
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ const demoPlans = [
|
|||
"Podcasts support with local TTS providers.",
|
||||
"Connects with 15+ external sources.",
|
||||
"Cross-Browser Extension for dynamic webpages including authenticated content",
|
||||
"Upcoming: Mergeable MindMaps",
|
||||
"Role-based access permissions",
|
||||
"Collaboration and multiplayer features",
|
||||
"Upcoming: Note Management",
|
||||
],
|
||||
description: "Open source version with powerful features",
|
||||
|
|
@ -32,9 +33,10 @@ const demoPlans = [
|
|||
features: [
|
||||
"Everything in Community",
|
||||
"Priority Support",
|
||||
"Role-based access permissions",
|
||||
"Collaboration and multiplayer features",
|
||||
"Advanced security features",
|
||||
"Audit logs and compliance",
|
||||
"SSO, OIDC & SAML",
|
||||
"SLA guarantee",
|
||||
],
|
||||
description: "For large organizations with specific needs",
|
||||
buttonText: "Contact Sales",
|
||||
|
|
|
|||
|
|
@ -36,19 +36,19 @@ import { cn } from "@/lib/utils";
|
|||
|
||||
// Define the form schema with Zod
|
||||
const searchSpaceFormSchema = z.object({
|
||||
name: z.string().min(3, "Name is required"),
|
||||
description: z.string().min(10, "Description is required"),
|
||||
name: z.string().min(3, "Name must be at least 3 characters"),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
// Define the type for the form values
|
||||
type SearchSpaceFormValues = z.infer<typeof searchSpaceFormSchema>;
|
||||
|
||||
interface SearchSpaceFormProps {
|
||||
onSubmit?: (data: { name: string; description: string }) => void;
|
||||
onSubmit?: (data: { name: string; description?: string }) => void;
|
||||
onDelete?: () => void;
|
||||
className?: string;
|
||||
isEditing?: boolean;
|
||||
initialData?: { name: string; description: string };
|
||||
initialData?: { name: string; description?: string };
|
||||
}
|
||||
|
||||
export function SearchSpaceForm({
|
||||
|
|
@ -229,7 +229,9 @@ export function SearchSpaceForm({
|
|||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormLabel>
|
||||
Description <span className="text-muted-foreground font-normal">(optional)</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter search space description" {...field} />
|
||||
</FormControl>
|
||||
|
|
|
|||
|
|
@ -413,19 +413,6 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<strong>Use cases:</strong> {role.examples}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{role.characteristics.map((char, idx) => (
|
||||
<Badge key={idx} variant="outline" className="text-xs">
|
||||
{char}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Assign LLM Configuration:</Label>
|
||||
<Select
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { type CommunityPrompt, useCommunityPrompts } from "@/hooks/use-community-prompts";
|
||||
import { useSearchSpace } from "@/hooks/use-search-space";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
||||
interface PromptConfigManagerProps {
|
||||
searchSpaceId: number;
|
||||
|
|
@ -78,14 +79,11 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps)
|
|||
|
||||
// Only send request if we have something to update
|
||||
if (Object.keys(payload).length > 0) {
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
SquareTerminal,
|
||||
Trash2,
|
||||
Undo2,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
|
@ -54,6 +55,7 @@ export const iconMap: Record<string, LucideIcon> = {
|
|||
Trash2,
|
||||
Podcast,
|
||||
FileText,
|
||||
Users,
|
||||
};
|
||||
|
||||
const defaultData = {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ export function NavMain({ items }: { items: NavItem[] }) {
|
|||
Podcasts: "podcasts",
|
||||
Logs: "logs",
|
||||
Platform: "platform",
|
||||
Team: "team",
|
||||
};
|
||||
|
||||
const key = titleMap[title];
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { getAuthHeaders } from "@/lib/auth-utils";
|
||||
import { GridPattern } from "./GridPattern";
|
||||
|
||||
interface DocumentUploadTabProps {
|
||||
|
|
@ -168,9 +169,7 @@ export function DocumentUploadTab({ searchSpaceId }: DocumentUploadTabProps) {
|
|||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/fileupload`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${window.localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
headers: getAuthHeaders(),
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
||||
const youtubeRegex =
|
||||
/^(https:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})$/;
|
||||
|
|
@ -66,14 +67,11 @@ export function YouTubeTab({ searchSpaceId }: YouTubeTabProps) {
|
|||
|
||||
const videoUrls = videoTags.map((tag) => tag.text);
|
||||
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
document_type: "YOUTUBE_VIDEO",
|
||||
content: videoUrls,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export * from "./use-document-by-chunk";
|
||||
export * from "./use-logs";
|
||||
export * from "./use-rbac";
|
||||
export * from "./use-search-source-connectors";
|
||||
export * from "./use-search-space";
|
||||
export * from "./use-user";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { getBearerToken } from "@/lib/auth-utils";
|
||||
|
||||
interface UseApiKeyReturn {
|
||||
apiKey: string | null;
|
||||
|
|
@ -17,7 +18,7 @@ export function useApiKey(): UseApiKeyReturn {
|
|||
// Load API key from localStorage
|
||||
const loadApiKey = () => {
|
||||
try {
|
||||
const token = localStorage.getItem("surfsense_bearer_token");
|
||||
const token = getBearerToken();
|
||||
setApiKey(token);
|
||||
} catch (error) {
|
||||
console.error("Error loading API key:", error);
|
||||
|
|
@ -32,17 +33,58 @@ export function useApiKey(): UseApiKeyReturn {
|
|||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const fallbackCopyTextToClipboard = (text: string) => {
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = text;
|
||||
|
||||
// Avoid scrolling to bottom
|
||||
textArea.style.top = "0";
|
||||
textArea.style.left = "0";
|
||||
textArea.style.position = "fixed";
|
||||
textArea.style.opacity = "0";
|
||||
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
try {
|
||||
const successful = document.execCommand("copy");
|
||||
document.body.removeChild(textArea);
|
||||
|
||||
if (successful) {
|
||||
setCopied(true);
|
||||
toast.success("API key copied to clipboard");
|
||||
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
} else {
|
||||
toast.error("Failed to copy API key");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Fallback: Oops, unable to copy", err);
|
||||
document.body.removeChild(textArea);
|
||||
toast.error("Failed to copy API key");
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = useCallback(async () => {
|
||||
if (!apiKey) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(apiKey);
|
||||
setCopied(true);
|
||||
toast.success("API key copied to clipboard");
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
// Use Clipboard API if available and in secure context
|
||||
await navigator.clipboard.writeText(apiKey);
|
||||
setCopied(true);
|
||||
toast.success("API key copied to clipboard");
|
||||
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
} else {
|
||||
// Fallback for non-secure contexts or browsers without clipboard API
|
||||
fallbackCopyTextToClipboard(apiKey);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to copy:", err);
|
||||
toast.error("Failed to copy API key");
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from "react";
|
|||
import type { ChatDetails } from "@/app/dashboard/[search_space_id]/chats/chats-client";
|
||||
import type { ResearchMode } from "@/components/chat";
|
||||
import type { Document } from "@/hooks/use-documents";
|
||||
import { getBearerToken } from "@/lib/auth-utils";
|
||||
|
||||
interface UseChatStateProps {
|
||||
search_space_id: string;
|
||||
|
|
@ -22,7 +23,7 @@ export function useChatState({ chat_id }: UseChatStateProps) {
|
|||
const [topK, setTopK] = useState<number>(5);
|
||||
|
||||
useEffect(() => {
|
||||
const bearerToken = localStorage.getItem("surfsense_bearer_token");
|
||||
const bearerToken = getBearerToken();
|
||||
setToken(bearerToken);
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
type SearchSourceConnector,
|
||||
useSearchSourceConnectors,
|
||||
} from "@/hooks/use-search-source-connectors";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
||||
const normalizeListInput = (value: unknown): string[] => {
|
||||
if (Array.isArray(value)) {
|
||||
|
|
@ -184,16 +185,11 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string)
|
|||
setIsFetchingRepos(true);
|
||||
setFetchedRepos(null);
|
||||
try {
|
||||
const token = localStorage.getItem("surfsense_bearer_token");
|
||||
if (!token) throw new Error("No auth token");
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/github/repositories`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ github_pat: values.github_pat }),
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
||||
// Types for connector API
|
||||
export interface ConnectorConfig {
|
||||
[key: string]: string;
|
||||
|
|
@ -32,14 +34,11 @@ export const getConnectorTypeDisplay = (type: string): string => {
|
|||
export const ConnectorService = {
|
||||
// Create a new connector
|
||||
async createConnector(data: CreateConnectorRequest): Promise<Connector> {
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
}
|
||||
);
|
||||
|
|
@ -54,13 +53,9 @@ export const ConnectorService = {
|
|||
|
||||
// Get all connectors
|
||||
async getConnectors(skip = 0, limit = 100): Promise<Connector[]> {
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors?skip=${skip}&limit=${limit}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
}
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -73,13 +68,9 @@ export const ConnectorService = {
|
|||
|
||||
// Get a specific connector
|
||||
async getConnector(connectorId: number): Promise<Connector> {
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
}
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -92,14 +83,11 @@ export const ConnectorService = {
|
|||
|
||||
// Update a connector
|
||||
async updateConnector(connectorId: number, data: CreateConnectorRequest): Promise<Connector> {
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
}
|
||||
);
|
||||
|
|
@ -114,14 +102,9 @@ export const ConnectorService = {
|
|||
|
||||
// Delete a connector
|
||||
async deleteConnector(connectorId: number): Promise<void> {
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
}
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
||||
export interface Chunk {
|
||||
id: number;
|
||||
|
|
@ -49,13 +50,10 @@ export function useDocumentByChunk() {
|
|||
setError(null);
|
||||
setDocument(null);
|
||||
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/by-chunk/${chunkId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "GET",
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
||||
export interface DocumentTypeCount {
|
||||
type: string;
|
||||
|
|
@ -23,11 +24,6 @@ export const useDocumentTypes = (searchSpaceId?: number, lazy: boolean = false)
|
|||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const token = localStorage.getItem("surfsense_bearer_token");
|
||||
|
||||
if (!token) {
|
||||
throw new Error("No authentication token found");
|
||||
}
|
||||
|
||||
// Build URL with optional search_space_id query parameter
|
||||
const url = new URL(
|
||||
|
|
@ -37,12 +33,9 @@ export const useDocumentTypes = (searchSpaceId?: number, lazy: boolean = false)
|
|||
url.searchParams.append("search_space_id", spaceId.toString());
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
const response = await authenticatedFetch(url.toString(), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
import { normalizeListResponse } from "@/lib/pagination";
|
||||
|
||||
export interface Document {
|
||||
|
|
@ -78,14 +79,9 @@ export function useDocuments(searchSpaceId: number, options?: UseDocumentsOption
|
|||
params.append("document_types", effectiveDocumentTypes.join(","));
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents?${params.toString()}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
method: "GET",
|
||||
}
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -159,14 +155,9 @@ export function useDocuments(searchSpaceId: number, options?: UseDocumentsOption
|
|||
params.append("document_types", effectiveDocumentTypes.join(","));
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/search?${params.toString()}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
method: "GET",
|
||||
}
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -193,14 +184,9 @@ export function useDocuments(searchSpaceId: number, options?: UseDocumentsOption
|
|||
const deleteDocument = useCallback(
|
||||
async (documentId: number) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/${documentId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
method: "DELETE",
|
||||
}
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -228,14 +214,9 @@ export function useDocuments(searchSpaceId: number, options?: UseDocumentsOption
|
|||
search_space_id: searchSpaceId.toString(),
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/type-counts?${params.toString()}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
method: "GET",
|
||||
}
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
||||
export interface LLMConfig {
|
||||
id: number;
|
||||
|
|
@ -61,14 +62,9 @@ export function useLLMConfigs(searchSpaceId: number | null) {
|
|||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/llm-configs?search_space_id=${searchSpaceId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
method: "GET",
|
||||
}
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -92,14 +88,11 @@ export function useLLMConfigs(searchSpaceId: number | null) {
|
|||
|
||||
const createLLMConfig = async (config: CreateLLMConfig): Promise<LLMConfig | null> => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/llm-configs`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(config),
|
||||
}
|
||||
);
|
||||
|
|
@ -122,14 +115,9 @@ export function useLLMConfigs(searchSpaceId: number | null) {
|
|||
|
||||
const deleteLLMConfig = async (id: number): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/llm-configs/${id}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
}
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -151,14 +139,11 @@ export function useLLMConfigs(searchSpaceId: number | null) {
|
|||
config: UpdateLLMConfig
|
||||
): Promise<LLMConfig | null> => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/llm-configs/${id}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(config),
|
||||
}
|
||||
);
|
||||
|
|
@ -203,14 +188,9 @@ export function useLLMPreferences(searchSpaceId: number | null) {
|
|||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/llm-preferences`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
method: "GET",
|
||||
}
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -239,14 +219,11 @@ export function useLLMPreferences(searchSpaceId: number | null) {
|
|||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/llm-preferences`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(newPreferences),
|
||||
}
|
||||
);
|
||||
|
|
@ -293,14 +270,9 @@ export function useGlobalLLMConfigs() {
|
|||
const fetchGlobalConfigs = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/global-llm-configs`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
method: "GET",
|
||||
}
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
||||
export type LogLevel = "DEBUG" | "INFO" | "WARNING" | "ERROR" | "CRITICAL";
|
||||
export type LogStatus = "IN_PROGRESS" | "SUCCESS" | "FAILED";
|
||||
|
|
@ -95,14 +96,9 @@ export function useLogs(searchSpaceId?: number, filters: LogFilters = {}) {
|
|||
if (options.skip !== undefined) params.append("skip", options.skip.toString());
|
||||
if (options.limit !== undefined) params.append("limit", options.limit.toString());
|
||||
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/logs?${params}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
method: "GET",
|
||||
}
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -147,14 +143,14 @@ export function useLogs(searchSpaceId?: number, filters: LogFilters = {}) {
|
|||
// Function to create a new log
|
||||
const createLog = useCallback(async (logData: Omit<Log, "id" | "created_at">) => {
|
||||
try {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/logs`, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
method: "POST",
|
||||
body: JSON.stringify(logData),
|
||||
});
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/logs`,
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
body: JSON.stringify(logData),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
|
|
@ -179,13 +175,10 @@ export function useLogs(searchSpaceId?: number, filters: LogFilters = {}) {
|
|||
updateData: Partial<Omit<Log, "id" | "created_at" | "search_space_id">>
|
||||
) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/logs/${logId}`,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "PUT",
|
||||
body: JSON.stringify(updateData),
|
||||
}
|
||||
|
|
@ -212,14 +205,9 @@ export function useLogs(searchSpaceId?: number, filters: LogFilters = {}) {
|
|||
// Function to delete a log
|
||||
const deleteLog = useCallback(async (logId: number) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/logs/${logId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
method: "DELETE",
|
||||
}
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -240,14 +228,9 @@ export function useLogs(searchSpaceId?: number, filters: LogFilters = {}) {
|
|||
// Function to get a single log
|
||||
const getLog = useCallback(async (logId: number) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/logs/${logId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
method: "GET",
|
||||
}
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -287,14 +270,9 @@ export function useLogsSummary(searchSpaceId: number, hours: number = 24) {
|
|||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/logs/search-space/${searchSpaceId}/summary?hours=${hours}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
method: "GET",
|
||||
}
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
687
surfsense_web/hooks/use-rbac.ts
Normal file
687
surfsense_web/hooks/use-rbac.ts
Normal file
|
|
@ -0,0 +1,687 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { authenticatedFetch, getBearerToken, handleUnauthorized } from "@/lib/auth-utils";
|
||||
|
||||
// ============ Types ============
|
||||
|
||||
export interface Role {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
permissions: string[];
|
||||
is_default: boolean;
|
||||
is_system_role: boolean;
|
||||
search_space_id: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Member {
|
||||
id: number;
|
||||
user_id: string;
|
||||
search_space_id: number;
|
||||
role_id: number | null;
|
||||
is_owner: boolean;
|
||||
joined_at: string;
|
||||
created_at: string;
|
||||
role: Role | null;
|
||||
user_email: string | null;
|
||||
}
|
||||
|
||||
export interface Invite {
|
||||
id: number;
|
||||
invite_code: string;
|
||||
search_space_id: number;
|
||||
role_id: number | null;
|
||||
created_by_id: string | null;
|
||||
expires_at: string | null;
|
||||
max_uses: number | null;
|
||||
uses_count: number;
|
||||
is_active: boolean;
|
||||
name: string | null;
|
||||
created_at: string;
|
||||
role: Role | null;
|
||||
}
|
||||
|
||||
export interface InviteCreate {
|
||||
name?: string;
|
||||
role_id?: number;
|
||||
expires_at?: string;
|
||||
max_uses?: number;
|
||||
}
|
||||
|
||||
export interface InviteUpdate {
|
||||
name?: string;
|
||||
role_id?: number;
|
||||
expires_at?: string;
|
||||
max_uses?: number;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface RoleCreate {
|
||||
name: string;
|
||||
description?: string;
|
||||
permissions: string[];
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
export interface RoleUpdate {
|
||||
name?: string;
|
||||
description?: string;
|
||||
permissions?: string[];
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
export interface PermissionInfo {
|
||||
value: string;
|
||||
name: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export interface UserAccess {
|
||||
search_space_id: number;
|
||||
search_space_name: string;
|
||||
is_owner: boolean;
|
||||
role_name: string | null;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export interface InviteInfo {
|
||||
search_space_name: string;
|
||||
role_name: string | null;
|
||||
is_valid: boolean;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
// ============ Members Hook ============
|
||||
|
||||
export function useMembers(searchSpaceId: number) {
|
||||
const [members, setMembers] = useState<Member[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchMembers = useCallback(async () => {
|
||||
if (!searchSpaceId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/members`,
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || "Failed to fetch members");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setMembers(data);
|
||||
setError(null);
|
||||
return data;
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to fetch members");
|
||||
console.error("Error fetching members:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchSpaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMembers();
|
||||
}, [fetchMembers]);
|
||||
|
||||
const updateMemberRole = useCallback(
|
||||
async (membershipId: number, roleId: number | null) => {
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/members/${membershipId}`,
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ role_id: roleId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || "Failed to update member role");
|
||||
}
|
||||
|
||||
const updatedMember = await response.json();
|
||||
setMembers((prev) => prev.map((m) => (m.id === membershipId ? updatedMember : m)));
|
||||
toast.success("Member role updated successfully");
|
||||
return updatedMember;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || "Failed to update member role");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[searchSpaceId]
|
||||
);
|
||||
|
||||
const removeMember = useCallback(
|
||||
async (membershipId: number) => {
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/members/${membershipId}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || "Failed to remove member");
|
||||
}
|
||||
|
||||
setMembers((prev) => prev.filter((m) => m.id !== membershipId));
|
||||
toast.success("Member removed successfully");
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || "Failed to remove member");
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[searchSpaceId]
|
||||
);
|
||||
|
||||
const leaveSearchSpace = useCallback(async () => {
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/members/me`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || "Failed to leave search space");
|
||||
}
|
||||
|
||||
toast.success("Successfully left the search space");
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || "Failed to leave search space");
|
||||
return false;
|
||||
}
|
||||
}, [searchSpaceId]);
|
||||
|
||||
return {
|
||||
members,
|
||||
loading,
|
||||
error,
|
||||
fetchMembers,
|
||||
updateMemberRole,
|
||||
removeMember,
|
||||
leaveSearchSpace,
|
||||
};
|
||||
}
|
||||
|
||||
// ============ Roles Hook ============
|
||||
|
||||
export function useRoles(searchSpaceId: number) {
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchRoles = useCallback(async () => {
|
||||
if (!searchSpaceId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/roles`,
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || "Failed to fetch roles");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setRoles(data);
|
||||
setError(null);
|
||||
return data;
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to fetch roles");
|
||||
console.error("Error fetching roles:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchSpaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoles();
|
||||
}, [fetchRoles]);
|
||||
|
||||
const createRole = useCallback(
|
||||
async (roleData: RoleCreate) => {
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/roles`,
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
body: JSON.stringify(roleData),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || "Failed to create role");
|
||||
}
|
||||
|
||||
const newRole = await response.json();
|
||||
setRoles((prev) => [...prev, newRole]);
|
||||
toast.success("Role created successfully");
|
||||
return newRole;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || "Failed to create role");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[searchSpaceId]
|
||||
);
|
||||
|
||||
const updateRole = useCallback(
|
||||
async (roleId: number, roleData: RoleUpdate) => {
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/roles/${roleId}`,
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "PUT",
|
||||
body: JSON.stringify(roleData),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || "Failed to update role");
|
||||
}
|
||||
|
||||
const updatedRole = await response.json();
|
||||
setRoles((prev) => prev.map((r) => (r.id === roleId ? updatedRole : r)));
|
||||
toast.success("Role updated successfully");
|
||||
return updatedRole;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || "Failed to update role");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[searchSpaceId]
|
||||
);
|
||||
|
||||
const deleteRole = useCallback(
|
||||
async (roleId: number) => {
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/roles/${roleId}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || "Failed to delete role");
|
||||
}
|
||||
|
||||
setRoles((prev) => prev.filter((r) => r.id !== roleId));
|
||||
toast.success("Role deleted successfully");
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || "Failed to delete role");
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[searchSpaceId]
|
||||
);
|
||||
|
||||
return {
|
||||
roles,
|
||||
loading,
|
||||
error,
|
||||
fetchRoles,
|
||||
createRole,
|
||||
updateRole,
|
||||
deleteRole,
|
||||
};
|
||||
}
|
||||
|
||||
// ============ Invites Hook ============
|
||||
|
||||
export function useInvites(searchSpaceId: number) {
|
||||
const [invites, setInvites] = useState<Invite[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchInvites = useCallback(async () => {
|
||||
if (!searchSpaceId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/invites`,
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || "Failed to fetch invites");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setInvites(data);
|
||||
setError(null);
|
||||
return data;
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to fetch invites");
|
||||
console.error("Error fetching invites:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchSpaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchInvites();
|
||||
}, [fetchInvites]);
|
||||
|
||||
const createInvite = useCallback(
|
||||
async (inviteData: InviteCreate) => {
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/invites`,
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
body: JSON.stringify(inviteData),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || "Failed to create invite");
|
||||
}
|
||||
|
||||
const newInvite = await response.json();
|
||||
setInvites((prev) => [...prev, newInvite]);
|
||||
toast.success("Invite created successfully");
|
||||
return newInvite;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || "Failed to create invite");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[searchSpaceId]
|
||||
);
|
||||
|
||||
const updateInvite = useCallback(
|
||||
async (inviteId: number, inviteData: InviteUpdate) => {
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/invites/${inviteId}`,
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "PUT",
|
||||
body: JSON.stringify(inviteData),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || "Failed to update invite");
|
||||
}
|
||||
|
||||
const updatedInvite = await response.json();
|
||||
setInvites((prev) => prev.map((i) => (i.id === inviteId ? updatedInvite : i)));
|
||||
toast.success("Invite updated successfully");
|
||||
return updatedInvite;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || "Failed to update invite");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[searchSpaceId]
|
||||
);
|
||||
|
||||
const revokeInvite = useCallback(
|
||||
async (inviteId: number) => {
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/invites/${inviteId}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || "Failed to revoke invite");
|
||||
}
|
||||
|
||||
setInvites((prev) => prev.filter((i) => i.id !== inviteId));
|
||||
toast.success("Invite revoked successfully");
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || "Failed to revoke invite");
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[searchSpaceId]
|
||||
);
|
||||
|
||||
return {
|
||||
invites,
|
||||
loading,
|
||||
error,
|
||||
fetchInvites,
|
||||
createInvite,
|
||||
updateInvite,
|
||||
revokeInvite,
|
||||
};
|
||||
}
|
||||
|
||||
// ============ Permissions Hook ============
|
||||
|
||||
export function usePermissions() {
|
||||
const [permissions, setPermissions] = useState<PermissionInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchPermissions = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/permissions`,
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || "Failed to fetch permissions");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setPermissions(data.permissions);
|
||||
setError(null);
|
||||
return data.permissions;
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to fetch permissions");
|
||||
console.error("Error fetching permissions:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPermissions();
|
||||
}, [fetchPermissions]);
|
||||
|
||||
// Group permissions by category
|
||||
const groupedPermissions = useMemo(() => {
|
||||
const groups: Record<string, PermissionInfo[]> = {};
|
||||
for (const perm of permissions) {
|
||||
if (!groups[perm.category]) {
|
||||
groups[perm.category] = [];
|
||||
}
|
||||
groups[perm.category].push(perm);
|
||||
}
|
||||
return groups;
|
||||
}, [permissions]);
|
||||
|
||||
return {
|
||||
permissions,
|
||||
groupedPermissions,
|
||||
loading,
|
||||
error,
|
||||
fetchPermissions,
|
||||
};
|
||||
}
|
||||
|
||||
// ============ User Access Hook ============
|
||||
|
||||
export function useUserAccess(searchSpaceId: number) {
|
||||
const [access, setAccess] = useState<UserAccess | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchAccess = useCallback(async () => {
|
||||
if (!searchSpaceId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/my-access`,
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || "Failed to fetch access info");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setAccess(data);
|
||||
setError(null);
|
||||
return data;
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to fetch access info");
|
||||
console.error("Error fetching access:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchSpaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccess();
|
||||
}, [fetchAccess]);
|
||||
|
||||
// Helper function to check if user has a specific permission
|
||||
const hasPermission = useCallback(
|
||||
(permission: string) => {
|
||||
if (!access) return false;
|
||||
// Owner/full access check
|
||||
if (access.permissions.includes("*")) return true;
|
||||
return access.permissions.includes(permission);
|
||||
},
|
||||
[access]
|
||||
);
|
||||
|
||||
// Helper function to check if user has any of the given permissions
|
||||
const hasAnyPermission = useCallback(
|
||||
(permissions: string[]) => {
|
||||
if (!access) return false;
|
||||
if (access.permissions.includes("*")) return true;
|
||||
return permissions.some((p) => access.permissions.includes(p));
|
||||
},
|
||||
[access]
|
||||
);
|
||||
|
||||
return {
|
||||
access,
|
||||
loading,
|
||||
error,
|
||||
fetchAccess,
|
||||
hasPermission,
|
||||
hasAnyPermission,
|
||||
};
|
||||
}
|
||||
|
||||
// ============ Invite Info Hook (Public) ============
|
||||
|
||||
export function useInviteInfo(inviteCode: string | null) {
|
||||
const [inviteInfo, setInviteInfo] = useState<InviteInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchInviteInfo = useCallback(async () => {
|
||||
if (!inviteCode) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/invites/${inviteCode}/info`,
|
||||
{
|
||||
method: "GET",
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || "Failed to fetch invite info");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setInviteInfo(data);
|
||||
setError(null);
|
||||
return data;
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to fetch invite info");
|
||||
console.error("Error fetching invite info:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [inviteCode]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchInviteInfo();
|
||||
}, [fetchInviteInfo]);
|
||||
|
||||
const acceptInvite = useCallback(async () => {
|
||||
if (!inviteCode) {
|
||||
toast.error("No invite code provided");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/invites/accept`,
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
body: JSON.stringify({ invite_code: inviteCode }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || "Failed to accept invite");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
toast.success(data.message || "Successfully joined the search space");
|
||||
return data;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || "Failed to accept invite");
|
||||
throw err;
|
||||
}
|
||||
}, [inviteCode]);
|
||||
|
||||
return {
|
||||
inviteInfo,
|
||||
loading,
|
||||
error,
|
||||
fetchInviteInfo,
|
||||
acceptInvite,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { authenticatedFetch, getBearerToken, handleUnauthorized } from "@/lib/auth-utils";
|
||||
|
||||
export interface SearchSourceConnector {
|
||||
id: number;
|
||||
|
|
@ -66,11 +67,6 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?:
|
|||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const token = localStorage.getItem("surfsense_bearer_token");
|
||||
|
||||
if (!token) {
|
||||
throw new Error("No authentication token found");
|
||||
}
|
||||
|
||||
// Build URL with optional search_space_id query parameter
|
||||
const url = new URL(
|
||||
|
|
@ -80,12 +76,9 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?:
|
|||
url.searchParams.append("search_space_id", spaceId.toString());
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
const response = await authenticatedFetch(url.toString(), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -176,24 +169,15 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?:
|
|||
spaceId: number
|
||||
) => {
|
||||
try {
|
||||
const token = localStorage.getItem("surfsense_bearer_token");
|
||||
|
||||
if (!token) {
|
||||
throw new Error("No authentication token found");
|
||||
}
|
||||
|
||||
// Add search_space_id as a query parameter
|
||||
const url = new URL(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors`
|
||||
);
|
||||
url.searchParams.append("search_space_id", spaceId.toString());
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
const response = await authenticatedFetch(url.toString(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(connectorData),
|
||||
});
|
||||
|
||||
|
|
@ -222,20 +206,11 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?:
|
|||
>
|
||||
) => {
|
||||
try {
|
||||
const token = localStorage.getItem("surfsense_bearer_token");
|
||||
|
||||
if (!token) {
|
||||
throw new Error("No authentication token found");
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(connectorData),
|
||||
}
|
||||
);
|
||||
|
|
@ -262,20 +237,11 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?:
|
|||
*/
|
||||
const deleteConnector = async (connectorId: number) => {
|
||||
try {
|
||||
const token = localStorage.getItem("surfsense_bearer_token");
|
||||
|
||||
if (!token) {
|
||||
throw new Error("No authentication token found");
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -302,12 +268,6 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?:
|
|||
endDate?: string
|
||||
) => {
|
||||
try {
|
||||
const token = localStorage.getItem("surfsense_bearer_token");
|
||||
|
||||
if (!token) {
|
||||
throw new Error("No authentication token found");
|
||||
}
|
||||
|
||||
// Build query parameters
|
||||
const params = new URLSearchParams({
|
||||
search_space_id: searchSpaceId.toString(),
|
||||
|
|
@ -319,16 +279,13 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?:
|
|||
params.append("end_date", endDate);
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${
|
||||
process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL
|
||||
}/api/v1/search-source-connectors/${connectorId}/index?${params.toString()}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
||||
interface SearchSpace {
|
||||
created_at: string;
|
||||
|
|
@ -29,23 +30,11 @@ export function useSearchSpace({ searchSpaceId, autoFetch = true }: UseSearchSpa
|
|||
if (typeof window === "undefined") return;
|
||||
|
||||
setLoading(true);
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
method: "GET",
|
||||
}
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (response.status === 401) {
|
||||
// Clear token and redirect to home
|
||||
localStorage.removeItem("surfsense_bearer_token");
|
||||
window.location.href = "/";
|
||||
throw new Error("Unauthorized: Redirecting to login page");
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch search space: ${response.status}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
||||
interface SearchSpace {
|
||||
id: number;
|
||||
|
|
@ -10,6 +11,8 @@ interface SearchSpace {
|
|||
created_at: string;
|
||||
citations_enabled: boolean;
|
||||
qna_custom_instructions: string | null;
|
||||
member_count: number;
|
||||
is_owner: boolean;
|
||||
}
|
||||
|
||||
export function useSearchSpaces() {
|
||||
|
|
@ -21,19 +24,14 @@ export function useSearchSpaces() {
|
|||
const fetchSearchSpaces = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
method: "GET",
|
||||
}
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
toast.error("Not authenticated");
|
||||
throw new Error("Not authenticated");
|
||||
toast.error("Failed to fetch search spaces");
|
||||
throw new Error("Failed to fetch search spaces");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
|
@ -54,19 +52,14 @@ export function useSearchSpaces() {
|
|||
const refreshSearchSpaces = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
method: "GET",
|
||||
}
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
toast.error("Not authenticated");
|
||||
throw new Error("Not authenticated");
|
||||
toast.error("Failed to fetch search spaces");
|
||||
throw new Error("Failed to fetch search spaces");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
|
|
@ -25,19 +26,10 @@ export function useUser() {
|
|||
if (typeof window === "undefined") return;
|
||||
|
||||
setLoading(true);
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/users/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
// Clear token and redirect to home
|
||||
localStorage.removeItem("surfsense_bearer_token");
|
||||
window.location.href = "/";
|
||||
throw new Error("Unauthorized: Redirecting to login page");
|
||||
}
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/users/me`,
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch user: ${response.status}`);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { th } from "date-fns/locale";
|
||||
import type z from "zod";
|
||||
import { getBearerToken, handleUnauthorized } from "../auth-utils";
|
||||
import { AppError, AuthenticationError, AuthorizationError, NotFoundError } from "../error";
|
||||
|
||||
enum ResponseType {
|
||||
|
|
@ -132,6 +132,8 @@ class BaseApiService {
|
|||
|
||||
switch (response.status) {
|
||||
case 401:
|
||||
// Use centralized auth handler for 401 responses
|
||||
handleUnauthorized();
|
||||
throw new AuthenticationError(
|
||||
"You are not authenticated. Please login again.",
|
||||
response.status,
|
||||
|
|
@ -261,6 +263,6 @@ class BaseApiService {
|
|||
}
|
||||
|
||||
export const baseApiService = new BaseApiService(
|
||||
typeof window !== "undefined" ? localStorage.getItem("surfsense_bearer_token") || "" : "",
|
||||
typeof window !== "undefined" ? getBearerToken() || "" : "",
|
||||
process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || ""
|
||||
);
|
||||
|
|
|
|||
173
surfsense_web/lib/auth-utils.ts
Normal file
173
surfsense_web/lib/auth-utils.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
/**
|
||||
* Authentication utilities for handling token expiration and redirects
|
||||
*/
|
||||
|
||||
const REDIRECT_PATH_KEY = "surfsense_redirect_path";
|
||||
const BEARER_TOKEN_KEY = "surfsense_bearer_token";
|
||||
|
||||
/**
|
||||
* Saves the current path and redirects to login page
|
||||
* Call this when a 401 response is received
|
||||
*/
|
||||
export function handleUnauthorized(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
// Save the current path (including search params and hash) for redirect after login
|
||||
const currentPath = window.location.pathname + window.location.search + window.location.hash;
|
||||
|
||||
// Don't save auth-related paths
|
||||
const excludedPaths = ["/auth", "/auth/callback", "/"];
|
||||
if (!excludedPaths.includes(window.location.pathname)) {
|
||||
localStorage.setItem(REDIRECT_PATH_KEY, currentPath);
|
||||
}
|
||||
|
||||
// Clear the token
|
||||
localStorage.removeItem(BEARER_TOKEN_KEY);
|
||||
|
||||
// Redirect to home page (which has login options)
|
||||
window.location.href = "/login";
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the stored redirect path and clears it from storage
|
||||
* Call this after successful login to redirect the user back
|
||||
*/
|
||||
export function getAndClearRedirectPath(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
const redirectPath = localStorage.getItem(REDIRECT_PATH_KEY);
|
||||
if (redirectPath) {
|
||||
localStorage.removeItem(REDIRECT_PATH_KEY);
|
||||
}
|
||||
return redirectPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the bearer token from localStorage
|
||||
*/
|
||||
export function getBearerToken(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
return localStorage.getItem(BEARER_TOKEN_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the bearer token in localStorage
|
||||
*/
|
||||
export function setBearerToken(token: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.setItem(BEARER_TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the bearer token from localStorage
|
||||
*/
|
||||
export function clearBearerToken(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.removeItem(BEARER_TOKEN_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the user is authenticated (has a token)
|
||||
*/
|
||||
export function isAuthenticated(): boolean {
|
||||
return !!getBearerToken();
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the current path and redirects to login page
|
||||
* Use this for client-side auth checks (e.g., in useEffect)
|
||||
* Unlike handleUnauthorized, this doesn't clear the token (user might not have one)
|
||||
*/
|
||||
export function redirectToLogin(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
// Save the current path (including search params and hash) for redirect after login
|
||||
const currentPath = window.location.pathname + window.location.search + window.location.hash;
|
||||
|
||||
// Don't save auth-related paths or home page
|
||||
const excludedPaths = ["/auth", "/auth/callback", "/", "/login", "/register"];
|
||||
if (!excludedPaths.includes(window.location.pathname)) {
|
||||
localStorage.setItem(REDIRECT_PATH_KEY, currentPath);
|
||||
}
|
||||
|
||||
// Redirect to login page
|
||||
window.location.href = "/login";
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates headers with authorization bearer token
|
||||
*/
|
||||
export function getAuthHeaders(additionalHeaders?: Record<string, string>): Record<string, string> {
|
||||
const token = getBearerToken();
|
||||
return {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...additionalHeaders,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticated fetch wrapper that handles 401 responses uniformly
|
||||
* Automatically redirects to login on 401 and saves the current path
|
||||
*/
|
||||
export async function authenticatedFetch(
|
||||
url: string,
|
||||
options?: RequestInit & { skipAuthRedirect?: boolean }
|
||||
): Promise<Response> {
|
||||
const { skipAuthRedirect = false, ...fetchOptions } = options || {};
|
||||
|
||||
const headers = getAuthHeaders(fetchOptions.headers as Record<string, string>);
|
||||
|
||||
const response = await fetch(url, {
|
||||
...fetchOptions,
|
||||
headers,
|
||||
});
|
||||
|
||||
// Handle 401 Unauthorized
|
||||
if (response.status === 401 && !skipAuthRedirect) {
|
||||
handleUnauthorized();
|
||||
throw new Error("Unauthorized: Redirecting to login page");
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type for the result of a fetch operation with built-in error handling
|
||||
*/
|
||||
export type FetchResult<T> =
|
||||
| { success: true; data: T; response: Response }
|
||||
| { success: false; error: string; status?: number };
|
||||
|
||||
/**
|
||||
* Authenticated fetch with JSON response handling
|
||||
* Returns a result object instead of throwing on non-401 errors
|
||||
*/
|
||||
export async function authenticatedFetchJson<T = unknown>(
|
||||
url: string,
|
||||
options?: RequestInit & { skipAuthRedirect?: boolean }
|
||||
): Promise<FetchResult<T>> {
|
||||
try {
|
||||
const response = await authenticatedFetch(url, options);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.detail || `Request failed: ${response.status}`,
|
||||
status: response.status,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return { success: true, data, response };
|
||||
} catch (err: any) {
|
||||
// Re-throw if it's the unauthorized redirect
|
||||
if (err.message?.includes("Unauthorized")) {
|
||||
throw err;
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: err.message || "Request failed",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -103,6 +103,7 @@
|
|||
"surfsense_dashboard": "SurfSense Dashboard",
|
||||
"welcome_message": "Welcome to your SurfSense dashboard.",
|
||||
"your_search_spaces": "Your Search Spaces",
|
||||
"shared": "Shared",
|
||||
"create_search_space": "Create Search Space",
|
||||
"add_new_search_space": "Add New Search Space",
|
||||
"loading": "Loading",
|
||||
|
|
@ -149,7 +150,8 @@
|
|||
"podcasts": "Podcasts",
|
||||
"logs": "Logs",
|
||||
"all_search_spaces": "All Search Spaces",
|
||||
"chat": "Chat"
|
||||
"chat": "Chat",
|
||||
"team": "Team"
|
||||
},
|
||||
"pricing": {
|
||||
"title": "SurfSense Pricing",
|
||||
|
|
@ -620,6 +622,7 @@
|
|||
"documents": "Documents",
|
||||
"connectors": "Connectors",
|
||||
"podcasts": "Podcasts",
|
||||
"editor": "Editor",
|
||||
"logs": "Logs",
|
||||
"chats": "Chats",
|
||||
"settings": "Settings",
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@
|
|||
"surfsense_dashboard": "SurfSense 仪表盘",
|
||||
"welcome_message": "欢迎来到您的 SurfSense 仪表盘。",
|
||||
"your_search_spaces": "您的搜索空间",
|
||||
"shared": "共享",
|
||||
"create_search_space": "创建搜索空间",
|
||||
"add_new_search_space": "添加新的搜索空间",
|
||||
"loading": "加载中",
|
||||
|
|
@ -149,7 +150,8 @@
|
|||
"podcasts": "播客",
|
||||
"logs": "日志",
|
||||
"all_search_spaces": "所有搜索空间",
|
||||
"chat": "聊天"
|
||||
"chat": "聊天",
|
||||
"team": "团队"
|
||||
},
|
||||
"pricing": {
|
||||
"title": "SurfSense 定价",
|
||||
|
|
@ -620,6 +622,7 @@
|
|||
"documents": "文档",
|
||||
"connectors": "连接器",
|
||||
"podcasts": "播客",
|
||||
"editor": "编辑器",
|
||||
"logs": "日志",
|
||||
"chats": "聊天",
|
||||
"settings": "设置",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ const withNextIntl = createNextIntlPlugin("./i18n/request.ts");
|
|||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
// Disable StrictMode for BlockNote compatibility with React 19/Next 15
|
||||
reactStrictMode: false,
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
|
|
@ -21,6 +23,17 @@ const nextConfig: NextConfig = {
|
|||
},
|
||||
],
|
||||
},
|
||||
// Mark BlockNote server packages as external
|
||||
serverExternalPackages: ["@blocknote/server-util"],
|
||||
|
||||
// Configure webpack to handle blocknote packages
|
||||
webpack: (config, { isServer }) => {
|
||||
if (isServer) {
|
||||
// Don't bundle these packages on the server
|
||||
config.externals = [...(config.externals || []), "@blocknote/server-util"];
|
||||
}
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
// Wrap the config with MDX and next-intl plugins
|
||||
|
|
|
|||
|
|
@ -22,9 +22,13 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^1.2.12",
|
||||
"@blocknote/core": "^0.42.3",
|
||||
"@blocknote/mantine": "^0.42.3",
|
||||
"@blocknote/react": "^0.42.3",
|
||||
"@blocknote/server-util": "^0.42.3",
|
||||
"@hookform/resolvers": "^4.1.3",
|
||||
"@llamaindex/chat-ui": "^0.5.17",
|
||||
"@next/third-parties": "^15.5.6",
|
||||
"@next/third-parties": "^15.5.7",
|
||||
"@number-flow/react": "^0.5.10",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
|
|
@ -70,7 +74,7 @@
|
|||
"jotai-tanstack-query": "^0.11.0",
|
||||
"lucide-react": "^0.477.0",
|
||||
"motion": "^12.23.22",
|
||||
"next": "^15.5.6",
|
||||
"next": "^15.5.7",
|
||||
"next-intl": "^3.26.5",
|
||||
"next-themes": "^0.4.6",
|
||||
"pg": "^8.16.3",
|
||||
|
|
|
|||
1444
surfsense_web/pnpm-lock.yaml
generated
1444
surfsense_web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue