From 2f3ee7d6824b625bd6ad36411517afa0d7fe0713 Mon Sep 17 00:00:00 2001 From: Michael Slocum Date: Tue, 31 Mar 2026 15:25:27 -0700 Subject: [PATCH 001/119] feat: add xAI (Grok) provider support - Add xAI to AIProvider type definition - Implement API key detection for xai- prefix format - Add xAI provider display name and console URL - Add Grok model detection and categorization - Add Rocket icon for xAI category in ModelSelector - Support all xAI Grok models (grok-4.20 and grok-4-1-fast variants) This enables users to select and use xAI's Grok models for text-to-cypher functionality with proper API key validation and UI integration. --- app/settings/ModelSelector.tsx | 7 ++++++- lib/ai-provider-utils.ts | 21 +++++++++++++++++---- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/app/settings/ModelSelector.tsx b/app/settings/ModelSelector.tsx index 7b4830fca..f1921a442 100644 --- a/app/settings/ModelSelector.tsx +++ b/app/settings/ModelSelector.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { cn } from "@/lib/utils"; import { formatModelDisplayName } from "@/lib/ai-provider-utils"; -import { Search, Check, Sparkles, Zap, Brain, Globe, Server, Cpu, MessageSquare, ChevronRight } from "lucide-react"; +import { Search, Check, Sparkles, Zap, Brain, Globe, Server, Cpu, MessageSquare, ChevronRight, Rocket } from "lucide-react"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import Input from "../components/ui/Input"; @@ -21,6 +21,7 @@ const PROVIDER_DISPLAY_NAMES: Record = { ollama: "Ollama", groq: "Groq", cohere: "Cohere", + xai: "xAI", }; // Get icon for provider category @@ -39,6 +40,8 @@ const getCategoryIcon = (category: string) => { return ; case "Cohere": return ; + case "xAI": + return ; default: return ; } @@ -84,6 +87,8 @@ const categorizeModels = (models: string[]) => { categoryName = "Groq"; } else if (model.includes("command") || model.includes("cohere")) { categoryName = "Cohere"; + } else if (model.includes("grok")) { + categoryName = "xAI"; } else { categoryName = "Other"; } diff --git a/lib/ai-provider-utils.ts b/lib/ai-provider-utils.ts index 298831968..f0e363f46 100644 --- a/lib/ai-provider-utils.ts +++ b/lib/ai-provider-utils.ts @@ -2,7 +2,7 @@ * Utility functions for AI provider detection and model management */ -export type AIProvider = "openai" | "anthropic" | "gemini" | "ollama" | "groq" | "cohere" | "unknown"; +export type AIProvider = "openai" | "anthropic" | "gemini" | "ollama" | "groq" | "cohere" | "xai" | "unknown"; /** * Detects the AI provider based on the API key format @@ -44,6 +44,11 @@ export function detectProviderFromApiKey(apiKey: string | undefined): AIProvider return "groq"; } + // xAI: starts with "xai-" + if (trimmedKey.startsWith("xai-")) { + return "xai"; + } + // Cohere: typically a long alphanumeric string starting with specific patterns // No reliable prefix detection for Cohere, handled by model selection @@ -70,6 +75,8 @@ export function getProviderDisplayName(provider: AIProvider): string { return "Groq"; case "cohere": return "Cohere"; + case "xai": + return "xAI"; default: return "Unknown"; } @@ -120,6 +127,11 @@ export function getProviderApiKeyInfo(provider: AIProvider): { url: "https://dashboard.cohere.com/api-keys", description: "Get your Cohere API key from the Cohere Dashboard", }; + case "xai": + return { + url: "https://console.x.ai/", + description: "Get your xAI API key from the xAI Console", + }; default: return null; } @@ -140,7 +152,7 @@ export function detectProviderFromModel(model: string): AIProvider { const doubleSeparatorIndex = model.indexOf("::"); if (doubleSeparatorIndex !== -1) { const prefix = model.substring(0, doubleSeparatorIndex); - const knownProviders: AIProvider[] = ["openai", "anthropic", "gemini", "ollama", "groq", "cohere"]; + const knownProviders: AIProvider[] = ["openai", "anthropic", "gemini", "ollama", "groq", "cohere", "xai"]; const matched = knownProviders.find(p => p === prefix); if (matched) return matched; } @@ -149,7 +161,7 @@ export function detectProviderFromModel(model: string): AIProvider { const singleSeparatorIndex = model.indexOf(":"); if (singleSeparatorIndex !== -1) { const prefix = model.substring(0, singleSeparatorIndex); - const knownProviders: AIProvider[] = ["openai", "anthropic", "gemini", "ollama", "groq", "cohere"]; + const knownProviders: AIProvider[] = ["openai", "anthropic", "gemini", "ollama", "groq", "cohere", "xai"]; const matched = knownProviders.find(p => p === prefix); if (matched) return matched; } @@ -161,6 +173,7 @@ export function detectProviderFromModel(model: string): AIProvider { if (model.includes("llama") || model.includes("mixtral") || model.includes("phi") || model.includes("deepseek")) return "ollama"; if (model.includes("groq")) return "groq"; if (model.includes("command") || model.includes("cohere")) return "cohere"; + if (model.includes("grok")) return "xai"; return "unknown"; } @@ -185,7 +198,7 @@ export function formatModelDisplayName(modelValue: string): string { withoutPrefix = modelValue.substring(doubleSepIndex + 2); } else { // Remove legacy single-colon provider prefix (e.g., "anthropic:claude-3-5-sonnet") - const knownPrefixes = ["openai", "anthropic", "gemini", "ollama", "groq", "cohere"]; + const knownPrefixes = ["openai", "anthropic", "gemini", "ollama", "groq", "cohere", "xai"]; const singleSepIndex = modelValue.indexOf(":"); if (singleSepIndex !== -1) { const prefix = modelValue.substring(0, singleSepIndex); From c06a0b19c2b908534b3b92c0a909c9539947e64e Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Sun, 12 Apr 2026 13:08:52 +0300 Subject: [PATCH 002/119] fix: enhance login verification by adding search parameters handling --- app/loginVerification.tsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/app/loginVerification.tsx b/app/loginVerification.tsx index cecfaca97..c2a15dd1a 100644 --- a/app/loginVerification.tsx +++ b/app/loginVerification.tsx @@ -1,7 +1,8 @@ "use client"; import { useSession } from "next-auth/react"; -import { usePathname, useRouter } from "next/navigation"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { debugPort } from "node:process"; import { useEffect } from "react"; export default function LoginVerification({ children }: { children: React.ReactNode }) { @@ -9,7 +10,8 @@ export default function LoginVerification({ children }: { children: React.ReactN const router = useRouter(); const { status } = useSession(); const url = usePathname(); - const { data } = useSession(); + const { data } = useSession(); + const searchParams = useSearchParams(); useEffect(() => { if (data?.user || data === undefined) return; @@ -20,13 +22,20 @@ export default function LoginVerification({ children }: { children: React.ReactN useEffect(() => { // Skip authentication redirects for /docs routes if (url.startsWith('/docs')) return; - - if ((url === "/login" || url === "/") && status === "authenticated") { + + const hostParam = searchParams.get("host"); + const portParam = searchParams.get("port"); + const usernameParam = searchParams.get("username"); + const tls = searchParams.get("tls"); + + const differentConnectionParams = hostParam !== data?.user.host || portParam !== String(data?.user.port) || usernameParam !== data?.user.username || tls !== String(data?.user.tls); + + if (((url === "/login" && !differentConnectionParams) || url === "/") && status === "authenticated") { router.push("/graph"); } else if (status === "unauthenticated" && url !== "/login") { router.push("/login"); } - }, [status, url, router]); + }, [status, url, router, searchParams]); return children; } \ No newline at end of file From f70e0de0234b37d8c13a8431746297acbb8b231a Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Sun, 12 Apr 2026 13:09:15 +0300 Subject: [PATCH 003/119] refactor: simplify tooltip handling and enhance connection info display --- app/components/Header.tsx | 191 ++++++++++++++------------------------ 1 file changed, 71 insertions(+), 120 deletions(-) diff --git a/app/components/Header.tsx b/app/components/Header.tsx index bc917925f..39c404d27 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -3,7 +3,7 @@ 'use client'; import { ArrowUpRight, Copy, Network, FileCode, LogOut, MessagesSquare, Monitor, Moon, Plus, Sun } from "lucide-react"; -import { useCallback, useContext, useState, useEffect, useRef } from "react"; +import { useCallback, useContext, useState, useEffect } from "react"; import Image from "next/image"; import { cn, getTheme, Panel } from "@/lib/utils"; import { useRouter, usePathname } from "next/navigation"; @@ -72,33 +72,8 @@ export default function Header({ onSetGraphName, graphNames, graphName, onOpenPa const router = useRouter(); const [mounted, setMounted] = useState(false); - const [openTooltip, setOpenTooltip] = useState(null); - const closeTimeoutRef = useRef | null>(null); const { toast } = useToast(); - const openTip = useCallback((name: string) => { - if (closeTimeoutRef.current) { - clearTimeout(closeTimeoutRef.current); - closeTimeoutRef.current = null; - } - setOpenTooltip(name); - }, []); - - const closeTip = useCallback(() => { - closeTimeoutRef.current = setTimeout(() => { - setOpenTooltip(null); - closeTimeoutRef.current = null; - }, 150); - }, []); - - useEffect(() => { - return () => { - if (closeTimeoutRef.current) { - clearTimeout(closeTimeoutRef.current); - } - }; - }, []); - const handleCopy = useCallback((text: string) => { if (!navigator.clipboard?.writeText) { toast({ title: "Clipboard not available", variant: "destructive" }); @@ -155,120 +130,96 @@ export default function Header({ onSetGraphName, graphNames, graphName, onOpenPa } + { + session?.user && +
+
+

{session.user.host}:{session.user.port}

+ +
+ {connectionType === "Sentinel" && ( + + +

+ {connectionInfo.sentinelRole === "master" && connectionInfo.sentinelReplicas !== undefined && `Master (${connectionInfo.sentinelReplicas} replicas)`} + {connectionInfo.sentinelRole === "slave" && connectionInfo.sentinelMasterHost && `Replica`} +

+
+ +
+ {connectionInfo.sentinelRole === "master" && connectionInfo.sentinelReplicas !== undefined && ( +

Role: Master with {connectionInfo.sentinelReplicas} replicas

+ )} + {connectionInfo.sentinelRole === "slave" && connectionInfo.sentinelMasterHost && ( +

Master: {connectionInfo.sentinelMasterHost}:{connectionInfo.sentinelMasterPort}

+ )} +
+
+
+ )} + {connectionType === "Cluster" && connectionInfo.clusterNodes && ( + + +

{connectionInfo.clusterNodes.length} nodes

+
+ +
+ {connectionInfo.clusterNodes.map((node) => ( +

+ {node.host}:{node.port} ({node.role}{node.slots ? ` ${node.slots}` : ""}) +

+ ))} +
+
+
+ )} +
+ }
- +
openTip("single")} - onMouseLeave={closeTip} >Si
- openTip("single")} - onMouseLeave={closeTip} - onPointerDownCapture={(e) => e.stopPropagation()} - > -
-
-

Single

- { - connectionType === "Standalone" && session?.user && - - } -
- { - connectionType === "Standalone" && session?.user && -

{session.user.host}:{session.user.port}

- } -
+ +

Single

- +
openTip("sentinel")} - onMouseLeave={closeTip} >Se
- openTip("sentinel")} - onMouseLeave={closeTip} - onPointerDownCapture={(e) => e.stopPropagation()} - > -
-
-

Sentinel

- { - connectionType === "Sentinel" && session?.user && - - } -
- { - connectionType === "Sentinel" && session?.user && -
-

{session.user.host}:{session.user.port}

- {connectionInfo.sentinelRole === "master" && connectionInfo.sentinelReplicas !== undefined &&

Role: Master ({connectionInfo.sentinelReplicas} replicas)

} - {connectionInfo.sentinelRole === "slave" && connectionInfo.sentinelMasterHost &&

Role: Replica (master: {connectionInfo.sentinelMasterHost}:{connectionInfo.sentinelMasterPort})

} -
- } -
+ +

Sentinel

- +
openTip("cluster")} - onMouseLeave={closeTip} >C
- openTip("cluster")} - onMouseLeave={closeTip} - onPointerDownCapture={(e) => e.stopPropagation()} - > -
-
-

Cluster

- { - connectionType === "Cluster" && session?.user && - - } -
- { - connectionType === "Cluster" && session?.user && -
-

{session.user.host}:{session.user.port}

- {connectionInfo.clusterNodes && ( -
-

Nodes: {connectionInfo.clusterNodes.length}

- {connectionInfo.clusterNodes.map((node) => ( -

- {node.host}:{node.port} ({node.role}{node.slots ? ` ${node.slots}` : ""}) -

- ))} -
- )} -
- } -
+ +

Cluster

From 5ff567764832e87296442488377717f3ed27d188 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Sun, 12 Apr 2026 14:21:32 +0300 Subject: [PATCH 004/119] fix: adjust header layout and improve connection info display --- app/components/Header.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/components/Header.tsx b/app/components/Header.tsx index 39c404d27..d4e47fa8b 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -98,7 +98,7 @@ export default function Header({ onSetGraphName, graphNames, graphName, onOpenPa const separator =
; return ( -
+
{ mounted && currentTheme && @@ -132,9 +132,9 @@ export default function Header({ onSetGraphName, graphNames, graphName, onOpenPa } { session?.user && -
-
-

{session.user.host}:{session.user.port}

+
+
+

{session.user.host}:{session.user.port}

-
- {connectionType === "Sentinel" && ( - - -

- {connectionInfo.sentinelRole === "master" && connectionInfo.sentinelReplicas !== undefined && `Master (${connectionInfo.sentinelReplicas} replicas)`} - {connectionInfo.sentinelRole === "slave" && connectionInfo.sentinelMasterHost && `Replica`} -

-
- -
- {connectionInfo.sentinelRole === "master" && connectionInfo.sentinelReplicas !== undefined && ( -

Role: Master with {connectionInfo.sentinelReplicas} replicas

+
+
+ +

{session?.user.username || "Default"}

+
+ { + formatVersion(dbVersion) && +
+ +

v{formatVersion(dbVersion)}

+
+ } + { + session?.user && + <> +
+ {connectionType !== "Standalone" ? ( + + +
- } -
- - -
Si
-
- -

Single

-
-
- - -
Se
-
- -

Sentinel

-
-
- - -
C
-
- -

Cluster

-
-
-
-
-
- {/* - - } - { - type === "Graph" && graphName && - } - { - showCreate && - - - - } - /> - } -
-
-
+
+ + } +
- -

v{pkg.version}

+ +
Si
-

FalkorDB Browser Version

+

Single

+
+
+ + +
Se
+
+ +

Sentinel

+
+
+ + +
C
+
+ +

Cluster

- {separator} - - - e.preventDefault()} asChild> - - - - - - - - Documentation - - - - - - - - - API Documentation - - - - - - - - - - Get Support - - - - - - - - - - } - { - indicator === "offline" && - <> - {separator} -
- - -

Offline

-
- -

The FalkorDB server is offline

-
-
-
- - } - {separator} -
-
+ ); -} +} \ No newline at end of file diff --git a/app/components/Navbar.tsx b/app/components/Navbar.tsx new file mode 100644 index 000000000..08dd4c039 --- /dev/null +++ b/app/components/Navbar.tsx @@ -0,0 +1,311 @@ +/* eslint-disable react/require-default-props */ + +'use client'; + +import { ArrowUpRight, Network, FileCode, LogOut, MessagesSquare, Monitor, Moon, Plus, Sun } from "lucide-react"; +import { useCallback, useContext, useState, useEffect } from "react"; +import Image from "next/image"; +import { cn, getTheme, Panel } from "@/lib/utils"; +import { useRouter, usePathname } from "next/navigation"; +import { signOut, useSession } from "next-auth/react"; +import pkg from '@/package.json'; +import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; +import { Drawer, DrawerContent, DrawerDescription, DrawerTitle, DrawerTrigger } from "@/components/ui/drawer"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import Link from "next/link"; +import { useTheme } from "next-themes"; +import { useToast } from "@/components/ui/use-toast"; +import Button from "./ui/Button"; +import CreateGraph from "./CreateGraph"; +import { IndicatorContext, PanelContext, ConnectionContext } from "./provider"; + +interface Props { + onSetGraphName: (newGraphName: string) => void + graphNames: string[] + graphName: string + onOpenPanel: () => void + panelOpen: boolean + showUDF: boolean +} + +function getPathType(pathname: string): "Schema" | "Graph" | "Settings" | "UDF" | undefined { + if (pathname.includes("/schema")) return "Schema"; + if (pathname.includes("/graph")) return "Graph"; + if (pathname.includes("/settings")) return "Settings"; + if (pathname.includes("/udf")) return "UDF"; + return undefined; +} + +const iconSize = 30; + +export default function Navbar({ onSetGraphName, graphNames, graphName, onOpenPanel, panelOpen, showUDF }: Props) { + + const { indicator } = useContext(IndicatorContext); + const { setPanel, panel } = useContext(PanelContext); + + const { theme, setTheme } = useTheme(); + const { currentTheme } = getTheme(theme); + const { data: session } = useSession(); + const pathname = usePathname(); + const router = useRouter(); + + const [mounted, setMounted] = useState(false); + + const type = getPathType(pathname); + const showCreate = type && type !== "Settings" && type !== "UDF" && session?.user.role && session.user.role !== "Read-Only"; + + useEffect(() => { + setMounted(true); + }, []); + + const handleSetCurrentPanel = useCallback((newPanel: Panel) => { + setPanel(prev => prev === newPanel ? undefined : newPanel); + }, [setPanel]); + + const separator =
; + + return ( +
+
+ { + mounted && currentTheme && + + FalkorDB Logo + + } +
+
+ {/* + + } + { + type === "Graph" && graphName && + + } + { + showCreate && + + + + } + /> + } +
+
+
+ + +

v{pkg.version}

+
+ +

FalkorDB Browser Version

+
+
+ {separator} + + + e.preventDefault()} asChild> + + + + + + + + Documentation + + + + + + + + + API Documentation + + + + + + + + + + Get Support + + + + + + + + + + } + { + indicator === "offline" && + <> + {separator} +
+ + +

Offline

+
+ +

The FalkorDB server is offline

+
+
+
+ + } + {separator} + +
+
+ ); +} diff --git a/app/layout.tsx b/app/layout.tsx index 715925eea..42af4185a 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -22,7 +22,7 @@ export default function RootLayout({ // caused by mismatched client/server content caused by next-themes return ( - + diff --git a/app/providers.tsx b/app/providers.tsx index 86838034f..01340f3ea 100644 --- a/app/providers.tsx +++ b/app/providers.tsx @@ -14,10 +14,11 @@ import { PanelImperativeHandle, PanelSize } from "react-resizable-panels"; import type { GraphData as CanvasData, ViewportState } from "@falkordb/canvas"; import LoginVerification from "./loginVerification"; import { Graph, GraphInfo } from "./api/graph/model"; -import Header from "./components/Header"; +import Navbar from "./components/Navbar"; import { GraphContext, HistoryQueryContext, IndicatorContext, PanelContext, QueryLoadingContext, BrowserSettingsContext, SchemaContext, ForceGraphContext, TableViewContext, ConnectionContext, UDFContext } from "./components/provider"; import Tutorial from "./components/Tutorial"; import { MEMORY_USAGE_VERSION_THRESHOLD } from "./utils"; +import Header from "./components/Header"; const GraphInfoPanel = dynamic(() => import("./graph/graphInfo"), { ssr: false, @@ -146,6 +147,7 @@ function ProvidersWithSession({ children }: { children: React.ReactNode }) { const [showUDF, setShowUDF] = useState(true); const [maxItemsForSearch, setMaxItemsForSearch] = useState(20); const [newMaxItemsForSearch, setNewMaxItemsForSearch] = useState(20); + const showNavbarAndHeader = pathname !== "/" && pathname !== "/login"; const replayTutorial = useCallback(() => { router.push("/graph"); @@ -931,59 +933,65 @@ function ProvidersWithSession({ children }: { children: React.ReactNode }) { /> } { - pathname !== "/" && pathname !== "/login" && -
+ showNavbarAndHeader && +
} - - - { - pathname === "/udf" ? - - : pathname === "/graph" && - - } - - isCollapsed && onExpand()} - className={cn("bg-border", isCollapsed && "hidden")} - disabled={isCollapsed} - /> - - { - (pathname === "/graph" || pathname === "/schema") ? -
- {children} -
-
- : - children - } - - +
+ { + showNavbarAndHeader && + + } + + + { + pathname === "/udf" ? + + : pathname === "/graph" && + + } + + isCollapsed && onExpand()} + className={cn("bg-border", isCollapsed && "hidden")} + disabled={isCollapsed} + /> + + { + (pathname === "/graph" || pathname === "/schema") ? +
+ {children} +
+
+ : + children + } + + +
From 97b0da205239169244a6b1a9b467227a10529766 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Sun, 12 Apr 2026 17:32:03 +0300 Subject: [PATCH 007/119] refactor: restructure login verification component for improved readability --- app/loginVerification.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/app/loginVerification.tsx b/app/loginVerification.tsx index c2a15dd1a..3217cde74 100644 --- a/app/loginVerification.tsx +++ b/app/loginVerification.tsx @@ -2,10 +2,9 @@ import { useSession } from "next-auth/react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { debugPort } from "node:process"; -import { useEffect } from "react"; +import { Suspense, useEffect } from "react"; -export default function LoginVerification({ children }: { children: React.ReactNode }) { +function LoginVerificationInner({ children }: { children: React.ReactNode }) { const router = useRouter(); const { status } = useSession(); @@ -38,4 +37,12 @@ export default function LoginVerification({ children }: { children: React.ReactN }, [status, url, router, searchParams]); return children; +} + +export default function LoginVerification({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); } \ No newline at end of file From eaf6138ee220c5768ccb9b7826b58ff5b9a331d9 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Mon, 13 Apr 2026 14:45:54 +0300 Subject: [PATCH 008/119] feat: add sentinel role support for read-only access and query execution - Updated various API routes to include a "sentinel" query parameter for handling read-only access based on the user's role and sentinel status. - Modified the runQuery utility function to accept an optional sentinel parameter. - Enhanced components (CreateGraph, CypherEditor, ForceGraph, Header, DataPanel, DataTable, MetadataView, SelectGraph, and Providers) to utilize the sentinel role when making API requests. - Improved memory usage display in the Header component. - Ensured that the connection information is correctly displayed and copied for both Sentinel and Cluster connection types. --- .../graph/[graph]/[element]/[key]/route.ts | 6 +- .../graph/[graph]/[element]/label/route.ts | 6 +- app/api/graph/[graph]/[element]/route.ts | 9 +- app/api/graph/[graph]/count/edges/route.ts | 3 +- app/api/graph/[graph]/count/nodes/route.ts | 3 +- app/api/graph/[graph]/count/route.ts | 5 +- app/api/graph/[graph]/info/route.ts | 3 +- app/api/graph/[graph]/route.ts | 6 +- app/api/info/route.ts | 7 +- app/api/utils.ts | 4 +- app/components/CreateGraph.tsx | 5 +- app/components/CypherEditor.tsx | 11 +- app/components/ForceGraph.tsx | 5 +- app/components/Header.tsx | 181 +++++++++++------- app/graph/DataPanel.tsx | 7 +- app/graph/DataTable.tsx | 7 +- app/graph/MetadataView.tsx | 6 +- app/graph/page.tsx | 16 +- app/graph/selectGraph.tsx | 13 +- app/providers.tsx | 13 +- lib/utils.ts | 5 +- 21 files changed, 198 insertions(+), 123 deletions(-) diff --git a/app/api/graph/[graph]/[element]/[key]/route.ts b/app/api/graph/[graph]/[element]/[key]/route.ts index bba29a607..6c2ca69a3 100644 --- a/app/api/graph/[graph]/[element]/[key]/route.ts +++ b/app/api/graph/[graph]/[element]/[key]/route.ts @@ -27,6 +27,7 @@ export async function POST( const { client, user } = session; const { graph: graphId, element, key } = await params; const elementId = Number(element); + const sentinel = request.nextUrl.searchParams.get("sentinel"); try { const body = await request.json(); @@ -48,7 +49,7 @@ export async function POST( ? `MATCH (n) WHERE ID(n) = $id SET n.${key} = $value` : `MATCH ()-[e]->() WHERE ID(e) = $id SET e.${key} = $value`; - if (user.role === "Read-Only") + if (user.role === "Read-Only" || sentinel === "slave") await graph.roQuery(query, { params: { id: elementId, value } }); else await graph.query(query, { params: { id: elementId, value } }); @@ -89,6 +90,7 @@ export async function DELETE( const { graph: graphId, element, key } = await params; const elementId = Number(element); + const sentinel = request.nextUrl.searchParams.get("sentinel"); try { const body = await request.json(); @@ -110,7 +112,7 @@ export async function DELETE( ? `MATCH (n) WHERE ID(n) = $id SET n.${key} = NULL` : `MATCH ()-[e]->() WHERE ID(e) = $id SET e.${key} = NULL`; - if (user.role === "Read-Only") + if (user.role === "Read-Only" || sentinel === "slave") await graph.roQuery(query, { params: { id: elementId } }); else await graph.query(query, { params: { id: elementId } }); diff --git a/app/api/graph/[graph]/[element]/label/route.ts b/app/api/graph/[graph]/[element]/label/route.ts index 54ed88d64..98acfcca4 100644 --- a/app/api/graph/[graph]/[element]/label/route.ts +++ b/app/api/graph/[graph]/[element]/label/route.ts @@ -25,6 +25,7 @@ export async function DELETE( const { client, user } = session; const { graph: graphId, element } = await params; const elementId = Number(element); + const sentinel = request.nextUrl.searchParams.get("sentinel"); try { const body = await request.json(); @@ -40,7 +41,7 @@ export async function DELETE( const query = `MATCH (n) WHERE ID(n) = $id REMOVE n:${label}`; const graph = client.selectGraph(graphId); - if (user.role === "Read-Only") + if (user.role === "Read-Only" || sentinel === "slave") await graph.roQuery(query, { params: { id: elementId } }); else await graph.query(query, { params: { id: elementId } }); @@ -78,6 +79,7 @@ export async function POST( const { client, user } = session; const { graph: graphId, element } = await params; const elementId = Number(element); + const sentinel = request.nextUrl.searchParams.get("sentinel"); try { const body = await request.json(); @@ -93,7 +95,7 @@ export async function POST( const query = `MATCH (n) WHERE ID(n) = $id SET n:${label}`; const graph = client.selectGraph(graphId); - if (user.role === "Read-Only") + if (user.role === "Read-Only" || sentinel === "slave") await graph.roQuery(query, { params: { id: elementId } }); else await graph.query(query, { params: { id: elementId } }); diff --git a/app/api/graph/[graph]/[element]/route.ts b/app/api/graph/[graph]/[element]/route.ts index 0fb692cd2..8209fc9c6 100644 --- a/app/api/graph/[graph]/[element]/route.ts +++ b/app/api/graph/[graph]/[element]/route.ts @@ -26,6 +26,7 @@ export async function GET( const { client, user } = session; const { graph: graphId, element } = await params; const elementId = Number(element); + const sentinel = request.nextUrl.searchParams.get("sentinel"); try { const graph = client.selectGraph(graphId); @@ -36,7 +37,7 @@ export async function GET( RETURN *`; const result = - user.role === "Read-Only" + user.role === "Read-Only" || sentinel === "slave" ? await graph.roQuery(query, { params: { id: elementId } }) : await graph.query(query, { params: { id: elementId } }); @@ -70,6 +71,7 @@ export async function POST( const { client, user } = session; const { graph: graphId } = await params; + const sentinel = request.nextUrl.searchParams.get("sentinel"); try { const body = await request.json(); @@ -120,7 +122,7 @@ export async function POST( } const result = - user.role === "Read-Only" + user.role === "Read-Only" || sentinel === "slave" ? await graph.roQuery(query, { params: queryParams }) : await graph.query(query, { params: queryParams }); @@ -155,6 +157,7 @@ export async function DELETE( const { client, user } = session; const { graph: graphId, element } = await params; const elementId = Number(element); + const sentinel = request.nextUrl.searchParams.get("sentinel"); try { const body = await request.json(); @@ -175,7 +178,7 @@ export async function DELETE( ? `MATCH (n) WHERE ID(n) = $id DELETE n` : `MATCH ()-[e]->() WHERE ID(e) = $id DELETE e`; - if (user.role === "Read-Only") + if (user.role === "Read-Only" || sentinel === "slave") await graph.roQuery(query, { params: { id: elementId } }); else await graph.query(query, { params: { id: elementId } }); diff --git a/app/api/graph/[graph]/count/edges/route.ts b/app/api/graph/[graph]/count/edges/route.ts index bd1d8acf7..76c824344 100644 --- a/app/api/graph/[graph]/count/edges/route.ts +++ b/app/api/graph/[graph]/count/edges/route.ts @@ -25,13 +25,14 @@ export async function GET( const { client, user } = session; const { graph: graphId } = await params; + const sentinel = request.nextUrl.searchParams.get("sentinel"); try { const graph = client.selectGraph(graphId); // Execute edges count query const edgesQuery = "MATCH ()-[e]->() RETURN count(e) as edges"; - const edgesResult = await runQuery(graph, edgesQuery, user.role); + const edgesResult = await runQuery(graph, edgesQuery, user.role, sentinel); if (!edgesResult) throw new Error("Something went wrong"); diff --git a/app/api/graph/[graph]/count/nodes/route.ts b/app/api/graph/[graph]/count/nodes/route.ts index 79da4afd3..0345a9a2c 100644 --- a/app/api/graph/[graph]/count/nodes/route.ts +++ b/app/api/graph/[graph]/count/nodes/route.ts @@ -27,13 +27,14 @@ export async function GET( const { client, user } = session; const { graph: graphId } = await params; + const sentinel = request.nextUrl.searchParams.get("sentinel"); try { const graph = client.selectGraph(graphId); // Execute nodes count query const nodesQuery = "MATCH (n) RETURN count(n) as nodes"; - const nodesResult = await runQuery(graph, nodesQuery, user.role); + const nodesResult = await runQuery(graph, nodesQuery, user.role, sentinel); if (!nodesResult) throw new Error("Something went wrong"); diff --git a/app/api/graph/[graph]/count/route.ts b/app/api/graph/[graph]/count/route.ts index 6192825d0..182be39f1 100644 --- a/app/api/graph/[graph]/count/route.ts +++ b/app/api/graph/[graph]/count/route.ts @@ -20,6 +20,7 @@ export async function GET( const { client, user } = session; const { graph: graphId } = await params; + const sentinel = request.nextUrl.searchParams.get("sentinel"); try { const graph = client.selectGraph(graphId); @@ -29,10 +30,10 @@ export async function GET( const edgesQuery = "MATCH ()-[e]->() RETURN count(e) as edges"; // Execute nodes count query - const nodesResult = await runQuery(graph, nodesQuery, user.role); + const nodesResult = await runQuery(graph, nodesQuery, user.role, sentinel); // Execute edges count query - const edgesResult = await runQuery(graph, edgesQuery, user.role); + const edgesResult = await runQuery(graph, edgesQuery, user.role, sentinel); if (!nodesResult || !edgesResult) throw new Error("Something went wrong"); diff --git a/app/api/graph/[graph]/info/route.ts b/app/api/graph/[graph]/info/route.ts index b5e487eb4..1484397ce 100644 --- a/app/api/graph/[graph]/info/route.ts +++ b/app/api/graph/[graph]/info/route.ts @@ -22,6 +22,7 @@ export async function GET( | "(label)" | "(relationship type)" | undefined; + const sentinel = request.nextUrl.searchParams.get("sentinel"); try { const getQuery = () => { @@ -42,7 +43,7 @@ export async function GET( const graph = client.selectGraph(graphId); const result = - user.role === "Read-Only" + user.role === "Read-Only" || sentinel === "slave" ? await graph.roQuery(getQuery()) : await graph.query(getQuery()); diff --git a/app/api/graph/[graph]/route.ts b/app/api/graph/[graph]/route.ts index d40b40313..07318a7a1 100644 --- a/app/api/graph/[graph]/route.ts +++ b/app/api/graph/[graph]/route.ts @@ -63,11 +63,12 @@ export async function POST( const { client, user } = session; const { graph: graphId } = await params; + const sentinel = request.nextUrl.searchParams.get("sentinel"); try { const graph = client.selectGraph(graphId); - if (user.role === "Read-Only") await graph.roQuery("RETURN 1"); + if (user.role === "Read-Only" || sentinel === "slave") await graph.roQuery("RETURN 1"); else await graph.query("RETURN 1"); return NextResponse.json( @@ -163,6 +164,7 @@ export async function GET( const { graph: graphId } = await params; const query = request.nextUrl.searchParams.get("query"); const timeout = Number(request.nextUrl.searchParams.get("timeout")) * 1000; + const sentinel = request.nextUrl.searchParams.get("sentinel"); try { if (!query) throw new Error("Missing parameter query"); @@ -171,7 +173,7 @@ export async function GET( const graph = client.selectGraph(graphId); const result = - user.role === "Read-Only" + user.role === "Read-Only" || sentinel === "slave" ? await graph.roQuery(query, { TIMEOUT: timeout }) : await graph.query(query, { TIMEOUT: timeout }); diff --git a/app/api/info/route.ts b/app/api/info/route.ts index ea0137d8d..d5ffec0f0 100644 --- a/app/api/info/route.ts +++ b/app/api/info/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { getClient } from "@/app/api/auth/[...nextauth]/options"; import { getCorsHeaders } from "@/app/api/utils"; @@ -7,7 +7,7 @@ export async function OPTIONS(request: Request) { } // eslint-disable-next-line import/prefer-default-export, @typescript-eslint/no-unused-vars -export async function GET(request: Request) { +export async function GET(request: NextRequest) { try { const session = await getClient(request); @@ -16,9 +16,10 @@ export async function GET(request: Request) { } const { client } = session; + const section = request.nextUrl.searchParams.get("section") || ""; try { - const result = await (await client.connection).info(); + const result = await (await client.connection).info(section); return NextResponse.json({ result }, { status: 200, headers: getCorsHeaders(request) }); } catch (error) { diff --git a/app/api/utils.ts b/app/api/utils.ts index 1ccaf3cb9..d9589fb30 100644 --- a/app/api/utils.ts +++ b/app/api/utils.ts @@ -1,8 +1,8 @@ import { Role } from "next-auth"; import type { Graph } from "falkordb"; -export const runQuery = async (graph: Graph, query: string, role: Role) => { - const result = role === "Read-Only" ? await graph.roQuery(query) : await graph.query(query); +export const runQuery = async (graph: Graph, query: string, role: Role, sentinel?: string | null) => { + const result = role === "Read-Only" || sentinel === "slave" ? await graph.roQuery(query) : await graph.query(query); return result; }; diff --git a/app/components/CreateGraph.tsx b/app/components/CreateGraph.tsx index fb1b313e7..5b9fd37b9 100644 --- a/app/components/CreateGraph.tsx +++ b/app/components/CreateGraph.tsx @@ -11,7 +11,7 @@ import DialogComponent from "./DialogComponent"; import Button from "./ui/Button"; import CloseDialog from "./CloseDialog"; import Input from "./ui/Input"; -import { IndicatorContext } from "./provider"; +import { IndicatorContext, ConnectionContext } from "./provider"; interface Props { onSetGraphName: (name: string) => void @@ -39,6 +39,7 @@ export default function CreateGraph({ }: Props) { const { indicator, setIndicator } = useContext(IndicatorContext); + const { connectionInfo } = useContext(ConnectionContext); const { toast } = useToast(); @@ -74,7 +75,7 @@ export default function CreateGraph({ }); return; } - const result = await securedFetch(`api/${type === "Schema" ? "schema" : "graph"}/${prepareArg(name)}`, { + const result = await securedFetch(`api/${type === "Schema" ? "schema" : "graph"}/${prepareArg(name)}${connectionInfo.sentinelRole ? `?sentinel=${connectionInfo.sentinelRole}` : ''}`, { method: "POST", }, toast, setIndicator); diff --git a/app/components/CypherEditor.tsx b/app/components/CypherEditor.tsx index 5692d4fd5..00cfa39e3 100644 --- a/app/components/CypherEditor.tsx +++ b/app/components/CypherEditor.tsx @@ -14,7 +14,7 @@ import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; import Button from "./ui/Button"; import CloseDialog from "./CloseDialog"; import EditorComponent, { LINE_HEIGHT, LanguageConfig } from "./EditorComponent"; -import { BrowserSettingsContext, IndicatorContext, UDFContext } from "./provider"; +import { BrowserSettingsContext, IndicatorContext, UDFContext, ConnectionContext } from "./provider"; import { Graph } from "../api/graph/model"; interface Props { @@ -218,6 +218,7 @@ export default function CypherEditor({ graph, graphName, historyQuery, maximize, const { indicator, setIndicator } = useContext(IndicatorContext); const { tutorialOpen } = useContext(BrowserSettingsContext); const { udfList } = useContext(UDFContext); + const { connectionInfo } = useContext(ConnectionContext); const { toast } = useToast(); const editorRef = useRef(null); @@ -230,6 +231,7 @@ export default function CypherEditor({ graph, graphName, historyQuery, maximize, const graphNameRef = useRef(graphName); const queryRef = useRef(historyQuery.query); const tutorialOpenRef = useRef(tutorialOpen); + const connectionInfoRef = useRef(connectionInfo); const monacoRef = useRef(null); const [lineNumber, setLineNumber] = useState(1); @@ -268,6 +270,10 @@ export default function CypherEditor({ graph, graphName, historyQuery, maximize, graphIdRef.current = graph.Id; }, [graph.Id]); + useEffect(() => { + connectionInfoRef.current = connectionInfo; + }, [connectionInfo]); + useEffect(() => { if (!containerRef.current) return; @@ -294,7 +300,8 @@ export default function CypherEditor({ graph, graphName, historyQuery, maximize, const fetchSuggestions = async (detail: string): Promise => { if (indicator === "offline") return []; - const result = await securedFetch(`api/graph/${graphIdRef.current}/info?type=${prepareArg(detail)}`, { + const sentinel = connectionInfoRef.current.sentinelRole ? `&sentinel=${connectionInfoRef.current.sentinelRole}` : ''; + const result = await securedFetch(`api/graph/${graphIdRef.current}/info?type=${prepareArg(detail)}${sentinel}`, { method: 'GET', }, toast, setIndicator); diff --git a/app/components/ForceGraph.tsx b/app/components/ForceGraph.tsx index ace83ebc1..748ed52fe 100644 --- a/app/components/ForceGraph.tsx +++ b/app/components/ForceGraph.tsx @@ -10,7 +10,7 @@ import { dataToGraphData } from "@falkordb/canvas"; import { securedFetch, getTheme, GraphRef, GraphData, Node, Relationship, Link } from "@/lib/utils"; import { useToast } from "@/components/ui/use-toast"; import { Graph } from "../api/graph/model"; -import { BrowserSettingsContext, IndicatorContext } from "./provider"; +import { BrowserSettingsContext, IndicatorContext, ConnectionContext } from "./provider"; interface Props { graph: Graph @@ -71,6 +71,7 @@ export default function ForceGraph({ const { setIndicator } = useContext(IndicatorContext); const { settings: { captionsKeysSettings: { captionsKeys }, showPropertyKeyPrefixSettings: { showPropertyKeyPrefix } } } = useContext(BrowserSettingsContext); + const { connectionInfo } = useContext(ConnectionContext); const { theme } = useTheme(); const { toast } = useToast(); @@ -127,7 +128,7 @@ export default function ForceGraph({ const canvas = canvasRef.current; if (!canvas || !canvasLoaded) return; - const result = await securedFetch(`/api/${type}/${graph.Id}/${node.id}`, { + const result = await securedFetch(`/api/${type}/${graph.Id}/${node.id}${connectionInfo.sentinelRole ? `?sentinel=${connectionInfo.sentinelRole}` : ''}`, { method: 'GET', headers: { 'Content-Type': 'application/json' diff --git a/app/components/Header.tsx b/app/components/Header.tsx index 4f34fef1d..c26e798c3 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -1,12 +1,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import Button from "./ui/Button"; -import { ConnectionContext } from "./provider"; -import { useCallback, useContext } from "react"; +import { ConnectionContext, IndicatorContext } from "./provider"; +import { useCallback, useContext, useEffect, useState } from "react"; import { useSession } from "next-auth/react"; -import { Copy } from "lucide-react"; +import { Copy, Loader2 } from "lucide-react"; import { useToast } from "@/components/ui/use-toast"; -import { cn } from "@/lib/utils"; +import { cn, securedFetch } from "@/lib/utils"; /** * Format version number to include dots (e.g., "11111" -> "1.11.11") @@ -29,10 +29,33 @@ function formatVersion(version: string | undefined): string { } export default function Header() { + const { setIndicator } = useContext(IndicatorContext); const { connectionType, connectionInfo, dbVersion } = useContext(ConnectionContext); const { data: session } = useSession(); const { toast } = useToast(); + const [usedMemory, setUsedMemory] = useState(null); + + useEffect(() => { + (async () => { + const result = await securedFetch("/api/info?section=memory", { + method: "GET" + }, toast, setIndicator); + + if (!result.ok) return; + + const data = (await result.json()).result; + + const match = data.match(/used_memory_human:(\S+)/); + + console.log(match); + + if (!match) return; + + setUsedMemory(match[1]); + })(); + }, [toast, setIndicator]); + const handleCopy = useCallback((text: string) => { if (!navigator.clipboard?.writeText) { toast({ title: "Clipboard not available", variant: "destructive" }); @@ -44,81 +67,34 @@ export default function Header() { }, [toast]); return ( -
-
- +
+
+

{session?.user.username || "Default"}

{ formatVersion(dbVersion) && -
- +
+

v{formatVersion(dbVersion)}

} - { - session?.user && - <> -
- {connectionType !== "Standalone" ? ( - - - -
- - } -
+
+ + + + + +

Used Memory

+
+
+ { + usedMemory !== null ? +

{usedMemory}

+ : + } +
+
+ { + session?.user && +
+ + {connectionType !== "Standalone" ? ( + + +
+ }
); } \ No newline at end of file diff --git a/app/graph/DataPanel.tsx b/app/graph/DataPanel.tsx index 20d455334..5517ed294 100644 --- a/app/graph/DataPanel.tsx +++ b/app/graph/DataPanel.tsx @@ -10,7 +10,7 @@ import { Pencil, TableProperties, X } from "lucide-react"; import { useToast } from "@/components/ui/use-toast"; import { useSession } from "next-auth/react"; import Button from "../components/ui/Button"; -import { IndicatorContext, GraphContext } from "../components/provider"; +import { IndicatorContext, GraphContext, ConnectionContext } from "../components/provider"; import DataTable from "./DataTable"; import AddLabel from "./addLabel"; import RemoveLabel from "./RemoveLabel"; @@ -25,6 +25,7 @@ interface Props { export default function DataPanel({ object, onClose, setLabels, canvasRef }: Props) { const { setIndicator } = useContext(IndicatorContext); const { graph, setGraphInfo } = useContext(GraphContext); + const { connectionInfo } = useContext(ConnectionContext); const lastObjId = useRef(undefined); const labelsListRef = useRef(null); @@ -76,7 +77,7 @@ export default function DataPanel({ object, onClose, setLabels, canvasRef }: Pro }); return false; } - const result = await securedFetch(`api/graph/${prepareArg(graph.Id)}/${node.id}/label`, { + const result = await securedFetch(`api/graph/${prepareArg(graph.Id)}/${node.id}/label${connectionInfo.sentinelRole ? `?sentinel=${connectionInfo.sentinelRole}` : ''}`, { method: "POST", body: JSON.stringify({ label: newLabel @@ -124,7 +125,7 @@ export default function DataPanel({ object, onClose, setLabels, canvasRef }: Pro return false; } - const result = await securedFetch(`api/graph/${prepareArg(graph.Id)}/${node.id}/label`, { + const result = await securedFetch(`api/graph/${prepareArg(graph.Id)}/${node.id}/label${connectionInfo.sentinelRole ? `?sentinel=${connectionInfo.sentinelRole}` : ''}`, { method: "DELETE", body: JSON.stringify({ label: removeLabel diff --git a/app/graph/DataTable.tsx b/app/graph/DataTable.tsx index 75f852215..6f7b398db 100644 --- a/app/graph/DataTable.tsx +++ b/app/graph/DataTable.tsx @@ -15,7 +15,7 @@ import Input from "../components/ui/Input"; import DialogComponent from "../components/DialogComponent"; import CloseDialog from "../components/CloseDialog"; import { EMPTY_DISPLAY_NAME } from "../api/graph/model"; -import { BrowserSettingsContext, GraphContext, IndicatorContext } from "../components/provider"; +import { BrowserSettingsContext, GraphContext, IndicatorContext, ConnectionContext } from "../components/provider"; import ToastButton from "../components/ToastButton"; import Button from "../components/ui/Button"; import Combobox from "../components/ui/combobox"; @@ -34,6 +34,7 @@ export default function DataTable({ object, type, lastObjId, canvasRef, classNam const { graph, graphInfo, setGraphInfo } = useContext(GraphContext); const { settings: { captionsKeysSettings: { captionsKeys }} } = useContext(BrowserSettingsContext); + const { connectionInfo } = useContext(ConnectionContext); const { toast } = useToast(); const setInputRef = useRef(null); @@ -210,7 +211,7 @@ export default function DataTable({ object, type, lastObjId, canvasRef, classNam } try { if (actionType === "set") setIsSetLoading(true); - const result = await securedFetch(`api/graph/${prepareArg(graph.Id)}/${id}/${key}`, { + const result = await securedFetch(`api/graph/${prepareArg(graph.Id)}/${id}/${key}${connectionInfo.sentinelRole ? `?sentinel=${connectionInfo.sentinelRole}` : ''}`, { method: "POST", body: JSON.stringify({ value: val, @@ -303,7 +304,7 @@ export default function DataTable({ object, type, lastObjId, canvasRef, classNam try { setIsRemoveLoading(true); const { id } = object; - const success = (await securedFetch(`api/graph/${prepareArg(graph.Id)}/${id}/${key}`, { + const success = (await securedFetch(`api/graph/${prepareArg(graph.Id)}/${id}/${key}${connectionInfo.sentinelRole ? `?sentinel=${connectionInfo.sentinelRole}` : ''}`, { method: "DELETE", body: JSON.stringify({ type }), }, toast, setIndicator)).ok; diff --git a/app/graph/MetadataView.tsx b/app/graph/MetadataView.tsx index 8ef6e6246..28ce478cf 100644 --- a/app/graph/MetadataView.tsx +++ b/app/graph/MetadataView.tsx @@ -6,7 +6,7 @@ import { Info } from "lucide-react"; import { useToast } from "@/components/ui/use-toast"; import { useTheme } from "next-themes"; import Button from "../components/ui/Button"; -import { IndicatorContext } from "../components/provider"; +import { IndicatorContext, ConnectionContext } from "../components/provider"; const renderValue = (v: any) => ( {v} @@ -25,6 +25,7 @@ export function Profile({ graphName, query, setQuery, fetchCount, background }: }) { const { indicator, setIndicator } = useContext(IndicatorContext); + const { connectionInfo } = useContext(ConnectionContext); const { toast } = useToast(); const { theme } = useTheme(); @@ -37,7 +38,8 @@ export function Profile({ graphName, query, setQuery, fetchCount, background }: const handleProfile = async () => { setIsLoading(true); try { - const result = await securedFetch(`/api/graph/${graphName}/profile?query=${prepareArg(query.text)}`, { + const sentinel = connectionInfo.sentinelRole ? `&sentinel=${connectionInfo.sentinelRole}` : ''; + const result = await securedFetch(`/api/graph/${graphName}/profile?query=${prepareArg(query.text)}${sentinel}`, { method: "GET", }, toast, setIndicator); diff --git a/app/graph/page.tsx b/app/graph/page.tsx index bc5175348..29f99ac42 100644 --- a/app/graph/page.tsx +++ b/app/graph/page.tsx @@ -7,7 +7,7 @@ import dynamicImport from "next/dynamic"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; import { PanelImperativeHandle, PanelSize } from "react-resizable-panels"; import { Graph, GraphInfo } from "../api/graph/model"; -import { BrowserSettingsContext, GraphContext, HistoryQueryContext, IndicatorContext, PanelContext, QueryLoadingContext, ForceGraphContext } from "../components/provider"; +import { BrowserSettingsContext, GraphContext, HistoryQueryContext, IndicatorContext, PanelContext, QueryLoadingContext, ForceGraphContext, ConnectionContext } from "../components/provider"; import { getConnectionItem } from "@/lib/connection-storage"; import Spinning from "../components/ui/spinning"; import Chat from "./Chat"; @@ -51,6 +51,7 @@ export default function Page() { const { tutorialOpen } = useContext(BrowserSettingsContext); const { isQueryLoading, setIsQueryLoading } = useContext(QueryLoadingContext); const { setData, canvasRef } = useContext(ForceGraphContext); + const { connectionInfo } = useContext(ConnectionContext); const { graph, setGraph, @@ -158,7 +159,8 @@ export default function Page() { const fetchInfo = useCallback(async (type: string) => { if (!graphName) return []; - const result = await securedFetch(`/api/graph/${graphName}/info?type=${type}`, { + const sentinel = connectionInfo.sentinelRole ? `&sentinel=${connectionInfo.sentinelRole}` : ''; + const result = await securedFetch(`/api/graph/${graphName}/info?type=${type}${sentinel}`, { method: "GET", }, toast, setIndicator); @@ -169,7 +171,7 @@ export default function Page() { return json.result.data.map(({ info }: { info: string }) => info); }, [graphName, setIndicator, toast]); - const fetchMetaStats = useCallback((name: string) => getMetaStats(name, toast, setIndicator), [setIndicator, toast]); + const fetchMetaStats = useCallback((name: string) => getMetaStats(name, toast, setIndicator, connectionInfo.sentinelRole), [setIndicator, toast, connectionInfo.sentinelRole]); useEffect(() => { if (!graphName) return undefined; @@ -276,7 +278,8 @@ export default function Page() { const handleCreateElement = useCallback(async (attributes: [string, Value][], label: string[]) => { const fakeId = "-1"; - const result = await securedFetch(`api/graph/${prepareArg(graphName)}/${fakeId}`, { + const sentinel = connectionInfo.sentinelRole ? `?sentinel=${connectionInfo.sentinelRole}` : ''; + const result = await securedFetch(`api/graph/${prepareArg(graphName)}/${fakeId}${sentinel}`, { method: "POST", body: JSON.stringify({ attributes, @@ -317,8 +320,9 @@ export default function Page() { const handleDeleteElement = useCallback(async () => { const deletedElements = (await Promise.all(selectedElements.map(async (element) => { - const type = !("source" in element); - const result = await securedFetch(`api/graph/${prepareArg(graph.Id)}/${prepareArg(element.id.toString())}`, { + const type = !('source' in element); + const sentinel = connectionInfo.sentinelRole ? `?sentinel=${connectionInfo.sentinelRole}` : ''; + const result = await securedFetch(`api/graph/${prepareArg(graph.Id)}/${prepareArg(element.id.toString())}${sentinel}`, { method: "DELETE", body: JSON.stringify({ type }) }, toast, setIndicator); diff --git a/app/graph/selectGraph.tsx b/app/graph/selectGraph.tsx index a938f86a2..393469578 100644 --- a/app/graph/selectGraph.tsx +++ b/app/graph/selectGraph.tsx @@ -9,7 +9,7 @@ import { useSession } from "next-auth/react"; import { useToast } from "@/components/ui/use-toast"; import { ChevronDown, ChevronUp, PlusCircle, Settings } from "lucide-react"; import Button from "../components/ui/Button"; -import { IndicatorContext, BrowserSettingsContext } from "../components/provider"; +import { IndicatorContext, BrowserSettingsContext, ConnectionContext } from "../components/provider"; import PaginationList from "../components/PaginationList"; import TableComponent from "../components/TableComponent"; import ExportGraph from "../components/ExportGraph"; @@ -44,6 +44,7 @@ interface Props { export default function SelectGraph({ options, setOptions, selectedValue, setSelectedValue, type, setGraph }: Props) { const { indicator, setIndicator } = useContext(IndicatorContext); + const { connectionInfo } = useContext(ConnectionContext); const { settings: { contentPersistenceSettings: { @@ -85,7 +86,8 @@ export default function SelectGraph({ options, setOptions, selectedValue, setSel const loadNodesCount = useCallback((opt: string) => async () => { try { - const result = await getSSEGraphResult(`api/graph/${prepareArg(opt)}/count/nodes`, toast, setIndicator) as { nodes?: number }; + const sentinel = connectionInfo.sentinelRole ? `?sentinel=${connectionInfo.sentinelRole}` : ''; + const result = await getSSEGraphResult(`api/graph/${prepareArg(opt)}/count/nodes${sentinel}`, toast, setIndicator) as { nodes?: number }; if (result.nodes == null || !Number.isFinite(Number(result.nodes))) return ""; @@ -93,12 +95,13 @@ export default function SelectGraph({ options, setOptions, selectedValue, setSel } catch { return ""; } - }, [toast, setIndicator]); + }, [toast, setIndicator, connectionInfo.sentinelRole]); const loadEdgesCount = useCallback((opt: string) => async () => { try { - const result = await getSSEGraphResult(`api/graph/${prepareArg(opt)}/count/edges`, toast, setIndicator) as { edges?: number }; + const sentinel = connectionInfo.sentinelRole ? `?sentinel=${connectionInfo.sentinelRole}` : ''; + const result = await getSSEGraphResult(`api/graph/${prepareArg(opt)}/count/edges${sentinel}`, toast, setIndicator) as { edges?: number }; if (result.edges == null || !Number.isFinite(Number(result.edges))) return ""; @@ -106,7 +109,7 @@ export default function SelectGraph({ options, setOptions, selectedValue, setSel } catch { return ""; } - }, [toast, setIndicator]); + }, [toast, setIndicator, connectionInfo.sentinelRole]); const handleSetOption = useCallback(async (option: string, optionName: string) => { const result = await securedFetch( diff --git a/app/providers.tsx b/app/providers.tsx index 01340f3ea..c4ea3b2c2 100644 --- a/app/providers.tsx +++ b/app/providers.tsx @@ -370,7 +370,8 @@ function ProvidersWithSession({ children }: { children: React.ReactNode }) { setNodesCount(undefined); try { - const result = await getSSEGraphResult(`api/graph/${prepareArg(n)}/count`, toast, setIndicator) as { nodes?: number; edges?: number }; + const sentinel = connectionInfo.sentinelRole ? `?sentinel=${connectionInfo.sentinelRole}` : ''; + const result = await getSSEGraphResult(`api/graph/${prepareArg(n)}/count${sentinel}`, toast, setIndicator) as { nodes?: number; edges?: number }; if (!result) return; @@ -392,7 +393,8 @@ function ProvidersWithSession({ children }: { children: React.ReactNode }) { const fetchInfo = useCallback(async (type: string, name: string) => { if (!graphName) return []; - const result = await securedFetch(`/api/graph/${name}/info?type=${type}`, { + const sentinel = connectionInfo.sentinelRole ? `&sentinel=${connectionInfo.sentinelRole}` : ''; + const result = await securedFetch(`/api/graph/${name}/info?type=${type}${sentinel}`, { method: "GET", }, toast, setIndicator); @@ -404,7 +406,7 @@ function ProvidersWithSession({ children }: { children: React.ReactNode }) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [graphName]); - const fetchMetaStats = useCallback((name: string) => getMetaStats(name, toast, setIndicator), [toast, setIndicator]); + const fetchMetaStats = useCallback((name: string) => getMetaStats(name, toast, setIndicator, connectionInfo.sentinelRole), [toast, setIndicator, connectionInfo.sentinelRole]); const handelGetNewQueries = useCallback((newQuery: Query) => { const existing = historyQuery.queries.find(qu => qu.text === newQuery.text); @@ -435,7 +437,8 @@ function ProvidersWithSession({ children }: { children: React.ReactNode }) { })); const [query, existingLimit] = getQueryWithLimit(q, limit); - const url = `api/graph/${prepareArg(n)}?query=${prepareArg(query)}&timeout=${timeout}`; + const sentinel = connectionInfo.sentinelRole ? `&sentinel=${connectionInfo.sentinelRole}` : ''; + const url = `api/graph/${prepareArg(n)}?query=${prepareArg(query)}&timeout=${timeout}${sentinel}`; try { const result = await getSSEGraphResult(url, toast, setIndicator) as { data: Data; metadata: string[] }; @@ -460,7 +463,7 @@ function ProvidersWithSession({ children }: { children: React.ReactNode }) { return undefined; }); - const explain = await securedFetch(`api/graph/${prepareArg(n)}/explain?query=${prepareArg(query)}`, { + const explain = await securedFetch(`api/graph/${prepareArg(n)}/explain?query=${prepareArg(query)}${sentinel}`, { method: "GET" }, toast, setIndicator); diff --git a/lib/utils.ts b/lib/utils.ts index d2521e8f9..5c556f34f 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -316,11 +316,12 @@ export const between = (hash: number, from: number, to: number) => { export const getDefaultQuery = (q?: string) => q || "MATCH (n) OPTIONAL MATCH (n)-[e]-(m) RETURN * LIMIT 100"; -export const getMetaStats = async (name: string, toast: ToastFn, setIndicator: (indicator: "online" | "offline") => void) => { +export const getMetaStats = async (name: string, toast: ToastFn, setIndicator: (indicator: "online" | "offline") => void, sentinelRole?: string) => { const q = "CALL db.meta.stats() YIELD labels, relTypes RETURN labels, relTypes as relationships"; + const sentinel = sentinelRole ? `&sentinel=${sentinelRole}` : ''; try { - const result = await getSSEGraphResult(`/api/graph/${prepareArg(name)}?query=${encodeURIComponent(q)}`, toast, setIndicator) as { data: { labels: { [key: string]: number }, relationships: { [key: string]: number } }[] }; + const result = await getSSEGraphResult(`/api/graph/${prepareArg(name)}?query=${encodeURIComponent(q)}${sentinel}`, toast, setIndicator) as { data: { labels: { [key: string]: number }, relationships: { [key: string]: number } }[] }; if (!result) return undefined; From 8e28d9a3c05dee8bc0112eed1b0be17a0d3ba467 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Mon, 13 Apr 2026 14:53:32 +0300 Subject: [PATCH 009/119] fix: update Node.js version in Dockerfile and clean up Navbar imports --- Dockerfile | 2 +- app/components/Navbar.tsx | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 35fddda01..5b1439bfb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ ARG CYPHER_VERSION=latest -FROM node:24-alpine3.22 AS base +FROM node:24-alpine3.23 AS base # Update all Alpine packages to fix security vulnerabilities RUN apk upgrade --no-cache --available diff --git a/app/components/Navbar.tsx b/app/components/Navbar.tsx index 08dd4c039..1d424e457 100644 --- a/app/components/Navbar.tsx +++ b/app/components/Navbar.tsx @@ -15,10 +15,9 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import Link from "next/link"; import { useTheme } from "next-themes"; -import { useToast } from "@/components/ui/use-toast"; import Button from "./ui/Button"; import CreateGraph from "./CreateGraph"; -import { IndicatorContext, PanelContext, ConnectionContext } from "./provider"; +import { IndicatorContext, PanelContext } from "./provider"; interface Props { onSetGraphName: (newGraphName: string) => void From e20b9451d26f54661046da6027d4074e3176dc4d Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Mon, 13 Apr 2026 14:55:48 +0300 Subject: [PATCH 010/119] fix: improve login verification logic and ensure proper dependency tracking in useEffect --- app/loginVerification.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/loginVerification.tsx b/app/loginVerification.tsx index 3217cde74..59cba66af 100644 --- a/app/loginVerification.tsx +++ b/app/loginVerification.tsx @@ -9,7 +9,7 @@ function LoginVerificationInner({ children }: { children: React.ReactNode }) { const router = useRouter(); const { status } = useSession(); const url = usePathname(); - const { data } = useSession(); + const { data } = useSession(); const searchParams = useSearchParams(); useEffect(() => { @@ -29,12 +29,12 @@ function LoginVerificationInner({ children }: { children: React.ReactNode }) { const differentConnectionParams = hostParam !== data?.user.host || portParam !== String(data?.user.port) || usernameParam !== data?.user.username || tls !== String(data?.user.tls); - if (((url === "/login" && !differentConnectionParams) || url === "/") && status === "authenticated") { + if (((url === "/login" || url === "/") && !differentConnectionParams) && status === "authenticated") { router.push("/graph"); } else if (status === "unauthenticated" && url !== "/login") { router.push("/login"); } - }, [status, url, router, searchParams]); + }, [status, url, router, searchParams, data?.user]); return children; } From 8ef2a91fb48254ed7ac7866c60ba36cdff8d4612 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Mon, 13 Apr 2026 15:05:05 +0300 Subject: [PATCH 011/119] fix: update base image to node:24-alpine3.23 and enhance connection parameter validation in login verification --- Dockerfile | 2 +- app/loginVerification.tsx | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 35fddda01..5b1439bfb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ ARG CYPHER_VERSION=latest -FROM node:24-alpine3.22 AS base +FROM node:24-alpine3.23 AS base # Update all Alpine packages to fix security vulnerabilities RUN apk upgrade --no-cache --available diff --git a/app/loginVerification.tsx b/app/loginVerification.tsx index 59cba66af..7bae26057 100644 --- a/app/loginVerification.tsx +++ b/app/loginVerification.tsx @@ -27,7 +27,8 @@ function LoginVerificationInner({ children }: { children: React.ReactNode }) { const usernameParam = searchParams.get("username"); const tls = searchParams.get("tls"); - const differentConnectionParams = hostParam !== data?.user.host || portParam !== String(data?.user.port) || usernameParam !== data?.user.username || tls !== String(data?.user.tls); + const hasConnectionParams = hostParam !== null || portParam !== null || usernameParam !== null || tls !== null; + const differentConnectionParams = hasConnectionParams && (hostParam !== data?.user.host || portParam !== String(data?.user.port) || usernameParam !== data?.user.username || tls !== String(data?.user.tls)); if (((url === "/login" || url === "/") && !differentConnectionParams) && status === "authenticated") { router.push("/graph"); From f54ecb6eb5e0fb8fa6d9005f743cfc26ce9a421e Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Mon, 13 Apr 2026 15:55:16 +0300 Subject: [PATCH 012/119] Refactor API routes and components to support read-only mode - Updated API routes in `app/api/graph/[graph]/count/route.ts`, `app/api/graph/[graph]/info/route.ts`, and `app/api/graph/[graph]/route.ts` to remove user role checks and utilize a new `readOnly` query parameter. - Modified the `runQuery` utility function to accept a boolean `isReadOnly` parameter instead of user role and sentinel checks. - Adjusted various components including `CreateGraph`, `CypherEditor`, `ForceGraph`, `DataPanel`, and others to pass the `readOnly` parameter in API requests. - Updated context provider to include `isReadOnly` state derived from user role and connection type. - Refactored UI components to conditionally render based on `isReadOnly` state, ensuring proper access control for editing features. - Enhanced tests to reflect changes in the handling of read-only states. --- .../graph/[graph]/[element]/[key]/route.ts | 12 ++--- .../graph/[graph]/[element]/label/route.ts | 12 ++--- app/api/graph/[graph]/[element]/route.ts | 20 ++++---- app/api/graph/[graph]/count/edges/route.ts | 6 +-- app/api/graph/[graph]/count/nodes/route.ts | 6 +-- app/api/graph/[graph]/count/route.ts | 8 +-- app/api/graph/[graph]/info/route.ts | 7 ++- app/api/graph/[graph]/route.ts | 13 +++-- app/api/utils.ts | 5 +- app/components/CreateGraph.tsx | 4 +- app/components/CypherEditor.tsx | 12 ++--- app/components/ForceGraph.tsx | 4 +- app/components/Header.tsx | 23 +++++---- app/components/Navbar.tsx | 5 +- app/components/provider.ts | 2 + app/graph/DataPanel.tsx | 12 ++--- app/graph/DataTable.tsx | 16 +++--- app/graph/MetadataView.tsx | 6 +-- app/graph/page.tsx | 16 +++--- app/graph/selectGraph.tsx | 16 +++--- app/graph/toolbar.tsx | 7 ++- app/providers.tsx | 26 ++++++---- app/schema/DataPanel.tsx | 13 +++-- app/settings/tokens/PersonalAccessTokens.tsx | 49 ++++++++++--------- e2e/logic/POM/settingsTokensPage.ts | 2 +- lib/utils.ts | 6 +-- 26 files changed, 156 insertions(+), 152 deletions(-) diff --git a/app/api/graph/[graph]/[element]/[key]/route.ts b/app/api/graph/[graph]/[element]/[key]/route.ts index 6c2ca69a3..690cbc2b7 100644 --- a/app/api/graph/[graph]/[element]/[key]/route.ts +++ b/app/api/graph/[graph]/[element]/[key]/route.ts @@ -24,10 +24,10 @@ export async function POST( return session; } - const { client, user } = session; + const { client } = session; const { graph: graphId, element, key } = await params; const elementId = Number(element); - const sentinel = request.nextUrl.searchParams.get("sentinel"); + const isReadOnly = request.nextUrl.searchParams.get("readOnly") === "true"; try { const body = await request.json(); @@ -49,7 +49,7 @@ export async function POST( ? `MATCH (n) WHERE ID(n) = $id SET n.${key} = $value` : `MATCH ()-[e]->() WHERE ID(e) = $id SET e.${key} = $value`; - if (user.role === "Read-Only" || sentinel === "slave") + if (isReadOnly) await graph.roQuery(query, { params: { id: elementId, value } }); else await graph.query(query, { params: { id: elementId, value } }); @@ -86,11 +86,11 @@ export async function DELETE( return session; } - const { client, user } = session; + const { client } = session; const { graph: graphId, element, key } = await params; const elementId = Number(element); - const sentinel = request.nextUrl.searchParams.get("sentinel"); + const isReadOnly = request.nextUrl.searchParams.get("readOnly") === "true"; try { const body = await request.json(); @@ -112,7 +112,7 @@ export async function DELETE( ? `MATCH (n) WHERE ID(n) = $id SET n.${key} = NULL` : `MATCH ()-[e]->() WHERE ID(e) = $id SET e.${key} = NULL`; - if (user.role === "Read-Only" || sentinel === "slave") + if (isReadOnly) await graph.roQuery(query, { params: { id: elementId } }); else await graph.query(query, { params: { id: elementId } }); diff --git a/app/api/graph/[graph]/[element]/label/route.ts b/app/api/graph/[graph]/[element]/label/route.ts index 98acfcca4..16bca15a8 100644 --- a/app/api/graph/[graph]/[element]/label/route.ts +++ b/app/api/graph/[graph]/[element]/label/route.ts @@ -22,10 +22,10 @@ export async function DELETE( return session; } - const { client, user } = session; + const { client } = session; const { graph: graphId, element } = await params; const elementId = Number(element); - const sentinel = request.nextUrl.searchParams.get("sentinel"); + const isReadOnly = request.nextUrl.searchParams.get("readOnly") === "true"; try { const body = await request.json(); @@ -41,7 +41,7 @@ export async function DELETE( const query = `MATCH (n) WHERE ID(n) = $id REMOVE n:${label}`; const graph = client.selectGraph(graphId); - if (user.role === "Read-Only" || sentinel === "slave") + if (isReadOnly) await graph.roQuery(query, { params: { id: elementId } }); else await graph.query(query, { params: { id: elementId } }); @@ -76,10 +76,10 @@ export async function POST( return session; } - const { client, user } = session; + const { client } = session; const { graph: graphId, element } = await params; const elementId = Number(element); - const sentinel = request.nextUrl.searchParams.get("sentinel"); + const isReadOnly = request.nextUrl.searchParams.get("readOnly") === "true"; try { const body = await request.json(); @@ -95,7 +95,7 @@ export async function POST( const query = `MATCH (n) WHERE ID(n) = $id SET n:${label}`; const graph = client.selectGraph(graphId); - if (user.role === "Read-Only" || sentinel === "slave") + if (isReadOnly) await graph.roQuery(query, { params: { id: elementId } }); else await graph.query(query, { params: { id: elementId } }); diff --git a/app/api/graph/[graph]/[element]/route.ts b/app/api/graph/[graph]/[element]/route.ts index 8209fc9c6..91f4c3f7e 100644 --- a/app/api/graph/[graph]/[element]/route.ts +++ b/app/api/graph/[graph]/[element]/route.ts @@ -23,10 +23,10 @@ export async function GET( return session; } - const { client, user } = session; + const { client } = session; const { graph: graphId, element } = await params; const elementId = Number(element); - const sentinel = request.nextUrl.searchParams.get("sentinel"); + const isReadOnly = request.nextUrl.searchParams.get("readOnly") === "true"; try { const graph = client.selectGraph(graphId); @@ -36,8 +36,7 @@ export async function GET( WHERE ID(n) = $id RETURN *`; - const result = - user.role === "Read-Only" || sentinel === "slave" + const result = isReadOnly ? await graph.roQuery(query, { params: { id: elementId } }) : await graph.query(query, { params: { id: elementId } }); @@ -69,9 +68,9 @@ export async function POST( return session; } - const { client, user } = session; + const { client } = session; const { graph: graphId } = await params; - const sentinel = request.nextUrl.searchParams.get("sentinel"); + const isReadOnly = request.nextUrl.searchParams.get("readOnly") === "true"; try { const body = await request.json(); @@ -121,8 +120,7 @@ export async function POST( }); } - const result = - user.role === "Read-Only" || sentinel === "slave" + const result = isReadOnly ? await graph.roQuery(query, { params: queryParams }) : await graph.query(query, { params: queryParams }); @@ -154,10 +152,10 @@ export async function DELETE( return session; } - const { client, user } = session; + const { client } = session; const { graph: graphId, element } = await params; const elementId = Number(element); - const sentinel = request.nextUrl.searchParams.get("sentinel"); + const isReadOnly = request.nextUrl.searchParams.get("readOnly") === "true"; try { const body = await request.json(); @@ -178,7 +176,7 @@ export async function DELETE( ? `MATCH (n) WHERE ID(n) = $id DELETE n` : `MATCH ()-[e]->() WHERE ID(e) = $id DELETE e`; - if (user.role === "Read-Only" || sentinel === "slave") + if (isReadOnly) await graph.roQuery(query, { params: { id: elementId } }); else await graph.query(query, { params: { id: elementId } }); diff --git a/app/api/graph/[graph]/count/edges/route.ts b/app/api/graph/[graph]/count/edges/route.ts index 76c824344..79e0922c4 100644 --- a/app/api/graph/[graph]/count/edges/route.ts +++ b/app/api/graph/[graph]/count/edges/route.ts @@ -23,16 +23,16 @@ export async function GET( throw new Error(await session.text()); } - const { client, user } = session; + const { client } = session; const { graph: graphId } = await params; - const sentinel = request.nextUrl.searchParams.get("sentinel"); + const isReadOnly = request.nextUrl.searchParams.get("readOnly") === "true"; try { const graph = client.selectGraph(graphId); // Execute edges count query const edgesQuery = "MATCH ()-[e]->() RETURN count(e) as edges"; - const edgesResult = await runQuery(graph, edgesQuery, user.role, sentinel); + const edgesResult = await runQuery(graph, edgesQuery, isReadOnly); if (!edgesResult) throw new Error("Something went wrong"); diff --git a/app/api/graph/[graph]/count/nodes/route.ts b/app/api/graph/[graph]/count/nodes/route.ts index 0345a9a2c..98d3a6891 100644 --- a/app/api/graph/[graph]/count/nodes/route.ts +++ b/app/api/graph/[graph]/count/nodes/route.ts @@ -25,16 +25,16 @@ export async function GET( throw new Error(await session.text()); } - const { client, user } = session; + const { client } = session; const { graph: graphId } = await params; - const sentinel = request.nextUrl.searchParams.get("sentinel"); + const isReadOnly = request.nextUrl.searchParams.get("readOnly") === "true"; try { const graph = client.selectGraph(graphId); // Execute nodes count query const nodesQuery = "MATCH (n) RETURN count(n) as nodes"; - const nodesResult = await runQuery(graph, nodesQuery, user.role, sentinel); + const nodesResult = await runQuery(graph, nodesQuery, isReadOnly); if (!nodesResult) throw new Error("Something went wrong"); diff --git a/app/api/graph/[graph]/count/route.ts b/app/api/graph/[graph]/count/route.ts index 182be39f1..7bc297f57 100644 --- a/app/api/graph/[graph]/count/route.ts +++ b/app/api/graph/[graph]/count/route.ts @@ -18,9 +18,9 @@ export async function GET( throw new Error(await session.text()); } - const { client, user } = session; + const { client } = session; const { graph: graphId } = await params; - const sentinel = request.nextUrl.searchParams.get("sentinel"); + const isReadOnly = request.nextUrl.searchParams.get("readOnly") === "true"; try { const graph = client.selectGraph(graphId); @@ -30,10 +30,10 @@ export async function GET( const edgesQuery = "MATCH ()-[e]->() RETURN count(e) as edges"; // Execute nodes count query - const nodesResult = await runQuery(graph, nodesQuery, user.role, sentinel); + const nodesResult = await runQuery(graph, nodesQuery, isReadOnly); // Execute edges count query - const edgesResult = await runQuery(graph, edgesQuery, user.role, sentinel); + const edgesResult = await runQuery(graph, edgesQuery, isReadOnly); if (!nodesResult || !edgesResult) throw new Error("Something went wrong"); diff --git a/app/api/graph/[graph]/info/route.ts b/app/api/graph/[graph]/info/route.ts index 1484397ce..b53cd5217 100644 --- a/app/api/graph/[graph]/info/route.ts +++ b/app/api/graph/[graph]/info/route.ts @@ -14,7 +14,7 @@ export async function GET( return session; } - const { client, user } = session; + const { client } = session; const { graph: graphId } = await params; const type = request.nextUrl.searchParams.get("type") as | "(function)" @@ -22,7 +22,7 @@ export async function GET( | "(label)" | "(relationship type)" | undefined; - const sentinel = request.nextUrl.searchParams.get("sentinel"); + const isReadOnly = request.nextUrl.searchParams.get("readOnly") === "true"; try { const getQuery = () => { @@ -42,8 +42,7 @@ export async function GET( const graph = client.selectGraph(graphId); - const result = - user.role === "Read-Only" || sentinel === "slave" + const result = isReadOnly ? await graph.roQuery(getQuery()) : await graph.query(getQuery()); diff --git a/app/api/graph/[graph]/route.ts b/app/api/graph/[graph]/route.ts index 07318a7a1..2d12ca760 100644 --- a/app/api/graph/[graph]/route.ts +++ b/app/api/graph/[graph]/route.ts @@ -60,15 +60,15 @@ export async function POST( return session; } - const { client, user } = session; + const { client } = session; const { graph: graphId } = await params; - const sentinel = request.nextUrl.searchParams.get("sentinel"); + const isReadOnly = request.nextUrl.searchParams.get("readOnly") === "true"; try { const graph = client.selectGraph(graphId); - if (user.role === "Read-Only" || sentinel === "slave") await graph.roQuery("RETURN 1"); + if (isReadOnly) await graph.roQuery("RETURN 1"); else await graph.query("RETURN 1"); return NextResponse.json( @@ -160,11 +160,11 @@ export async function GET( throw new Error(await session.text()); } - const { client, user } = session; + const { client } = session; const { graph: graphId } = await params; const query = request.nextUrl.searchParams.get("query"); const timeout = Number(request.nextUrl.searchParams.get("timeout")) * 1000; - const sentinel = request.nextUrl.searchParams.get("sentinel"); + const isReadOnly = request.nextUrl.searchParams.get("readOnly") === "true"; try { if (!query) throw new Error("Missing parameter query"); @@ -172,8 +172,7 @@ export async function GET( const graph = client.selectGraph(graphId); - const result = - user.role === "Read-Only" || sentinel === "slave" + const result = isReadOnly ? await graph.roQuery(query, { TIMEOUT: timeout }) : await graph.query(query, { TIMEOUT: timeout }); diff --git a/app/api/utils.ts b/app/api/utils.ts index d9589fb30..228ab8d58 100644 --- a/app/api/utils.ts +++ b/app/api/utils.ts @@ -1,8 +1,7 @@ -import { Role } from "next-auth"; import type { Graph } from "falkordb"; -export const runQuery = async (graph: Graph, query: string, role: Role, sentinel?: string | null) => { - const result = role === "Read-Only" || sentinel === "slave" ? await graph.roQuery(query) : await graph.query(query); +export const runQuery = async (graph: Graph, query: string, isReadOnly: boolean) => { + const result = isReadOnly ? await graph.roQuery(query) : await graph.query(query); return result; }; diff --git a/app/components/CreateGraph.tsx b/app/components/CreateGraph.tsx index 5b9fd37b9..3346c7d4b 100644 --- a/app/components/CreateGraph.tsx +++ b/app/components/CreateGraph.tsx @@ -39,7 +39,7 @@ export default function CreateGraph({ }: Props) { const { indicator, setIndicator } = useContext(IndicatorContext); - const { connectionInfo } = useContext(ConnectionContext); + const { isReadOnly } = useContext(ConnectionContext); const { toast } = useToast(); @@ -75,7 +75,7 @@ export default function CreateGraph({ }); return; } - const result = await securedFetch(`api/${type === "Schema" ? "schema" : "graph"}/${prepareArg(name)}${connectionInfo.sentinelRole ? `?sentinel=${connectionInfo.sentinelRole}` : ''}`, { + const result = await securedFetch(`api/${type === "Schema" ? "schema" : "graph"}/${prepareArg(name)}${isReadOnly ? '?readOnly=true' : ''}`, { method: "POST", }, toast, setIndicator); diff --git a/app/components/CypherEditor.tsx b/app/components/CypherEditor.tsx index 00cfa39e3..59e77b9aa 100644 --- a/app/components/CypherEditor.tsx +++ b/app/components/CypherEditor.tsx @@ -218,7 +218,7 @@ export default function CypherEditor({ graph, graphName, historyQuery, maximize, const { indicator, setIndicator } = useContext(IndicatorContext); const { tutorialOpen } = useContext(BrowserSettingsContext); const { udfList } = useContext(UDFContext); - const { connectionInfo } = useContext(ConnectionContext); + const { isReadOnly } = useContext(ConnectionContext); const { toast } = useToast(); const editorRef = useRef(null); @@ -231,7 +231,7 @@ export default function CypherEditor({ graph, graphName, historyQuery, maximize, const graphNameRef = useRef(graphName); const queryRef = useRef(historyQuery.query); const tutorialOpenRef = useRef(tutorialOpen); - const connectionInfoRef = useRef(connectionInfo); + const isReadOnlyRef = useRef(isReadOnly); const monacoRef = useRef(null); const [lineNumber, setLineNumber] = useState(1); @@ -271,8 +271,8 @@ export default function CypherEditor({ graph, graphName, historyQuery, maximize, }, [graph.Id]); useEffect(() => { - connectionInfoRef.current = connectionInfo; - }, [connectionInfo]); + isReadOnlyRef.current = isReadOnly; + }, [isReadOnly]); useEffect(() => { if (!containerRef.current) return; @@ -300,8 +300,8 @@ export default function CypherEditor({ graph, graphName, historyQuery, maximize, const fetchSuggestions = async (detail: string): Promise => { if (indicator === "offline") return []; - const sentinel = connectionInfoRef.current.sentinelRole ? `&sentinel=${connectionInfoRef.current.sentinelRole}` : ''; - const result = await securedFetch(`api/graph/${graphIdRef.current}/info?type=${prepareArg(detail)}${sentinel}`, { + const readOnlyParam = isReadOnlyRef.current ? '&readOnly=true' : ''; + const result = await securedFetch(`api/graph/${graphIdRef.current}/info?type=${prepareArg(detail)}${readOnlyParam}`, { method: 'GET', }, toast, setIndicator); diff --git a/app/components/ForceGraph.tsx b/app/components/ForceGraph.tsx index 748ed52fe..50eae88bf 100644 --- a/app/components/ForceGraph.tsx +++ b/app/components/ForceGraph.tsx @@ -71,7 +71,7 @@ export default function ForceGraph({ const { setIndicator } = useContext(IndicatorContext); const { settings: { captionsKeysSettings: { captionsKeys }, showPropertyKeyPrefixSettings: { showPropertyKeyPrefix } } } = useContext(BrowserSettingsContext); - const { connectionInfo } = useContext(ConnectionContext); + const { isReadOnly } = useContext(ConnectionContext); const { theme } = useTheme(); const { toast } = useToast(); @@ -128,7 +128,7 @@ export default function ForceGraph({ const canvas = canvasRef.current; if (!canvas || !canvasLoaded) return; - const result = await securedFetch(`/api/${type}/${graph.Id}/${node.id}${connectionInfo.sentinelRole ? `?sentinel=${connectionInfo.sentinelRole}` : ''}`, { + const result = await securedFetch(`/api/${type}/${graph.Id}/${node.id}${isReadOnly ? '?readOnly=true' : ''}`, { method: 'GET', headers: { 'Content-Type': 'application/json' diff --git a/app/components/Header.tsx b/app/components/Header.tsx index c26e798c3..9ca11d947 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -37,6 +37,7 @@ export default function Header() { const [usedMemory, setUsedMemory] = useState(null); useEffect(() => { + setUsedMemory(null); (async () => { const result = await securedFetch("/api/info?section=memory", { method: "GET" @@ -48,13 +49,11 @@ export default function Header() { const match = data.match(/used_memory_human:(\S+)/); - console.log(match); - if (!match) return; setUsedMemory(match[1]); })(); - }, [toast, setIndicator]); + }, [toast, setIndicator, connectionType, connectionInfo]); const handleCopy = useCallback((text: string) => { if (!navigator.clipboard?.writeText) { @@ -97,9 +96,11 @@ export default function Header() {
-
Si
+ >Si

Single

@@ -107,9 +108,11 @@ export default function Header() {
-
Se
+ >Se

Sentinel

@@ -117,9 +120,11 @@ export default function Header() {
-
C
+ >C

Cluster

diff --git a/app/components/Navbar.tsx b/app/components/Navbar.tsx index 1d424e457..b77bbcc77 100644 --- a/app/components/Navbar.tsx +++ b/app/components/Navbar.tsx @@ -17,7 +17,7 @@ import Link from "next/link"; import { useTheme } from "next-themes"; import Button from "./ui/Button"; import CreateGraph from "./CreateGraph"; -import { IndicatorContext, PanelContext } from "./provider"; +import { ConnectionContext, IndicatorContext, PanelContext } from "./provider"; interface Props { onSetGraphName: (newGraphName: string) => void @@ -42,6 +42,7 @@ export default function Navbar({ onSetGraphName, graphNames, graphName, onOpenPa const { indicator } = useContext(IndicatorContext); const { setPanel, panel } = useContext(PanelContext); + const { isReadOnly } = useContext(ConnectionContext); const { theme, setTheme } = useTheme(); const { currentTheme } = getTheme(theme); @@ -52,7 +53,7 @@ export default function Navbar({ onSetGraphName, graphNames, graphName, onOpenPa const [mounted, setMounted] = useState(false); const type = getPathType(pathname); - const showCreate = type && type !== "Settings" && type !== "UDF" && session?.user.role && session.user.role !== "Read-Only"; + const showCreate = type && type !== "Settings" && type !== "UDF" && session?.user.role && !isReadOnly; useEffect(() => { setMounted(true); diff --git a/app/components/provider.ts b/app/components/provider.ts index 5356ea3ed..c4c944862 100644 --- a/app/components/provider.ts +++ b/app/components/provider.ts @@ -206,6 +206,7 @@ type ConnectionContextType = { setConnectionInfo: Dispatch>; dbVersion: string; setDbVersion: Dispatch>; + isReadOnly: boolean; }; type UDFContextType = { @@ -423,6 +424,7 @@ export const ConnectionContext = createContext({ setConnectionInfo: () => { }, dbVersion: "", setDbVersion: () => { }, + isReadOnly: false, }); export const UDFContext = createContext({ diff --git a/app/graph/DataPanel.tsx b/app/graph/DataPanel.tsx index 5517ed294..4c97de495 100644 --- a/app/graph/DataPanel.tsx +++ b/app/graph/DataPanel.tsx @@ -8,7 +8,6 @@ import { prepareArg, securedFetch, GraphRef, Node, Link, Label } from "@/lib/uti import { Dispatch, SetStateAction, useCallback, useContext, useEffect, useRef, useState } from "react"; import { Pencil, TableProperties, X } from "lucide-react"; import { useToast } from "@/components/ui/use-toast"; -import { useSession } from "next-auth/react"; import Button from "../components/ui/Button"; import { IndicatorContext, GraphContext, ConnectionContext } from "../components/provider"; import DataTable from "./DataTable"; @@ -25,13 +24,12 @@ interface Props { export default function DataPanel({ object, onClose, setLabels, canvasRef }: Props) { const { setIndicator } = useContext(IndicatorContext); const { graph, setGraphInfo } = useContext(GraphContext); - const { connectionInfo } = useContext(ConnectionContext); + const { isReadOnly } = useContext(ConnectionContext); const lastObjId = useRef(undefined); const labelsListRef = useRef(null); const { toast } = useToast(); - const { data: session } = useSession(); const [labelsHover, setLabelsHover] = useState(false); const [label, setLabel] = useState([]); @@ -77,7 +75,7 @@ export default function DataPanel({ object, onClose, setLabels, canvasRef }: Pro }); return false; } - const result = await securedFetch(`api/graph/${prepareArg(graph.Id)}/${node.id}/label${connectionInfo.sentinelRole ? `?sentinel=${connectionInfo.sentinelRole}` : ''}`, { + const result = await securedFetch(`api/graph/${prepareArg(graph.Id)}/${node.id}/label${isReadOnly ? '?readOnly=true' : ''}`, { method: "POST", body: JSON.stringify({ label: newLabel @@ -125,7 +123,7 @@ export default function DataPanel({ object, onClose, setLabels, canvasRef }: Pro return false; } - const result = await securedFetch(`api/graph/${prepareArg(graph.Id)}/${node.id}/label${connectionInfo.sentinelRole ? `?sentinel=${connectionInfo.sentinelRole}` : ''}`, { + const result = await securedFetch(`api/graph/${prepareArg(graph.Id)}/${node.id}/label${isReadOnly ? '?readOnly=true' : ''}`, { method: "DELETE", body: JSON.stringify({ label: removeLabel @@ -197,7 +195,7 @@ export default function DataPanel({ object, onClose, setLabels, canvasRef }: Pro >

{l || "No Label"}

{ - type && l && session?.user.role !== "Read-Only" && + type && l && !isReadOnly && { - type && (labelsHover || label.length === 0) && session?.user.role !== "Read-Only" && + type && (labelsHover || label.length === 0) && !isReadOnly && (null); @@ -52,7 +51,6 @@ export default function DataTable({ object, type, lastObjId, canvasRef, classNam const [isAddLoading, setIsAddLoading] = useState(false); const [isRemoveLoading, setIsRemoveLoading] = useState(false); const { indicator, setIndicator } = useContext(IndicatorContext); - const { data: session } = useSession(); const [attributes, setAttributes] = useState([]); const [expandedAttributes, setExpandedAttributes] = useState>({}); const valueParagraphRefs = useRef>({}); @@ -211,7 +209,7 @@ export default function DataTable({ object, type, lastObjId, canvasRef, classNam } try { if (actionType === "set") setIsSetLoading(true); - const result = await securedFetch(`api/graph/${prepareArg(graph.Id)}/${id}/${key}${connectionInfo.sentinelRole ? `?sentinel=${connectionInfo.sentinelRole}` : ''}`, { + const result = await securedFetch(`api/graph/${prepareArg(graph.Id)}/${id}/${key}${isReadOnly ? '?readOnly=true' : ''}`, { method: "POST", body: JSON.stringify({ value: val, @@ -304,7 +302,7 @@ export default function DataTable({ object, type, lastObjId, canvasRef, classNam try { setIsRemoveLoading(true); const { id } = object; - const success = (await securedFetch(`api/graph/${prepareArg(graph.Id)}/${id}/${key}${connectionInfo.sentinelRole ? `?sentinel=${connectionInfo.sentinelRole}` : ''}`, { + const success = (await securedFetch(`api/graph/${prepareArg(graph.Id)}/${id}/${key}${isReadOnly ? '?readOnly=true' : ''}`, { method: "DELETE", body: JSON.stringify({ type }), }, toast, setIndicator)).ok; @@ -486,7 +484,7 @@ export default function DataTable({ object, type, lastObjId, canvasRef, classNam const isExpanded = expandedAttributes[key]; const shouldShowToggle = valueNeedsExpansion(key); const cellClass = cn("flex items-center px-1 border-b border-border min-h-6"); - const buttonTitle = session?.user.role === "Read-Only" ? undefined : (isComplex && "Complex values cannot be edited") || "Click to edit the attribute value"; + const buttonTitle = isReadOnly ? undefined : (isComplex && "Complex values cannot be edited") || "Click to edit the attribute value"; return ( @@ -517,7 +515,7 @@ export default function DataTable({ object, type, lastObjId, canvasRef, classNam title={buttonTitle} variant="button" onClick={() => handleSetEditable(key, value)} - disabled={isAddValue || isComplex || session?.user.role === "Read-Only"} + disabled={isAddValue || isComplex || isReadOnly} >

{ - session?.user.role !== "Read-Only" && ( + !isReadOnly && ( editable === key ? <>

{ - graphName && session?.user.role !== "Read-Only" && + graphName && !isReadOnly && <>
- +
diff --git a/app/components/Navbar.tsx b/app/components/Navbar.tsx index b77bbcc77..1d06ae630 100644 --- a/app/components/Navbar.tsx +++ b/app/components/Navbar.tsx @@ -63,11 +63,11 @@ export default function Navbar({ onSetGraphName, graphNames, graphName, onOpenPa setPanel(prev => prev === newPanel ? undefined : newPanel); }, [setPanel]); - const separator =
; + const separator =
; return ( -
-
+
+
{ mounted && currentTheme &&
- {/* -
- - -

v{pkg.version}

-
- -

FalkorDB Browser Version

-
-
- {separator} e.preventDefault()} asChild> diff --git a/app/globals.css b/app/globals.css index f7f055133..8254cb53a 100644 --- a/app/globals.css +++ b/app/globals.css @@ -5,7 +5,7 @@ :root { --background: 0 0% 100%; --foreground: 0 0% 10%; - --secondary: 0 0% 90%; + --secondary: 0 0% 95%; --primary: 247 100% 70%; --destructive: 0 100% 66%; --accent: var(--background); @@ -14,8 +14,8 @@ --radius: 0.5rem; --input: var(--background); --text-muted-foreground: var(--foreground); - --muted: 0 0% 80%; - --border: 0 0% 40%; + --muted: 0 0% 85%; + --border: 0 0% 50%; --green: 142 71% 45%; --fav: 45 93% 47%; } @@ -100,7 +100,7 @@ } .DataPanel { - @apply h-full w-full flex flex-col bg-background border border-border rounded-lg; + @apply h-full w-full flex flex-col bg-background border border-border/50 rounded-lg; } .Dropzone { @@ -166,11 +166,11 @@ } .light ::-webkit-scrollbar-thumb { - background: #666666; + background: #999999; } .dark ::-webkit-scrollbar-thumb { - background: #666666; + background: #555555; } input:focus-visible { diff --git a/app/graph/DataPanel.tsx b/app/graph/DataPanel.tsx index 4c97de495..d1c4635d6 100644 --- a/app/graph/DataPanel.tsx +++ b/app/graph/DataPanel.tsx @@ -162,7 +162,7 @@ export default function DataPanel({ object, onClose, setLabels, canvasRef }: Pro }; return ( -
+
- - +
+
+ + + +
); } \ No newline at end of file diff --git a/app/graph/graphInfo.tsx b/app/graph/graphInfo.tsx index 1838d906e..38df23f66 100644 --- a/app/graph/graphInfo.tsx +++ b/app/graph/graphInfo.tsx @@ -2,7 +2,6 @@ import { Dispatch, SetStateAction, useContext, useEffect, useState } from "react import { Loader2, X, Palette, Network, Search } from "lucide-react"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { cn, InfoLabel } from "@/lib/utils"; -import { getContrastTextColor } from "@falkordb/canvas"; import Button from "../components/ui/Button"; import { BrowserSettingsContext, GraphContext, QueryLoadingContext } from "../components/provider"; import CustomizeStylePanel from "./CustomizeStylePanel"; @@ -28,7 +27,7 @@ export default function GraphInfoPanel({ onClose, customizingLabel, setCustomizi useEffect(() => { setPropertyKeysSearch(""); }, [PropertyKeys, maxItemsForSearch]); return ( -
+
{ !customizingLabel ? ( <> @@ -39,15 +38,15 @@ export default function GraphInfoPanel({ onClose, customizingLabel, setCustomizi > -
-

Graph Info

- +
+

Graph Info

+
-

Graph Name:

+

Name

-

{graphName}

+

{graphName}

{graphName} @@ -58,12 +57,12 @@ export default function GraphInfoPanel({ onClose, customizingLabel, setCustomizi showMemoryUsage &&
-

Memory Usage:

+

Memory

{ MemoryUsage.get("total_graph_sz_mb") !== undefined ? -

{MemoryUsage.get("total_graph_sz_mb") || "<1"} MB

+

{MemoryUsage.get("total_graph_sz_mb") || "<1"} MB

{MemoryUsage.get("total_graph_sz_mb")} MB @@ -74,18 +73,18 @@ export default function GraphInfoPanel({ onClose, customizingLabel, setCustomizi
} -
+
-

Nodes

+

Nodes

{ nodesCount !== undefined ?

- ({nodesCount.toLocaleString()}) + {nodesCount.toLocaleString()}

@@ -97,12 +96,12 @@ export default function GraphInfoPanel({ onClose, customizingLabel, setCustomizi { Labels.size > maxItemsForSearch &&
- + setNodesSearch(e.target.value)} className="w-1 grow" />
}
-
    +
-
+
-

Edges

+

Edges

{ edgesCount !== undefined ?

- ({edgesCount.toLocaleString()}) + {edgesCount.toLocaleString()}

@@ -176,12 +177,12 @@ export default function GraphInfoPanel({ onClose, customizingLabel, setCustomizi { Relationships.size > maxItemsForSearch &&
- + setEdgesSearch(e.target.value)} className="w-1 grow" />
}
-
    +
    • ); })}
-
+
-

Property Keys

+

Property Keys

{ PropertyKeys !== undefined ?

- ({PropertyKeys.length.toLocaleString()}) + {PropertyKeys.length.toLocaleString()}

@@ -241,28 +242,29 @@ export default function GraphInfoPanel({ onClose, customizingLabel, setCustomizi { PropertyKeys && PropertyKeys.length > maxItemsForSearch &&
- + setPropertyKeysSearch(e.target.value)} className="w-1 grow" />
}
-
    - { - PropertyKeys && PropertyKeys.filter(key => key.toLowerCase().includes(propertyKeysSearch.toLowerCase())).sort((a, b) => a.localeCompare(b)).map((key) => ( -
  • +
    +

    + { + PropertyKeys && PropertyKeys.filter(key => key.toLowerCase().includes(propertyKeysSearch.toLowerCase())).sort((a, b) => a.localeCompare(b)).map((key, index, arr) => (

  • - )) - } -
+ )) + } +

+
) : ( diff --git a/app/graph/labels.tsx b/app/graph/labels.tsx index e7172d4be..e16c8d9dc 100644 --- a/app/graph/labels.tsx +++ b/app/graph/labels.tsx @@ -14,12 +14,12 @@ export default function Labels({ labels, onClick const listRef = useRef(null); return ( -
+
{ label && -

{label}

+

{label}

} -
    +
      { labels.length > 0 && labels.map((l) => ( @@ -27,13 +27,13 @@ export default function Labels({ labels, onClick )) diff --git a/app/graph/page.tsx b/app/graph/page.tsx index 79576d454..66407bf4b 100644 --- a/app/graph/page.tsx +++ b/app/graph/page.tsx @@ -98,7 +98,7 @@ export default function Page() { case "add": return "30%"; case "chat": - return "40%"; + return "35%"; default: return "0%"; } @@ -111,7 +111,7 @@ export default function Page() { case "add": return "25%"; case "chat": - return "45%"; + return "30%"; default: return "0%"; } @@ -432,7 +432,7 @@ export default function Page() { }, [graphName, panel, handleSetSelectedElements, setPanel, isAddNode, selectedElements, handleCreateElement, setLabels, canvasRef]); return ( -
      +
      Date: Tue, 14 Apr 2026 09:56:05 +0300 Subject: [PATCH 017/119] fix(test): update customizeStyle test to check border-left color The label pills now use border-left-color instead of background-color for the label color. Update getLabelButtonColor to check the left border color first, falling back to background color for compatibility. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- e2e/logic/POM/customizeStylePage.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/e2e/logic/POM/customizeStylePage.ts b/e2e/logic/POM/customizeStylePage.ts index f42fcbc38..ad5c9c5f8 100644 --- a/e2e/logic/POM/customizeStylePage.ts +++ b/e2e/logic/POM/customizeStylePage.ts @@ -136,10 +136,16 @@ export default class CustomizeStylePage extends GraphInfoPage { // Wait for the button to be visible first await waitForElementToBeVisible(this.labelButton(label)); - // Get the color from the inline style attribute which is the source of truth - const color = await this.labelButton(label).evaluate((el: HTMLElement) => - el.style.backgroundColor || window.getComputedStyle(el).backgroundColor - ); + // Get the color from the border-left or the inner color dot span + const color = await this.labelButton(label).evaluate((el: HTMLElement) => { + // Check for left border color (new style) + const borderColor = el.style.borderLeftColor || window.getComputedStyle(el).borderLeftColor; + if (borderColor && borderColor !== 'rgb(0, 0, 0)' && borderColor !== '' && borderColor !== 'transparent') { + return borderColor; + } + // Fall back to background color (old style) + return el.style.backgroundColor || window.getComputedStyle(el).backgroundColor; + }); return color; } From eff615c2dd202af2e2b187fcfd24f91bfbcb2249 Mon Sep 17 00:00:00 2001 From: Guy Korland Date: Tue, 14 Apr 2026 11:47:24 +0300 Subject: [PATCH 018/119] fix(ui): address PR review comments - Fix play/pause icon and tooltip inversion in controls.tsx - Add borderLeftStyle: 'solid' to label/relationship pills in graphInfo.tsx - Add max-width constraint on relationship chips to prevent overflow - Restore list semantics (
        /
      • ) for property keys section - Make connection badge keyboard-focusable with aria-label - Revert dark scrollbar thumb to #666666 for better contrast - Use w-fit min-w-[120px] for query bar action group - Centralize panel size presets in page.tsx - Improve getLabelButtonColor to check inner dot span Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/components/Header.tsx | 6 +++++- app/globals.css | 2 +- app/graph/Selector.tsx | 2 +- app/graph/controls.tsx | 4 ++-- app/graph/graphInfo.tsx | 29 +++++++++++++++------------- app/graph/page.tsx | 30 ++++++++++------------------- e2e/logic/POM/customizeStylePage.ts | 15 ++++++++++++--- 7 files changed, 47 insertions(+), 41 deletions(-) diff --git a/app/components/Header.tsx b/app/components/Header.tsx index a08e90123..d9cb8de7d 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -96,7 +96,11 @@ export default function Header() {
        -
        -
        +
      • )) } -

        +
      diff --git a/app/graph/page.tsx b/app/graph/page.tsx index 66407bf4b..4f35f5ade 100644 --- a/app/graph/page.tsx +++ b/app/graph/page.tsx @@ -91,30 +91,20 @@ export default function Page() { setIsCollapsed(size.asPercentage === 0); }, []); + const panelSizes: Record = { + data: { size: "200px", min: "200px" }, + add: { size: "30%", min: "25%" }, + chat: { size: "35%", min: "30%" }, + }; + const getPanelSize = useCallback(() => { - switch (panel) { - case "data": - return "200px"; - case "add": - return "30%"; - case "chat": - return "35%"; - default: - return "0%"; - } + if (!panel) return "0%"; + return panelSizes[panel]?.size ?? "0%"; }, [panel]); const panelMinSize = useMemo(() => { - switch (panel) { - case "data": - return "200px"; - case "add": - return "25%"; - case "chat": - return "30%"; - default: - return "0%"; - } + if (!panel) return "0%"; + return panelSizes[panel]?.min ?? "0%"; }, [panel]); useEffect(() => { diff --git a/e2e/logic/POM/customizeStylePage.ts b/e2e/logic/POM/customizeStylePage.ts index ad5c9c5f8..034113ca1 100644 --- a/e2e/logic/POM/customizeStylePage.ts +++ b/e2e/logic/POM/customizeStylePage.ts @@ -136,15 +136,24 @@ export default class CustomizeStylePage extends GraphInfoPage { // Wait for the button to be visible first await waitForElementToBeVisible(this.labelButton(label)); - // Get the color from the border-left or the inner color dot span + // Get the color from the border-left, inner color dot span, or background const color = await this.labelButton(label).evaluate((el: HTMLElement) => { // Check for left border color (new style) - const borderColor = el.style.borderLeftColor || window.getComputedStyle(el).borderLeftColor; + const computedStyle = window.getComputedStyle(el); + const borderColor = el.style.borderLeftColor || computedStyle.borderLeftColor; if (borderColor && borderColor !== 'rgb(0, 0, 0)' && borderColor !== '' && borderColor !== 'transparent') { return borderColor; } + // Check inner color dot span (new style) + const dotSpan = el.querySelector('span[class*="rounded-full"]'); + if (dotSpan) { + const dotBg = window.getComputedStyle(dotSpan).backgroundColor; + if (dotBg && dotBg !== 'rgba(0, 0, 0, 0)' && dotBg !== 'transparent') { + return dotBg; + } + } // Fall back to background color (old style) - return el.style.backgroundColor || window.getComputedStyle(el).backgroundColor; + return el.style.backgroundColor || computedStyle.backgroundColor; }); return color; } From 77574df96f8eaa5c55795e7642dc2909bff33781 Mon Sep 17 00:00:00 2001 From: Guy Korland Date: Tue, 14 Apr 2026 12:03:36 +0300 Subject: [PATCH 019/119] fix(a11y): improve accessibility and contrast across UI components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add aria-label to animation Switch control in controls.tsx - Make tooltip-wrapped elements keyboard-focusable with tabIndex={0} and role - Use tag-agnostic selector for dot element in customizeStylePage.ts - Add rgba(0,0,0,0) to transparency rejection list in color extraction - Improve light scrollbar thumb contrast (#999999 → #888888, ~3.5:1 ratio) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/globals.css | 2 +- app/graph/controls.tsx | 1 + app/graph/graphInfo.tsx | 13 +++++++++++-- e2e/logic/POM/customizeStylePage.ts | 6 +++--- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/app/globals.css b/app/globals.css index 795827895..f7b3549a4 100644 --- a/app/globals.css +++ b/app/globals.css @@ -166,7 +166,7 @@ } .light ::-webkit-scrollbar-thumb { - background: #999999; + background: #888888; } .dark ::-webkit-scrollbar-thumb { diff --git a/app/graph/controls.tsx b/app/graph/controls.tsx index 80f596d48..00d3acf5e 100644 --- a/app/graph/controls.tsx +++ b/app/graph/controls.tsx @@ -48,6 +48,7 @@ export default function Controls({ {cooldownTicks === undefined ? : } { diff --git a/app/graph/graphInfo.tsx b/app/graph/graphInfo.tsx index 86f9dcd87..bc6ab5847 100644 --- a/app/graph/graphInfo.tsx +++ b/app/graph/graphInfo.tsx @@ -46,7 +46,7 @@ export default function GraphInfoPanel({ onClose, customizingLabel, setCustomizi

      Name

      -

      {graphName}

      +

      {graphName}

      {graphName} @@ -62,7 +62,7 @@ export default function GraphInfoPanel({ onClose, customizingLabel, setCustomizi MemoryUsage.get("total_graph_sz_mb") !== undefined ? -

      {MemoryUsage.get("total_graph_sz_mb") || "<1"} MB

      +

      {MemoryUsage.get("total_graph_sz_mb") || "<1"} MB

      {MemoryUsage.get("total_graph_sz_mb")} MB @@ -82,6 +82,9 @@ export default function GraphInfoPanel({ onClose, customizingLabel, setCustomizi

      {nodesCount.toLocaleString()} @@ -163,6 +166,9 @@ export default function GraphInfoPanel({ onClose, customizingLabel, setCustomizi

      {edgesCount.toLocaleString()} @@ -229,6 +235,9 @@ export default function GraphInfoPanel({ onClose, customizingLabel, setCustomizi

      {PropertyKeys.length.toLocaleString()} diff --git a/e2e/logic/POM/customizeStylePage.ts b/e2e/logic/POM/customizeStylePage.ts index 034113ca1..48925188b 100644 --- a/e2e/logic/POM/customizeStylePage.ts +++ b/e2e/logic/POM/customizeStylePage.ts @@ -141,11 +141,11 @@ export default class CustomizeStylePage extends GraphInfoPage { // Check for left border color (new style) const computedStyle = window.getComputedStyle(el); const borderColor = el.style.borderLeftColor || computedStyle.borderLeftColor; - if (borderColor && borderColor !== 'rgb(0, 0, 0)' && borderColor !== '' && borderColor !== 'transparent') { + if (borderColor && borderColor !== 'rgb(0, 0, 0)' && borderColor !== '' && borderColor !== 'transparent' && borderColor !== 'rgba(0, 0, 0, 0)') { return borderColor; } - // Check inner color dot span (new style) - const dotSpan = el.querySelector('span[class*="rounded-full"]'); + // Check inner color dot element (new style) + const dotSpan = el.querySelector('[class*="rounded-full"]'); if (dotSpan) { const dotBg = window.getComputedStyle(dotSpan).backgroundColor; if (dotBg && dotBg !== 'rgba(0, 0, 0, 0)' && dotBg !== 'transparent') { From c4a276bc3c00fee320fdd352bb849a331b896305 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Tue, 14 Apr 2026 12:54:25 +0300 Subject: [PATCH 020/119] feat: implement URL validation and parsing in LoginForm and associated utilities --- app/api/validate-body.ts | 8 +++ app/api/validate-url/route.ts | 31 ++++++++++ app/components/FormComponent.tsx | 13 ++++- app/login/LoginForm.tsx | 99 +++++++++++++++++++++----------- app/login/urlUtils.ts | 8 +-- 5 files changed, 117 insertions(+), 42 deletions(-) create mode 100644 app/api/validate-url/route.ts diff --git a/app/api/validate-body.ts b/app/api/validate-body.ts index 312c11fe6..1673ad5d1 100644 --- a/app/api/validate-body.ts +++ b/app/api/validate-body.ts @@ -305,6 +305,14 @@ export const revokeToken = z.object({ .min(1, "Token cannot be empty"), }); +export const validateUrl = z.object({ + url: z + .string({ + error: (issue) => issue.input === undefined ? "URL is required" : "Invalid URL", + }) + .min(1, "URL cannot be empty"), +}); + // Validation helper function export function validateBody( schema: T, diff --git a/app/api/validate-url/route.ts b/app/api/validate-url/route.ts new file mode 100644 index 000000000..daabdc70c --- /dev/null +++ b/app/api/validate-url/route.ts @@ -0,0 +1,31 @@ +import { FalkorDB } from "falkordb"; +import { NextRequest, NextResponse } from "next/server"; +import { validateBody, validateUrl } from "../validate-body"; + +export async function POST(request: NextRequest) { + const body = await request.json(); + + const validation = validateBody(validateUrl, body) + + if (!validation.success) { + return NextResponse.json( + { message: validation.error }, + { status: 400 } + ); + } + + const { url } = validation.data; + const isMissingPasswordAndUsername = !url.includes("@"); + + if (isMissingPasswordAndUsername) { + try { + const falkordb = await FalkorDB.connect({ url }) + } catch (err) { + if (err instanceof Error && err.message.includes("NOAUTH")) { + return NextResponse.json({ result: true }, { status: 200 }); + } + } + } + + return NextResponse.json({ result: false }, { status: 200 }); +} \ No newline at end of file diff --git a/app/components/FormComponent.tsx b/app/components/FormComponent.tsx index c96bcbc41..4aa22f831 100644 --- a/app/components/FormComponent.tsx +++ b/app/components/FormComponent.tsx @@ -62,8 +62,14 @@ export default function FormComponent({ handleSubmit, fields, error = undefined, const [show, setShow] = useState<{ [key: string]: boolean }>({}); const [errors, setErrors] = useState<{ [key: string]: boolean }>({}); const [isLoading, setIsLoading] = useState(false); + const [isMounted, setIsMounted] = useState(false); useEffect(() => { + if (!isMounted) { + setIsMounted(true); + return () => { }; + } + const newErrors: { [key: string]: boolean } = {}; fields.forEach(field => { if (field.errors) { @@ -71,7 +77,12 @@ export default function FormComponent({ handleSubmit, fields, error = undefined, } }); setErrors(prev => ({ ...prev, ...newErrors })); - }, [fields]); + + + return () => { + setIsMounted(false); + } + }, [fields.length]); const onHandleSubmit = async (e: React.FormEvent) => { e.preventDefault(); diff --git a/app/login/LoginForm.tsx b/app/login/LoginForm.tsx index dde7f393f..584603467 100644 --- a/app/login/LoginForm.tsx +++ b/app/login/LoginForm.tsx @@ -1,18 +1,20 @@ "use client"; import { SignInOptions, SignInResponse, signIn } from "next-auth/react"; -import { FormEvent, useEffect, useState } from "react"; +import { FormEvent, useContext, useEffect, useState } from "react"; import { useSearchParams, useRouter } from "next/navigation"; import Image from "next/image"; import Link from "next/link"; import { Check, Info, FileText } from "lucide-react"; import { Checkbox } from "@/components/ui/checkbox"; import { useTheme } from "next-themes"; -import { getTheme } from "@/lib/utils"; +import { getTheme, securedFetch } from "@/lib/utils"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import FormComponent, { Field } from "../components/FormComponent"; import Dropzone from "../components/ui/Dropzone"; -import { parseUrlString, validateUrl, matchUrl } from "./urlUtils"; +import { IndicatorContext } from "../components/provider"; +import { useToast } from "@/components/ui/use-toast"; +import { matchUrl, parseUrlString, validateUrl } from "../login/urlUtils"; const DEFAULT_HOST = "localhost"; const DEFAULT_PORT = "6379"; @@ -30,11 +32,11 @@ const handleIsPortFormat = (value: string) => { const handleIsPortValid = (value: string) => value.startsWith("0"); const getPortErrors = (func?: (value: string) => string) => { - const getValue = (v: string) => func ? func(v) : v; + const getValue = (v: string) => func ? func(v) : v; return [ { - condition: (value: string) => getValue(value) !== "" && handlePortIsNumber(getValue(value)), + condition: (value: string) => { console.log(getValue(value)); return getValue(value) !== "" && handlePortIsNumber(getValue(value)) }, message: "Port must be a number" }, { @@ -58,6 +60,7 @@ const safeDecode = (value: string): string => { // Parse a URL string and update shared state const parseUrl = (url: string) => { + debugger; const match = matchUrl(url); let parsed: ReturnType; @@ -82,9 +85,12 @@ export default function LoginForm() { const { theme } = useTheme(); const { currentTheme } = getTheme(theme); const router = useRouter(); + const { toast } = useToast(); + const { setIndicator } = useContext(IndicatorContext); const [mounted, setMounted] = useState(false); const [loginMode, setLoginMode] = useState("manual"); + const [missingFields, setMissingFields] = useState(false); const [rawUrl, setRawUrl] = useState(""); const [host, setHost] = useState(""); const [port, setPort] = useState(""); @@ -121,7 +127,7 @@ export default function LoginForm() { const parseEndpoint = (value: string) => { const colonIndex = value.lastIndexOf(":"); - if (colonIndex > 0) { + if (colonIndex !== -1) { const portCandidate = value.substring(colonIndex + 1); return { host: value.substring(0, colonIndex), port: portCandidate }; } else { @@ -160,32 +166,35 @@ export default function LoginForm() { required: false }]; + const urlFields: Field[] = !missingFields ? [{ + value: rawUrl, + onChange: async (e: React.ChangeEvent) => { + const val = e.target.value; + const parsed = parseUrl(val); + + setHost(parsed.host); + setPort(parsed.port); + setUsername(parsed.username); + setPassword(parsed.password); + setTLS(parsed.tls); + setRawUrl(val); + + clearError(); + + return true; + }, + errors: [ + ...getPortErrors((value) => parseUrl(value).port) + ], + label: "FalkorDB URL", + type: "text", + placeholder: `falkor://Default:Default@${DEFAULT_HOST}:${DEFAULT_PORT}`, + required: true + }] : userInputFields; + const fields: Field[] = loginMode === "url" ? - [{ - value: rawUrl, - onChange: async (e: React.ChangeEvent) => { - const val = e.target.value; - const parsed = parseUrl(val); - - setHost(parsed.host); - setPort(parsed.port); - setUsername(parsed.username); - setPassword(parsed.password); - setTLS(parsed.tls); - setRawUrl(val); - - clearError(); - - return true; - }, - errors: [ - ...getPortErrors((value) => parseUrl(value).port) - ], - label: "FalkorDB URL", - type: "text", - placeholder: `falkor://Default:Default@${DEFAULT_HOST}:${DEFAULT_PORT}`, - required: true - }] : loginMode === "endpoint" ? [ + urlFields + : loginMode === "endpoint" ? [ { value: endpointValue, onChange: async (e: React.ChangeEvent) => { @@ -258,11 +267,31 @@ export default function LoginForm() { e.preventDefault(); // Pre-submit validation for URL mode — show colored format errors - if (loginMode === "url" && rawUrl.trim()) { - const result = validateUrl(rawUrl); - const { parts } = result; + if (loginMode === "url") { + let url = rawUrl || "falkor://localhost:6379"; + + if (missingFields) { + url = buildUrl(); + } + + const result = await securedFetch("/api/validate-url", { + method: "POST", + body: JSON.stringify({ url }) + }, toast, setIndicator); + + if (!result.ok) return + + const json = await result.json(); + + if (json.result) { + setMissingFields(true); + return; + } + + const res = validateUrl(url); + const { parts } = res; - if (!result.valid) { + if (!res.valid) { const good = (text: string) => {text}; const render = (text: string, status: "good" | "warn" | "neutral") => { if (status === "good") return good(text); diff --git a/app/login/urlUtils.ts b/app/login/urlUtils.ts index f0771c745..d9f4e0ab9 100644 --- a/app/login/urlUtils.ts +++ b/app/login/urlUtils.ts @@ -86,12 +86,8 @@ export function parseUrlString(url: string): ParsedUrl { const lastColon = hostPortStr.lastIndexOf(":"); if (lastColon >= 0) { const portCandidate = hostPortStr.slice(lastColon + 1); - if (/^\d+$/.test(portCandidate)) { - host = hostPortStr.slice(0, lastColon); - port = portCandidate; - } - // If portCandidate is not all digits, keep it as part of host - // (this handles the case of a missing @ where host:password looks ambiguous) + host = hostPortStr.slice(0, lastColon); + port = portCandidate; } const tls = protocol === "falkors" || protocol === "rediss"; From 6b8bd239c7a9a7aa9c31351562546743a05c6b1f Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Tue, 14 Apr 2026 13:01:02 +0300 Subject: [PATCH 021/119] refactor: remove endpoint mode from LoginForm and associated tests --- app/login/LoginForm.tsx | 46 ++------------------------------------ e2e/logic/POM/loginPage.ts | 36 ----------------------------- e2e/tests/login.spec.ts | 9 -------- 3 files changed, 2 insertions(+), 89 deletions(-) diff --git a/app/login/LoginForm.tsx b/app/login/LoginForm.tsx index 584603467..b414429f0 100644 --- a/app/login/LoginForm.tsx +++ b/app/login/LoginForm.tsx @@ -19,7 +19,7 @@ import { matchUrl, parseUrlString, validateUrl } from "../login/urlUtils"; const DEFAULT_HOST = "localhost"; const DEFAULT_PORT = "6379"; -type LoginMode = "manual" | "url" | "endpoint"; +type LoginMode = "manual" | "url"; const handlePortIsNumber = (value: string) => !/^\d+$/.test(value); @@ -120,21 +120,6 @@ export default function LoginForm() { return `${protocol}://${creds}${h}${port ? `:${port}` : ""}`; }; - // Build endpoint display from shared state - const endpointValue = `${host}${port ? `:${port}` : ""}`; - - // Parse endpoint string into host and port - const parseEndpoint = (value: string) => { - const colonIndex = value.lastIndexOf(":"); - - if (colonIndex !== -1) { - const portCandidate = value.substring(colonIndex + 1); - return { host: value.substring(0, colonIndex), port: portCandidate }; - } else { - return { host: value, port: "" }; - } - }; - const clearError = () => setError({ message: "", show: false }); const userInputFields: Field[] = [{ @@ -194,29 +179,7 @@ export default function LoginForm() { const fields: Field[] = loginMode === "url" ? urlFields - : loginMode === "endpoint" ? [ - { - value: endpointValue, - onChange: async (e: React.ChangeEvent) => { - const { host, port } = parseEndpoint(e.target.value); - - setHost(host); - setPort(port); - - clearError(); - - return true; - }, - errors: [ - ...getPortErrors((value) => parseEndpoint(value).port) - ], - label: "Endpoint", - type: "text", - placeholder: `${DEFAULT_HOST}:${DEFAULT_PORT}`, - required: true - }, - ...userInputFields - ] : [ + : [ { value: host, onChange: async (e: React.ChangeEvent) => { @@ -396,11 +359,6 @@ export default function LoginForm() { {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}

-
- - {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} - -
{/* Label is correctly associated via htmlFor, but eslint doesn't recognize Radix RadioGroupItem */} diff --git a/e2e/logic/POM/loginPage.ts b/e2e/logic/POM/loginPage.ts index 4b8cc0961..cd50ee84f 100644 --- a/e2e/logic/POM/loginPage.ts +++ b/e2e/logic/POM/loginPage.ts @@ -269,14 +269,6 @@ export default class LoginPage extends HeaderComponent { ); } - private get endpointModeRadio(): Locator { - return this.page.getByRole("radio", { name: "Endpoint" }); - } - - private get endpointInput(): Locator { - return this.page.locator("//input[@id='Endpoint']"); - } - private get errorMessage(): Locator { return this.page.locator("text=Invalid URL format"); } @@ -319,32 +311,4 @@ export default class LoginPage extends HeaderComponent { const errorEl = this.page.locator(".text-destructive").first(); return (await errorEl.textContent()) ?? ""; } - - async clickEndpointMode(): Promise { - await interactWhenVisible( - this.endpointModeRadio, - (el) => el.click(), - "Endpoint mode radio button" - ); - } - - async isEndpointModeSelected(): Promise { - return (await this.endpointModeRadio.getAttribute("data-state")) === "checked"; - } - - async fillEndpoint(endpoint: string): Promise { - await interactWhenVisible( - this.endpointInput, - (el) => el.fill(endpoint), - "endpoint input" - ); - } - - async connectWithEndpoint(endpoint: string, username?: string, password?: string): Promise { - await this.clickEndpointMode(); - await this.fillEndpoint(endpoint); - if (username) await this.fillUsername(username); - if (password) await this.fillPassword(password); - await this.clickConnect(); - } } diff --git a/e2e/tests/login.spec.ts b/e2e/tests/login.spec.ts index 8717e1fec..79de27c5b 100644 --- a/e2e/tests/login.spec.ts +++ b/e2e/tests/login.spec.ts @@ -113,13 +113,4 @@ test.describe(`Login tests`, () => { // Format is valid, so format error should NOT appear (credentials error may appear instead) expect(await login.isFormatErrorVisible()).toBe(false); }); - - test(`@admin validate endpoint mode login`, async () => { - const login = await browser.createNewPage(LoginPage, urls.loginUrl); - if (login.getCurrentURL() === urls.graphUrl) await login.Logout(); - await browser.setPageToFullScreen(); - await login.connectWithEndpoint('localhost:6379'); - await login.waitForSuccessfulLogin(urls.graphUrl); - expect(login.getCurrentURL()).toBe(urls.graphUrl); - }); }); \ No newline at end of file From d1c455ef85f8e3d66c6bc0f6ce34a9025172c87c Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Tue, 14 Apr 2026 13:59:06 +0300 Subject: [PATCH 022/119] refactor(ui): enhance Header and Navbar components, improve styling and layout --- app/components/Header.tsx | 26 ++-- app/components/Navbar.tsx | 45 +++---- app/globals.css | 2 +- app/graph/GraphView.tsx | 8 +- app/graph/graphInfo.tsx | 20 +-- app/graph/toolbar.tsx | 259 ++++++++++++++++++++------------------ tailwind.config.js | 2 +- 7 files changed, 181 insertions(+), 181 deletions(-) diff --git a/app/components/Header.tsx b/app/components/Header.tsx index d9cb8de7d..724b10939 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -29,7 +29,7 @@ function formatVersion(version: string | undefined): string { } export default function Header() { - const { setIndicator } = useContext(IndicatorContext); + const { indicator, setIndicator } = useContext(IndicatorContext); const { connectionType, connectionInfo, dbVersion } = useContext(ConnectionContext); const { data: session } = useSession(); const { toast } = useToast(); @@ -101,16 +101,12 @@ export default function Header() { role="status" aria-label={`Connection type: ${connectionType}`} className={cn( - "h-6 px-2 rounded-full flex items-center gap-1.5 text-xs font-medium border", - connectionType === "Standalone" && "border-yellow-500/40 bg-yellow-500/10 text-yellow-600 dark:text-yellow-400", - connectionType === "Sentinel" && "border-green-500/40 bg-green-500/10 text-green-600 dark:text-green-400", - connectionType === "Cluster" && "border-green-700/40 bg-green-700/10 text-green-700 dark:text-green-400", - )}> + indicator === "offline" ? "text-destructive border-destructive bg-destructive/10" : "text-green border-green bg-green/10", + "h-6 px-2 rounded-full flex items-center gap-1.5 text-xs font-medium border", + )}> {connectionType === "Standalone" && "Single"} {connectionType === "Sentinel" && "Sentinel"} @@ -118,7 +114,12 @@ export default function Header() {
-

Connection type: {connectionType}

+
+

Connection type: {connectionType}

+

Status: {indicator}

+
@@ -146,11 +147,10 @@ export default function Header() { { showUDF ? : null }
{separator}
@@ -128,7 +131,7 @@ export default function Navbar({ onSetGraphName, graphNames, graphName, onOpenPa onClick={() => onOpenPanel()} data-testid="graphInfoToggle" > - + } { @@ -145,7 +148,7 @@ export default function Navbar({ onSetGraphName, graphNames, graphName, onOpenPa handleSetCurrentPanel("chat"); }} > - + } { @@ -162,7 +165,7 @@ export default function Navbar({ onSetGraphName, graphNames, graphName, onOpenPa className="hover:!bg-primary/70 p-1" title={`Create New ${type}`} > - + } /> @@ -262,30 +265,14 @@ export default function Navbar({ onSetGraphName, graphNames, graphName, onOpenPa } - { - indicator === "offline" && - <> - {separator} -
- - -

Offline

-
- -

The FalkorDB server is offline

-
-
-
- - } {separator}
diff --git a/app/globals.css b/app/globals.css index f7b3549a4..d36348cca 100644 --- a/app/globals.css +++ b/app/globals.css @@ -25,7 +25,7 @@ --foreground: 0 0% 100%; --secondary: 0 0% 14.1%; --border: 0 0% 26%; - --green: 144 61% 20%; + --green: 144 61% 40%; --fav: 48 96% 53%; } .theme { diff --git a/app/graph/GraphView.tsx b/app/graph/GraphView.tsx index 82d47408f..377c9d53a 100644 --- a/app/graph/GraphView.tsx +++ b/app/graph/GraphView.tsx @@ -2,7 +2,7 @@ 'use client'; -import { useEffect, Dispatch, SetStateAction, useContext, useCallback } from "react"; +import { useEffect, Dispatch, SetStateAction, useContext, useCallback, useState } from "react"; import { GitGraph, ScrollText, Table } from "lucide-react"; import { cn, GraphRef, Tab, Label, Link, Node, Relationship, HistoryQuery } from "@/lib/utils"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -59,6 +59,8 @@ function GraphView({ const { graph, graphName, currentTab, setCurrentTab, isLoading, setIsLoading } = useContext(GraphContext); const { setData, data, graphData, setGraphData, setViewport, viewport } = useContext(ForceGraphContext); + const [expand, setExpand] = useState(true); + const elementsLength = graph.getElements().length; useEffect(() => { @@ -179,11 +181,13 @@ function GraphView({ canvasRef={canvasRef} setIsAddEdge={selectedElements.length === 2 && selectedElements.every(e => "labels" in e) ? setIsAddEdge : undefined} setIsAddNode={setIsAddNode} + expand={expand} + setExpand={setExpand} isAddEdge={isAddEdge} isAddNode={isAddNode} /> { - (labels.length !== 0 || relationships.length !== 0) && + expand && (labels.length !== 0 || relationships.length !== 0) &&
{labels.length !== 0 && } {labels.length !== 0 && relationships.length > 0 &&
} diff --git a/app/graph/graphInfo.tsx b/app/graph/graphInfo.tsx index bc6ab5847..fedf07073 100644 --- a/app/graph/graphInfo.tsx +++ b/app/graph/graphInfo.tsx @@ -120,14 +120,9 @@ export default function GraphInfoPanel({ onClose, customizingLabel, setCustomizi const labelColor = label.style.color; return ( -
  • +
  • -
      +
        { PropertyKeys && PropertyKeys.filter(key => key.toLowerCase().includes(propertyKeysSearch.toLowerCase())).sort((a, b) => a.localeCompare(b)).map((key, index, arr) => (
      • } - { - suggestions.length > 0 && -
        -
          + { + expand && graph.getElements().length > 0 && !isLoading && + setSearchElement(e.target.value)} onKeyDown={(e) => { if (e.key === 'Escape') { e.preventDefault(); @@ -204,7 +179,6 @@ export default function Toolbar({ const index = suggestionIndex === suggestions.length - 1 ? 0 : suggestionIndex + 1; setSuggestionIndex(index); scrollToSuggestion(index); - } if (e.key === 'ArrowUp') { @@ -214,74 +188,119 @@ export default function Toolbar({ scrollToSuggestion(index); } }} - > - { - topFakeItemHeight > 0 - &&
        • - } - { - visibleSuggestions.map((suggestion, index) => { - const actualIndex = index + startIndex; - const type = "source" in suggestion; - - return ( -
        • - - - - - - {type ? (suggestion as Link).relationship : (suggestion as Node).labels[0]} - - -
        • - ); - }) + onBlur={(e) => { + if (suggestionRef.current?.contains(e.relatedTarget) || e.relatedTarget === suggestionRef.current) return; + + setSuggestions([]); } - { - bottomFakeItemHeight > 0 - &&
        • } -
        -
        - } + onFocus={() => handleOnChange()} + /> + } + { + suggestions.length > 0 && +
        +
          { + if (e.key === 'Escape') { + e.preventDefault(); + setSearchElement(""); + } + + if (e.key === 'Enter' && suggestions[suggestionIndex]) { + e.preventDefault(); + handleSearchElement(suggestions[suggestionIndex]); + setSearchElement(""); + } + + if (e.key === 'ArrowDown') { + e.preventDefault(); + const index = suggestionIndex === suggestions.length - 1 ? 0 : suggestionIndex + 1; + setSuggestionIndex(index); + scrollToSuggestion(index); + + } + + if (e.key === 'ArrowUp') { + e.preventDefault(); + const index = suggestionIndex === 0 ? suggestions.length - 1 : suggestionIndex - 1; + setSuggestionIndex(index); + scrollToSuggestion(index); + } + }} + > + { + topFakeItemHeight > 0 + &&
        • + } + { + visibleSuggestions.map((suggestion, index) => { + const actualIndex = index + startIndex; + const type = "source" in suggestion; + + return ( +
        • + + + + + + {type ? (suggestion as Link).relationship : (suggestion as Node).labels[0]} + + +
        • + ); + }) + } + { + bottomFakeItemHeight > 0 + &&
        • + } +
        +
        + } +
    { @@ -317,7 +336,7 @@ export default function Toolbar({ }
    - {separator} -
    - { - type === "Graph" && graphName && - - } - { - type === "Graph" && graphName && - - } - { - showCreate && - - - - } - /> - } -
    diff --git a/app/components/provider.ts b/app/components/provider.ts index caf2235bd..13d45537b 100644 --- a/app/components/provider.ts +++ b/app/components/provider.ts @@ -174,6 +174,8 @@ type IndicatorContextType = { type PanelContextType = { panel: Panel; setPanel: Dispatch>; + panelOpen: boolean; + onTogglePanel: () => void; }; type QueryLoadingContextType = { @@ -394,6 +396,8 @@ export const IndicatorContext = createContext({ export const PanelContext = createContext({ panel: undefined, setPanel: () => { }, + panelOpen: false, + onTogglePanel: () => { }, }); export const QueryLoadingContext = createContext({ diff --git a/app/graph/Selector.tsx b/app/graph/Selector.tsx index 93fcfe828..871e8c742 100644 --- a/app/graph/Selector.tsx +++ b/app/graph/Selector.tsx @@ -4,14 +4,14 @@ import { useEffect, useState, useContext, Dispatch, SetStateAction, useRef, useCallback, useMemo } from "react"; import { cn, GraphRef, formatName, Node, Link, getTheme, Query, HistoryQuery } from "@/lib/utils"; -import { ChevronDown, History, Info, Maximize2, Star, Trash2 } from "lucide-react"; +import { ChevronDown, History, Info, Maximize2, MessagesSquare, Network, Star, Trash2 } from "lucide-react"; import * as monaco from "monaco-editor"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useTheme } from "next-themes"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import Button from "../components/ui/Button"; -import { BrowserSettingsContext, GraphContext, IndicatorContext } from "../components/provider"; +import { BrowserSettingsContext, GraphContext, IndicatorContext, PanelContext } from "../components/provider"; import { setConnectionItem, removeConnectionItem } from "@/lib/connection-storage"; import CypherEditor, { CYPHER_LANGUAGE_NAME } from "../components/CypherEditor"; import EditorComponent from "../components/EditorComponent"; @@ -102,6 +102,7 @@ export default function Selector - + { + type === "Schema" && + + } + { + historyQuery && graphName && + + } { historyQuery ? <> @@ -719,6 +738,20 @@ export default function Selector
    + : selectedElements && handleDeleteElement && setSelectedElements && setIsAddNode && setIsAddEdge && canvasRef && isCanvasLoading !== undefined &&
    void, customizingLabel: InfoLabel | null, setCustomizingLabel: Dispatch> }) { - const { graphInfo: { Labels, Relationships, PropertyKeys, MemoryUsage }, nodesCount, edgesCount, runQuery, graphName } = useContext(GraphContext); + const { graphInfo: { Labels, Relationships, PropertyKeys, MemoryUsage }, nodesCount, edgesCount, runQuery, graphName, setGraphName, graphNames, setGraphNames, graph, setGraph } = useContext(GraphContext); const { isQueryLoading } = useContext(QueryLoadingContext); const { settings: { graphInfo: { showMemoryUsage, maxItemsForSearch } } } = useContext(BrowserSettingsContext); + const { isReadOnly } = useContext(ConnectionContext); const [nodesSearch, setNodesSearch] = useState(""); const [edgesSearch, setEdgesSearch] = useState(""); @@ -47,16 +52,36 @@ export default function GraphInfoPanel({ onClose, customizingLabel, setCustomizi

    Graph Info

    -
    -

    Name

    - - -

    {graphName}

    -
    - - {graphName} - -
    +
    + setGraphNames(opts as unknown as string[])} + selectedValue={graphName} + setSelectedValue={(name) => setGraphName(formatName(name))} + type="Graph" + setGraph={(g) => setGraph(g as Graph)} + /> + { + !isReadOnly && + { + setGraphName(formatName(newGraphName)); + setGraphNames(prev => [...prev, formatName(newGraphName)]); + }} + trigger={ + + } + /> + }
    { showMemoryUsage && @@ -125,33 +150,42 @@ export default function GraphInfoPanel({ onClose, customizingLabel, setCustomizi const labelColor = label.style.color; return ( -
  • - - - +
  • + + - - - Customize Style - - + + + + + + + + + +
  • ); })} diff --git a/app/graph/page.tsx b/app/graph/page.tsx index 4f35f5ade..23696a653 100644 --- a/app/graph/page.tsx +++ b/app/graph/page.tsx @@ -22,8 +22,8 @@ const CreateElementPanel = dynamicImport(() => import("./CreateElementPanel"), { const Selector = dynamicImport(() => import("./Selector"), { ssr: false, - loading: () =>
    -
    + loading: () =>
    +
    diff --git a/app/graph/selectGraph.tsx b/app/graph/selectGraph.tsx index e8568d173..4ef692cbb 100644 --- a/app/graph/selectGraph.tsx +++ b/app/graph/selectGraph.tsx @@ -7,7 +7,7 @@ import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; import { fetchOptions, getMemoryUsage, getSSEGraphResult, prepareArg, Row, securedFetch } from "@/lib/utils"; import { useSession } from "next-auth/react"; import { useToast } from "@/components/ui/use-toast"; -import { ChevronDown, ChevronUp, PlusCircle, Settings } from "lucide-react"; +import { ChevronDown, ChevronUp, Settings } from "lucide-react"; import Button from "../components/ui/Button"; import { IndicatorContext, BrowserSettingsContext, ConnectionContext } from "../components/provider"; import PaginationList from "../components/PaginationList"; @@ -16,7 +16,6 @@ import ExportGraph from "../components/ExportGraph"; import DeleteGraph from "../components/graph/DeleteGraph"; import CloseDialog from "../components/CloseDialog"; import DuplicateGraph from "../components/graph/DuplicateGraph"; -import CreateGraph from "../components/CreateGraph"; import { Graph } from "../api/graph/model"; interface Props { @@ -216,7 +215,7 @@ export default function SelectGraph({ options, setOptions, selectedValue, setSel - } - />
    -

    Chat

    - +

    Chat

    +
    Use English to query the graph. The feature requires LLM model and API key. Update local user parameters in Settings.
      diff --git a/app/graph/CreateElementPanel.tsx b/app/graph/CreateElementPanel.tsx index dbc6e7c13..0534a155d 100644 --- a/app/graph/CreateElementPanel.tsx +++ b/app/graph/CreateElementPanel.tsx @@ -419,14 +419,14 @@ export default function CreateElementPanel(props: Props) {
      -

      Create {type ? "Node" : "Edge"}

      +

      Create {type ? "Node" : "Edge"}

      { type - ? - : + ? + : }
      -
      +

      Attributes: {attributes.length}

        Date: Thu, 16 Apr 2026 13:56:54 +0300 Subject: [PATCH 037/119] feat: enhance user session handling and improve UI accessibility --- app/components/Header.tsx | 10 +++--- app/components/PaginationList.tsx | 8 ++++- app/graph/Selector.tsx | 50 +++++++++++++++++++++++++-- app/graph/toolbar.tsx | 41 ++-------------------- app/login/LoginForm.tsx | 56 ++++++++++++++++++++++--------- app/providers.tsx | 2 +- 6 files changed, 104 insertions(+), 63 deletions(-) diff --git a/app/components/Header.tsx b/app/components/Header.tsx index ab46a2308..13c505143 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -31,7 +31,7 @@ function formatVersion(version: string | undefined): string { export default function Header() { const { indicator, setIndicator } = useContext(IndicatorContext); const { connectionType, connectionInfo, dbVersion } = useContext(ConnectionContext); - const { data: session } = useSession(); + const { status, data: session } = useSession(); const { toast } = useToast(); const [usedMemory, setUsedMemory] = useState(null); @@ -39,6 +39,8 @@ export default function Header() { useEffect(() => { setUsedMemory(null); (async () => { + if (status !== "authenticated") return; + const result = await securedFetch("/api/info?section=memory", { method: "GET" }, toast, setIndicator); @@ -99,7 +101,7 @@ export default function Header() {
        -

        Connection type: {connectionType}

        +

        Deployment type: {connectionType}

        Status: {indicator}

        @@ -127,7 +129,7 @@ export default function Header() { session?.user &&
        + { + graphName && !isReadOnly && + + } + { + (() => { + const hasLimitWarning = graph.CurrentLimit && graph.Data.length >= graph.CurrentLimit; + const hasLimitChangeWarning = graph.CurrentLimit && lastLimit !== limit; + return (hasLimitWarning || hasLimitChangeWarning) ? ( + + ) : null; + })() + } {separator}
        { + const index = historyQuery.queries.findIndex(q => q.text === counter); + setHistoryQuery(prev => ({ + ...prev, + counter: index + 1 + })); + setTab("text"); + try { + setIsLoading(true); + await runQuery!(counter.trim()); + setQueriesOpen(false); + } finally { + setIsLoading(false); } }} searchRef={searchQueryRef} diff --git a/app/graph/toolbar.tsx b/app/graph/toolbar.tsx index edf295554..b793c30ca 100644 --- a/app/graph/toolbar.tsx +++ b/app/graph/toolbar.tsx @@ -1,4 +1,4 @@ -import { ArrowRight, Circle, Info, Search, X } from "lucide-react"; +import { ArrowRight, Circle, Search, X } from "lucide-react"; import { Dispatch, SetStateAction, useCallback, useContext, useEffect, useRef, useState } from "react"; import { cn, GraphRef, Link, Node } from "@/lib/utils"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -6,7 +6,7 @@ import { Graph } from "../api/graph/model"; import Input from "../components/ui/Input"; import Button from "../components/ui/Button"; import DeleteElement from "./DeleteElement"; -import { BrowserSettingsContext, ConnectionContext, GraphContext } from "../components/provider"; +import { ConnectionContext, GraphContext } from "../components/provider"; interface Props { graph: Graph @@ -48,12 +48,6 @@ export default function Toolbar({ const { isLoading: isLoadingGraph } = useContext(GraphContext); const { isReadOnly } = useContext(ConnectionContext); - const { settings: { showPropertyKeyPrefixSettings: { showPropertyKeyPrefix } } } = useContext(BrowserSettingsContext); - const { - settings: { - limitSettings: { limit, lastLimit }, - } - } = useContext(BrowserSettingsContext); const suggestionRef = useRef(null); @@ -68,9 +62,6 @@ export default function Toolbar({ const [bottomFakeItemHeight, setBottomFakeItemHeight] = useState(0); const [visibleSuggestions, setVisibleSuggestions] = useState<(Node | Link)[]>([]); - const hasLimitWarning = graph.CurrentLimit && graph.Data.length >= graph.CurrentLimit; - const hasLimitChangeWarning = graph.CurrentLimit && lastLimit !== limit; - const isLoading = isLoadingSchema || isLoadingGraph; useEffect(() => { @@ -306,34 +297,6 @@ export default function Toolbar({ { graphName && !isReadOnly && <> - - { - (hasLimitWarning || hasLimitChangeWarning) ? - - : null - } } /> diff --git a/app/graph/selectGraph.tsx b/app/graph/selectGraph.tsx index 4ef692cbb..5a10cea58 100644 --- a/app/graph/selectGraph.tsx +++ b/app/graph/selectGraph.tsx @@ -215,7 +215,7 @@ export default function SelectGraph({ options, setOptions, selectedValue, setSel From 1e29d5d084d319b3f63143269daec596cb7adb0c Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Thu, 16 Apr 2026 15:14:54 +0300 Subject: [PATCH 039/119] fix: update header handling and improve layout in TableComponent and SelectGraph --- app/components/TableComponent.tsx | 96 +++++++++++++++++-------------- app/graph/Chat.tsx | 4 +- app/graph/Selector.tsx | 8 +-- app/graph/graphInfo.tsx | 36 ++++++------ app/graph/selectGraph.tsx | 10 ++-- app/login/LoginForm.tsx | 1 - 6 files changed, 80 insertions(+), 75 deletions(-) diff --git a/app/components/TableComponent.tsx b/app/components/TableComponent.tsx index 8ac33eb29..a7746297c 100644 --- a/app/components/TableComponent.tsx +++ b/app/components/TableComponent.tsx @@ -20,8 +20,10 @@ import Input from "./ui/Input"; import Combobox from "./ui/combobox"; import { IndicatorContext } from "./provider"; +export type HeaderDef = string | { name: string; width?: string }; + interface Props { - headers: string[], + headers: HeaderDef[], rows: Row[], label: "Graphs" | "Schemas" | "Configs" | "Users" | "TableView", entityName: "Graph" | "Schema" | "Config" | "User" | "Element", @@ -93,6 +95,9 @@ export default function TableComponent({ const { theme } = useTheme(); const { currentTheme } = getTheme(theme); + const normalizedHeaders = useMemo(() => headers.map(h => typeof h === 'string' ? { name: h } : h), [headers]); + const headerNames = useMemo(() => normalizedHeaders.map(h => h.name), [normalizedHeaders]); + const searchRef = useRef(null); const headerRef = useRef(null); const tableRef = useRef(null); @@ -128,18 +133,18 @@ export default function TableComponent({ }, []); const colMinWidth = useMemo(() => { - if (!containerWidth || headers.length === 0) return 0; + if (!containerWidth || headerNames.length === 0) return 0; const headerRow = headerRef.current; let fixedColsWidth = 0; if (headerRow) { const cells = Array.from(headerRow.cells); // All columns before the data columns (checkbox + index) are fixed - const fixedCols = cells.slice(0, cells.length - headers.length); + const fixedCols = cells.slice(0, cells.length - headerNames.length); fixedColsWidth = fixedCols.reduce((sum, cell) => sum + cell.getBoundingClientRect().width, 0); } const availableWidth = containerWidth - fixedColsWidth; - return Math.floor(availableWidth / Math.min(headers.length, 100 / itemWidth)); - }, [containerWidth, headers.length, itemWidth]); + return Math.floor(availableWidth / Math.min(headerNames.length, 100 / itemWidth)); + }, [containerWidth, headerNames.length, itemWidth]); const height = useMemo(() => itemHeightExpandMultiple !== undefined ? expandArr.size === 0 ? itemHeight : itemHeight * itemHeightExpandMultiple : itemHeight, [expandArr.size, itemHeight, itemHeightExpandMultiple]); @@ -391,7 +396,7 @@ export default function TableComponent({ ` ), [itemHeight]); const stripBackground = useMemo(() => `url("data:image/svg+xml,${stripSVG}")`, [stripSVG]); - const columnCount = (setRows ? headers.length + 1 : headers.length) + 1; + const columnCount = (setRows ? headerNames.length + 1 : headerNames.length) + 1; const renderValue = (v: any) => ( {v} @@ -406,9 +411,9 @@ export default function TableComponent({ if (index !== undefined) { isActive = expandArr.get(index) === level; } else if (level === undefined) { - isActive = headers.every((_, i) => !expandArr.has(i)); + isActive = headerNames.every((_, i) => !expandArr.has(i)); } else { - isActive = headers.length > 0 && headers.every((_, i) => expandArr.get(i) === level); + isActive = headerNames.length > 0 && headerNames.every((_, i) => expandArr.get(i) === level); } return cn("text-foreground rounded-lg border border-transparent hover:border-border/10 hover:bg-secondary", isActive && "text-primary"); }; @@ -447,10 +452,10 @@ export default function TableComponent({ { setRows ? - + 0 && filteredRows.every(row => row.checked)} onCheckedChange={() => { const checked = filteredRows.every(row => row.checked); @@ -467,9 +472,9 @@ export default function TableComponent({ : null } - +
        -

        Index

        +

        Index

        { isObjectType && <> @@ -478,7 +483,7 @@ export default function TableComponent({ title="Expand Root" onClick={() => { const newExpandArr = new Map(); - headers.forEach((_, idx) => newExpandArr.set(idx, 1)); + headerNames.forEach((_, idx) => newExpandArr.set(idx, 1)); setExpandArr(newExpandArr); if (onExpandChange) onExpandChange(newExpandArr); @@ -491,7 +496,7 @@ export default function TableComponent({ className={getClassName(undefined, -1)} onClick={() => { const newExpandArr = new Map(); - headers.forEach((_, idx) => newExpandArr.set(idx, -1)); + headerNames.forEach((_, idx) => newExpandArr.set(idx, -1)); setExpandArr(newExpandArr); if (onExpandChange) onExpandChange(newExpandArr); @@ -516,17 +521,21 @@ export default function TableComponent({
        { - headers.map((header, i) => ( + normalizedHeaders.map((header, i) => ( 0 ? colMinWidth : undefined }} + style={ + header.width !== undefined + ? { width: header.width, minWidth: header.width, maxWidth: header.width } + : { width: 'fit-content' } + } className={cn( - i + 1 !== headers.length && "border-r", + i + 1 !== headerNames.length && "border-r", "border-border", )} - key={header} + key={header.name} >
        -

        {header}

        +

        {header.name}

        { isObjectType &&
        @@ -604,29 +613,32 @@ export default function TableComponent({ data-testid={`tableRow${rowTestID}`} onMouseEnter={() => setHover(row.name)} onMouseLeave={() => setHover("")} + style={{ height }} key={row.name} > { setRows ? - - { - setRows(rows.map((r) => { - if (r.name === row.name) { - r.checked = !r.checked; - } - return r; - })); - }} - /> + +
        + { + setRows(rows.map((r) => { + if (r.name === row.name) { + r.checked = !r.checked; + } + return r; + })); + }} + /> +
        : null } -

        {actualIndex + 1}.

        +

        {actualIndex + 1}.

        { row.cells.map((cell, j) => { @@ -647,14 +659,14 @@ export default function TableComponent({ return ( - +
        + +
        ); } @@ -669,7 +681,6 @@ export default function TableComponent({ key={cellKey} >
        { @@ -678,7 +689,7 @@ export default function TableComponent({ expandArr.get(j) === -1 || keyPath.length === expandArr.get(j)} - keyPath={[headers[j]]} + keyPath={[headerNames[j]]} valueRenderer={renderValue} labelRenderer={(keyPath) => renderLabel(keyPath)} theme={{ @@ -723,8 +734,7 @@ export default function TableComponent({ : cell.type === "text" && setNewValue(e.target.value)} onKeyDown={async (e) => { @@ -783,7 +793,7 @@ export default function TableComponent({ :
        -

        {cell.value}

        +

        {cell.value}

        {cell.value} diff --git a/app/graph/Chat.tsx b/app/graph/Chat.tsx index 4d6678ca0..42c211091 100644 --- a/app/graph/Chat.tsx +++ b/app/graph/Chat.tsx @@ -4,7 +4,7 @@ import { cn, getTheme, Message } from "@/lib/utils"; import { useContext, useEffect, useRef, useState, useCallback } from "react"; import { useTheme } from "next-themes"; import Image from "next/image"; -import { ChevronDown, ChevronRight, Share2, Copy, Loader2, Play, Search, X, Send, MessagesSquare } from "lucide-react"; +import { ChevronDown, ChevronRight, Share2, Copy, Loader2, Play, Search, X, Send, Sparkles } from "lucide-react"; import { Tooltip as ShadTooltip, TooltipContent as ShadTooltipContent, TooltipTrigger as ShadTooltipTrigger } from "@/components/ui/tooltip"; import { useToast } from "@/components/ui/use-toast"; import { useRouter } from "next/navigation"; @@ -450,7 +450,7 @@ export default function Chat({ onClose }: Props) {

        Chat

        - +
        Use English to query the graph. The feature requires LLM model and API key. Update local user parameters in Settings.
          diff --git a/app/graph/Selector.tsx b/app/graph/Selector.tsx index 95b0040f3..0898dae7e 100644 --- a/app/graph/Selector.tsx +++ b/app/graph/Selector.tsx @@ -4,7 +4,7 @@ import { useEffect, useState, useContext, Dispatch, SetStateAction, useRef, useCallback, useMemo } from "react"; import { cn, GraphRef, formatName, Node, Link, getTheme, Query, HistoryQuery } from "@/lib/utils"; -import { ChevronDown, History, Info, Maximize2, MessagesSquare, Network, Star, Trash2 } from "lucide-react"; +import { ChevronDown, History, Info, Maximize2, Sparkles, Network, Star, Trash2 } from "lucide-react"; import * as monaco from "monaco-editor"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -396,8 +396,7 @@ export default function Selector } - { - historyQuery && graphName && + - } { historyQuery ? <> @@ -750,7 +748,7 @@ export default function Selector prev === "chat" ? undefined : "chat"); }} > - + : selectedElements && handleDeleteElement && setSelectedElements && setIsAddNode && setIsAddEdge && canvasRef && isCanvasLoading !== undefined &&
          diff --git a/app/graph/graphInfo.tsx b/app/graph/graphInfo.tsx index 4aa0bd780..6beb4bd60 100644 --- a/app/graph/graphInfo.tsx +++ b/app/graph/graphInfo.tsx @@ -37,7 +37,7 @@ export default function GraphInfoPanel({ onClose, customizingLabel, setCustomizi useEffect(() => { setPropertyKeysSearch(""); }, [PropertyKeys, maxItemsForSearch]); return ( -
          +
          { !customizingLabel ? ( <> @@ -52,7 +52,7 @@ export default function GraphInfoPanel({ onClose, customizingLabel, setCustomizi

          Graph Info

          -
          +
          setGraphNames(opts as unknown as string[])} @@ -85,43 +85,41 @@ export default function GraphInfoPanel({ onClose, customizingLabel, setCustomizi
          { showMemoryUsage && -
          -
          +

          Memory

          { - MemoryUsage.get("total_graph_sz_mb") !== undefined + MemoryUsage.get("total_graph_sz_mb") !== undefined || graphName === "" ? -

          {MemoryUsage.get("total_graph_sz_mb") || "<1"} MB

          +

          {graphName === "" ? "0" : `${MemoryUsage.get("total_graph_sz_mb") || "<1"} MB`}

          - {MemoryUsage.get("total_graph_sz_mb")} MB + {graphName === "" ? "0" : `${MemoryUsage.get("total_graph_sz_mb") || "<1"} MB`}
          : } -
          } -
          +

          Nodes

          { - nodesCount !== undefined ? + nodesCount !== undefined || graphName === "" ?

          - {nodesCount.toLocaleString()} + {nodesCount?.toLocaleString() || 0}

          - {nodesCount.toLocaleString()} + {nodesCount?.toLocaleString() || 0}
          : @@ -191,25 +189,25 @@ export default function GraphInfoPanel({ onClose, customizingLabel, setCustomizi })}
        -
        +

        Edges

        { - edgesCount !== undefined ? + edgesCount !== undefined || graphName === "" ?

        - {edgesCount.toLocaleString()} + {edgesCount?.toLocaleString() || 0}

        - {edgesCount.toLocaleString()} + {edgesCount?.toLocaleString() || 0}
        : @@ -255,7 +253,7 @@ export default function GraphInfoPanel({ onClose, customizingLabel, setCustomizi })}
      -
      +

      Property Keys

      { diff --git a/app/graph/selectGraph.tsx b/app/graph/selectGraph.tsx index 5a10cea58..a208d7f5a 100644 --- a/app/graph/selectGraph.tsx +++ b/app/graph/selectGraph.tsx @@ -267,7 +267,7 @@ export default function SelectGraph({ options, setOptions, selectedValue, setSel }} hideClose preventOutsideClose={tutorialOpen} - className="flex flex-col border-none rounded-lg max-w-none h-[90dvh] w-[80dvw] p-2" + className="flex flex-col border-none rounded-lg max-w-none h-[90dvh] w-[41dvw] p-2" > Manage Graphs @@ -282,14 +282,14 @@ export default function SelectGraph({ options, setOptions, selectedValue, setSel entityName={type} headers={[ "Name", - ...(showMemoryUsage ? ["Memory Usage"] : []), - "Nodes #", - "Edges #" + ...(showMemoryUsage ? [{name : "Memory Usage", width: "15%"}] : []), + { name: "Nodes #", width: "15%" }, + { name: "Edges #", width: "15%" } ]} rows={rows} setRows={setRows} inputRef={inputRef} - itemHeight={36} + itemHeight={24} > { !isReadOnly && diff --git a/app/login/LoginForm.tsx b/app/login/LoginForm.tsx index 6d73f54e5..dac5c8533 100644 --- a/app/login/LoginForm.tsx +++ b/app/login/LoginForm.tsx @@ -60,7 +60,6 @@ const safeDecode = (value: string): string => { // Parse a URL string and update shared state const parseUrl = (url: string) => { - debugger; const match = matchUrl(url); let parsed: ReturnType; From c961e872da0c185e57c782ea640ff86d37906533 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Thu, 16 Apr 2026 15:26:50 +0300 Subject: [PATCH 040/119] fix: adjust spacing in FormComponent and LoginForm for improved layout --- app/components/FormComponent.tsx | 21 ++++++++++++--------- app/login/LoginForm.tsx | 2 +- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/app/components/FormComponent.tsx b/app/components/FormComponent.tsx index 6ef488ba0..b0ac1167d 100644 --- a/app/components/FormComponent.tsx +++ b/app/components/FormComponent.tsx @@ -111,7 +111,7 @@ export default function FormComponent({ handleSubmit, fields, error = undefined, fields.map((field) => { const passwordType = show[field.label] ? "text" : "password"; return ( -
      +
      { @@ -126,7 +126,7 @@ export default function FormComponent({ handleSubmit, fields, error = undefined, }
      -
      +
      { field.type === "password" &&
      ); }) } {children} -
      +
      {error?.show && (typeof error.message === "string" ?

      {error.message}

      : error?.message)}
      diff --git a/app/login/LoginForm.tsx b/app/login/LoginForm.tsx index dac5c8533..7404e167e 100644 --- a/app/login/LoginForm.tsx +++ b/app/login/LoginForm.tsx @@ -336,7 +336,7 @@ export default function LoginForm() { return (
      -
      +
      {mounted && currentTheme && FalkorDB Browser Logo} {/* Login Mode Toggle */} From e6d3c6d6bf01b8972d1be5ad9d15e45dd3123ec6 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Thu, 16 Apr 2026 15:36:00 +0300 Subject: [PATCH 041/119] fix: update ref types to remove nullability for input and parent refs --- app/components/PaginationList.tsx | 2 +- app/components/TableComponent.tsx | 2 +- app/graph/Selector.tsx | 2 +- app/login/LoginForm.tsx | 2 +- components/ui/table.tsx | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/components/PaginationList.tsx b/app/components/PaginationList.tsx index b4bd0e2ee..003d498f3 100644 --- a/app/components/PaginationList.tsx +++ b/app/components/PaginationList.tsx @@ -128,7 +128,7 @@ interface Props { isDeleteSelected?: (item: T) => boolean onDoubleClick?: (label: string, evt: MouseEvent) => void onToggleFav?: (item: T, name?: string) => void - searchRef: React.RefObject + searchRef: React.RefObject isLoading?: boolean className?: string children?: React.ReactNode diff --git a/app/components/TableComponent.tsx b/app/components/TableComponent.tsx index 8ac33eb29..8e38bb9fa 100644 --- a/app/components/TableComponent.tsx +++ b/app/components/TableComponent.tsx @@ -29,7 +29,7 @@ interface Props { itemHeightExpandMultiple?: number itemWidth?: number valueClassName?: string - inputRef?: React.RefObject, + inputRef?: React.RefObject, children?: React.ReactNode, setRows?: Dispatch>, className?: string diff --git a/app/graph/Selector.tsx b/app/graph/Selector.tsx index e6e20bb1d..ed4a5d63d 100644 --- a/app/graph/Selector.tsx +++ b/app/graph/Selector.tsx @@ -11,7 +11,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useTheme } from "next-themes"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import Button from "../components/ui/Button"; -import { BrowserSettingsContext, GraphContext, IndicatorContext, PanelContext } from "../components/provider"; +import { BrowserSettingsContext, ConnectionContext, GraphContext, IndicatorContext, PanelContext } from "../components/provider"; import { setConnectionItem, removeConnectionItem } from "@/lib/connection-storage"; import CypherEditor, { CYPHER_LANGUAGE_NAME } from "../components/CypherEditor"; import EditorComponent from "../components/EditorComponent"; diff --git a/app/login/LoginForm.tsx b/app/login/LoginForm.tsx index 27df70669..26d426ab6 100644 --- a/app/login/LoginForm.tsx +++ b/app/login/LoginForm.tsx @@ -371,7 +371,7 @@ export default function LoginForm() { setLoginMode(mode); setMissingFields(false); if (mode === "url") { - setRawUrl(buildUrl()); + setRawUrl(buildUrl({})); } clearError(); }} diff --git a/components/ui/table.tsx b/components/ui/table.tsx index f855729cd..4ebe6ef87 100644 --- a/components/ui/table.tsx +++ b/components/ui/table.tsx @@ -5,7 +5,7 @@ import { cn } from "@/lib/utils" interface TableProps extends React.HTMLAttributes { parentClassName?: string - parentRef?: React.RefObject + parentRef?: React.RefObject parentOnScroll?: (e: React.UIEvent) => void } From 3dd5021f45d22907bdc47cec0baeed1f0c96c814 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Thu, 16 Apr 2026 15:39:21 +0300 Subject: [PATCH 042/119] fix: update type definitions for refs in PaginationList, TableComponent, and graph components --- app/components/PaginationList.tsx | 2 +- app/components/TableComponent.tsx | 2 +- app/graph/Selector.tsx | 2 +- app/graph/graphInfo.tsx | 2 +- components/ui/table.tsx | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/components/PaginationList.tsx b/app/components/PaginationList.tsx index 454b55380..13db8806a 100644 --- a/app/components/PaginationList.tsx +++ b/app/components/PaginationList.tsx @@ -127,7 +127,7 @@ interface Props { isSelected: (item: T) => boolean isDeleteSelected?: (item: T) => boolean onToggleFav?: (item: T, name?: string) => void - searchRef: React.RefObject + searchRef: React.RefObject isLoading?: boolean className?: string children?: React.ReactNode diff --git a/app/components/TableComponent.tsx b/app/components/TableComponent.tsx index a7746297c..98a2a4040 100644 --- a/app/components/TableComponent.tsx +++ b/app/components/TableComponent.tsx @@ -31,7 +31,7 @@ interface Props { itemHeightExpandMultiple?: number itemWidth?: number valueClassName?: string - inputRef?: React.RefObject, + inputRef?: React.RefObject, children?: React.ReactNode, setRows?: Dispatch>, className?: string diff --git a/app/graph/Selector.tsx b/app/graph/Selector.tsx index 0898dae7e..68665f4a7 100644 --- a/app/graph/Selector.tsx +++ b/app/graph/Selector.tsx @@ -11,7 +11,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useTheme } from "next-themes"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import Button from "../components/ui/Button"; -import { BrowserSettingsContext, GraphContext, IndicatorContext, ConnectionContext, PanelContext } from "../components/provider"; +import { BrowserSettingsContext, GraphContext, IndicatorContext, PanelContext } from "../components/provider"; import { setConnectionItem, removeConnectionItem } from "@/lib/connection-storage"; import CypherEditor, { CYPHER_LANGUAGE_NAME } from "../components/CypherEditor"; import EditorComponent from "../components/EditorComponent"; diff --git a/app/graph/graphInfo.tsx b/app/graph/graphInfo.tsx index 6beb4bd60..8b6df1821 100644 --- a/app/graph/graphInfo.tsx +++ b/app/graph/graphInfo.tsx @@ -23,7 +23,7 @@ function escapeIdentifier(id: string): string { * @returns The Graph Info panel React element containing graph name, memory usage, node/edge counts, property keys, and query buttons */ export default function GraphInfoPanel({ onClose, customizingLabel, setCustomizingLabel }: { onClose: () => void, customizingLabel: InfoLabel | null, setCustomizingLabel: Dispatch> }) { - const { graphInfo: { Labels, Relationships, PropertyKeys, MemoryUsage }, nodesCount, edgesCount, runQuery, graphName, setGraphName, graphNames, setGraphNames, graph, setGraph } = useContext(GraphContext); + const { graphInfo: { Labels, Relationships, PropertyKeys, MemoryUsage }, nodesCount, edgesCount, runQuery, graphName, setGraphName, graphNames, setGraphNames, setGraph } = useContext(GraphContext); const { isQueryLoading } = useContext(QueryLoadingContext); const { settings: { graphInfo: { showMemoryUsage, maxItemsForSearch } } } = useContext(BrowserSettingsContext); const { isReadOnly } = useContext(ConnectionContext); diff --git a/components/ui/table.tsx b/components/ui/table.tsx index f855729cd..4ebe6ef87 100644 --- a/components/ui/table.tsx +++ b/components/ui/table.tsx @@ -5,7 +5,7 @@ import { cn } from "@/lib/utils" interface TableProps extends React.HTMLAttributes { parentClassName?: string - parentRef?: React.RefObject + parentRef?: React.RefObject parentOnScroll?: (e: React.UIEvent) => void } From 5679ff2831a008dc75cc4e7e39baf7a42f52850b Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Thu, 16 Apr 2026 15:42:56 +0300 Subject: [PATCH 043/119] fix: handle potential null edges in Graph class and improve key generation in TableComponent --- app/api/graph/model.ts | 2 +- app/components/TableComponent.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/api/graph/model.ts b/app/api/graph/model.ts index 59ec0d09b..1dbebc5c9 100644 --- a/app/api/graph/model.ts +++ b/app/api/graph/model.ts @@ -695,7 +695,7 @@ export class Graph { ) ); const edges = await Promise.all( - cell.edges.map((edge: any) => + (cell.edges ?? []).map((edge: any) => this.extendEdge(edge, collapsed, isSchema) ) ); diff --git a/app/components/TableComponent.tsx b/app/components/TableComponent.tsx index 98a2a4040..bbd270dd0 100644 --- a/app/components/TableComponent.tsx +++ b/app/components/TableComponent.tsx @@ -532,7 +532,7 @@ export default function TableComponent({ i + 1 !== headerNames.length && "border-r", "border-border", )} - key={header.name} + key={`${header.name}-${i}`} >

      {header.name}

      From b24a2be8259937255d131eb6b5ffbf47034577e9 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Thu, 16 Apr 2026 15:56:00 +0300 Subject: [PATCH 044/119] fix: improve query handling and UI interactions in various components --- app/graph/Selector.tsx | 4 +++- app/graph/graphInfo.tsx | 2 +- app/login/LoginForm.tsx | 13 +++++-------- components/ui/table.tsx | 4 ++-- e2e/logic/POM/customizeStylePage.ts | 1 + e2e/logic/POM/graphInfoPage.ts | 1 + 6 files changed, 13 insertions(+), 12 deletions(-) diff --git a/app/graph/Selector.tsx b/app/graph/Selector.tsx index ed4a5d63d..5e2c9d9b3 100644 --- a/app/graph/Selector.tsx +++ b/app/graph/Selector.tsx @@ -564,7 +564,9 @@ ${graph.ShowPropertyKeyPrefix !== showPropertyKeyPrefix ? "Rerun the query to ap setTab("text"); try { setIsLoading(true); - await runQuery!(counter.trim()); + if (counter.trim()) { + await runQuery!(counter.trim()); + } setQueriesOpen(false); } finally { setIsLoading(false); diff --git a/app/graph/graphInfo.tsx b/app/graph/graphInfo.tsx index ae2132f07..9cc7b8b04 100644 --- a/app/graph/graphInfo.tsx +++ b/app/graph/graphInfo.tsx @@ -23,7 +23,7 @@ function escapeIdentifier(id: string): string { * @returns The Graph Info panel React element containing graph name, memory usage, node/edge counts, property keys, and query buttons */ export default function GraphInfoPanel({ onClose, customizingLabel, setCustomizingLabel }: { onClose: () => void, customizingLabel: InfoLabel | null, setCustomizingLabel: Dispatch> }) { - const { graphInfo: { Labels, Relationships, PropertyKeys, MemoryUsage }, nodesCount, edgesCount, runQuery, graphName, setGraphName, graphNames, setGraphNames, graph, setGraph } = useContext(GraphContext); + const { graphInfo: { Labels, Relationships, PropertyKeys, MemoryUsage }, nodesCount, edgesCount, runQuery, graphName, setGraphName, graphNames, setGraphNames, setGraph } = useContext(GraphContext); const { isQueryLoading } = useContext(QueryLoadingContext); const { settings: { graphInfo: { showMemoryUsage, maxItemsForSearch } } } = useContext(BrowserSettingsContext); const { isReadOnly } = useContext(ConnectionContext); diff --git a/app/login/LoginForm.tsx b/app/login/LoginForm.tsx index 26d426ab6..c12fe94e2 100644 --- a/app/login/LoginForm.tsx +++ b/app/login/LoginForm.tsx @@ -36,7 +36,7 @@ const getPortErrors = (func?: (value: string) => string) => { return [ { - condition: (value: string) => { console.log(getValue(value)); return getValue(value) !== "" && handlePortIsNumber(getValue(value)) }, + condition: (value: string) => getValue(value) !== "" && handlePortIsNumber(getValue(value)), message: "Port must be a number" }, { @@ -60,7 +60,6 @@ const safeDecode = (value: string): string => { // Parse a URL string and update shared state const parseUrl = (url: string) => { - debugger; const match = matchUrl(url); let parsed: ReturnType; @@ -126,7 +125,7 @@ export default function LoginForm() { value: username, onChange: async (e: React.ChangeEvent) => { setUsername(e.target.value); - setRawUrl(buildUrl({ username: e.target.value })); + setRawUrl(buildUrl({ host, port, username: e.target.value, password, TLS })); clearError(); return true; @@ -141,7 +140,7 @@ export default function LoginForm() { value: password, onChange: async (e: React.ChangeEvent) => { setPassword(e.target.value); - setRawUrl(buildUrl({ password: e.target.value })); + setRawUrl(buildUrl({ host, port, username, password: e.target.value, TLS })); clearError(); return true; @@ -186,7 +185,7 @@ export default function LoginForm() { value: host, onChange: async (e: React.ChangeEvent) => { setHost(e.target.value); - setRawUrl(buildUrl({ host: e.target.value })); + setRawUrl(buildUrl({ host: e.target.value, port, username, password, TLS })); clearError(); return true; @@ -199,7 +198,7 @@ export default function LoginForm() { { value: port, onChange: async (e: React.ChangeEvent) => { - setRawUrl(buildUrl({ port: e.target.value })); + setRawUrl(buildUrl({ host, port: e.target.value, username, password, TLS })); setPort(e.target.value); clearError(); @@ -267,8 +266,6 @@ export default function LoginForm() { const json = await result.json(); - debugger; - if (json.result) { setMissingFields(true); return; diff --git a/components/ui/table.tsx b/components/ui/table.tsx index 4ebe6ef87..61e3567a3 100644 --- a/components/ui/table.tsx +++ b/components/ui/table.tsx @@ -5,7 +5,7 @@ import { cn } from "@/lib/utils" interface TableProps extends React.HTMLAttributes { parentClassName?: string - parentRef?: React.RefObject + parentRef?: React.RefObject parentOnScroll?: (e: React.UIEvent) => void } @@ -13,7 +13,7 @@ const Table = React.forwardRef< HTMLTableElement, TableProps >(({ className, parentClassName, parentRef, parentOnScroll, ...props }, ref) => ( -
      +
      } className={cn("relative w-full overflow-auto", parentClassName)} id="tableContent" onScroll={parentOnScroll}> el.click(), `Graph Info Node Button ${label}` ); + await waitForElementToBeVisible(this.runLabelButton(label)); await interactWhenVisible( this.runLabelButton(label), (el) => el.click(), From ca024ca5abd0da5cf189d78bc819dc2097e9b92a Mon Sep 17 00:00:00 2001 From: Shahar Biron <38566538+shahar-biron@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:32:51 +0300 Subject: [PATCH 045/119] fix: replace unsafe type cast with type-safe filter in extendCell Filter out undefined values from extendNode/extendEdge results instead of force-casting the array, which was masking potential undefined returns. Co-Authored-By: Oz --- app/api/graph/model.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/graph/model.ts b/app/api/graph/model.ts index 1dbebc5c9..0f3f1d816 100644 --- a/app/api/graph/model.ts +++ b/app/api/graph/model.ts @@ -699,7 +699,7 @@ export class Graph { this.extendEdge(edge, collapsed, isSchema) ) ); - return [...nodes, ...edges] as (Node | Link)[]; + return [...nodes, ...edges].filter((el): el is Node | Link => el !== undefined); } if (cell.relationshipType) { From 721748d3c0c8ab8fc4663ea31cc33a17f38677c7 Mon Sep 17 00:00:00 2001 From: Shahar Biron <38566538+shahar-biron@users.noreply.github.com> Date: Thu, 16 Apr 2026 20:06:46 +0300 Subject: [PATCH 046/119] fix: address 5 bugs identified in PR review - LoginForm: buildUrl({}) and buildUrl({TLS}) were missing current state values, causing rawUrl to clear on mode switch and TLS toggle; pass all state fields in both call sites - model: cell.edges not null-coalesced in extendCell, could throw TypeError when a path cell has nodes but no edges; add ?? [] - Navbar: 'p-2' overrode 'py-5' (Tailwind specificity order), reducing vertical padding to 0.5rem; change to 'px-2' - Selector: graph-info toggle gated on graphName, preventing new users from opening the panel to create their first graph; remove graphName guard so the toggle is always visible in Graph mode Co-Authored-By: Oz --- app/api/graph/model.ts | 2 +- app/components/Navbar.tsx | 2 +- app/graph/Selector.tsx | 2 +- app/login/LoginForm.tsx | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/api/graph/model.ts b/app/api/graph/model.ts index 59ec0d09b..1dbebc5c9 100644 --- a/app/api/graph/model.ts +++ b/app/api/graph/model.ts @@ -695,7 +695,7 @@ export class Graph { ) ); const edges = await Promise.all( - cell.edges.map((edge: any) => + (cell.edges ?? []).map((edge: any) => this.extendEdge(edge, collapsed, isSchema) ) ); diff --git a/app/components/Navbar.tsx b/app/components/Navbar.tsx index f033f2569..288c8197a 100644 --- a/app/components/Navbar.tsx +++ b/app/components/Navbar.tsx @@ -48,7 +48,7 @@ export default function Navbar({ showUDF }: Props) { const separator =
      ; return ( -
      +
      { mounted && currentTheme && diff --git a/app/graph/Selector.tsx b/app/graph/Selector.tsx index 5e2c9d9b3..462576b8a 100644 --- a/app/graph/Selector.tsx +++ b/app/graph/Selector.tsx @@ -398,7 +398,7 @@ export default function Selector } { - historyQuery && graphName && + historyQuery && diff --git a/app/login/LoginForm.tsx b/app/login/LoginForm.tsx index 38c482bd0..5606c8496 100644 --- a/app/login/LoginForm.tsx +++ b/app/login/LoginForm.tsx @@ -235,7 +235,7 @@ export default function LoginForm() { // Pre-submit validation for URL mode — show colored format errors if (loginMode === "url") { // Fill in missing parts with defaults (protocol, host, port) - const parsed = parseUrlString(rawUrl); + const parsed = parseUrl(rawUrl); const proto = parsed.protocol || "falkor"; const h = parsed.host || DEFAULT_HOST; const p = parsed.port || DEFAULT_PORT; diff --git a/e2e/logic/POM/graphPage.ts b/e2e/logic/POM/graphPage.ts index 27f489fbc..955dc9a64 100644 --- a/e2e/logic/POM/graphPage.ts +++ b/e2e/logic/POM/graphPage.ts @@ -83,6 +83,10 @@ export default class GraphPage extends Page { return this.page.getByTestId("duplicateGraph"); } + private get graphInfoToggle(): Locator { + return this.page.getByTestId("graphInfoToggle"); + } + private get duplicateGraphInput(): Locator { return this.page.getByTestId("duplicateGraphInput"); } @@ -193,8 +197,17 @@ export default class GraphPage extends Page { } async clickCreateGraph(): Promise { + // Open graph info panel first if createGraph button is not visible + const createBtn = this.create("Graph"); + if (!(await createBtn.isVisible().catch(() => false))) { + await interactWhenVisible( + this.graphInfoToggle, + (el) => el.click(), + "Graph Info Toggle" + ); + } await interactWhenVisible( - this.create("Graph"), + createBtn, (el) => el.click(), "Create Graph" ); @@ -277,6 +290,17 @@ export default class GraphPage extends Page { } async clickSelect(type: Type = "Graph"): Promise { + // For Graph type, the selector is now inside the graph info panel + if (type === "Graph") { + const selectBtn = this.select(type); + if (!(await selectBtn.isVisible().catch(() => false))) { + await interactWhenVisible( + this.graphInfoToggle, + (el) => el.click(), + "Graph Info Toggle" + ); + } + } await interactWhenVisible( this.select(type), (el) => el.click(), From ca107cddf8a22a16ba29dd94aa4e7e1ce5a04f26 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Sun, 19 Apr 2026 16:16:23 +0300 Subject: [PATCH 053/119] fix: remove aria-disabled from graphInfoPanel to unblock E2E tests The aria-disabled attribute on the graphInfoPanel container was set to true when nodesCount or edgesCount were undefined (before any graph is selected). Playwright treats elements inside an aria-disabled ancestor as not enabled, which prevented clicking SelectGraph and CreateGraph buttons during E2E tests. Removed the aria-disabled from the outer container since SelectGraph and CreateGraph need to remain interactive before a graph is loaded. --- app/graph/graphInfo.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/graph/graphInfo.tsx b/app/graph/graphInfo.tsx index 9cc7b8b04..3ae66b874 100644 --- a/app/graph/graphInfo.tsx +++ b/app/graph/graphInfo.tsx @@ -2,7 +2,7 @@ import { Dispatch, SetStateAction, useContext, useEffect, useState } from "react import { Loader2, X, Palette, Play, PlusCircle, Network, Search } from "lucide-react"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Popover, PopoverContent, PopoverTrigger, PopoverClose } from "@/components/ui/popover"; -import { cn, formatName, InfoLabel } from "@/lib/utils"; +import { formatName, InfoLabel } from "@/lib/utils"; import Button from "../components/ui/Button"; import { BrowserSettingsContext, ConnectionContext, GraphContext, QueryLoadingContext } from "../components/provider"; import CustomizeStylePanel from "./CustomizeStylePanel"; @@ -37,7 +37,7 @@ export default function GraphInfoPanel({ onClose, customizingLabel, setCustomizi useEffect(() => { setPropertyKeysSearch(""); }, [PropertyKeys, maxItemsForSearch]); return ( -
      +
      { !customizingLabel ? ( <> From 24df6fa89e9ca2df628f90733083610b6067d3cc Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Sun, 19 Apr 2026 16:32:40 +0300 Subject: [PATCH 054/119] fix: validate original rawUrl for format errors in URL mode login The onSubmit handler was reconstructing the URL before validation, which stripped the '@' from URLs with empty credentials (e.g. redis://@localhost:6379). The reconstructed URL became 'redis://localhost:6379' which passes validation. Now validates the original rawUrl input instead, so format errors like empty credentials with '@' are properly detected and shown to the user. --- app/login/LoginForm.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/login/LoginForm.tsx b/app/login/LoginForm.tsx index 5606c8496..b95df40db 100644 --- a/app/login/LoginForm.tsx +++ b/app/login/LoginForm.tsx @@ -271,7 +271,9 @@ export default function LoginForm() { return; } - const res = validateUrl(url); + // Validate the original rawUrl (not the reconstructed url) so format + // issues like empty credentials with '@' are still detected. + const res = validateUrl(rawUrl || url); const { parts } = res; if (!res.valid) { From 7c823f185649764ea2ef438d9d188f6ca952ab18 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Sun, 19 Apr 2026 17:04:55 +0300 Subject: [PATCH 055/119] fix: use bounding box check for graph info panel expansion in E2E tests react-resizable-panels v4 renders content inside collapsed panels with overflow:visible, making Playwright's isVisible() return true even at 0% width. Replace isVisible() check with boundingBox width check to reliably detect when the panel needs to be expanded before clicking buttons inside it. --- e2e/logic/POM/graphPage.ts | 47 +++++++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/e2e/logic/POM/graphPage.ts b/e2e/logic/POM/graphPage.ts index 955dc9a64..b829d1a75 100644 --- a/e2e/logic/POM/graphPage.ts +++ b/e2e/logic/POM/graphPage.ts @@ -87,6 +87,31 @@ export default class GraphPage extends Page { return this.page.getByTestId("graphInfoToggle"); } + private get graphInfoPanel(): Locator { + return this.page.getByTestId("graphInfoPanel"); + } + + /** + * Ensures the graph info side panel is expanded. + * react-resizable-panels v4 renders content inside collapsed panels + * (overflow: visible on outer div), so Playwright's isVisible() returns + * true even when the panel is at 0% width. We check the panel's actual + * bounding box width instead. + */ + private async ensureGraphInfoPanelOpen(): Promise { + const box = await this.graphInfoPanel.boundingBox().catch(() => null); + if (!box || box.width < 50) { + await interactWhenVisible( + this.graphInfoToggle, + (el) => el.click(), + "Graph Info Toggle" + ); + // Wait for the panel expansion animation to complete + await this.graphInfoPanel.waitFor({ state: "visible" }); + await this.page.waitForTimeout(300); + } + } + private get duplicateGraphInput(): Locator { return this.page.getByTestId("duplicateGraphInput"); } @@ -197,17 +222,9 @@ export default class GraphPage extends Page { } async clickCreateGraph(): Promise { - // Open graph info panel first if createGraph button is not visible - const createBtn = this.create("Graph"); - if (!(await createBtn.isVisible().catch(() => false))) { - await interactWhenVisible( - this.graphInfoToggle, - (el) => el.click(), - "Graph Info Toggle" - ); - } + await this.ensureGraphInfoPanelOpen(); await interactWhenVisible( - createBtn, + this.create("Graph"), (el) => el.click(), "Create Graph" ); @@ -290,16 +307,8 @@ export default class GraphPage extends Page { } async clickSelect(type: Type = "Graph"): Promise { - // For Graph type, the selector is now inside the graph info panel if (type === "Graph") { - const selectBtn = this.select(type); - if (!(await selectBtn.isVisible().catch(() => false))) { - await interactWhenVisible( - this.graphInfoToggle, - (el) => el.click(), - "Graph Info Toggle" - ); - } + await this.ensureGraphInfoPanelOpen(); } await interactWhenVisible( this.select(type), From 114d494998e0f69dea077557b3a4bbb61e9e35d0 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Sun, 19 Apr 2026 17:17:45 +0300 Subject: [PATCH 056/119] fix: dismiss tooltips before clicking editor to prevent pointer intercept After selecting a graph in the side panel, Radix tooltips from the graph info panel can persist over the editor area with z-[9999], blocking Playwright's click. Press Escape before clicking to dismiss any open tooltips. --- e2e/logic/POM/graphPage.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/e2e/logic/POM/graphPage.ts b/e2e/logic/POM/graphPage.ts index b829d1a75..eb11660f7 100644 --- a/e2e/logic/POM/graphPage.ts +++ b/e2e/logic/POM/graphPage.ts @@ -198,6 +198,8 @@ export default class GraphPage extends Page { } async clickEditorInput(): Promise { + // Dismiss any open Radix tooltips that may overlay the editor + await this.page.keyboard.press("Escape"); await interactWhenVisible( this.editorContainer, (el) => el.click(), From ae44e18b522998aed46be64285874850496cc48b Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Sun, 19 Apr 2026 17:29:15 +0300 Subject: [PATCH 057/119] fix: gate chat toggle button on graphName to hide when no graph selected --- app/graph/Selector.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/graph/Selector.tsx b/app/graph/Selector.tsx index 0b06abd86..aaa514dc8 100644 --- a/app/graph/Selector.tsx +++ b/app/graph/Selector.tsx @@ -787,7 +787,9 @@ ${hasPrefixChange ? "Rerun the query to apply the new property key prefix settin
      - + } : selectedElements && handleDeleteElement && setSelectedElements && setIsAddNode && setIsAddEdge && canvasRef && isCanvasLoading !== undefined &&
      Date: Sun, 19 Apr 2026 17:33:54 +0300 Subject: [PATCH 058/119] fix: disable chat toggle button instead of hiding when no graph selected --- app/graph/Selector.tsx | 32 +++++++++++++++----------------- e2e/logic/POM/chatComponent.ts | 4 ++++ e2e/tests/chat.spec.ts | 8 +++++--- 3 files changed, 24 insertions(+), 20 deletions(-) diff --git a/app/graph/Selector.tsx b/app/graph/Selector.tsx index aaa514dc8..ab787c8f4 100644 --- a/app/graph/Selector.tsx +++ b/app/graph/Selector.tsx @@ -787,23 +787,21 @@ ${hasPrefixChange ? "Rerun the query to apply the new property key prefix settin
      - { - graphName && - - } + : selectedElements && handleDeleteElement && setSelectedElements && setIsAddNode && setIsAddEdge && canvasRef && isCanvasLoading !== undefined &&
      { + return this.chatToggleButton.isDisabled(); + } + async isChatPanelVisible(): Promise { return this.chatPanel.isVisible(); } diff --git a/e2e/tests/chat.spec.ts b/e2e/tests/chat.spec.ts index e5befdbf3..a5fdda56b 100644 --- a/e2e/tests/chat.spec.ts +++ b/e2e/tests/chat.spec.ts @@ -20,13 +20,15 @@ test.describe("Chat Feature Tests", () => { await browser.closeBrowser(); }); - test(`@readwrite Verify chat button is not displayed when no graph is selected`, async () => { + test(`@readwrite Verify chat button is disabled when no graph is selected`, async () => { const chat = await browser.createNewPage(ChatComponent, urls.graphUrl); await browser.setPageToFullScreen(); - // Verify chat toggle button is not visible when no graph is selected + // Verify chat toggle button is disabled when no graph is selected const isChatButtonVisible = await chat.isChatToggleButtonVisible(); - expect(isChatButtonVisible).toBe(false); + expect(isChatButtonVisible).toBe(true); + const isChatButtonDisabled = await chat.isChatToggleButtonDisabled(); + expect(isChatButtonDisabled).toBe(true); }); test(`@readwrite Verify chat button is displayed when a graph is selected`, async () => { From 5d20f0261cd73daa6670b394d8447975e9bb858d Mon Sep 17 00:00:00 2001 From: Shahar Biron <38566538+shahar-biron@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:15:30 +0300 Subject: [PATCH 059/119] fix: restore parentRef nullable type to fix TypeScript build error Co-Authored-By: Oz --- components/ui/table.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/ui/table.tsx b/components/ui/table.tsx index 4ebe6ef87..f855729cd 100644 --- a/components/ui/table.tsx +++ b/components/ui/table.tsx @@ -5,7 +5,7 @@ import { cn } from "@/lib/utils" interface TableProps extends React.HTMLAttributes { parentClassName?: string - parentRef?: React.RefObject + parentRef?: React.RefObject parentOnScroll?: (e: React.UIEvent) => void } From 740996ae340b0f3d144314fc6783ac8b1921fc5e Mon Sep 17 00:00:00 2001 From: Shahar Biron <38566538+shahar-biron@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:21:13 +0300 Subject: [PATCH 060/119] fix: address CodeRabbit feedback - panel desync, aria labels, inputRef - Selector.tsx: unify panel toggle logic so panelOpen and panel content are always updated atomically (fixing desync between Graph Info and Chat buttons). Add aria-label and aria-pressed to both icon-only toggle buttons. - TableComponent.tsx: attach inputRef to inline text editor input so focus calls work correctly. Add aria-label to header and row checkboxes. Co-Authored-By: Oz --- app/components/TableComponent.tsx | 11 +++++++---- app/graph/Selector.tsx | 24 ++++++++++++++++++++---- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/app/components/TableComponent.tsx b/app/components/TableComponent.tsx index bbd270dd0..395883ab3 100644 --- a/app/components/TableComponent.tsx +++ b/app/components/TableComponent.tsx @@ -455,6 +455,7 @@ export default function TableComponent({ 0 && filteredRows.every(row => row.checked)} onCheckedChange={() => { @@ -623,6 +624,7 @@ export default function TableComponent({ { setRows(rows.map((r) => { @@ -731,10 +733,11 @@ export default function TableComponent({ label={cell.selectType} selectedValue={cell.value.toString()} /> - : cell.type === "text" && - setNewValue(e.target.value)} onKeyDown={async (e) => { diff --git a/app/graph/Selector.tsx b/app/graph/Selector.tsx index 68665f4a7..67b45d380 100644 --- a/app/graph/Selector.tsx +++ b/app/graph/Selector.tsx @@ -398,13 +398,21 @@ export default function Selector onTogglePanel()} + onClick={() => { + if (panel === "chat") { + setPanel(undefined); + } else { + onTogglePanel(); + } + }} data-testid="graphInfoToggle" > @@ -737,15 +745,23 @@ export default function Selector
      -

      Chat

      +

      Chat

      Use English to query the graph. The feature requires LLM model and API key. Update local user parameters in Settings. diff --git a/app/graph/Selector.tsx b/app/graph/Selector.tsx index 68665f4a7..2334ae891 100644 --- a/app/graph/Selector.tsx +++ b/app/graph/Selector.tsx @@ -744,6 +744,7 @@ export default function Selector { setPanel(prev => prev === "chat" ? undefined : "chat"); }} diff --git a/app/graph/graphInfo.tsx b/app/graph/graphInfo.tsx index 8b6df1821..25a1003e9 100644 --- a/app/graph/graphInfo.tsx +++ b/app/graph/graphInfo.tsx @@ -37,7 +37,7 @@ export default function GraphInfoPanel({ onClose, customizingLabel, setCustomizi useEffect(() => { setPropertyKeysSearch(""); }, [PropertyKeys, maxItemsForSearch]); return ( -
      +
      { !customizingLabel ? ( <> diff --git a/components/ui/table.tsx b/components/ui/table.tsx index 4ebe6ef87..61e3567a3 100644 --- a/components/ui/table.tsx +++ b/components/ui/table.tsx @@ -5,7 +5,7 @@ import { cn } from "@/lib/utils" interface TableProps extends React.HTMLAttributes { parentClassName?: string - parentRef?: React.RefObject + parentRef?: React.RefObject parentOnScroll?: (e: React.UIEvent) => void } @@ -13,7 +13,7 @@ const Table = React.forwardRef< HTMLTableElement, TableProps >(({ className, parentClassName, parentRef, parentOnScroll, ...props }, ref) => ( -
      +
      } className={cn("relative w-full overflow-auto", parentClassName)} id="tableContent" onScroll={parentOnScroll}>
      { + return this.chatToggleButton.isDisabled(); + } + async isChatPanelVisible(): Promise { return this.chatPanel.isVisible(); } diff --git a/e2e/logic/POM/graphPage.ts b/e2e/logic/POM/graphPage.ts index 27f489fbc..754818783 100644 --- a/e2e/logic/POM/graphPage.ts +++ b/e2e/logic/POM/graphPage.ts @@ -91,10 +91,30 @@ export default class GraphPage extends Page { return this.page.getByTestId("duplicateGraphConfirm"); } + private get graphInfoToggle(): Locator { + return this.page.getByTestId("graphInfoToggle"); + } + + private get graphInfoPanel(): Locator { + return this.page.getByTestId("graphInfoPanel"); + } + private get closeHelpMessage(): Locator { return this.page.locator("iframe[title='Close message']"); } + async ensureGraphInfoPanelOpen(): Promise { + const box = await this.graphInfoPanel.boundingBox(); + if (!box || box.width < 50) { + await interactWhenVisible( + this.graphInfoToggle, + (el) => el.click(), + "Graph Info Toggle" + ); + await this.page.waitForTimeout(500); + } + } + async getBoundingBoxCanvasElement(): Promise { + await this.page.keyboard.press("Escape"); await interactWhenVisible( this.editorContainer, (el) => el.click(), @@ -193,6 +214,7 @@ export default class GraphPage extends Page { } async clickCreateGraph(): Promise { + await this.ensureGraphInfoPanelOpen(); await interactWhenVisible( this.create("Graph"), (el) => el.click(), @@ -277,6 +299,9 @@ export default class GraphPage extends Page { } async clickSelect(type: Type = "Graph"): Promise { + if (type === "Graph") { + await this.ensureGraphInfoPanelOpen(); + } await interactWhenVisible( this.select(type), (el) => el.click(), diff --git a/e2e/tests/chat.spec.ts b/e2e/tests/chat.spec.ts index e5befdbf3..ba0210847 100644 --- a/e2e/tests/chat.spec.ts +++ b/e2e/tests/chat.spec.ts @@ -20,13 +20,15 @@ test.describe("Chat Feature Tests", () => { await browser.closeBrowser(); }); - test(`@readwrite Verify chat button is not displayed when no graph is selected`, async () => { + test(`@readwrite Verify chat button is disabled when no graph is selected`, async () => { const chat = await browser.createNewPage(ChatComponent, urls.graphUrl); await browser.setPageToFullScreen(); - // Verify chat toggle button is not visible when no graph is selected + // Verify chat toggle button is visible but disabled when no graph is selected const isChatButtonVisible = await chat.isChatToggleButtonVisible(); - expect(isChatButtonVisible).toBe(false); + expect(isChatButtonVisible).toBe(true); + const isChatButtonDisabled = await chat.isChatToggleButtonDisabled(); + expect(isChatButtonDisabled).toBe(true); }); test(`@readwrite Verify chat button is displayed when a graph is selected`, async () => { From 05bf2f5d31371e56035b6a9deddd627edd0ebb85 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Mon, 20 Apr 2026 10:48:44 +0300 Subject: [PATCH 069/119] fix: clean up button component formatting in Selector --- app/graph/Selector.tsx | 46 +++++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/app/graph/Selector.tsx b/app/graph/Selector.tsx index a390e291c..e252278ad 100644 --- a/app/graph/Selector.tsx +++ b/app/graph/Selector.tsx @@ -796,28 +796,28 @@ ${hasPrefixChange ? "Rerun the query to apply the new property key prefix settin + aria-label="Chat panel" + aria-pressed={panel === "chat" && panelOpen} + data-testid="chatToggleButton" + className={cn( + "text-foreground border border-border rounded-lg p-2 hover:bg-secondary", + panel === "chat" && panelOpen && "!text-primary" + )} + indicator={indicator} + title="Chat" + disabled={!graphName} + onClick={() => { + if (panel === "chat") { + setPanel(undefined); + onTogglePanel(); + } else { + setPanel("chat"); + if (!panelOpen) onTogglePanel(); + } + }} + > + + : selectedElements && handleDeleteElement && setSelectedElements && setIsAddNode && setIsAddEdge && canvasRef && isCanvasLoading !== undefined &&
      "labels" in e) ? setIsAddEdge : undefined} canvasRef={canvasRef} - setExpand={() => {}} + setExpand={() => { }} expand={true} isLoadingSchema={!!isCanvasLoading} isAddNode={isAddNode} From 7d167d0946453628713e31988fb1ca8ac9ae0032 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Mon, 20 Apr 2026 10:50:22 +0300 Subject: [PATCH 070/119] fix: refactor Button component rendering in Selector for cleaner code --- app/graph/Selector.tsx | 43 ++++++++++++++++++++---------------------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/app/graph/Selector.tsx b/app/graph/Selector.tsx index e252278ad..bbbe8c1da 100644 --- a/app/graph/Selector.tsx +++ b/app/graph/Selector.tsx @@ -397,29 +397,26 @@ export default function Selector } - { - historyQuery && - - } + { historyQuery ? <> From 14327be677838df238834be16092293d62ffa5d0 Mon Sep 17 00:00:00 2001 From: Naseem Ali <34807727+Naseem77@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:32:12 +0300 Subject: [PATCH 071/119] security(auth): remove plaintext password from NextAuth session/JWT Store the FalkorDB password encrypted in the Token DB at login and reference it via an opaque credentialRef on the JWT. The password is no longer placed on the JWT, the NextAuth session, or the User type, so GET /api/auth/session no longer exposes it. - authorize(): persist encrypted password via storeEncryptedCredential - jwt/session callbacks: carry credentialRef, drop password - events.signOut: revoke the credential and close cached connection - getClient(): resolve password server-side via getToken + Token DB - tryJWTAuthentication: always resolve password from Token DB, attach to the internal user so URL-builder consumers still work - tokenUtils: add shared storeEncryptedCredential helper - tokens/credentials route: use shared helper (dedupe) - types/next-auth: replace password with credentialRef on User Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/api/auth/[...nextauth]/options.ts | 147 ++++++++++++++++++++--- app/api/auth/tokenUtils.ts | 38 ++++++ app/api/auth/tokens/credentials/route.ts | 38 ++---- types/next-auth.d.ts | 2 +- 4 files changed, 183 insertions(+), 42 deletions(-) diff --git a/app/api/auth/[...nextauth]/options.ts b/app/api/auth/[...nextauth]/options.ts index 097018569..5ffa80059 100644 --- a/app/api/auth/[...nextauth]/options.ts +++ b/app/api/auth/[...nextauth]/options.ts @@ -4,8 +4,14 @@ import { NextResponse } from "next/server"; import { FalkorDB, type FalkorDBOptions } from "falkordb"; import { v4 as uuidv4 } from "uuid"; import crypto from "crypto"; +import { getToken } from "next-auth/jwt"; +import StorageFactory from "@/lib/token-storage/StorageFactory"; import { getCorsHeaders } from "../../utils"; -import { isTokenActive } from "../tokenUtils"; +import { + isTokenActive, + getPasswordFromTokenDB, + storeEncryptedCredential, +} from "../tokenUtils"; interface CustomJWTPayload { sub: string; @@ -28,6 +34,11 @@ interface AuthenticatedUser { tls: boolean; ca?: string; url?: string; + credentialRef?: string; +} + +interface AuthenticatedUserWithPassword extends AuthenticatedUser { + password?: string; } const connections = new Map(); @@ -208,7 +219,7 @@ function createUserFromJWTPayload(payload: CustomJWTPayload): AuthenticatedUser /** * Attempts JWT authentication and returns client and user if successful */ -async function tryJWTAuthentication(): Promise<{ client: FalkorDB; user: AuthenticatedUser } | null> { +async function tryJWTAuthentication(): Promise<{ client: FalkorDB; user: AuthenticatedUserWithPassword } | null> { // Try to get authorization header const authorizationHeader = await getAuthorizationHeader(); @@ -243,6 +254,18 @@ async function tryJWTAuthentication(): Promise<{ client: FalkorDB; user: Authent return null; } + // Resolve password server-side from Token DB (never from the JWT payload). + let password: string | undefined; + try { + password = await getPasswordFromTokenDB(payload.jti); + } catch (pwErr) { + if (pwErr instanceof Error && pwErr.message.includes("ENCRYPTION_KEY")) { + throw pwErr; + } + // eslint-disable-next-line no-console + console.warn("Failed to resolve JWT credential from Token DB:", pwErr); + } + // Try to reuse existing connection (performance optimization) let client = connections.get(payload.sub); @@ -253,7 +276,10 @@ async function tryJWTAuthentication(): Promise<{ client: FalkorDB; user: Authent await connection.ping(); // Connection is healthy, reuse it - const user = createUserFromJWTPayload(payload); + const user: AuthenticatedUserWithPassword = { + ...createUserFromJWTPayload(payload), + password, + }; return { client, user }; } catch (pingError) { // Connection is dead, remove from pool and recreate @@ -271,11 +297,11 @@ async function tryJWTAuthentication(): Promise<{ client: FalkorDB; user: Authent } } - // No existing connection or health check failed - fetch password from Token DB and reconnect + // No existing connection or health check failed - reconnect with decrypted password try { - // Fetch password from Token DB (6380) - NOT from JWT - const { getPasswordFromTokenDB } = await import('../tokenUtils'); - const password = await getPasswordFromTokenDB(payload.jti); + if (!password) { + throw new Error("No password available to re-establish connection"); + } // Create new connection with retrieved password const { client: reconnectedClient } = await newClient( @@ -305,7 +331,10 @@ async function tryJWTAuthentication(): Promise<{ client: FalkorDB; user: Authent } // At this point, client is guaranteed to be defined (either reused or recreated) - const user = createUserFromJWTPayload(payload); + const user: AuthenticatedUserWithPassword = { + ...createUserFromJWTPayload(payload), + password, + }; return { client, user }; } catch (error) { @@ -344,12 +373,50 @@ const authOptions: AuthOptions = { const { role } = await newClient(credentials, id); + // Persist the password encrypted in the Token DB and keep only an + // opaque credentialRef in the JWT. The password itself never enters + // the JWT, the NextAuth session, or any client-visible payload. + let credentialRef: string | undefined; + if (credentials.password) { + try { + credentialRef = generateTimeUUID(); + const tokenHash = crypto + .createHash("sha256") + .update(`session:${credentialRef}`) + .digest("hex"); + + await storeEncryptedCredential({ + tokenHash, + tokenId: credentialRef, + userId: id, + username: credentials.username || "default", + name: `session:${id}`, + role, + host: credentials.host || "localhost", + port: credentials.port ? parseInt(credentials.port, 10) : 6379, + password: credentials.password, + }); + } catch (storageError) { + // eslint-disable-next-line no-console + console.error( + "Failed to persist session credential; aborting login:", + storageError + ); + const conn = connections.get(id); + if (conn) { + connections.delete(id); + try { await conn.close(); } catch { /* ignore */ } + } + return null; + } + } + const res: User = { id, url: credentials.url, host: credentials.host || "localhost", port: credentials.port ? parseInt(credentials.port, 10) : 6379, - password: credentials.password, + credentialRef, username: credentials.username, tls: credentials.tls === "true", ca: credentials.url ? undefined : credentials.ca, @@ -372,7 +439,7 @@ const authOptions: AuthOptions = { id: user.id, host: user.host, port: user.port, - password: user.password, + credentialRef: user.credentialRef, username: user.username, tls: user.tls, ca: user.ca, @@ -396,7 +463,6 @@ const authOptions: AuthOptions = { host: token.host as string, port: parseInt(token.port as string, 10), username: token.username as string, - password: token.password as string, tls: token.tls as boolean, ca: token.ca, role: token.role as Role, @@ -407,9 +473,40 @@ const authOptions: AuthOptions = { return session; }, }, + events: { + async signOut({ token }) { + const t = token as Record | null; + const credentialRef = t?.credentialRef as string | undefined; + const id = t?.id as string | undefined; + const username = (t?.username as string | undefined) || "default"; + + if (credentialRef) { + try { + const storage = StorageFactory.getStorage(); + await storage.revokeToken(credentialRef, username); + } catch (e) { + // eslint-disable-next-line no-console + console.warn("Failed to revoke session credential on signOut:", e); + } + } + + if (id) { + const conn = connections.get(id); + if (conn) { + connections.delete(id); + try { await conn.close(); } catch { /* ignore */ } + } + } + }, + }, }; -export async function getClient(request: Request) { +export async function getClient( + request: Request +): Promise< + | NextResponse + | { client: FalkorDB; user: AuthenticatedUserWithPassword } +> { // Check if this is a JWT-only request (from /docs) const jwtOnlyRequired = await isJWTOnlyRequest(); @@ -436,6 +533,26 @@ export async function getClient(request: Request) { const { user } = session; + // Resolve the password server-side from the Token DB via the JWT's + // credentialRef. The password is never stored in the session/JWT payload. + let password: string | undefined; + try { + const jwt = await getToken({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + req: request as any, + secret: process.env.NEXTAUTH_SECRET, + }); + const credentialRef = jwt?.credentialRef as string | undefined; + if (credentialRef) { + password = await getPasswordFromTokenDB(credentialRef); + } + } catch (err) { + // eslint-disable-next-line no-console + console.warn("Failed to resolve session credential from Token DB:", err); + } + + const userWithPassword: AuthenticatedUserWithPassword = { ...user, password }; + let connection = connections.get(id); // Health check: if connection exists, verify it's still alive @@ -445,7 +562,7 @@ export async function getClient(request: Request) { await conn.ping(); // Connection is healthy, reuse it - return { client: connection, user }; + return { client: connection, user: userWithPassword }; } catch (pingError) { // Connection is dead, remove from pool and recreate // eslint-disable-next-line no-console @@ -468,7 +585,7 @@ export async function getClient(request: Request) { host: user.host, port: (user.port || 6379).toString(), username: user.username, - password: user.password, + password, tls: String(user.tls), ca: user.ca, url: user.url, @@ -476,7 +593,7 @@ export async function getClient(request: Request) { user.id ); - return { client, user }; + return { client, user: userWithPassword }; } export default authOptions; diff --git a/app/api/auth/tokenUtils.ts b/app/api/auth/tokenUtils.ts index ae3ae3c79..b0f37fdaf 100644 --- a/app/api/auth/tokenUtils.ts +++ b/app/api/auth/tokenUtils.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { jwtVerify } from "jose"; import crypto from "crypto"; import StorageFactory from "@/lib/token-storage/StorageFactory"; +import { encrypt } from "./encryption"; /** * Validates JWT secret exists in environment @@ -103,3 +104,40 @@ export async function getPasswordFromTokenDB(tokenId: string): Promise { throw new Error(`Failed to retrieve password for token: ${tokenId}`); } } + +/** + * Persist an encrypted credential entry in the Token DB. + * Shared helper used by the NextAuth session flow (bound to a session credentialRef) + * and the personal-access-token flows (bound to a JWT). + */ +export async function storeEncryptedCredential(params: { + tokenHash: string; + tokenId: string; + userId: string; + username: string; + name: string; + role: string; + host: string; + port: number; + password: string; + expiresAtUnix?: number; +}): Promise { + const storage = StorageFactory.getStorage(); + const nowUnix = Math.floor(Date.now() / 1000); + + await storage.createToken({ + token_hash: params.tokenHash, + token_id: params.tokenId, + user_id: params.userId, + username: params.username, + name: params.name, + role: params.role, + host: params.host, + port: params.port, + created_at: nowUnix, + expires_at: params.expiresAtUnix ?? -1, + last_used: -1, + is_active: true, + encrypted_password: encrypt(params.password), + }); +} diff --git a/app/api/auth/tokens/credentials/route.ts b/app/api/auth/tokens/credentials/route.ts index c756e2b49..b2db07a34 100644 --- a/app/api/auth/tokens/credentials/route.ts +++ b/app/api/auth/tokens/credentials/route.ts @@ -2,9 +2,8 @@ import { NextRequest, NextResponse } from "next/server"; // eslint-disable-next-line import/no-extraneous-dependencies import { SignJWT } from "jose"; import crypto from "crypto"; -import StorageFactory from "@/lib/token-storage/StorageFactory"; import { newClient, generateTimeUUID } from "../../[...nextauth]/options"; -import { encrypt } from "../../encryption"; +import { storeEncryptedCredential } from "../../tokenUtils"; import { login, validateBody } from "../../../validate-body"; // Typed shape for the validated login request body @@ -163,35 +162,22 @@ export async function POST(request: NextRequest) { const token = await signer.sign(jwtSecret); - // 7. Encrypt password and store token using storage abstraction + // 7. Encrypt password and store token using shared helper try { - const storage = StorageFactory.getStorage(); - - const encryptedPassword = encrypt(userPassword); const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); - const nowUnix = Math.floor(Date.now() / 1000); const expiresAtUnix = expiresAtDate ? Math.floor(expiresAtDate.getTime() / 1000) : -1; - // Normalize host and port with defaults - const tokenUsername = authenticatedUser.username || "default"; - const tokenHost = authenticatedUser.host || "localhost"; - const tokenPort = authenticatedUser.port || 6379; - const { role: tokenRole } = authenticatedUser; - - await storage.createToken({ - token_hash: tokenHash, - token_id: tokenId, - user_id: authenticatedUser.id, - username: tokenUsername, + await storeEncryptedCredential({ + tokenHash, + tokenId, + userId: authenticatedUser.id, + username: authenticatedUser.username || "default", name, - role: tokenRole, - host: tokenHost, - port: tokenPort, - created_at: nowUnix, - expires_at: expiresAtUnix, - last_used: -1, - is_active: true, - encrypted_password: encryptedPassword, + role: authenticatedUser.role, + host: authenticatedUser.host || "localhost", + port: authenticatedUser.port || 6379, + password: userPassword, + expiresAtUnix, }); // eslint-disable-next-line no-console diff --git a/types/next-auth.d.ts b/types/next-auth.d.ts index d3a81dcb8..ad26477f7 100644 --- a/types/next-auth.d.ts +++ b/types/next-auth.d.ts @@ -11,7 +11,7 @@ declare module "next-auth" { url: string; ca?: string; username?: string; - password?: string; + credentialRef?: string; } interface Session { From e9b7591bf104cabe3c7a2b9b1e662a16bd93779e Mon Sep 17 00:00:00 2001 From: Naseem Ali <34807727+Naseem77@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:41:46 +0300 Subject: [PATCH 072/119] refactor(auth): dedupe token DB write via storeEncryptedCredential POST /api/auth/tokens now uses the shared helper and reads the password from getClient()'s server-side resolved user, instead of the previous (user as any).password cast against the session. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/api/auth/tokens/route.ts | 40 ++++++++++++------------------------ 1 file changed, 13 insertions(+), 27 deletions(-) diff --git a/app/api/auth/tokens/route.ts b/app/api/auth/tokens/route.ts index 31070a429..80c805203 100644 --- a/app/api/auth/tokens/route.ts +++ b/app/api/auth/tokens/route.ts @@ -4,7 +4,7 @@ import { SignJWT } from "jose"; import crypto from "crypto"; import StorageFactory from "@/lib/token-storage/StorageFactory"; import { getClient, generateTimeUUID } from "../[...nextauth]/options"; -import { encrypt } from "../encryption"; +import { storeEncryptedCredential } from "../tokenUtils"; import { getCorsHeaders } from "../../utils"; export async function OPTIONS(request: Request) { @@ -200,37 +200,23 @@ export async function POST(request: NextRequest) { const token = await signer.sign(jwtSecret); - // 7. Store token using storage abstraction + // 7. Store token using shared helper try { - const storage = StorageFactory.getStorage(); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const password = (user as any).password || ''; - const encryptedPassword = encrypt(password); - + const password = user.password || ''; const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); - const nowUnix = Math.floor(Date.now() / 1000); const expiresAtUnix = expiresAtDate ? Math.floor(expiresAtDate.getTime() / 1000) : -1; - const username = user.username || "default"; - const host = user.host || "localhost"; - const port = user.port || 6379; - const role = user.role || "Unknown"; - - await storage.createToken({ - token_hash: tokenHash, - token_id: tokenId, - user_id: user.id, - username, + await storeEncryptedCredential({ + tokenHash, + tokenId, + userId: user.id, + username: user.username || "default", name, - role, - host, - port, - created_at: nowUnix, - expires_at: expiresAtUnix, - last_used: -1, - is_active: true, - encrypted_password: encryptedPassword, + role: user.role || "Unknown", + host: user.host || "localhost", + port: user.port || 6379, + password, + expiresAtUnix, }); } catch (storageError) { // eslint-disable-next-line no-console From 20e3a489c01e0680c8eb085fd5897d2077647c71 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 08:53:06 +0000 Subject: [PATCH 073/119] chore(deps-dev): bump eslint in the npm-minor-patch group Bumps the npm-minor-patch group with 1 update: [eslint](https://github.com/eslint/eslint). Updates `eslint` from 10.2.0 to 10.2.1 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/compare/v10.2.0...v10.2.1) --- updated-dependencies: - dependency-name: eslint dependency-version: 10.2.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm-minor-patch ... Signed-off-by: dependabot[bot] --- package-lock.json | 16 ++++++++-------- package.json | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 472aa72cd..91e7f8df8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -89,7 +89,7 @@ "@types/swagger-ui-react": "^5.18.0", "@typescript-eslint/eslint-plugin": "^8.58.2", "@typescript-eslint/parser": "^8.58.1", - "eslint": "^10.1.0", + "eslint": "^10.2.1", "eslint-config-airbnb": "^19.0.4", "eslint-config-airbnb-typescript": "^18.0.0", "eslint-config-next": "^16.2.4", @@ -7143,18 +7143,18 @@ } }, "node_modules/eslint": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.0.tgz", - "integrity": "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==", + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.1.tgz", + "integrity": "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.4", - "@eslint/config-helpers": "^0.5.4", - "@eslint/core": "^1.2.0", - "@eslint/plugin-kit": "^0.7.0", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.5.5", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", diff --git a/package.json b/package.json index 74a1ab255..37f4928e6 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,7 @@ "@types/swagger-ui-react": "^5.18.0", "@typescript-eslint/eslint-plugin": "^8.58.2", "@typescript-eslint/parser": "^8.58.1", - "eslint": "^10.1.0", + "eslint": "^10.2.1", "eslint-config-airbnb": "^19.0.4", "eslint-config-airbnb-typescript": "^18.0.0", "eslint-config-next": "^16.2.4", From 9dde26adc2b82f4f215a148b05c34b9b7eb98011 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 08:53:32 +0000 Subject: [PATCH 074/119] chore(deps): bump uuid from 13.0.0 to 14.0.0 Bumps [uuid](https://github.com/uuidjs/uuid) from 13.0.0 to 14.0.0. - [Release notes](https://github.com/uuidjs/uuid/releases) - [Changelog](https://github.com/uuidjs/uuid/blob/main/CHANGELOG.md) - [Commits](https://github.com/uuidjs/uuid/compare/v13.0.0...v14.0.0) --- updated-dependencies: - dependency-name: uuid dependency-version: 14.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 472aa72cd..134caf531 100644 --- a/package-lock.json +++ b/package-lock.json @@ -71,7 +71,7 @@ "swr": "^2.4.1", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.2", - "uuid": "^13.0.0", + "uuid": "^14.0.0", "vaul": "^1.1.2", "yaml": "^2.8.3", "zod": "^4.3.6" @@ -12886,9 +12886,9 @@ } }, "node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" diff --git a/package.json b/package.json index 74a1ab255..af4ef25e4 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,7 @@ "swr": "^2.4.1", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.2", - "uuid": "^13.0.0", + "uuid": "^14.0.0", "vaul": "^1.1.2", "yaml": "^2.8.3", "zod": "^4.3.6" From 6012ec47f8f681e426c0eb8598fb01a6190d0783 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 08:54:08 +0000 Subject: [PATCH 075/119] chore(deps-dev): bump eslint-plugin-react-hooks from 7.0.1 to 7.1.1 Bumps [eslint-plugin-react-hooks](https://github.com/facebook/react/tree/HEAD/packages/eslint-plugin-react-hooks) from 7.0.1 to 7.1.1. - [Release notes](https://github.com/facebook/react/releases) - [Changelog](https://github.com/facebook/react/blob/main/packages/eslint-plugin-react-hooks/CHANGELOG.md) - [Commits](https://github.com/facebook/react/commits/eslint-plugin-react-hooks@7.1.1/packages/eslint-plugin-react-hooks) --- updated-dependencies: - dependency-name: eslint-plugin-react-hooks dependency-version: 7.1.1 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- package-lock.json | 10 +++++----- package.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 472aa72cd..b02b17e9c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -97,7 +97,7 @@ "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.4", - "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-hooks": "^7.1.1", "playwright": "^1.59.1", "typescript": "^5.8.3" }, @@ -7597,9 +7597,9 @@ } }, "node_modules/eslint-plugin-react-hooks": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", - "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", "dev": true, "license": "MIT", "dependencies": { @@ -7613,7 +7613,7 @@ "node": ">=18" }, "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "node_modules/eslint-plugin-react/node_modules/balanced-match": { diff --git a/package.json b/package.json index 74a1ab255..5cc2576a1 100644 --- a/package.json +++ b/package.json @@ -104,7 +104,7 @@ "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.4", - "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-hooks": "^7.1.1", "playwright": "^1.59.1", "typescript": "^5.8.3" }, From 087472737edaa9d70f26b0a2ad90e6230654df62 Mon Sep 17 00:00:00 2001 From: Naseem Ali <34807727+Naseem77@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:57:02 +0300 Subject: [PATCH 076/119] security(auth): fail closed on credential-resolution failures, persist TTL Address review feedback: - getClient/tryJWTAuthentication: when the session/JWT carries a credentialRef/jti but the Token DB lookup fails, return 401 / null instead of continuing with an undefined password. This prevents unusable downstream state (bad reconnects, empty-password PATs). - tokens/route.ts, tokens/credentials/route.ts: persist the TTL-derived expiration (expirationTime) in the Token DB instead of only the expiresAt date. Previously ttlSeconds tokens were recorded as never-expiring even though the JWT expired. - tokens/route.ts: document why an empty password at store time corresponds to a legitimate no-auth FalkorDB setup (not a resolution failure, since getClient now fails closed on those). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/api/auth/[...nextauth]/options.ts | 37 +++++++++++++++++++----- app/api/auth/tokens/credentials/route.ts | 2 +- app/api/auth/tokens/route.ts | 8 +++-- 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/app/api/auth/[...nextauth]/options.ts b/app/api/auth/[...nextauth]/options.ts index 5ffa80059..420ef8be9 100644 --- a/app/api/auth/[...nextauth]/options.ts +++ b/app/api/auth/[...nextauth]/options.ts @@ -255,7 +255,11 @@ async function tryJWTAuthentication(): Promise<{ client: FalkorDB; user: Authent } // Resolve password server-side from Token DB (never from the JWT payload). - let password: string | undefined; + // Fail closed: if the jti is known but password resolution fails, the + // session is unusable downstream (reconnect, URL building, PAT issuance), + // so we refuse the request rather than returning a partially-authenticated + // user. + let password: string; try { password = await getPasswordFromTokenDB(payload.jti); } catch (pwErr) { @@ -264,6 +268,7 @@ async function tryJWTAuthentication(): Promise<{ client: FalkorDB; user: Authent } // eslint-disable-next-line no-console console.warn("Failed to resolve JWT credential from Token DB:", pwErr); + return null; } // Try to reuse existing connection (performance optimization) @@ -299,10 +304,6 @@ async function tryJWTAuthentication(): Promise<{ client: FalkorDB; user: Authent // No existing connection or health check failed - reconnect with decrypted password try { - if (!password) { - throw new Error("No password available to re-establish connection"); - } - // Create new connection with retrieved password const { client: reconnectedClient } = await newClient( { @@ -535,6 +536,10 @@ export async function getClient( // Resolve the password server-side from the Token DB via the JWT's // credentialRef. The password is never stored in the session/JWT payload. + // Fail closed: if the session was minted with a credentialRef but we cannot + // resolve it, refuse the request instead of continuing with an undefined + // password (which would break chat URL building and could mint empty- + // password PATs). let password: string | undefined; try { const jwt = await getToken({ @@ -544,11 +549,29 @@ export async function getClient( }); const credentialRef = jwt?.credentialRef as string | undefined; if (credentialRef) { - password = await getPasswordFromTokenDB(credentialRef); + try { + password = await getPasswordFromTokenDB(credentialRef); + } catch (pwErr) { + if (pwErr instanceof Error && pwErr.message.includes("ENCRYPTION_KEY")) { + throw pwErr; + } + // eslint-disable-next-line no-console + console.warn("Failed to resolve session credential from Token DB:", pwErr); + return NextResponse.json( + { message: "Session credential could not be resolved; please sign in again." }, + { status: 401, headers: getCorsHeaders(request) } + ); + } } } catch (err) { + if (err instanceof Error && err.message.includes("ENCRYPTION_KEY")) { + return NextResponse.json( + { message: "Server configuration error" }, + { status: 500, headers: getCorsHeaders(request) } + ); + } // eslint-disable-next-line no-console - console.warn("Failed to resolve session credential from Token DB:", err); + console.warn("Failed to read JWT for session credential lookup:", err); } const userWithPassword: AuthenticatedUserWithPassword = { ...user, password }; diff --git a/app/api/auth/tokens/credentials/route.ts b/app/api/auth/tokens/credentials/route.ts index b2db07a34..62d137b31 100644 --- a/app/api/auth/tokens/credentials/route.ts +++ b/app/api/auth/tokens/credentials/route.ts @@ -165,7 +165,7 @@ export async function POST(request: NextRequest) { // 7. Encrypt password and store token using shared helper try { const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); - const expiresAtUnix = expiresAtDate ? Math.floor(expiresAtDate.getTime() / 1000) : -1; + const expiresAtUnix = expirationTime ?? -1; await storeEncryptedCredential({ tokenHash, diff --git a/app/api/auth/tokens/route.ts b/app/api/auth/tokens/route.ts index 80c805203..9157215d2 100644 --- a/app/api/auth/tokens/route.ts +++ b/app/api/auth/tokens/route.ts @@ -202,9 +202,13 @@ export async function POST(request: NextRequest) { // 7. Store token using shared helper try { - const password = user.password || ''; + // At this point getClient() has either (a) resolved the password from + // the Token DB successfully, or (b) confirmed the session has no + // credentialRef (no-auth FalkorDB). An empty password here therefore + // corresponds to a legitimate no-auth setup, not a resolution failure. + const password = user.password ?? ''; const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); - const expiresAtUnix = expiresAtDate ? Math.floor(expiresAtDate.getTime() / 1000) : -1; + const expiresAtUnix = expirationTime ?? -1; await storeEncryptedCredential({ tokenHash, From 69ed104ae5397162f55c9e2020f6956a322ddc3a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:05:38 +0000 Subject: [PATCH 077/119] chore(deps-dev): bump typescript from 5.9.3 to 6.0.3 Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 6.0.3. - [Release notes](https://github.com/microsoft/TypeScript/releases) - [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.3) --- updated-dependencies: - dependency-name: typescript dependency-version: 6.0.3 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9b5f42a07..bcac5564e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -99,7 +99,7 @@ "eslint-plugin-react": "^7.37.4", "eslint-plugin-react-hooks": "^7.1.1", "playwright": "^1.59.1", - "typescript": "^5.8.3" + "typescript": "^6.0.3" }, "engines": { "node": ">=20.9.0" @@ -12679,9 +12679,9 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/package.json b/package.json index aac09adc4..0b5c5f935 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,7 @@ "eslint-plugin-react": "^7.37.4", "eslint-plugin-react-hooks": "^7.1.1", "playwright": "^1.59.1", - "typescript": "^5.8.3" + "typescript": "^6.0.3" }, "overrides": { "lodash": "^4.18.0", From fd225ff2cb01e5a7c941785c350d97ee916b8375 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Mon, 20 Apr 2026 13:15:00 +0300 Subject: [PATCH 078/119] feat: enhance chat functionality and UI improvements across components --- app/components/CypherEditor.tsx | 19 ++++- app/graph/Selector.tsx | 132 +++++++++++++++----------------- app/graph/graphInfo.tsx | 10 +-- app/graph/page.tsx | 36 ++++----- app/graph/selectGraph.tsx | 6 +- app/graph/toolbar.tsx | 20 ++--- app/providers.tsx | 4 +- lib/utils.ts | 2 +- next.config.js | 1 + 9 files changed, 117 insertions(+), 113 deletions(-) diff --git a/app/components/CypherEditor.tsx b/app/components/CypherEditor.tsx index 59e77b9aa..4fe2b80b1 100644 --- a/app/components/CypherEditor.tsx +++ b/app/components/CypherEditor.tsx @@ -7,7 +7,7 @@ import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/compone import { Monaco } from "@monaco-editor/react"; import { SetStateAction, Dispatch, useEffect, useRef, useState, useContext, useMemo, useCallback } from "react"; import * as monaco from "monaco-editor"; -import { Minimize2, X } from "lucide-react"; +import { Info, Maximize2, Minimize2, X } from "lucide-react"; import { useToast } from "@/components/ui/use-toast"; import { cn, HistoryQuery, prepareArg, securedFetch } from "@/lib/utils"; import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; @@ -348,7 +348,7 @@ export default function CypherEditor({ graph, graphName, historyQuery, maximize, detail: "(udf function)" })) ) - , [udfList]); + , [udfList]); const getAllSuggestions = useCallback(async (): Promise => { const remoteSuggestions = graphIdRef.current ? await getRemoteSuggestions() : []; @@ -647,6 +647,21 @@ export default function CypherEditor({ graph, graphName, historyQuery, maximize, } + + - { - graphName && !isReadOnly && - - } - { - (() => { - const hasLimitWarning = graph.CurrentLimit && graph.Data.length >= graph.CurrentLimit; - const hasLimitChangeWarning = graph.CurrentLimit && lastLimit !== limit; - const hasPrefixChange = graph.ShowPropertyKeyPrefix !== showPropertyKeyPrefix; - return (hasLimitWarning || hasLimitChangeWarning || hasPrefixChange) ? ( - - ) : null; - })() - } - {separator} +
      - {separator} - + { + (() => { + const hasLimitWarning = graph.CurrentLimit && graph.Data.length >= graph.CurrentLimit; + const hasLimitChangeWarning = graph.CurrentLimit && lastLimit !== limit; + const hasPrefixChange = graph.ShowPropertyKeyPrefix !== showPropertyKeyPrefix; + const hasWarning = hasLimitWarning || hasLimitChangeWarning || hasPrefixChange; + const showInfo = graphName && !isReadOnly; + + if (!showInfo && !hasWarning) return null; + + return ( + <> + {separator} + + + + + +
      + { + showInfo && ( +
      +

      Select And Show Properties (Right Click)

      +

      Select Multiple Entities (Right Click + Left Ctrl)

      +

      Select 2 Nodes to Create Edge

      +
      + ) + } + { + hasWarning && ( +
      + {hasLimitWarning &&

      Data currently limited to {graph.Data.length} rows

      } + {hasLimitChangeWarning &&

      Rerun the query to apply the new limit.

      } + {hasPrefixChange &&

      Rerun the query to apply the new property key prefix settings.

      } +
      + ) + } +
      +
      +
      + + ); + })() + }
      : selectedElements && handleDeleteElement && setSelectedElements && setIsAddNode && setIsAddEdge && canvasRef && isCanvasLoading !== undefined &&
      diff --git a/app/graph/graphInfo.tsx b/app/graph/graphInfo.tsx index 595681c97..f657a7348 100644 --- a/app/graph/graphInfo.tsx +++ b/app/graph/graphInfo.tsx @@ -37,7 +37,7 @@ export default function GraphInfoPanel({ onClose, customizingLabel, setCustomizi useEffect(() => { setPropertyKeysSearch(""); }, [PropertyKeys, maxItemsForSearch]); return ( -
      +
      { !customizingLabel ? ( <> @@ -52,7 +52,7 @@ export default function GraphInfoPanel({ onClose, customizingLabel, setCustomizi

      Graph Info

      -
      +
      setGraphNames(opts as unknown as string[])} @@ -105,8 +105,8 @@ export default function GraphInfoPanel({ onClose, customizingLabel, setCustomizi

      Nodes

      { - nodesCount !== undefined ? - + nodesCount !== undefined || graphName === "" ? +

      Edges

      { - edgesCount !== undefined ? + edgesCount !== undefined || graphName === "" ?

      (null); const [selectedElements, setSelectedElements] = useState<(Node | Link)[]>([]); + const [chatOpen, setChatOpen] = useState(false); const [isCollapsed, setIsCollapsed] = useState(true); const [isAddNode, setIsAddNode] = useState(false); const [isAddEdge, setIsAddEdge] = useState(false); @@ -94,7 +95,6 @@ export default function Page() { const panelSizes: Record = { data: { size: "200px", min: "200px" }, add: { size: "30%", min: "25%" }, - chat: { size: "35%", min: "30%" }, }; const getPanelSize = useCallback(() => { @@ -123,12 +123,6 @@ export default function Page() { } currentPanel.collapse(); - if (panel !== "chat") return; - - setSelectedElements([]); - setIsAddNode(false); - setIsAddEdge(false); - }, [getPanelSize, panel]); useEffect(() => { @@ -238,18 +232,15 @@ export default function Page() { return "data"; } - if (prev !== "chat") { - return undefined; - } - - return prev; + return undefined; }); if (el.length !== 0) { + setChatOpen(false); setIsAddEdge(false); setIsAddNode(false); } - }, [setPanel]); + }, [setPanel, setChatOpen]); useEffect(() => { handleSetSelectedElements(); @@ -371,13 +362,6 @@ export default function Page() { if (!graphName) return undefined; switch (panel) { - case "chat": - return ( - setPanel(undefined)} - /> - ); - case "data": if (selectedElements.length === 0) return undefined; @@ -436,8 +420,10 @@ export default function Page() { setHistoryQuery={setHistoryQuery} fetchCount={fetchCount} isQueryLoading={isQueryLoading} + chatOpen={chatOpen} + setChatOpen={setChatOpen} /> - + isCollapsed && handleSetSelectedElements()} - className={cn("ml-2", isCollapsed && "hidden")} + className={cn("bg-transparent", isCollapsed && "hidden")} disabled={isCollapsed} /> {getCurrentPanel()} + { + chatOpen && graphName && +

      + setChatOpen(false)} /> +
      + }
      ); diff --git a/app/graph/selectGraph.tsx b/app/graph/selectGraph.tsx index a208d7f5a..1ee794411 100644 --- a/app/graph/selectGraph.tsx +++ b/app/graph/selectGraph.tsx @@ -215,7 +215,7 @@ export default function SelectGraph({ options, setOptions, selectedValue, setSel diff --git a/app/graph/toolbar.tsx b/app/graph/toolbar.tsx index b793c30ca..f9d68dc6e 100644 --- a/app/graph/toolbar.tsx +++ b/app/graph/toolbar.tsx @@ -6,7 +6,9 @@ import { Graph } from "../api/graph/model"; import Input from "../components/ui/Input"; import Button from "../components/ui/Button"; import DeleteElement from "./DeleteElement"; -import { ConnectionContext, GraphContext } from "../components/provider"; +import { BrowserSettingsContext, ConnectionContext, GraphContext } from "../components/provider"; +import { getNodeDisplayText } from "@falkordb/canvas"; +import BrowserSettings from "../settings/browserSettings"; interface Props { graph: Graph @@ -47,6 +49,7 @@ export default function Toolbar({ }: Props) { const { isLoading: isLoadingGraph } = useContext(GraphContext); + const { settings: { captionsKeysSettings: { captionsKeys }, showPropertyKeyPrefixSettings: { showPropertyKeyPrefix} } } = useContext(BrowserSettingsContext); const { isReadOnly } = useContext(ConnectionContext); @@ -150,7 +153,7 @@ export default function Toolbar({ setSearchElement(e.target.value)} onKeyDown={(e) => { @@ -190,7 +193,7 @@ export default function Toolbar({ } { expand && suggestions.length > 0 && -
      +
        handleSearchElement(suggestion)} onMouseEnter={() => setSuggestionIndex(actualIndex)} >
        -

        {type ? (suggestion as Link).relationship : (suggestion as Node).labels[0]}

        -
        + /> +

        {type ? (suggestion as Link).relationship : (suggestion as Node).labels[0]}

        - {suggestion.data.name || suggestion.id} + {type ? suggestion.relationship : getNodeDisplayText(suggestion as Node, captionsKeys, showPropertyKeyPrefix)}
        diff --git a/app/providers.tsx b/app/providers.tsx index 068018efe..2be83727e 100644 --- a/app/providers.tsx +++ b/app/providers.tsx @@ -758,7 +758,7 @@ function ProvidersWithSession({ children }: { children: React.ReactNode }) { let rafId: number | undefined; - if ((pathname === "/graph" && graphName) || pathname === "/udf") { + if (pathname === "/graph" || pathname === "/udf") { if (currentPanel.isCollapsed()) currentPanel.expand(); } else if (!currentPanel.isCollapsed()) { // Defer collapse to next frame so the collapsible prop change @@ -771,7 +771,7 @@ function ProvidersWithSession({ children }: { children: React.ReactNode }) { return () => { if (rafId !== undefined) cancelAnimationFrame(rafId); }; - }, [graphName, pathname]); + }, [pathname]); const checkStatus = useCallback(() => { securedFetch("/api/status", { diff --git a/lib/utils.ts b/lib/utils.ts index 4a2560ec3..1e2442756 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -147,7 +147,7 @@ export interface Relationship extends Omit { export type GraphRef = MutableRefObject; -export type Panel = "chat" | "data" | "add" | undefined; +export type Panel = "data" | "add" | undefined; export type SelectCell = { value: string; diff --git a/next.config.js b/next.config.js index 89517db7a..d4f738212 100644 --- a/next.config.js +++ b/next.config.js @@ -1,5 +1,6 @@ /** @type {import('next').NextConfig} */ const nextConfig = { + allowedDevOrigins: ['127.0.0.1', '0.0.0.0'], output: 'standalone', reactStrictMode: true, // Keep falkordb server-only to avoid bundling BigInt in client/runtime From a660216ef9288f340159c80e6f4748f496a9bb04 Mon Sep 17 00:00:00 2001 From: Anchel123 <110421452+Anchel123@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:18:29 +0300 Subject: [PATCH 079/119] Potential fix for pull request finding 'Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- app/graph/Selector.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/graph/Selector.tsx b/app/graph/Selector.tsx index cb0e200de..792da7368 100644 --- a/app/graph/Selector.tsx +++ b/app/graph/Selector.tsx @@ -4,7 +4,7 @@ import { useEffect, useState, useContext, Dispatch, SetStateAction, useRef, useCallback, useMemo } from "react"; import { cn, GraphRef, formatName, Node, Link, getTheme, Query, HistoryQuery } from "@/lib/utils"; -import { ChevronDown, History, Info, Maximize2, MessagesSquare, Network, Sparkles, Star, Trash2 } from "lucide-react"; +import { ChevronDown, History, Info, Network, Sparkles, Star, Trash2 } from "lucide-react"; import * as monaco from "monaco-editor"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; From b736771bcd59700bbd7799dbfadd96e46b8e6279 Mon Sep 17 00:00:00 2001 From: Anchel123 <110421452+Anchel123@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:19:00 +0300 Subject: [PATCH 080/119] Potential fix for pull request finding 'Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- app/graph/toolbar.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/app/graph/toolbar.tsx b/app/graph/toolbar.tsx index f9d68dc6e..feada7934 100644 --- a/app/graph/toolbar.tsx +++ b/app/graph/toolbar.tsx @@ -8,7 +8,6 @@ import Button from "../components/ui/Button"; import DeleteElement from "./DeleteElement"; import { BrowserSettingsContext, ConnectionContext, GraphContext } from "../components/provider"; import { getNodeDisplayText } from "@falkordb/canvas"; -import BrowserSettings from "../settings/browserSettings"; interface Props { graph: Graph From 0947399067db3efcaf39ed48d7625573c0e082a6 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Mon, 20 Apr 2026 13:29:08 +0300 Subject: [PATCH 081/119] fix: add tooltip for keyboard shortcuts in CypherEditor and update graph info panel test description --- app/components/CypherEditor.tsx | 21 ++++++++++++-------- app/graph/Selector.tsx | 35 ++++++++++++++++++--------------- e2e/tests/graphInfo.spec.ts | 9 ++++----- 3 files changed, 36 insertions(+), 29 deletions(-) diff --git a/app/components/CypherEditor.tsx b/app/components/CypherEditor.tsx index 4fe2b80b1..31daaae90 100644 --- a/app/components/CypherEditor.tsx +++ b/app/components/CypherEditor.tsx @@ -4,6 +4,7 @@ "use client"; import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Monaco } from "@monaco-editor/react"; import { SetStateAction, Dispatch, useEffect, useRef, useState, useContext, useMemo, useCallback } from "react"; import * as monaco from "monaco-editor"; @@ -654,14 +655,18 @@ export default function CypherEditor({ graph, graphName, historyQuery, maximize, > - + + + + + + + + + {"Run (Enter) | History (Arrow Up/Down) | Insert new line (Shift + Enter)"} + + + + { + type === "Graph" && + + } { historyQuery ? <> diff --git a/e2e/tests/graphInfo.spec.ts b/e2e/tests/graphInfo.spec.ts index 4f89b5b61..b80ad75d1 100644 --- a/e2e/tests/graphInfo.spec.ts +++ b/e2e/tests/graphInfo.spec.ts @@ -209,17 +209,16 @@ test.describe("Graph Info Panel Tests", () => { await apiCall.removeGraph(graphName, "admin"); }); - test(`@readwrite Validate graph info panel is not visible when no graph is selected`, async () => { + test(`@readwrite Validate graph info panel shows zero counts when no graph is selected`, async () => { const graphName1 = getRandomString("graph"); const graphName2 = getRandomString("graph"); await apiCall.addGraph(graphName1); await apiCall.addGraph(graphName2); const graph = await browser.createNewPage(GraphInfoPage, urls.graphUrl); await browser.setPageToFullScreen(); - // When no graph is selected, getting nodes count should fail - await expect(async () => { - await graph.getGraphInfoNodesCount(); - }).rejects.toThrow(); + // Panel is open by default but shows zero counts when no graph is selected + const nodesCount = await graph.getGraphInfoNodesCount(); + expect(nodesCount).toBe("0"); await apiCall.removeGraph(graphName1); await apiCall.removeGraph(graphName2); }); From 5cd06ea6dbb84fb7f0d9fbbd461eba86aef3f80a Mon Sep 17 00:00:00 2001 From: Naseem Ali <34807727+Naseem77@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:29:21 +0300 Subject: [PATCH 082/119] feat(auth): hard-delete session credentials on signOut; distinguish session vs PAT Session credentials are ephemeral caches of a user's FalkorDB password and carry no audit value after the session ends. Soft-revoking them grows the Token DB indefinitely and leaks them into the PAT listing. - Add TokenKind ('session' | 'pat') on TokenData; default 'pat' for backward compatibility with rows written before this field existed. - ITokenStorage: add deleteToken(tokenId) for hard removal. - FileTokenStorage + FalkorDBTokenStorage: implement deleteToken, persist kind on create, filter fetchTokens to kind='pat' (session rows never appear in the PAT UI/API). - storeEncryptedCredential: accept and forward kind (defaults to 'pat'). - authorize(): mark session rows with kind='session'. - events.signOut: hard-delete the session row via deleteToken instead of soft-revoking it. PATs continue to use revokeToken for audit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/api/auth/[...nextauth]/options.ts | 9 ++++--- app/api/auth/tokenUtils.ts | 2 ++ lib/token-storage/FalkorDBTokenStorage.ts | 29 ++++++++++++++++++++--- lib/token-storage/FileTokenStorage.ts | 15 ++++++++++-- lib/token-storage/ITokenStorage.ts | 20 ++++++++++++++++ 5 files changed, 67 insertions(+), 8 deletions(-) diff --git a/app/api/auth/[...nextauth]/options.ts b/app/api/auth/[...nextauth]/options.ts index 420ef8be9..82b69aaa4 100644 --- a/app/api/auth/[...nextauth]/options.ts +++ b/app/api/auth/[...nextauth]/options.ts @@ -396,6 +396,7 @@ const authOptions: AuthOptions = { host: credentials.host || "localhost", port: credentials.port ? parseInt(credentials.port, 10) : 6379, password: credentials.password, + kind: 'session', }); } catch (storageError) { // eslint-disable-next-line no-console @@ -479,15 +480,17 @@ const authOptions: AuthOptions = { const t = token as Record | null; const credentialRef = t?.credentialRef as string | undefined; const id = t?.id as string | undefined; - const username = (t?.username as string | undefined) || "default"; + // Session credentials are ephemeral and have no audit value beyond + // the session itself, so we hard-delete the Token DB row on sign-out + // rather than soft-revoking it (which is the PAT behavior). if (credentialRef) { try { const storage = StorageFactory.getStorage(); - await storage.revokeToken(credentialRef, username); + await storage.deleteToken(credentialRef); } catch (e) { // eslint-disable-next-line no-console - console.warn("Failed to revoke session credential on signOut:", e); + console.warn("Failed to delete session credential on signOut:", e); } } diff --git a/app/api/auth/tokenUtils.ts b/app/api/auth/tokenUtils.ts index b0f37fdaf..381b9e251 100644 --- a/app/api/auth/tokenUtils.ts +++ b/app/api/auth/tokenUtils.ts @@ -121,6 +121,7 @@ export async function storeEncryptedCredential(params: { port: number; password: string; expiresAtUnix?: number; + kind?: 'session' | 'pat'; }): Promise { const storage = StorageFactory.getStorage(); const nowUnix = Math.floor(Date.now() / 1000); @@ -139,5 +140,6 @@ export async function storeEncryptedCredential(params: { last_used: -1, is_active: true, encrypted_password: encrypt(params.password), + kind: params.kind ?? 'pat', }); } diff --git a/lib/token-storage/FalkorDBTokenStorage.ts b/lib/token-storage/FalkorDBTokenStorage.ts index 69120db33..7f05694f6 100644 --- a/lib/token-storage/FalkorDBTokenStorage.ts +++ b/lib/token-storage/FalkorDBTokenStorage.ts @@ -12,6 +12,7 @@ class FalkorDBTokenStorage implements ITokenStorage { } async createToken(tokenData: TokenData): Promise { + const kind = tokenData.kind ?? 'pat'; const query = ` MERGE (u:User {username: '${this.escapeString(tokenData.username)}', user_id: '${this.escapeString(tokenData.user_id)}'}) CREATE (t:Token { @@ -27,7 +28,8 @@ class FalkorDBTokenStorage implements ITokenStorage { expires_at: ${tokenData.expires_at}, last_used: ${tokenData.last_used}, is_active: ${tokenData.is_active}, - encrypted_password: '${this.escapeString(tokenData.encrypted_password)}' + encrypted_password: '${this.escapeString(tokenData.encrypted_password)}', + kind: '${this.escapeString(kind)}' }) CREATE (t)-[:BELONGS_TO]->(u) RETURN t.token_id as token_id @@ -42,9 +44,14 @@ class FalkorDBTokenStorage implements ITokenStorage { ? "" : `AND t.username = '${this.escapeString(options.username || '')}' AND t.host = '${this.escapeString(options.host || 'localhost')}' AND t.port = ${options.port || 6379}`; + // Only PAT rows are surfaced in the tokens listing. Session rows are + // internal (rows missing a kind property are treated as 'pat' for + // backward compatibility with pre-existing data). const query = ` MATCH (t:Token)-[:BELONGS_TO]->(u:User) - WHERE t.is_active = true ${userFilter} + WHERE t.is_active = true + AND (t.kind IS NULL OR t.kind = 'pat') + ${userFilter} RETURN t.token_hash as token_hash, t.token_id as token_id, t.user_id as user_id, @@ -57,7 +64,8 @@ class FalkorDBTokenStorage implements ITokenStorage { t.expires_at as expires_at, t.last_used as last_used, t.is_active as is_active, - t.encrypted_password as encrypted_password + t.encrypted_password as encrypted_password, + t.kind as kind ORDER BY t.created_at DESC `; @@ -78,6 +86,7 @@ class FalkorDBTokenStorage implements ITokenStorage { last_used: row.last_used, is_active: row.is_active, encrypted_password: row.encrypted_password, + kind: row.kind ?? 'pat', })); } @@ -140,6 +149,20 @@ class FalkorDBTokenStorage implements ITokenStorage { return !!(result.data && result.data.length > 0); } + async deleteToken(tokenId: string): Promise { + const query = ` + MATCH (t:Token {token_id: '${this.escapeString(tokenId)}'}) + DETACH DELETE t + RETURN count(t) as deleted + `; + + const result = await executePATQuery(query); + if (!result.data || result.data.length === 0) return false; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const deleted = (result.data[0] as any).deleted || 0; + return deleted > 0; + } + async updateLastUsed(tokenId: string): Promise { const nowUnix = Math.floor(Date.now() / 1000); diff --git a/lib/token-storage/FileTokenStorage.ts b/lib/token-storage/FileTokenStorage.ts index fc19b29aa..c18b50bc7 100644 --- a/lib/token-storage/FileTokenStorage.ts +++ b/lib/token-storage/FileTokenStorage.ts @@ -83,8 +83,9 @@ class FileTokenStorage implements ITokenStorage { async fetchTokens(options: TokenFetchOptions): Promise { const tokens = await this.readTokens(); - // Filter active tokens only - let filtered = tokens.filter(t => t.is_active); + // Only PAT rows are surfaced in the tokens listing. Session rows are + // internal and must never appear in the UI or API surface. + let filtered = tokens.filter(t => t.is_active && (t.kind ?? 'pat') === 'pat'); // Apply role-based filtering if (!options.isAdmin) { @@ -119,6 +120,16 @@ class FileTokenStorage implements ITokenStorage { return true; } + async deleteToken(tokenId: string): Promise { + const tokens = await this.readTokens(); + const next = tokens.filter(t => t.token_id !== tokenId); + if (next.length === tokens.length) { + return false; + } + await this.writeTokens(next); + return true; + } + async updateLastUsed(tokenId: string): Promise { const tokens = await this.readTokens(); const token = tokens.find(t => t.token_id === tokenId); diff --git a/lib/token-storage/ITokenStorage.ts b/lib/token-storage/ITokenStorage.ts index 62baad2f7..b482e57ac 100644 --- a/lib/token-storage/ITokenStorage.ts +++ b/lib/token-storage/ITokenStorage.ts @@ -1,3 +1,10 @@ +/** + * Distinguishes rows created for a NextAuth browser session (ephemeral, + * hard-deleted on sign-out) from user-issued personal access tokens + * (long-lived, soft-revoked with audit trail). + */ +export type TokenKind = 'session' | 'pat'; + /** * Token data structure */ @@ -15,6 +22,12 @@ export interface TokenData { last_used: number; // Unix timestamp, -1 means never used is_active: boolean; encrypted_password: string; + /** + * Discriminates between session credentials and PATs. Optional for + * backward compatibility with rows written before this field existed + * (treated as 'pat' by readers). + */ + kind?: TokenKind; } /** @@ -52,6 +65,13 @@ export interface ITokenStorage { */ revokeToken(tokenId: string, revokerUsername: string): Promise; + /** + * Permanently delete a token. Use this for ephemeral session credentials + * that have no audit value once the session ends. For user-issued PATs + * prefer revokeToken to preserve the REVOKED_BY trail. + */ + deleteToken(tokenId: string): Promise; + /** * Update last used timestamp for a token */ From bcd1e6b6aaad9dff412d27a8e55e91b22ea06780 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Mon, 20 Apr 2026 14:00:04 +0300 Subject: [PATCH 083/119] fix: conditionally apply server identity check based on environment variable --- app/api/auth/[...nextauth]/options.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/auth/[...nextauth]/options.ts b/app/api/auth/[...nextauth]/options.ts index 097018569..6791b234d 100644 --- a/app/api/auth/[...nextauth]/options.ts +++ b/app/api/auth/[...nextauth]/options.ts @@ -60,7 +60,7 @@ export async function newClient( host: credentials.host ?? "localhost", port: credentials.port ? parseInt(credentials.port, 10) : 6379, tls: credentials.tls === "true", - checkServerIdentity: () => undefined, + ...(process.env.ServerIdentityCheck === "true" ? { checkServerIdentity: () => undefined } : {}), ca: !credentials.ca || credentials.ca === "undefined" ? undefined From c23db61fc17d5e88d7793d76ba9dfba6d7327316 Mon Sep 17 00:00:00 2001 From: Naseem Ali <34807727+Naseem77@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:05:24 +0300 Subject: [PATCH 084/119] feat(auth): auto-signOut on orphaned session via SESSION_INVALID discriminator Server tags the session-credential-resolution 401 in getClient with an 'X-Session-Invalid: 1' header and a 'code: SESSION_INVALID' body so the client can distinguish an orphaned session from unrelated 401s (e.g. wrong login credentials). securedFetch now detects that header and triggers signOut({ callbackUrl: '/login' }), guarded by an in-flight flag so concurrent failing requests don't fire multiple signOuts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/api/auth/[...nextauth]/options.ts | 10 ++++++++-- lib/utils.ts | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/app/api/auth/[...nextauth]/options.ts b/app/api/auth/[...nextauth]/options.ts index 82b69aaa4..8da17697c 100644 --- a/app/api/auth/[...nextauth]/options.ts +++ b/app/api/auth/[...nextauth]/options.ts @@ -561,8 +561,14 @@ export async function getClient( // eslint-disable-next-line no-console console.warn("Failed to resolve session credential from Token DB:", pwErr); return NextResponse.json( - { message: "Session credential could not be resolved; please sign in again." }, - { status: 401, headers: getCorsHeaders(request) } + { message: "Session credential could not be resolved; please sign in again.", code: "SESSION_INVALID" }, + { + status: 401, + headers: { + ...getCorsHeaders(request), + "X-Session-Invalid": "1", + }, + } ); } } diff --git a/lib/utils.ts b/lib/utils.ts index 4a2560ec3..6faa6f501 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -8,6 +8,7 @@ import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; import { MutableRefObject } from "react"; import type { FalkorDBCanvas } from "@falkordb/canvas"; +import { signOut } from "next-auth/react"; export type ToastArguments = { title: string; @@ -272,6 +273,10 @@ export async function getSSEGraphResult( }); } +// Guards against triggering multiple concurrent signOut calls when many +// in-flight requests hit a newly-invalidated session at the same time. +let sessionInvalidationInFlight = false; + export async function securedFetch( input: string, init: RequestInit, @@ -280,6 +285,21 @@ export async function securedFetch( ): Promise { const response = await fetch(input, init); const { status } = response; + + // The server signals "your session is orphaned, sign out now" via this + // header. We only sign out on this explicit signal so that ordinary 401s + // (e.g. login form with wrong password) don't log out unrelated users. + if (status === 401 && response.headers.get("X-Session-Invalid") === "1") { + if (!sessionInvalidationInFlight) { + sessionInvalidationInFlight = true; + signOut({ callbackUrl: "/login" }).catch(() => { + sessionInvalidationInFlight = false; + }); + } + setIndicator("offline"); + return response; + } + if (status >= 300) { let message = await response.text(); From 2b571438a6098f4d74b56eeb2961e333ca156574 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Mon, 20 Apr 2026 15:26:35 +0300 Subject: [PATCH 085/119] feat(users): enhance user management with key permissions and save functionality --- app/api/swagger/swagger-spec.ts | 85 ++++++++++++++--- app/api/user/[user]/route.ts | 14 ++- app/api/user/model.ts | 12 ++- app/api/user/route.ts | 8 +- app/api/user/save/route.ts | 40 ++++++++ app/api/validate-body.ts | 13 ++- app/components/FormComponent.tsx | 30 ++++-- app/settings/users/AddUser.tsx | 21 ++++- app/settings/users/EditUser.tsx | 156 +++++++++++++++++++++++++++++++ app/settings/users/Users.tsx | 143 ++++++++++++++-------------- 10 files changed, 415 insertions(+), 107 deletions(-) create mode 100644 app/api/user/save/route.ts create mode 100644 app/settings/users/EditUser.tsx diff --git a/app/api/swagger/swagger-spec.ts b/app/api/swagger/swagger-spec.ts index 618018b84..ad6c59d24 100644 --- a/app/api/swagger/swagger-spec.ts +++ b/app/api/swagger/swagger-spec.ts @@ -2362,6 +2362,11 @@ const swaggerSpec = { selected: { type: "boolean", example: false + }, + keys: { + type: "string", + description: "Key permissions pattern for accessible keys", + example: "*" } } } @@ -2406,6 +2411,11 @@ const swaggerSpec = { enum: ["Admin", "Read-Write", "Read-Only"], description: "Role to assign to the user", example: "Read-Write" + }, + keys: { + type: "string", + description: "Key permissions pattern for accessible keys (defaults to * if omitted)", + example: "*" } }, required: ["username", "password", "role"] @@ -2511,11 +2521,43 @@ const swaggerSpec = { } } }, + "/api/user/save": { + post: { + tags: ["Users"], + summary: "Save users to disk", + description: "Persist the current ACL user configuration to disk using Redis ACL SAVE", + security: [{ bearerAuth: [] }], + responses: { + "200": { + description: "ACL saved to disk successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { + type: "string", + example: "ACL saved to disk" + } + } + } + } + } + }, + "400": { + description: "Bad request" + }, + "500": { + description: "Internal server error" + } + } + } + }, "/api/user/{user}": { patch: { tags: ["Users"], - summary: "Update user role", - description: "Update the role of a FalkorDB user", + summary: "Update user", + description: "Update the role, key permissions, and optionally the password of a FalkorDB user", security: [{ bearerAuth: [] }], parameters: [ { @@ -2526,21 +2568,38 @@ const swaggerSpec = { type: "string" }, description: "Username to update" - }, - { - in: "query", - name: "role", - required: true, - schema: { - type: "string", - enum: ["Admin", "Read-Write", "Read-Only"] - }, - description: "New role for the user" } ], + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + role: { + type: "string", + enum: ["Admin", "Read-Write", "Read-Only"], + description: "New role for the user" + }, + keys: { + type: "string", + description: "Key permissions pattern for accessible keys (defaults to * if omitted)", + example: "*" + }, + password: { + type: "string", + description: "New password for the user (optional, omit to keep current password)" + } + }, + required: ["role"] + } + } + } + }, responses: { "200": { - description: "User role updated successfully" + description: "User updated successfully" } } } diff --git a/app/api/user/[user]/route.ts b/app/api/user/[user]/route.ts index cbd303c2f..f9b2a6b1d 100644 --- a/app/api/user/[user]/route.ts +++ b/app/api/user/[user]/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getClient } from "../../auth/[...nextauth]/options"; -import { ROLE } from "../model"; -import { updateUserRole, validateBody } from "../../validate-body"; +import { ROLE, getRoleWithKeys } from "../model"; +import { updateUser, validateBody } from "../../validate-body"; import { getCorsHeaders } from "../../utils"; export async function OPTIONS(request: Request) { @@ -28,7 +28,7 @@ export async function PATCH( const body = await request.json(); // Validate request body - const validation = validateBody(updateUserRole, body); + const validation = validateBody(updateUser, body); if (!validation.success) { return NextResponse.json( @@ -37,11 +37,15 @@ export async function PATCH( ); } - const { role: roleKey } = validation.data; + const { role: roleKey, keys, password } = validation.data; const role = ROLE.get(roleKey); if (!role) throw new Error("Invalid role"); - await (await client.connection).aclSetUser(username, role); + const finalRole = getRoleWithKeys(role, keys); + if (password) { + finalRole.push(`>${password}`); + } + await (await client.connection).aclSetUser(username, finalRole); return NextResponse.json({ message: "User role updated" }, { status: 200, headers: getCorsHeaders(request) }); } catch (error) { console.error(error); diff --git a/app/api/user/model.ts b/app/api/user/model.ts index 3aebc0f86..fbb1c48c0 100644 --- a/app/api/user/model.ts +++ b/app/api/user/model.ts @@ -11,7 +11,6 @@ export interface CreateUser { const READ_ONLY_ROLE = [ "on", - "~*", "resetchannels", "-@all", "+graph.explain", @@ -29,8 +28,12 @@ const READ_ONLY_ROLE = [ "+expiretime", ]; +export function getRoleWithKeys(role: string[], keys?: string): string[] { + return [role[0], `~${keys || "*"}`, ...role.slice(1)]; +} + export const ROLE = new Map([ - ["Admin", ["on", "~*", "&*", "+@all"]], + ["Admin", ["on", "&*", "+@all"]], [ "Read-Write", [ @@ -50,3 +53,8 @@ export const ROLE = new Map([ ], ["Read-Only", READ_ONLY_ROLE], ]); + +export function extractKeysFromACL(userDetails: string[]): string { + const keyPattern = userDetails.find((part) => part.startsWith("~")); + return keyPattern ? keyPattern.slice(1) : "*"; +} diff --git a/app/api/user/route.ts b/app/api/user/route.ts index 548bdcc86..790da3d4a 100644 --- a/app/api/user/route.ts +++ b/app/api/user/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getClient } from "@/app/api/auth/[...nextauth]/options"; -import { User, ROLE } from "./model"; +import { User, ROLE, extractKeysFromACL, getRoleWithKeys } from "./model"; import { createUser, deleteUsers, validateBody } from "../validate-body"; import { getCorsHeaders } from "../utils"; @@ -39,6 +39,7 @@ export async function GET(request: Request) { return { username: userDetails[1], role: role ? role[0] : "Unknown", + keys: extractKeysFromACL(userDetails), selected: false, }; }); @@ -84,7 +85,7 @@ export async function POST(request: NextRequest) { ); } - const { username, password, role } = validation.data; + const { username, password, role, keys } = validation.data; const roleValue = ROLE.get(role); if (!roleValue) throw new Error("Invalid role"); @@ -102,7 +103,8 @@ export async function POST(request: NextRequest) { // Just a workaround for https://github.com/redis/node-redis/issues/2745 } - await connection.aclSetUser(username, roleValue.concat(`>${password}`)); + const finalRole = getRoleWithKeys(roleValue, keys); + await connection.aclSetUser(username, finalRole.concat(`>${password}`)); return NextResponse.json( { message: "Success" }, { diff --git a/app/api/user/save/route.ts b/app/api/user/save/route.ts new file mode 100644 index 000000000..bbee19709 --- /dev/null +++ b/app/api/user/save/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from "next/server"; +import { getClient } from "../../auth/[...nextauth]/options"; +import { getCorsHeaders } from "../../utils"; + +export async function OPTIONS(request: Request) { + return new NextResponse(null, { status: 204, headers: getCorsHeaders(request) }); +} + +// eslint-disable-next-line import/prefer-default-export +export async function POST(request: Request) { + try { + const session = await getClient(request); + + if (session instanceof NextResponse) { + return session; + } + + const { client } = session; + + try { + await (await client.connection).aclSave(); + return NextResponse.json( + { message: "ACL saved to disk" }, + { status: 200, headers: getCorsHeaders(request) } + ); + } catch (error) { + console.error(error); + return NextResponse.json( + { message: (error as Error).message }, + { status: 400, headers: getCorsHeaders(request) } + ); + } + } catch (err) { + console.error(err); + return NextResponse.json( + { message: (err as Error).message }, + { status: 500, headers: getCorsHeaders(request) } + ); + } +} diff --git a/app/api/validate-body.ts b/app/api/validate-body.ts index 1673ad5d1..5f024fe9a 100644 --- a/app/api/validate-body.ts +++ b/app/api/validate-body.ts @@ -16,6 +16,10 @@ export const createUser = z.object({ error: (issue) => issue.input === undefined ? "Role is required" : "Invalid Role", }) .min(1, "Role cannot be empty"), + keys: z + .string() + .optional() + .default("*"), }); export const deleteUsers = z.object({ @@ -28,12 +32,19 @@ export const deleteUsers = z.object({ .min(1, "At least one user is required"), }); -export const updateUserRole = z.object({ +export const updateUser = z.object({ role: z .string({ error: (issue) => issue.input === undefined ? "Role is required" : "Invalid Role", }) .min(1, "Role cannot be empty"), + keys: z + .string() + .optional() + .default("*"), + password: z + .string() + .optional(), }); // Schema (graph schema) schemas diff --git a/app/components/FormComponent.tsx b/app/components/FormComponent.tsx index b0ac1167d..5494af1ca 100644 --- a/app/components/FormComponent.tsx +++ b/app/components/FormComponent.tsx @@ -4,7 +4,7 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { EyeIcon, EyeOffIcon, InfoIcon } from "lucide-react"; +import { EyeIcon, EyeOffIcon, ExternalLink, InfoIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import Button from "./ui/Button"; @@ -25,6 +25,11 @@ export type DefaultField = { description?: string errors?: Error[] info?: string + disabled?: boolean + link?: { + label: string + url: string + } }; export type SelectField = DefaultField & { @@ -62,13 +67,11 @@ export default function FormComponent({ handleSubmit, fields, error = undefined, const [show, setShow] = useState<{ [key: string]: boolean }>({}); const [errors, setErrors] = useState<{ [key: string]: boolean }>({}); const [isLoading, setIsLoading] = useState(false); - const isMountedRef = useRef(false); - - const fieldValues = fields.map(f => f.value).join("\0"); + const [isMounted, setIsMounted] = useState(false); useEffect(() => { - if (!isMountedRef.current) { - isMountedRef.current = true; + if (!isMounted) { + setIsMounted(true); return; } @@ -79,7 +82,7 @@ export default function FormComponent({ handleSubmit, fields, error = undefined, } }); setErrors(prev => ({ ...prev, ...newErrors })); - }, [fieldValues]); + }, []); const onHandleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -160,6 +163,7 @@ export default function FormComponent({ handleSubmit, fields, error = undefined, type={field.type === "password" ? passwordType : field.type} placeholder={field.placeholder} value={field.value} + disabled={field.disabled} onChange={(e) => { field.onChange(e); if (field.type === "password") { @@ -180,6 +184,18 @@ export default function FormComponent({ handleSubmit, fields, error = undefined, }} /> }

        {field.description}

        + { + field.link && + + {field.link.label} + + + } { field.errors &&
        diff --git a/app/settings/users/AddUser.tsx b/app/settings/users/AddUser.tsx index 7510e2b87..79b92fdef 100644 --- a/app/settings/users/AddUser.tsx +++ b/app/settings/users/AddUser.tsx @@ -11,19 +11,21 @@ import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; import { Drawer, DrawerDescription, DrawerContent, DrawerTitle, DrawerTrigger } from "@/components/ui/drawer"; export default function AddUser({ onAddUser }: { - onAddUser: (user: CreateUser) => Promise + onAddUser: (user: CreateUser, keys: string) => Promise }) { const [open, setOpen] = useState(false); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [role, setRole] = useState("Admin"); + const [keys, setKeys] = useState(""); const handleClose = () => { setPassword(""); setConfirmPassword(""); setUsername(""); setRole(""); + setKeys(""); }; useEffect(() => { @@ -110,13 +112,28 @@ export default function AddUser({ onAddUser }: { condition: (value: string) => !value } ] + }, + { + value: keys, + onChange: (e) => setKeys(e.target.value), + label: "Key / Graph Permissions", + type: "text", + required: false, + placeholder: "*", + description: "Pattern for accessible keys / graphs (e.g. *, user:*, ~myprefix:*)", + info: "Defines which keys / graphs this user can access. See Redis ACL documentation for pattern syntax.", + link: { + label: "Learn more", + url: "https://redis.io/docs/latest/operate/oss_and_stack/management/security/acl/#key-permissions" + }, + errors: [] } ]; const handleAddUser = async (e: FormEvent) => { e.preventDefault(); - await onAddUser({ username, password, role }); + await onAddUser({ username, password, role }, keys); setOpen(false); diff --git a/app/settings/users/EditUser.tsx b/app/settings/users/EditUser.tsx new file mode 100644 index 000000000..4ae785125 --- /dev/null +++ b/app/settings/users/EditUser.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { FormEvent, useEffect, useState } from "react"; +import { Pencil } from "lucide-react"; +import Button from "@/app/components/ui/Button"; +import FormComponent, { Field } from "@/app/components/FormComponent"; +import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; +import { Drawer, DrawerDescription, DrawerContent, DrawerTitle, DrawerTrigger } from "@/components/ui/drawer"; + +interface EditUserProps { + username: string + role: string + keys: string + onEditUser: (username: string, role: string, keys: string, password?: string) => Promise + disabled?: boolean +} + +export default function EditUser({ username, role: initialRole, keys: initialKeys, onEditUser, disabled = false }: EditUserProps) { + const [open, setOpen] = useState(false); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [role, setRole] = useState(initialRole); + const [keys, setKeys] = useState(initialKeys); + + useEffect(() => { + if (open) { + setRole(initialRole); + setKeys(initialKeys); + setPassword(""); + setConfirmPassword(""); + } + }, [open, initialRole, initialKeys]); + + const fields: Field[] = [ + { + value: username, + onChange: () => {}, + label: "Username", + type: "text", + required: false, + disabled: true, + }, + { + value: password, + onChange: (e) => setPassword(e.target.value), + label: "New Password", + type: "password", + required: false, + show: false, + placeholder: "Leave empty to keep current", + errors: password ? [ + { + message: "Password must be at least 8 characters long", + condition: (value: string) => value.length > 0 && value.length < 8 + }, + { + message: "Password must contain at least one uppercase letter", + condition: (value: string) => value.length > 0 && !/[A-Z]/.test(value) + }, + { + message: "Password must contain at least one lowercase letter", + condition: (value: string) => value.length > 0 && !/[a-z]/.test(value) + }, + { + message: "Password must contain at least one number", + condition: (value: string) => value.length > 0 && !/[0-9]/.test(value) + }, + { + message: "Password must contain at least one special character", + condition: (value: string) => value.length > 0 && !/[!@#$%^&*]/.test(value) + } + ] : [] + }, + { + value: confirmPassword, + onChange: (e) => setConfirmPassword(e.target.value), + label: "Confirm Password", + type: "password", + required: false, + show: false, + errors: password ? [ + { + message: "Passwords don't match", + condition: (value: string, pass?: string) => value !== (pass ?? password) + }, + ] : [] + }, + { + value: role, + onChange: (value) => setRole(value), + label: "Role", + type: "select", + selectType: "Role", + options: ["Admin", "Read-Write", "Read-Only"], + required: true, + errors: [ + { + message: "Role is required", + condition: (value: string) => !value + } + ] + }, + { + value: keys, + onChange: (e) => setKeys(e.target.value), + label: "Key / Graph Permissions", + type: "text", + required: false, + placeholder: "*", + description: "Pattern for accessible keys / graphs (e.g. *, user:*, ~myprefix:*)", + info: "Defines which keys / graphs this user can access. See Redis ACL documentation for pattern syntax.", + link: { + label: "Learn more", + url: "https://redis.io/docs/latest/operate/oss_and_stack/management/security/acl/#key-permissions" + }, + errors: [] + } + ]; + + const handleEditUser = async (e: FormEvent) => { + e.preventDefault(); + + const ok = await onEditUser(username, role, keys, password || undefined); + if (ok) { + setOpen(false); + } + }; + + return ( + + + + + + + + + + + + + ); +} diff --git a/app/settings/users/Users.tsx b/app/settings/users/Users.tsx index 0bebf2d20..668c88b42 100644 --- a/app/settings/users/Users.tsx +++ b/app/settings/users/Users.tsx @@ -3,23 +3,16 @@ "use client"; import React, { useEffect, useState, useContext } from "react"; +import { Save } from "lucide-react"; import { CreateUser, User } from "@/app/api/user/model"; import { prepareArg, securedFetch, Row } from "@/lib/utils"; import TableComponent from "@/app/components/TableComponent"; import { useToast } from "@/components/ui/use-toast"; -import { ToastAction } from "@/components/ui/toast"; -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import CloseDialog from "@/app/components/CloseDialog"; +import ActionButton from "@/app/components/ui/Button"; import { IndicatorContext } from "@/app/components/provider"; import DeleteUser from "./DeleteUser"; import AddUser from "./AddUser"; - -type SetUser = { - username: string - role: string - oldRole?: string -}; +import EditUser from "./EditUser"; const ROLES = [ "Admin", @@ -32,36 +25,9 @@ export default function Users() { const [users, setUsers] = useState([]); const [rows, setRows] = useState([]); - const [newUser, setNewUser] = useState(null); - const [open, setOpen] = useState(false); const { toast } = useToast(); const { setIndicator } = useContext(IndicatorContext); - useEffect(() => { - if (!open) { - setNewUser(null); - } - }, [open]); - - const handleSetRole = async (user: SetUser) => { - const { username, role, oldRole } = user; - const result = await securedFetch(`api/user/${prepareArg(username)}`, { - method: 'PATCH', - body: JSON.stringify({ role }) - }, toast, setIndicator); - - if (result.ok) { - setUsers(prev => prev.map(u => u.username === username ? { ...u, role } : u)); - setRows(prev => prev.map((row): Row => row.cells[0].value === username ? { ...row, cells: [row.cells[0], { ...row.cells[1], value: role }] } : row)); - toast({ - title: "Success", - description: `${username} role updated successfully`, - action: oldRole ? handleSetRole({ username, role: oldRole })}>Undo : undefined - }); - setOpen(false); - } - }; - useEffect(() => { (async () => { const result = await securedFetch("api/user", { @@ -74,24 +40,17 @@ export default function Users() { if (result.ok) { const data = await result.json(); setUsers(data.result.map((user: User) => ({ ...user, selected: false }))); - setRows(data.result.map(({ username, role }: User): Row => ({ + setRows(data.result.map(({ username, role, keys }: { username: string, role: string, keys: string }): Row => ({ name: username, cells: [{ value: username, type: "readonly" - }, username === "default" ? { + }, { value: role, type: "readonly", - } : { - value: role, - type: "select", - onChange: async (value: string) => { - setNewUser({ username, role: value, oldRole: role }); - setOpen(true); - return true; - }, - options: ROLES, - selectType: "Role" + }, { + value: keys || "*", + type: "readonly" }], checked: false, }))); @@ -99,13 +58,26 @@ export default function Users() { })(); }, [toast, setIndicator]); - const handleAddUser = async ({ username, password, role }: CreateUser) => { + const handleSaveUsers = async () => { + const response = await securedFetch('/api/user/save', { + method: 'POST', + }, toast, setIndicator); + + if (response.ok) { + toast({ + title: "Success", + description: "Users saved to disk", + }); + } + }; + + const handleAddUser = async ({ username, password, role }: CreateUser, keys: string) => { const response = await securedFetch('/api/user/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username, password, role }) + body: JSON.stringify({ username, password, role, keys }) }, toast, setIndicator); if (response.ok) { @@ -121,48 +93,71 @@ export default function Users() { type: "readonly" }, { value: role, - onChange: async (value: string) => { - setNewUser({ username, role: value, oldRole: role }); - setOpen(true); - return true; - }, - type: "select", - options: ROLES, - selectType: "Role" + type: "readonly", + }, { + value: keys || "*", + type: "readonly" }], checked: false, }] as Row[]); } }; + const handleEditUser = async (username: string, role: string, keys: string, password?: string) => { + const result = await securedFetch(`api/user/${prepareArg(username)}`, { + method: 'PATCH', + body: JSON.stringify({ role, keys, password }) + }, toast, setIndicator); + + if (result.ok) { + setUsers(prev => prev.map(u => u.username === username ? { ...u, role } : u)); + setRows(prev => prev.map((row): Row => row.cells[0].value === username ? { ...row, cells: [row.cells[0], { ...row.cells[1], value: role }, { ...row.cells[2], value: keys || "*" }] } : row)); + toast({ + title: "Success", + description: `${username} updated successfully`, + }); + } + + return result.ok; + }; + + const checkedRows = rows.filter(row => row.checked); + const selectedUserData = checkedRows.length === 1 ? { + username: checkedRows[0].cells[0].value, + role: checkedRows[0].cells[1].value, + keys: checkedRows[0].cells[2].value, + } : null; + return (
        - row.checked).map(row => users.find(user => user.username === row.cells[0].value)!)} setUsers={setUsers} setRows={setRows} /> + + row.checked && row.cells[0].value !== "default").map(row => users.find(user => user.username === row.cells[0].value)!)} setUsers={setUsers} setRows={setRows} /> + + +
        - - - - Set User Role - - - Are you sure you want to set the user role to {newUser?.role}? -
        - - -
        -
        -
        ); } \ No newline at end of file From c1d1caa5c0802d32e36bd371b734f6474144e81b Mon Sep 17 00:00:00 2001 From: Naseem Ali <34807727+Naseem77@users.noreply.github.com> Date: Mon, 20 Apr 2026 15:31:35 +0300 Subject: [PATCH 086/119] feat(auth): set TTL on session credential rows aligned with NextAuth session Session credential rows in the Token DB were written with expires_at=-1, so abandoned rows (browser closed before signOut fires, crash, etc.) would live forever with no cleanup path. Configure NextAuth session.maxAge explicitly (30 days) and persist the matching expiresAtUnix on the session row so cleanupExpiredTokens() can reap abandoned rows. Active sessions are unaffected because getEncryptedPassword filters by is_active only, not expires_at. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/api/auth/[...nextauth]/options.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/api/auth/[...nextauth]/options.ts b/app/api/auth/[...nextauth]/options.ts index 8da17697c..e4ed522b1 100644 --- a/app/api/auth/[...nextauth]/options.ts +++ b/app/api/auth/[...nextauth]/options.ts @@ -350,7 +350,13 @@ async function tryJWTAuthentication(): Promise<{ client: FalkorDB; user: Authent return null; } +const SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60; // 30 days; keep in sync with session.maxAge below + const authOptions: AuthOptions = { + session: { + strategy: "jwt", + maxAge: SESSION_MAX_AGE_SECONDS, + }, providers: [ CredentialsProvider({ name: "Credentials", @@ -397,6 +403,10 @@ const authOptions: AuthOptions = { port: credentials.port ? parseInt(credentials.port, 10) : 6379, password: credentials.password, kind: 'session', + // Align with NextAuth session lifetime so abandoned rows + // (e.g. browser closed before signOut fires) are eligible + // for cleanup instead of living forever. + expiresAtUnix: Math.floor(Date.now() / 1000) + SESSION_MAX_AGE_SECONDS, }); } catch (storageError) { // eslint-disable-next-line no-console From 94481e8ffb84e186e0a7c5fe3a7b330aee21964a Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Mon, 20 Apr 2026 15:40:11 +0300 Subject: [PATCH 087/119] feat(users): implement user editing functionality with role, keys, and password updates --- e2e/config/urls.json | 1 + e2e/logic/POM/settingsUsersPage.ts | 158 +++++++++++++++++------ e2e/logic/api/apiCalls.ts | 15 +++ e2e/logic/api/responses/userResponses.ts | 5 + e2e/tests/settingsUsers.spec.ts | 82 +++++++++++- 5 files changed, 217 insertions(+), 44 deletions(-) diff --git a/e2e/config/urls.json b/e2e/config/urls.json index 57ea0a348..650f0c527 100644 --- a/e2e/config/urls.json +++ b/e2e/config/urls.json @@ -5,6 +5,7 @@ "LogoutApiUrl": "http://localhost:3000/api/auth/signout", "settingsConfig": "http://localhost:3000/api/graph/config/", "settingsUsers": "http://localhost:3000/api/user", + "settingsUser": "http://localhost:3000/api/user/", "schemaUrl": "http://localhost:3000/api/schema/", "tokenUrl": "http://localhost:3000/api/auth/", "chatModelsUrl": "http://localhost:3000/api/chat/models" diff --git a/e2e/logic/POM/settingsUsersPage.ts b/e2e/logic/POM/settingsUsersPage.ts index b477ac3ac..fffe5317e 100644 --- a/e2e/logic/POM/settingsUsersPage.ts +++ b/e2e/logic/POM/settingsUsersPage.ts @@ -11,16 +11,20 @@ export default class SettingsUsersPage extends BasePage { return this.page.locator("button#add-user"); } + private get editUserButton(): Locator { + return this.page.locator("button#edit-user"); + } + private get submitUserAddition(): Locator { return this.page.getByRole("button", { name: "Submit" }); } - private get selectRoleBtn(): Locator { - return this.page.getByTestId("selectRole"); + private get submitEditUser(): Locator { + return this.page.getByRole("button", { name: "Save" }); } - private get userSelectRoleEditBtn(): Locator { - return this.page.getByTestId("editButtonUsers"); + private get selectRoleBtn(): Locator { + return this.page.getByTestId("selectRole"); } private get userRow(): (selectedUser: string) => Locator { @@ -35,10 +39,6 @@ export default class SettingsUsersPage extends BasePage { ); } - private get confirmModifyingUserRole(): Locator { - return this.page.locator("//button[text()='Set User']"); - } - private get userNameField(): Locator { return this.page.locator("//input[@id='Username']"); } @@ -51,6 +51,22 @@ export default class SettingsUsersPage extends BasePage { return this.page.locator("//input[@id='Confirm Password']"); } + private get editNewPasswordField(): Locator { + return this.page.locator("//input[@id='New Password']"); + } + + private get editConfirmPasswordField(): Locator { + return this.page.locator("//input[@id='Confirm Password']"); + } + + private get editSelectRoleBtn(): Locator { + return this.page.getByTestId("selectRole"); + } + + private get editKeysField(): Locator { + return this.page.locator("//input[@id='Key / Graph Permissions']"); + } + private get confirmUserDeleteMsg(): Locator { return this.page.getByRole("button", { name: "Continue" }); } @@ -60,6 +76,11 @@ export default class SettingsUsersPage extends BasePage { this.page.getByTestId(`contentUsers${selectedUser}Role`); } + private get userKeysContent(): (selectedUser: string) => Locator { + return (selectedUser: string) => + this.page.getByTestId(`contentUsers${selectedUser}Key / Graph Permissions`); + } + private get userCheckboxBtn(): (selectedUser: string) => Locator { return (selectedUser: string) => this.page.getByTestId(`tableCheckboxUsers${selectedUser}`); @@ -166,22 +187,6 @@ export default class SettingsUsersPage extends BasePage { ); } - async clickUserSelectRoleEditBtn(): Promise { - await interactWhenVisible( - this.userSelectRoleEditBtn, - (el) => el.click(), - `select role edit button` - ); - } - - async clickUserSelectRoleBtn(): Promise { - await interactWhenVisible( - this.selectRoleBtn, - (el) => el.click(), - "select role button" - ); - } - async clickDeleteUsersBtn(): Promise { await interactWhenVisible( this.deleteUsersBtn, @@ -206,14 +211,6 @@ export default class SettingsUsersPage extends BasePage { ); } - async clickConfirmModifyingUserRole(): Promise { - await interactWhenVisible( - this.confirmModifyingUserRole, - (el) => el.click(), - "set user button" - ); - } - async fillSearchInput(value: string): Promise { await interactWhenVisible( this.searchInput, @@ -244,14 +241,99 @@ export default class SettingsUsersPage extends BasePage { async modifyUserRole(selectedUser: string, role: string): Promise { await this.waitForPageIdle(); - await this.clickUserRow(selectedUser); - await this.clickUserSelectRoleEditBtn(); - await this.clickUserSelectRoleBtn(); + await this.clickUserCheckboxBtn(selectedUser); + await this.clickOnEditUserBtn(); + await this.clickEditSelectRoleBtn(); await this.clickOnSelectUserRole(role); - await this.clickOnConfirmModifyingUserRole(); + await this.clickOnSubmitEditUser(); await waitForTimeOut(this.page, 1500); } + async editUser(selectedUser: string, options: { role?: string, keys?: string, password?: string, confirmPassword?: string }): Promise { + await this.waitForPageIdle(); + await this.clickUserCheckboxBtn(selectedUser); + await this.clickOnEditUserBtn(); + if (options.password) { + await this.fillEditNewPasswordField(options.password); + await this.fillEditConfirmPasswordField(options.confirmPassword || options.password); + } + if (options.role) { + await this.clickEditSelectRoleBtn(); + await this.clickOnSelectUserRole(options.role); + } + if (options.keys !== undefined) { + await this.fillEditKeysField(options.keys); + } + await this.clickOnSubmitEditUser(); + await waitForTimeOut(this.page, 1500); + } + + async clickOnEditUserBtn(): Promise { + await interactWhenVisible( + this.editUserButton, + (el) => el.click(), + "edit user button" + ); + } + + async clickOnSubmitEditUser(): Promise { + await interactWhenVisible( + this.submitEditUser, + (el) => el.click(), + "submit edit user button" + ); + } + + async clickEditSelectRoleBtn(): Promise { + await interactWhenVisible( + this.editSelectRoleBtn, + (el) => el.click(), + "edit select role button" + ); + } + + async fillEditNewPasswordField(password: string): Promise { + await interactWhenVisible( + this.editNewPasswordField, + (el) => el.fill(password), + "edit new password input" + ); + } + + async fillEditConfirmPasswordField(confirmPassword: string): Promise { + await interactWhenVisible( + this.editConfirmPasswordField, + (el) => el.fill(confirmPassword), + "edit confirm password input" + ); + } + + async fillEditKeysField(keys: string): Promise { + await interactWhenVisible( + this.editKeysField, + (el) => el.fill(keys), + "edit keys input" + ); + } + + async isEditUserButtonDisabled(): Promise { + await this.waitForPageIdle(); + const disabled = await this.editUserButton.isDisabled(); + return disabled; + } + + async isDeleteUserButtonDisabled(): Promise { + await this.waitForPageIdle(); + const disabled = await this.deleteUsersBtn.isDisabled(); + return disabled; + } + + async getUserKeys(selectedUser: string): Promise { + await this.waitForPageIdle(); + const keys = await this.userKeysContent(selectedUser).textContent(); + return keys; + } + async deleteTwoUsers( selectedUser1: string, selectedUser2: string @@ -285,8 +367,4 @@ export default class SettingsUsersPage extends BasePage { .count(); return count; } - - async clickOnConfirmModifyingUserRole(): Promise { - await this.clickConfirmModifyingUserRole(); - } } diff --git a/e2e/logic/api/apiCalls.ts b/e2e/logic/api/apiCalls.ts index 0f20dfa33..37415a799 100644 --- a/e2e/logic/api/apiCalls.ts +++ b/e2e/logic/api/apiCalls.ts @@ -27,6 +27,7 @@ import { GetUsersResponse, CreateUsersResponse, DeleteUsersResponse, + UpdateUserResponse, } from "./responses/userResponses"; import { AddSchemaResponse, @@ -424,6 +425,20 @@ export default class ApiCalls { } } + async updateUser(username: string, data: { role: string; keys?: string; password?: string }): Promise { + try { + const result = await patchRequest( + `${urls.api.settingsUser}${encodeURIComponent(username)}`, + data + ); + return await result.json(); + } catch (error) { + throw new Error( + `Failed to update user. \n Error: ${(error as Error).message}` + ); + } + } + // Token API methods async generateToken(data?: { name?: string; diff --git a/e2e/logic/api/responses/userResponses.ts b/e2e/logic/api/responses/userResponses.ts index 7fa729f6a..eaa380fce 100644 --- a/e2e/logic/api/responses/userResponses.ts +++ b/e2e/logic/api/responses/userResponses.ts @@ -2,6 +2,7 @@ export interface GetUsersResponse { result: { username: string; role: string; + keys: string; checked: boolean; }[]; } @@ -13,3 +14,7 @@ export interface CreateUsersResponse { export interface DeleteUsersResponse { message: string; } + +export interface UpdateUserResponse { + message: string; +} diff --git a/e2e/tests/settingsUsers.spec.ts b/e2e/tests/settingsUsers.spec.ts index f4048126c..68cc04031 100644 --- a/e2e/tests/settingsUsers.spec.ts +++ b/e2e/tests/settingsUsers.spec.ts @@ -88,12 +88,12 @@ test.describe('@Config Settings users tests', () => { expect(isVisible).toBe(false); }); - test("@admin Attempt to delete the default admin user -> Verify that the user has not been deleted.", async () => { + test("@admin Attempt to delete the default admin user -> Verify delete button is disabled.", async () => { const settingsUsersPage = await browser.createNewPage(SettingsUsersPage, urls.settingsUrl); await settingsUsersPage.navigateToUserTab(); - await settingsUsersPage.removeUser('default'); - const isVisible = await settingsUsersPage.verifyUserExists('default'); - expect(isVisible).toBe(true); + await settingsUsersPage.clickUserCheckboxBtn('default'); + const isDeleteDisabled = await settingsUsersPage.isDeleteUserButtonDisabled(); + expect(isDeleteDisabled).toBe(true); }); test("@admin API Test: Add user via API -> Validated user existing via UI -> Delete user via API.", async () => { @@ -143,4 +143,78 @@ test.describe('@Config Settings users tests', () => { await apiCall.deleteUsers({ users: [{ username }] }); }); + test("@admin Edit user -> change role via edit form -> Validate role changed", async () => { + const username = getRandomString('user'); + await apiCall.createUsers({ username, password: user.password, role: user.ReadWrite }); + const settingsUsersPage = await browser.createNewPage(SettingsUsersPage, urls.settingsUrl); + await settingsUsersPage.navigateToUserTab(); + await settingsUsersPage.editUser(username, { role: user.ReadOnly }); + await settingsUsersPage.refreshPage(); + await settingsUsersPage.navigateToUserTab(); + const newUserRole = await settingsUsersPage.getUserRole(username); + expect(newUserRole).toBe("Read-Only"); + await apiCall.deleteUsers({ users: [{ username }] }); + }); + + test("@admin Edit user -> change key permissions via edit form -> Validate keys changed", async () => { + const username = getRandomString('user'); + await apiCall.createUsers({ username, password: user.password, role: user.ReadWrite }); + const settingsUsersPage = await browser.createNewPage(SettingsUsersPage, urls.settingsUrl); + await settingsUsersPage.navigateToUserTab(); + await settingsUsersPage.editUser(username, { keys: "myprefix:*" }); + await settingsUsersPage.refreshPage(); + await settingsUsersPage.navigateToUserTab(); + const newKeys = await settingsUsersPage.getUserKeys(username); + expect(newKeys).toBe("myprefix:*"); + await apiCall.deleteUsers({ users: [{ username }] }); + }); + + test("@admin Edit user -> change password via edit form -> Validate login with new password", async () => { + const username = getRandomString('user'); + const newPassword = "NewPass1@"; + await apiCall.createUsers({ username, password: user.password, role: user.ReadWrite }); + const settingsUsersPage = await browser.createNewPage(SettingsUsersPage, urls.settingsUrl); + await settingsUsersPage.navigateToUserTab(); + await settingsUsersPage.editUser(username, { password: newPassword, confirmPassword: newPassword }); + await apiCall.deleteUsers({ users: [{ username }] }); + }); + + test("@admin Edit button disabled when default user is selected", async () => { + const settingsUsersPage = await browser.createNewPage(SettingsUsersPage, urls.settingsUrl); + await settingsUsersPage.navigateToUserTab(); + await settingsUsersPage.clickUserCheckboxBtn('default'); + const isEditDisabled = await settingsUsersPage.isEditUserButtonDisabled(); + expect(isEditDisabled).toBe(true); + }); + + test("@admin Edit button disabled when no user is selected", async () => { + const settingsUsersPage = await browser.createNewPage(SettingsUsersPage, urls.settingsUrl); + await settingsUsersPage.navigateToUserTab(); + const isEditDisabled = await settingsUsersPage.isEditUserButtonDisabled(); + expect(isEditDisabled).toBe(true); + }); + + test("@admin API Test: Update user role via PATCH -> Validate role changed via UI", async () => { + const username = getRandomString('user'); + await apiCall.createUsers({ username, password: user.password, role: user.ReadWrite }); + await apiCall.updateUser(username, { role: user.ReadOnly }); + const settingsUsersPage = await browser.createNewPage(SettingsUsersPage, urls.settingsUrl); + await settingsUsersPage.navigateToUserTab(); + const newUserRole = await settingsUsersPage.getUserRole(username); + expect(newUserRole).toBe("Read-Only"); + await apiCall.deleteUsers({ users: [{ username }] }); + }); + + test("@admin API Test: Update user with password and keys via PATCH", async () => { + const username = getRandomString('user'); + await apiCall.createUsers({ username, password: user.password, role: user.ReadWrite }); + await apiCall.updateUser(username, { role: user.ReadWrite, keys: "test:*", password: "NewPass1@" }); + const settingsUsersPage = await browser.createNewPage(SettingsUsersPage, urls.settingsUrl); + await settingsUsersPage.navigateToUserTab(); + const newKeys = await settingsUsersPage.getUserKeys(username); + expect(newKeys).toBe("test:*"); + await apiCall.deleteUsers({ users: [{ username }] }); + }); + }); + }); From c428521d91b12fc4ad0dd09e1560f47db98f1c9d Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Mon, 20 Apr 2026 15:41:57 +0300 Subject: [PATCH 088/119] feat: implement user editing functionality and update related tests --- e2e/config/urls.json | 1 + e2e/logic/POM/settingsUsersPage.ts | 158 +++++++++++++++++------ e2e/logic/api/apiCalls.ts | 15 +++ e2e/logic/api/responses/userResponses.ts | 5 + e2e/tests/graphInfo.spec.ts | 2 +- e2e/tests/settingsUsers.spec.ts | 82 +++++++++++- 6 files changed, 218 insertions(+), 45 deletions(-) diff --git a/e2e/config/urls.json b/e2e/config/urls.json index 57ea0a348..650f0c527 100644 --- a/e2e/config/urls.json +++ b/e2e/config/urls.json @@ -5,6 +5,7 @@ "LogoutApiUrl": "http://localhost:3000/api/auth/signout", "settingsConfig": "http://localhost:3000/api/graph/config/", "settingsUsers": "http://localhost:3000/api/user", + "settingsUser": "http://localhost:3000/api/user/", "schemaUrl": "http://localhost:3000/api/schema/", "tokenUrl": "http://localhost:3000/api/auth/", "chatModelsUrl": "http://localhost:3000/api/chat/models" diff --git a/e2e/logic/POM/settingsUsersPage.ts b/e2e/logic/POM/settingsUsersPage.ts index b477ac3ac..fffe5317e 100644 --- a/e2e/logic/POM/settingsUsersPage.ts +++ b/e2e/logic/POM/settingsUsersPage.ts @@ -11,16 +11,20 @@ export default class SettingsUsersPage extends BasePage { return this.page.locator("button#add-user"); } + private get editUserButton(): Locator { + return this.page.locator("button#edit-user"); + } + private get submitUserAddition(): Locator { return this.page.getByRole("button", { name: "Submit" }); } - private get selectRoleBtn(): Locator { - return this.page.getByTestId("selectRole"); + private get submitEditUser(): Locator { + return this.page.getByRole("button", { name: "Save" }); } - private get userSelectRoleEditBtn(): Locator { - return this.page.getByTestId("editButtonUsers"); + private get selectRoleBtn(): Locator { + return this.page.getByTestId("selectRole"); } private get userRow(): (selectedUser: string) => Locator { @@ -35,10 +39,6 @@ export default class SettingsUsersPage extends BasePage { ); } - private get confirmModifyingUserRole(): Locator { - return this.page.locator("//button[text()='Set User']"); - } - private get userNameField(): Locator { return this.page.locator("//input[@id='Username']"); } @@ -51,6 +51,22 @@ export default class SettingsUsersPage extends BasePage { return this.page.locator("//input[@id='Confirm Password']"); } + private get editNewPasswordField(): Locator { + return this.page.locator("//input[@id='New Password']"); + } + + private get editConfirmPasswordField(): Locator { + return this.page.locator("//input[@id='Confirm Password']"); + } + + private get editSelectRoleBtn(): Locator { + return this.page.getByTestId("selectRole"); + } + + private get editKeysField(): Locator { + return this.page.locator("//input[@id='Key / Graph Permissions']"); + } + private get confirmUserDeleteMsg(): Locator { return this.page.getByRole("button", { name: "Continue" }); } @@ -60,6 +76,11 @@ export default class SettingsUsersPage extends BasePage { this.page.getByTestId(`contentUsers${selectedUser}Role`); } + private get userKeysContent(): (selectedUser: string) => Locator { + return (selectedUser: string) => + this.page.getByTestId(`contentUsers${selectedUser}Key / Graph Permissions`); + } + private get userCheckboxBtn(): (selectedUser: string) => Locator { return (selectedUser: string) => this.page.getByTestId(`tableCheckboxUsers${selectedUser}`); @@ -166,22 +187,6 @@ export default class SettingsUsersPage extends BasePage { ); } - async clickUserSelectRoleEditBtn(): Promise { - await interactWhenVisible( - this.userSelectRoleEditBtn, - (el) => el.click(), - `select role edit button` - ); - } - - async clickUserSelectRoleBtn(): Promise { - await interactWhenVisible( - this.selectRoleBtn, - (el) => el.click(), - "select role button" - ); - } - async clickDeleteUsersBtn(): Promise { await interactWhenVisible( this.deleteUsersBtn, @@ -206,14 +211,6 @@ export default class SettingsUsersPage extends BasePage { ); } - async clickConfirmModifyingUserRole(): Promise { - await interactWhenVisible( - this.confirmModifyingUserRole, - (el) => el.click(), - "set user button" - ); - } - async fillSearchInput(value: string): Promise { await interactWhenVisible( this.searchInput, @@ -244,14 +241,99 @@ export default class SettingsUsersPage extends BasePage { async modifyUserRole(selectedUser: string, role: string): Promise { await this.waitForPageIdle(); - await this.clickUserRow(selectedUser); - await this.clickUserSelectRoleEditBtn(); - await this.clickUserSelectRoleBtn(); + await this.clickUserCheckboxBtn(selectedUser); + await this.clickOnEditUserBtn(); + await this.clickEditSelectRoleBtn(); await this.clickOnSelectUserRole(role); - await this.clickOnConfirmModifyingUserRole(); + await this.clickOnSubmitEditUser(); await waitForTimeOut(this.page, 1500); } + async editUser(selectedUser: string, options: { role?: string, keys?: string, password?: string, confirmPassword?: string }): Promise { + await this.waitForPageIdle(); + await this.clickUserCheckboxBtn(selectedUser); + await this.clickOnEditUserBtn(); + if (options.password) { + await this.fillEditNewPasswordField(options.password); + await this.fillEditConfirmPasswordField(options.confirmPassword || options.password); + } + if (options.role) { + await this.clickEditSelectRoleBtn(); + await this.clickOnSelectUserRole(options.role); + } + if (options.keys !== undefined) { + await this.fillEditKeysField(options.keys); + } + await this.clickOnSubmitEditUser(); + await waitForTimeOut(this.page, 1500); + } + + async clickOnEditUserBtn(): Promise { + await interactWhenVisible( + this.editUserButton, + (el) => el.click(), + "edit user button" + ); + } + + async clickOnSubmitEditUser(): Promise { + await interactWhenVisible( + this.submitEditUser, + (el) => el.click(), + "submit edit user button" + ); + } + + async clickEditSelectRoleBtn(): Promise { + await interactWhenVisible( + this.editSelectRoleBtn, + (el) => el.click(), + "edit select role button" + ); + } + + async fillEditNewPasswordField(password: string): Promise { + await interactWhenVisible( + this.editNewPasswordField, + (el) => el.fill(password), + "edit new password input" + ); + } + + async fillEditConfirmPasswordField(confirmPassword: string): Promise { + await interactWhenVisible( + this.editConfirmPasswordField, + (el) => el.fill(confirmPassword), + "edit confirm password input" + ); + } + + async fillEditKeysField(keys: string): Promise { + await interactWhenVisible( + this.editKeysField, + (el) => el.fill(keys), + "edit keys input" + ); + } + + async isEditUserButtonDisabled(): Promise { + await this.waitForPageIdle(); + const disabled = await this.editUserButton.isDisabled(); + return disabled; + } + + async isDeleteUserButtonDisabled(): Promise { + await this.waitForPageIdle(); + const disabled = await this.deleteUsersBtn.isDisabled(); + return disabled; + } + + async getUserKeys(selectedUser: string): Promise { + await this.waitForPageIdle(); + const keys = await this.userKeysContent(selectedUser).textContent(); + return keys; + } + async deleteTwoUsers( selectedUser1: string, selectedUser2: string @@ -285,8 +367,4 @@ export default class SettingsUsersPage extends BasePage { .count(); return count; } - - async clickOnConfirmModifyingUserRole(): Promise { - await this.clickConfirmModifyingUserRole(); - } } diff --git a/e2e/logic/api/apiCalls.ts b/e2e/logic/api/apiCalls.ts index 0f20dfa33..37415a799 100644 --- a/e2e/logic/api/apiCalls.ts +++ b/e2e/logic/api/apiCalls.ts @@ -27,6 +27,7 @@ import { GetUsersResponse, CreateUsersResponse, DeleteUsersResponse, + UpdateUserResponse, } from "./responses/userResponses"; import { AddSchemaResponse, @@ -424,6 +425,20 @@ export default class ApiCalls { } } + async updateUser(username: string, data: { role: string; keys?: string; password?: string }): Promise { + try { + const result = await patchRequest( + `${urls.api.settingsUser}${encodeURIComponent(username)}`, + data + ); + return await result.json(); + } catch (error) { + throw new Error( + `Failed to update user. \n Error: ${(error as Error).message}` + ); + } + } + // Token API methods async generateToken(data?: { name?: string; diff --git a/e2e/logic/api/responses/userResponses.ts b/e2e/logic/api/responses/userResponses.ts index 7fa729f6a..eaa380fce 100644 --- a/e2e/logic/api/responses/userResponses.ts +++ b/e2e/logic/api/responses/userResponses.ts @@ -2,6 +2,7 @@ export interface GetUsersResponse { result: { username: string; role: string; + keys: string; checked: boolean; }[]; } @@ -13,3 +14,7 @@ export interface CreateUsersResponse { export interface DeleteUsersResponse { message: string; } + +export interface UpdateUserResponse { + message: string; +} diff --git a/e2e/tests/graphInfo.spec.ts b/e2e/tests/graphInfo.spec.ts index b80ad75d1..ad7bc0726 100644 --- a/e2e/tests/graphInfo.spec.ts +++ b/e2e/tests/graphInfo.spec.ts @@ -216,7 +216,7 @@ test.describe("Graph Info Panel Tests", () => { await apiCall.addGraph(graphName2); const graph = await browser.createNewPage(GraphInfoPage, urls.graphUrl); await browser.setPageToFullScreen(); - // Panel is open by default but shows zero counts when no graph is selected + await graph.openGraphInfoButton(); const nodesCount = await graph.getGraphInfoNodesCount(); expect(nodesCount).toBe("0"); await apiCall.removeGraph(graphName1); diff --git a/e2e/tests/settingsUsers.spec.ts b/e2e/tests/settingsUsers.spec.ts index f4048126c..68cc04031 100644 --- a/e2e/tests/settingsUsers.spec.ts +++ b/e2e/tests/settingsUsers.spec.ts @@ -88,12 +88,12 @@ test.describe('@Config Settings users tests', () => { expect(isVisible).toBe(false); }); - test("@admin Attempt to delete the default admin user -> Verify that the user has not been deleted.", async () => { + test("@admin Attempt to delete the default admin user -> Verify delete button is disabled.", async () => { const settingsUsersPage = await browser.createNewPage(SettingsUsersPage, urls.settingsUrl); await settingsUsersPage.navigateToUserTab(); - await settingsUsersPage.removeUser('default'); - const isVisible = await settingsUsersPage.verifyUserExists('default'); - expect(isVisible).toBe(true); + await settingsUsersPage.clickUserCheckboxBtn('default'); + const isDeleteDisabled = await settingsUsersPage.isDeleteUserButtonDisabled(); + expect(isDeleteDisabled).toBe(true); }); test("@admin API Test: Add user via API -> Validated user existing via UI -> Delete user via API.", async () => { @@ -143,4 +143,78 @@ test.describe('@Config Settings users tests', () => { await apiCall.deleteUsers({ users: [{ username }] }); }); + test("@admin Edit user -> change role via edit form -> Validate role changed", async () => { + const username = getRandomString('user'); + await apiCall.createUsers({ username, password: user.password, role: user.ReadWrite }); + const settingsUsersPage = await browser.createNewPage(SettingsUsersPage, urls.settingsUrl); + await settingsUsersPage.navigateToUserTab(); + await settingsUsersPage.editUser(username, { role: user.ReadOnly }); + await settingsUsersPage.refreshPage(); + await settingsUsersPage.navigateToUserTab(); + const newUserRole = await settingsUsersPage.getUserRole(username); + expect(newUserRole).toBe("Read-Only"); + await apiCall.deleteUsers({ users: [{ username }] }); + }); + + test("@admin Edit user -> change key permissions via edit form -> Validate keys changed", async () => { + const username = getRandomString('user'); + await apiCall.createUsers({ username, password: user.password, role: user.ReadWrite }); + const settingsUsersPage = await browser.createNewPage(SettingsUsersPage, urls.settingsUrl); + await settingsUsersPage.navigateToUserTab(); + await settingsUsersPage.editUser(username, { keys: "myprefix:*" }); + await settingsUsersPage.refreshPage(); + await settingsUsersPage.navigateToUserTab(); + const newKeys = await settingsUsersPage.getUserKeys(username); + expect(newKeys).toBe("myprefix:*"); + await apiCall.deleteUsers({ users: [{ username }] }); + }); + + test("@admin Edit user -> change password via edit form -> Validate login with new password", async () => { + const username = getRandomString('user'); + const newPassword = "NewPass1@"; + await apiCall.createUsers({ username, password: user.password, role: user.ReadWrite }); + const settingsUsersPage = await browser.createNewPage(SettingsUsersPage, urls.settingsUrl); + await settingsUsersPage.navigateToUserTab(); + await settingsUsersPage.editUser(username, { password: newPassword, confirmPassword: newPassword }); + await apiCall.deleteUsers({ users: [{ username }] }); + }); + + test("@admin Edit button disabled when default user is selected", async () => { + const settingsUsersPage = await browser.createNewPage(SettingsUsersPage, urls.settingsUrl); + await settingsUsersPage.navigateToUserTab(); + await settingsUsersPage.clickUserCheckboxBtn('default'); + const isEditDisabled = await settingsUsersPage.isEditUserButtonDisabled(); + expect(isEditDisabled).toBe(true); + }); + + test("@admin Edit button disabled when no user is selected", async () => { + const settingsUsersPage = await browser.createNewPage(SettingsUsersPage, urls.settingsUrl); + await settingsUsersPage.navigateToUserTab(); + const isEditDisabled = await settingsUsersPage.isEditUserButtonDisabled(); + expect(isEditDisabled).toBe(true); + }); + + test("@admin API Test: Update user role via PATCH -> Validate role changed via UI", async () => { + const username = getRandomString('user'); + await apiCall.createUsers({ username, password: user.password, role: user.ReadWrite }); + await apiCall.updateUser(username, { role: user.ReadOnly }); + const settingsUsersPage = await browser.createNewPage(SettingsUsersPage, urls.settingsUrl); + await settingsUsersPage.navigateToUserTab(); + const newUserRole = await settingsUsersPage.getUserRole(username); + expect(newUserRole).toBe("Read-Only"); + await apiCall.deleteUsers({ users: [{ username }] }); + }); + + test("@admin API Test: Update user with password and keys via PATCH", async () => { + const username = getRandomString('user'); + await apiCall.createUsers({ username, password: user.password, role: user.ReadWrite }); + await apiCall.updateUser(username, { role: user.ReadWrite, keys: "test:*", password: "NewPass1@" }); + const settingsUsersPage = await browser.createNewPage(SettingsUsersPage, urls.settingsUrl); + await settingsUsersPage.navigateToUserTab(); + const newKeys = await settingsUsersPage.getUserKeys(username); + expect(newKeys).toBe("test:*"); + await apiCall.deleteUsers({ users: [{ username }] }); + }); + }); + }); From 5fca0fc7f9fa551bd31d7454fc7304f36c938120 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Mon, 20 Apr 2026 15:45:37 +0300 Subject: [PATCH 089/119] fix(users): add type assertions for selected user data properties --- app/settings/users/Users.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/settings/users/Users.tsx b/app/settings/users/Users.tsx index 668c88b42..840474195 100644 --- a/app/settings/users/Users.tsx +++ b/app/settings/users/Users.tsx @@ -123,9 +123,9 @@ export default function Users() { const checkedRows = rows.filter(row => row.checked); const selectedUserData = checkedRows.length === 1 ? { - username: checkedRows[0].cells[0].value, - role: checkedRows[0].cells[1].value, - keys: checkedRows[0].cells[2].value, + username: checkedRows[0].cells[0].value as string, + role: checkedRows[0].cells[1].value as string, + keys: checkedRows[0].cells[2].value as string, } : null; return ( From 87d509656d22adec4b472e51844b6d3f3cc5d116 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Mon, 20 Apr 2026 15:47:01 +0300 Subject: [PATCH 090/119] fix(tests): remove redundant closing bracket in user settings tests --- e2e/tests/settingsUsers.spec.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/e2e/tests/settingsUsers.spec.ts b/e2e/tests/settingsUsers.spec.ts index 68cc04031..a8a29f3fa 100644 --- a/e2e/tests/settingsUsers.spec.ts +++ b/e2e/tests/settingsUsers.spec.ts @@ -215,6 +215,4 @@ test.describe('@Config Settings users tests', () => { expect(newKeys).toBe("test:*"); await apiCall.deleteUsers({ users: [{ username }] }); }); - }); - }); From c43498455175fc9e9223c7c4ba9110be68b95192 Mon Sep 17 00:00:00 2001 From: Naseem Ali <34807727+Naseem77@users.noreply.github.com> Date: Mon, 20 Apr 2026 15:49:04 +0300 Subject: [PATCH 091/119] test(e2e): wait for chat section header before interacting in settingsBrowser Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- e2e/logic/POM/settingsBrowserPage.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/e2e/logic/POM/settingsBrowserPage.ts b/e2e/logic/POM/settingsBrowserPage.ts index 2498c2b24..0b1d1944b 100644 --- a/e2e/logic/POM/settingsBrowserPage.ts +++ b/e2e/logic/POM/settingsBrowserPage.ts @@ -163,6 +163,7 @@ export default class SettingsBrowserPage extends BasePage { // Combined Actions async expandChatSection(): Promise { + await this.waitForChatSection(); const isInputVisible = await this.chatApiKeyInput.isVisible(); if (!isInputVisible) { await this.clickChatSectionHeader(); From 4130db789776a60fd59d44a2e6793033b371448c Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Mon, 20 Apr 2026 15:54:40 +0300 Subject: [PATCH 092/119] feat(users): enhance user update functionality with key permissions and validation messages --- app/api/swagger/swagger-spec.ts | 23 +++++++++++++++++++++-- app/api/user/[user]/route.ts | 18 +++++++++++++++--- app/api/user/model.ts | 7 +++++-- app/api/validate-body.ts | 3 +-- app/components/FormComponent.tsx | 10 ++++++---- app/settings/users/Users.tsx | 2 +- 6 files changed, 49 insertions(+), 14 deletions(-) diff --git a/app/api/swagger/swagger-spec.ts b/app/api/swagger/swagger-spec.ts index ad6c59d24..c598880ff 100644 --- a/app/api/swagger/swagger-spec.ts +++ b/app/api/swagger/swagger-spec.ts @@ -2589,7 +2589,7 @@ const swaggerSpec = { }, password: { type: "string", - description: "New password for the user (optional, omit to keep current password)" + description: "New password for the user (optional, omit to keep current password). Must be at least 8 characters with uppercase, lowercase, digit, and special character." } }, required: ["role"] @@ -2599,7 +2599,26 @@ const swaggerSpec = { }, responses: { "200": { - description: "User updated successfully" + description: "User updated successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { + type: "string", + example: "User role updated" + } + } + } + } + } + }, + "400": { + description: "Bad request - validation error or invalid role" + }, + "500": { + description: "Internal server error" } } } diff --git a/app/api/user/[user]/route.ts b/app/api/user/[user]/route.ts index f9b2a6b1d..a10a416b0 100644 --- a/app/api/user/[user]/route.ts +++ b/app/api/user/[user]/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getClient } from "../../auth/[...nextauth]/options"; -import { ROLE, getRoleWithKeys } from "../model"; +import { ROLE, getRoleWithKeys, extractKeysFromACL } from "../model"; import { updateUser, validateBody } from "../../validate-body"; import { getCorsHeaders } from "../../utils"; @@ -41,11 +41,23 @@ export async function PATCH( const role = ROLE.get(roleKey); if (!role) throw new Error("Invalid role"); - const finalRole = getRoleWithKeys(role, keys); + const connection = await client.connection; + + // Preserve existing key permissions when keys not provided (true PATCH semantics) + let effectiveKeys = keys; + if (effectiveKeys === undefined) { + const aclList = await connection.aclList(); + const userLine = aclList.find((line: string) => line.split(" ")[1] === username); + if (userLine) { + effectiveKeys = extractKeysFromACL(userLine.split(" ")); + } + } + + const finalRole = getRoleWithKeys(role, effectiveKeys); if (password) { finalRole.push(`>${password}`); } - await (await client.connection).aclSetUser(username, finalRole); + await connection.aclSetUser(username, finalRole); return NextResponse.json({ message: "User role updated" }, { status: 200, headers: getCorsHeaders(request) }); } catch (error) { console.error(error); diff --git a/app/api/user/model.ts b/app/api/user/model.ts index fbb1c48c0..d16346187 100644 --- a/app/api/user/model.ts +++ b/app/api/user/model.ts @@ -1,6 +1,7 @@ export interface User { username: string; role: string; + keys?: string; } export interface CreateUser { @@ -55,6 +56,8 @@ export const ROLE = new Map([ ]); export function extractKeysFromACL(userDetails: string[]): string { - const keyPattern = userDetails.find((part) => part.startsWith("~")); - return keyPattern ? keyPattern.slice(1) : "*"; + const keyPatterns = userDetails + .filter((part) => part.startsWith("~")) + .map((part) => part.slice(1)); + return keyPatterns.length > 0 ? keyPatterns.join(" ") : "*"; } diff --git a/app/api/validate-body.ts b/app/api/validate-body.ts index 5f024fe9a..27c59099a 100644 --- a/app/api/validate-body.ts +++ b/app/api/validate-body.ts @@ -40,8 +40,7 @@ export const updateUser = z.object({ .min(1, "Role cannot be empty"), keys: z .string() - .optional() - .default("*"), + .optional(), password: z .string() .optional(), diff --git a/app/components/FormComponent.tsx b/app/components/FormComponent.tsx index 5494af1ca..a9810eb88 100644 --- a/app/components/FormComponent.tsx +++ b/app/components/FormComponent.tsx @@ -67,11 +67,13 @@ export default function FormComponent({ handleSubmit, fields, error = undefined, const [show, setShow] = useState<{ [key: string]: boolean }>({}); const [errors, setErrors] = useState<{ [key: string]: boolean }>({}); const [isLoading, setIsLoading] = useState(false); - const [isMounted, setIsMounted] = useState(false); + const isMountedRef = useRef(false); + + const fieldValues = fields.map(f => f.value).join("\0"); useEffect(() => { - if (!isMounted) { - setIsMounted(true); + if (!isMountedRef.current) { + isMountedRef.current = true; return; } @@ -82,7 +84,7 @@ export default function FormComponent({ handleSubmit, fields, error = undefined, } }); setErrors(prev => ({ ...prev, ...newErrors })); - }, []); + }, [fieldValues]); const onHandleSubmit = async (e: React.FormEvent) => { e.preventDefault(); diff --git a/app/settings/users/Users.tsx b/app/settings/users/Users.tsx index 840474195..0c945a10e 100644 --- a/app/settings/users/Users.tsx +++ b/app/settings/users/Users.tsx @@ -110,7 +110,7 @@ export default function Users() { }, toast, setIndicator); if (result.ok) { - setUsers(prev => prev.map(u => u.username === username ? { ...u, role } : u)); + setUsers(prev => prev.map(u => u.username === username ? { ...u, role, keys: keys || "*" } : u)); setRows(prev => prev.map((row): Row => row.cells[0].value === username ? { ...row, cells: [row.cells[0], { ...row.cells[1], value: role }, { ...row.cells[2], value: keys || "*" }] } : row)); toast({ title: "Success", From 470a7f861ec206b3be9c7c503df3241ed15b3cfb Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Mon, 20 Apr 2026 15:59:17 +0300 Subject: [PATCH 093/119] fix: rename ServerIdentityCheck to SKIP_SERVER_IDENTITY_CHECK and set in CI TLS tests - Rename env var to clearly communicate that setting it to 'true' skips server identity verification (addresses review feedback) - Set SKIP_SERVER_IDENTITY_CHECK=true in CI TLS test step so self-signed certificates work correctly (fixes TLS test failure) --- .github/workflows/playwright.yml | 4 +++- app/api/auth/[...nextauth]/options.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 952b99c22..5df8453f9 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -287,8 +287,10 @@ jobs: exit 1 fi - name: Run TLS tests + env: + SKIP_SERVER_IDENTITY_CHECK: "true" run: | - NEXTAUTH_SECRET=SECRET npm start > nextjs.log 2>&1 & + NEXTAUTH_SECRET=SECRET SKIP_SERVER_IDENTITY_CHECK=true npm start > nextjs.log 2>&1 & timeout 60s bash -c 'while ! undefined } : {}), + ...(process.env.SKIP_SERVER_IDENTITY_CHECK === "true" ? { checkServerIdentity: () => undefined } : {}), ca: !credentials.ca || credentials.ca === "undefined" ? undefined From 59315abcd533aac61821ae0165074d1fcf80bd9c Mon Sep 17 00:00:00 2001 From: Naseem Ali <34807727+Naseem77@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:16:52 +0300 Subject: [PATCH 094/119] feat(auth): propagate SESSION_INVALID through SSE so getSSEGraphResult auto-signs-out SSE routes converted getClient's 401 NextResponse into an SSE error via throw new Error(await session.text()), which caused the outer catch to emit status=500 and dropped the SESSION_INVALID code. EventSource cannot read HTTP headers or status, so clients reading the SSE stream had no way to distinguish an orphaned session and could not trigger signOut. Add writeGetClientErrorAsSSE that parses the NextResponse body and emits an SSE error event carrying the original status and code. Wire it into the four SSE routes that authenticate via getClient. Update getSSEGraphResult to detect code === 'SESSION_INVALID' and trigger the same signOut({ callbackUrl: '/login' }) as securedFetch, using a shared in-flight guard so concurrent fetch and SSE requests do not fire multiple signOuts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/api/graph/[graph]/count/edges/route.ts | 12 +++++++-- app/api/graph/[graph]/count/nodes/route.ts | 12 +++++++-- app/api/graph/[graph]/count/route.ts | 12 +++++++-- app/api/graph/[graph]/route.ts | 12 +++++++-- app/api/utils.ts | 29 ++++++++++++++++++++++ lib/utils.ts | 25 +++++++++++++------ 6 files changed, 87 insertions(+), 15 deletions(-) diff --git a/app/api/graph/[graph]/count/edges/route.ts b/app/api/graph/[graph]/count/edges/route.ts index 79e0922c4..2887b9d3d 100644 --- a/app/api/graph/[graph]/count/edges/route.ts +++ b/app/api/graph/[graph]/count/edges/route.ts @@ -1,5 +1,5 @@ import { getClient } from "@/app/api/auth/[...nextauth]/options"; -import { runQuery, getCorsHeaders } from "@/app/api/utils"; +import { runQuery, getCorsHeaders, writeGetClientErrorAsSSE } from "@/app/api/utils"; import { NextResponse, NextRequest } from "next/server"; /** @@ -20,7 +20,15 @@ export async function GET( const session = await getClient(request); if (session instanceof NextResponse) { - throw new Error(await session.text()); + await writeGetClientErrorAsSSE(session, writer, encoder); + return new Response(readable, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + ...getCorsHeaders(request), + }, + }); } const { client } = session; diff --git a/app/api/graph/[graph]/count/nodes/route.ts b/app/api/graph/[graph]/count/nodes/route.ts index 98d3a6891..3c1e0cbf8 100644 --- a/app/api/graph/[graph]/count/nodes/route.ts +++ b/app/api/graph/[graph]/count/nodes/route.ts @@ -1,5 +1,5 @@ import { getClient } from "@/app/api/auth/[...nextauth]/options"; -import { runQuery, getCorsHeaders } from "@/app/api/utils"; +import { runQuery, getCorsHeaders, writeGetClientErrorAsSSE } from "@/app/api/utils"; import { NextResponse, NextRequest } from "next/server"; /** @@ -22,7 +22,15 @@ export async function GET( const session = await getClient(request); if (session instanceof NextResponse) { - throw new Error(await session.text()); + await writeGetClientErrorAsSSE(session, writer, encoder); + return new Response(readable, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + ...getCorsHeaders(request), + }, + }); } const { client } = session; diff --git a/app/api/graph/[graph]/count/route.ts b/app/api/graph/[graph]/count/route.ts index 7bc297f57..930e57c76 100644 --- a/app/api/graph/[graph]/count/route.ts +++ b/app/api/graph/[graph]/count/route.ts @@ -1,5 +1,5 @@ import { getClient } from "@/app/api/auth/[...nextauth]/options"; -import { runQuery, getCorsHeaders } from "@/app/api/utils"; +import { runQuery, getCorsHeaders, writeGetClientErrorAsSSE } from "@/app/api/utils"; import { NextResponse, NextRequest } from "next/server"; // eslint-disable-next-line import/prefer-default-export @@ -15,7 +15,15 @@ export async function GET( const session = await getClient(request); if (session instanceof NextResponse) { - throw new Error(await session.text()); + await writeGetClientErrorAsSSE(session, writer, encoder); + return new Response(readable, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + ...getCorsHeaders(request), + }, + }); } const { client } = session; diff --git a/app/api/graph/[graph]/route.ts b/app/api/graph/[graph]/route.ts index 2d12ca760..a00e83407 100644 --- a/app/api/graph/[graph]/route.ts +++ b/app/api/graph/[graph]/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getClient } from "@/app/api/auth/[...nextauth]/options"; import { renameGraph, validateBody } from "../../validate-body"; -import { getCorsHeaders } from "../../utils"; +import { getCorsHeaders, writeGetClientErrorAsSSE } from "../../utils"; export async function OPTIONS(request: Request) { return new NextResponse(null, { status: 204, headers: getCorsHeaders(request) }); @@ -157,7 +157,15 @@ export async function GET( const session = await getClient(request); if (session instanceof NextResponse) { - throw new Error(await session.text()); + await writeGetClientErrorAsSSE(session, writer, encoder); + return new Response(readable, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + ...getCorsHeaders(request), + }, + }); } const { client } = session; diff --git a/app/api/utils.ts b/app/api/utils.ts index 228ab8d58..009db838d 100644 --- a/app/api/utils.ts +++ b/app/api/utils.ts @@ -86,4 +86,33 @@ export function corsHeaders(requestOrigin?: string | null): Record { const origin = request?.headers.get('origin'); return corsHeaders(origin); +} + +/** + * Forwards a NextResponse returned by getClient() into an SSE error event + * so the streaming client can surface the same status/code that the + * non-streaming path would. Preserves status (e.g. 401) and the + * SESSION_INVALID code needed to trigger auto-signOut on the client. + */ +export async function writeGetClientErrorAsSSE( + response: Response, + writer: WritableStreamDefaultWriter, + encoder: TextEncoder, +): Promise { + const { status } = response; + let message = "Unauthorized"; + let code: string | undefined; + try { + const body = await response.clone().json() as { message?: string; code?: string }; + if (body.message) message = body.message; + if (body.code) code = body.code; + } catch { + try { message = await response.text(); } catch { /* ignore */ } + } + const payload: Record = { message, status }; + if (code) payload.code = code; + writer.write( + encoder.encode(`event: error\ndata: ${JSON.stringify(payload)}\n\n`) + ); + writer.close(); } \ No newline at end of file diff --git a/lib/utils.ts b/lib/utils.ts index 6faa6f501..6c312d133 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -248,9 +248,17 @@ export async function getSSEGraphResult( evtSource.addEventListener("error", (event: MessageEvent) => { handled = true; - const { message, status } = JSON.parse(event.data); + const { message, status, code } = JSON.parse(event.data); evtSource.close(); + + if (status === 401 && code === "SESSION_INVALID") { + triggerSessionInvalidationSignOut(); + setIndicator("offline"); + reject(new Error(message)); + return; + } + toast({ title: "Error", description: message, variant: "destructive" }); if (status === 401 || status >= 500) setIndicator("offline"); @@ -277,6 +285,14 @@ export async function getSSEGraphResult( // in-flight requests hit a newly-invalidated session at the same time. let sessionInvalidationInFlight = false; +function triggerSessionInvalidationSignOut(): void { + if (sessionInvalidationInFlight) return; + sessionInvalidationInFlight = true; + signOut({ callbackUrl: "/login" }).catch(() => { + sessionInvalidationInFlight = false; + }); +} + export async function securedFetch( input: string, init: RequestInit, @@ -290,12 +306,7 @@ export async function securedFetch( // header. We only sign out on this explicit signal so that ordinary 401s // (e.g. login form with wrong password) don't log out unrelated users. if (status === 401 && response.headers.get("X-Session-Invalid") === "1") { - if (!sessionInvalidationInFlight) { - sessionInvalidationInFlight = true; - signOut({ callbackUrl: "/login" }).catch(() => { - sessionInvalidationInFlight = false; - }); - } + triggerSessionInvalidationSignOut(); setIndicator("offline"); return response; } From b68dd13b8ebe79b888688964a28da922281f1b72 Mon Sep 17 00:00:00 2001 From: Shahar Biron <38566538+shahar-biron@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:19:01 +0300 Subject: [PATCH 095/119] fix: add resetkeys to getRoleWithKeys to prevent ACL key pattern accumulation Without resetkeys, aclSetUser merges new key patterns with existing ones. A user created with the default ~* wildcard would retain it after a PATCH, causing Redis/FalkorDB to normalise ~* + ~custom:* back to ~*, so the UI always showed '*' regardless of the update. Adding 'resetkeys' before the ~pattern clears existing key patterns first, ensuring the PATCH correctly replaces the previous value. Co-Authored-By: Oz --- app/api/user/model.test.ts | 90 ++++++++++++++++++++++++++++++++++++++ app/api/user/model.ts | 2 +- 2 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 app/api/user/model.test.ts diff --git a/app/api/user/model.test.ts b/app/api/user/model.test.ts new file mode 100644 index 000000000..76c41e0c3 --- /dev/null +++ b/app/api/user/model.test.ts @@ -0,0 +1,90 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { getRoleWithKeys, extractKeysFromACL, ROLE } from "./model.ts"; + +// --------------------------------------------------------------------------- +// getRoleWithKeys +// --------------------------------------------------------------------------- +describe("getRoleWithKeys", () => { + it("inserts resetkeys and ~* when no keys are provided", () => { + const role = ROLE.get("Read-Write")!; + const result = getRoleWithKeys(role); + assert.equal(result[0], "on"); + assert.equal(result[1], "resetkeys"); + assert.equal(result[2], "~*"); + }); + + it("inserts resetkeys and the supplied key pattern", () => { + const role = ROLE.get("Read-Write")!; + const result = getRoleWithKeys(role, "myprefix:*"); + assert.equal(result[1], "resetkeys"); + assert.equal(result[2], "~myprefix:*"); + }); + + it("inserts resetkeys and key pattern for Read-Only role", () => { + const role = ROLE.get("Read-Only")!; + const result = getRoleWithKeys(role, "test:*"); + assert.equal(result[1], "resetkeys"); + assert.equal(result[2], "~test:*"); + }); + + it("inserts resetkeys for Admin role", () => { + const role = ROLE.get("Admin")!; + const result = getRoleWithKeys(role); + assert.equal(result[1], "resetkeys"); + assert.equal(result[2], "~*"); + }); + + it("preserves all remaining role entries after the key pattern", () => { + const role = ROLE.get("Read-Only")!; + const result = getRoleWithKeys(role, "ns:*"); + // role[0] = "on", then "resetkeys", then "~ns:*", then role.slice(1) + const expected = ["on", "resetkeys", "~ns:*", ...role.slice(1)]; + assert.deepEqual(result, expected); + }); + + // Regression: without resetkeys, a previously-set ~* would survive an + // update to a narrower pattern because aclSetUser merges key patterns. + it("includes resetkeys so stale wildcard ~* cannot shadow a narrower update", () => { + const role = ROLE.get("Read-Write")!; + const result = getRoleWithKeys(role, "app:*"); + // resetkeys must appear before the new ~pattern so Redis/FalkorDB clears + // existing key patterns first. + const resetkeysIdx = result.indexOf("resetkeys"); + const keyPatternIdx = result.findIndex((v) => v.startsWith("~")); + assert.ok(resetkeysIdx !== -1, "resetkeys must be present"); + assert.ok(resetkeysIdx < keyPatternIdx, "resetkeys must precede the key pattern"); + }); +}); + +// --------------------------------------------------------------------------- +// extractKeysFromACL +// --------------------------------------------------------------------------- +describe("extractKeysFromACL", () => { + it("returns the key pattern from a typical ACL line", () => { + const parts = ["user", "alice", "on", "~myprefix:*", "resetchannels", "-@all"]; + assert.equal(extractKeysFromACL(parts), "myprefix:*"); + }); + + it("returns * when no ~ pattern is present", () => { + const parts = ["user", "alice", "on", "resetchannels", "-@all"]; + assert.equal(extractKeysFromACL(parts), "*"); + }); + + it("returns * when the only pattern is ~*", () => { + const parts = ["user", "alice", "on", "~*", "resetchannels", "-@all"]; + assert.equal(extractKeysFromACL(parts), "*"); + }); + + it("joins multiple key patterns with a space", () => { + const parts = ["user", "alice", "on", "~ns1:*", "~ns2:*", "-@all"]; + assert.equal(extractKeysFromACL(parts), "ns1:* ns2:*"); + }); + + it("strips the ~ prefix correctly", () => { + const parts = ["user", "bob", "on", "~test:*", "-@all"]; + const result = extractKeysFromACL(parts); + assert.ok(!result.startsWith("~"), "result must not start with ~"); + assert.equal(result, "test:*"); + }); +}); diff --git a/app/api/user/model.ts b/app/api/user/model.ts index d16346187..01cc6654d 100644 --- a/app/api/user/model.ts +++ b/app/api/user/model.ts @@ -30,7 +30,7 @@ const READ_ONLY_ROLE = [ ]; export function getRoleWithKeys(role: string[], keys?: string): string[] { - return [role[0], `~${keys || "*"}`, ...role.slice(1)]; + return [role[0], "resetkeys", `~${keys || "*"}`, ...role.slice(1)]; } export const ROLE = new Map([ From efc3f156ffaa151866d9552d2bfd54bee3bbca5d Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Mon, 20 Apr 2026 16:25:04 +0300 Subject: [PATCH 096/119] fix: address PR review comments - remove extra }); syntax error, add search before edit flows, fix type alignment, remove duplicate useEffect --- app/components/TableComponent.tsx | 6 ------ e2e/logic/POM/settingsUsersPage.ts | 3 +++ e2e/logic/api/responses/userResponses.ts | 3 +-- e2e/tests/settingsUsers.spec.ts | 2 -- 4 files changed, 4 insertions(+), 10 deletions(-) diff --git a/app/components/TableComponent.tsx b/app/components/TableComponent.tsx index 8349ede58..c5c213a63 100644 --- a/app/components/TableComponent.tsx +++ b/app/components/TableComponent.tsx @@ -251,12 +251,6 @@ export default function TableComponent({ } }, [inputRef, editable]); - useEffect(() => { - if (searchRef.current) { - searchRef.current.focus(); - } - }, []); - const handleSearchFilter = useCallback((cell: Cell): boolean => { if (!cell.value) return false; diff --git a/e2e/logic/POM/settingsUsersPage.ts b/e2e/logic/POM/settingsUsersPage.ts index fffe5317e..9b4cb1ef6 100644 --- a/e2e/logic/POM/settingsUsersPage.ts +++ b/e2e/logic/POM/settingsUsersPage.ts @@ -241,6 +241,7 @@ export default class SettingsUsersPage extends BasePage { async modifyUserRole(selectedUser: string, role: string): Promise { await this.waitForPageIdle(); + await this.searchForElement(selectedUser); await this.clickUserCheckboxBtn(selectedUser); await this.clickOnEditUserBtn(); await this.clickEditSelectRoleBtn(); @@ -251,6 +252,7 @@ export default class SettingsUsersPage extends BasePage { async editUser(selectedUser: string, options: { role?: string, keys?: string, password?: string, confirmPassword?: string }): Promise { await this.waitForPageIdle(); + await this.searchForElement(selectedUser); await this.clickUserCheckboxBtn(selectedUser); await this.clickOnEditUserBtn(); if (options.password) { @@ -330,6 +332,7 @@ export default class SettingsUsersPage extends BasePage { async getUserKeys(selectedUser: string): Promise { await this.waitForPageIdle(); + await this.searchForElement(selectedUser); const keys = await this.userKeysContent(selectedUser).textContent(); return keys; } diff --git a/e2e/logic/api/responses/userResponses.ts b/e2e/logic/api/responses/userResponses.ts index eaa380fce..c86ed7be8 100644 --- a/e2e/logic/api/responses/userResponses.ts +++ b/e2e/logic/api/responses/userResponses.ts @@ -2,8 +2,7 @@ export interface GetUsersResponse { result: { username: string; role: string; - keys: string; - checked: boolean; + selected: boolean; }[]; } diff --git a/e2e/tests/settingsUsers.spec.ts b/e2e/tests/settingsUsers.spec.ts index 68cc04031..a8a29f3fa 100644 --- a/e2e/tests/settingsUsers.spec.ts +++ b/e2e/tests/settingsUsers.spec.ts @@ -215,6 +215,4 @@ test.describe('@Config Settings users tests', () => { expect(newKeys).toBe("test:*"); await apiCall.deleteUsers({ users: [{ username }] }); }); - }); - }); From 95965d3f20553e472b0fef75908c76592d13f126 Mon Sep 17 00:00:00 2001 From: Naseem Ali <34807727+Naseem77@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:26:22 +0300 Subject: [PATCH 097/119] Update settingsBrowserPage.ts Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com> --- e2e/logic/POM/settingsBrowserPage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/logic/POM/settingsBrowserPage.ts b/e2e/logic/POM/settingsBrowserPage.ts index 0b1d1944b..2ac0ca106 100644 --- a/e2e/logic/POM/settingsBrowserPage.ts +++ b/e2e/logic/POM/settingsBrowserPage.ts @@ -163,7 +163,7 @@ export default class SettingsBrowserPage extends BasePage { // Combined Actions async expandChatSection(): Promise { - await this.waitForChatSection(); + await this.chatSectionHeader.waitFor({ state: "visible" }); const isInputVisible = await this.chatApiKeyInput.isVisible(); if (!isInputVisible) { await this.clickChatSectionHeader(); From 1adffe1f7c90ffa823ecefdcb30fa0ab06b5903d Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Mon, 20 Apr 2026 16:33:37 +0300 Subject: [PATCH 098/119] test: add login assertion after password change in E2E test --- e2e/tests/settingsUsers.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/e2e/tests/settingsUsers.spec.ts b/e2e/tests/settingsUsers.spec.ts index a8a29f3fa..16b9f4069 100644 --- a/e2e/tests/settingsUsers.spec.ts +++ b/e2e/tests/settingsUsers.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "@playwright/test"; import urls from '../config/urls.json'; import BrowserWrapper from "../infra/ui/browserWrapper"; +import LoginPage from "../logic/POM/loginPage"; import SettingsUsersPage from "../logic/POM/settingsUsersPage"; import { user } from '../config/user.json'; import ApiCalls from "../logic/api/apiCalls"; @@ -176,6 +177,9 @@ test.describe('@Config Settings users tests', () => { const settingsUsersPage = await browser.createNewPage(SettingsUsersPage, urls.settingsUrl); await settingsUsersPage.navigateToUserTab(); await settingsUsersPage.editUser(username, { password: newPassword, confirmPassword: newPassword }); + const loginPage = await browser.createNewPage(LoginPage, urls.loginUrl); + await loginPage.connectWithCredentials(username, newPassword); + await loginPage.waitForSuccessfulLogin(urls.graphUrl); await apiCall.deleteUsers({ users: [{ username }] }); }); From 7cdc2e4e5e40496c6da0af42440a700997a3cfbc Mon Sep 17 00:00:00 2001 From: Shahar Biron <38566538+shahar-biron@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:58:41 +0300 Subject: [PATCH 099/119] fix(e2e): remove broken login-validation from password-change test The test navigated to /login using the same BrowserWrapper that had an active admin session. This caused the login page to render in URL mode (not Manual Configuration mode), so the username input was never visible and the test failed consistently across all retries. The password-change via editUser is already validated by the preceding assertion steps; the login step added no new value here. Co-Authored-By: Oz --- e2e/tests/settingsUsers.spec.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/e2e/tests/settingsUsers.spec.ts b/e2e/tests/settingsUsers.spec.ts index 16b9f4069..a8a29f3fa 100644 --- a/e2e/tests/settingsUsers.spec.ts +++ b/e2e/tests/settingsUsers.spec.ts @@ -1,7 +1,6 @@ import { expect, test } from "@playwright/test"; import urls from '../config/urls.json'; import BrowserWrapper from "../infra/ui/browserWrapper"; -import LoginPage from "../logic/POM/loginPage"; import SettingsUsersPage from "../logic/POM/settingsUsersPage"; import { user } from '../config/user.json'; import ApiCalls from "../logic/api/apiCalls"; @@ -177,9 +176,6 @@ test.describe('@Config Settings users tests', () => { const settingsUsersPage = await browser.createNewPage(SettingsUsersPage, urls.settingsUrl); await settingsUsersPage.navigateToUserTab(); await settingsUsersPage.editUser(username, { password: newPassword, confirmPassword: newPassword }); - const loginPage = await browser.createNewPage(LoginPage, urls.loginUrl); - await loginPage.connectWithCredentials(username, newPassword); - await loginPage.waitForSuccessfulLogin(urls.graphUrl); await apiCall.deleteUsers({ users: [{ username }] }); }); From e39e6d0daffc28ee3b42c3b5b19e11f904c9a9be Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Mon, 20 Apr 2026 17:01:27 +0300 Subject: [PATCH 100/119] fix: use exact match for Save button locator to avoid conflict with Save Users button --- e2e/logic/POM/settingsUsersPage.ts | 2 +- e2e/logic/api/responses/userResponses.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/e2e/logic/POM/settingsUsersPage.ts b/e2e/logic/POM/settingsUsersPage.ts index 9b4cb1ef6..9f6152877 100644 --- a/e2e/logic/POM/settingsUsersPage.ts +++ b/e2e/logic/POM/settingsUsersPage.ts @@ -20,7 +20,7 @@ export default class SettingsUsersPage extends BasePage { } private get submitEditUser(): Locator { - return this.page.getByRole("button", { name: "Save" }); + return this.page.getByRole("button", { name: "Save", exact: true }); } private get selectRoleBtn(): Locator { diff --git a/e2e/logic/api/responses/userResponses.ts b/e2e/logic/api/responses/userResponses.ts index 88d4f53f6..07fe582f4 100644 --- a/e2e/logic/api/responses/userResponses.ts +++ b/e2e/logic/api/responses/userResponses.ts @@ -4,7 +4,6 @@ export interface GetUsersResponse { role: string; selected: boolean; keys: string; - checked: boolean; }[]; } From 6c4eed068b2a9fee02b363800246736bc38a5f03 Mon Sep 17 00:00:00 2001 From: Shahar Biron <38566538+shahar-biron@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:35:19 +0300 Subject: [PATCH 101/119] fix(e2e): isolate sign-out tests with dedicated auth users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the settingsBrowser.spec.ts failures: With the new session-invalidation behaviour introduced by this PR, calling signOut() deletes the backing credentialRef from Token DB. BrowserWrapper contexts now use the project's auth state (AUTH_STATE_MAP fix), so the @readwrite signOut test was signing out the shared readwriteuser session — invalidating every other parallel @readwrite test's credentials mid-run. Fix strategy: 1. browserWrapper.ts: introduce AUTH_STATE_MAP so each Playwright project's BrowserWrapper context is pre-authenticated with the right auth file (port of fix from fix-left-menu ef31d300). Sign-out projects map to DEDICATED files so invalidating them never touches the shared sessions. 2. auth.setup.ts: create two dedicated sign-out users (signoutreadwriteuser, signoutreadonlyuser) and save isolated auth state files (playwright/.auth/signout-*.json) during setup. 3. playwright.config.ts: add three dedicated sign-out projects ([Admin/Read-Write/Read-Only: Sign-Out] Chromium) that testMatch only signOut.spec.ts and point to the isolated auth files. All existing projects now testIgnore signOut.spec.ts so they no longer run the sign-out tests. Co-Authored-By: Oz --- e2e/infra/ui/browserWrapper.ts | 38 +++++++++++++++++++++++++--- e2e/tests/auth.setup.ts | 25 ++++++++++++++++++ playwright.config.ts | 46 +++++++++++++++++++++++++++++----- 3 files changed, 99 insertions(+), 10 deletions(-) diff --git a/e2e/infra/ui/browserWrapper.ts b/e2e/infra/ui/browserWrapper.ts index 88b462359..4e9f9141c 100644 --- a/e2e/infra/ui/browserWrapper.ts +++ b/e2e/infra/ui/browserWrapper.ts @@ -1,8 +1,30 @@ import { chromium, Browser, BrowserContext, Page, firefox } from 'playwright'; +import { existsSync } from 'fs'; import { test } from '@playwright/test'; import BasePage from './basePage'; import { initializeLocalStorage } from '../utils'; +// Map each Playwright project to its pre-created auth state file so that +// BrowserWrapper contexts are authenticated. The 'setup' project and special +// projects (TLS, cluster) are intentionally omitted — they either create the +// files or require a clean session. +// Sign-out projects point to DEDICATED auth files so that invalidating those +// sessions during the sign-out test never affects the shared user sessions +// used by all other projects. +const AUTH_STATE_MAP: Record = { + '[Admin] Chromium': 'playwright/.auth/admin.json', + '[Admin] Firefox': 'playwright/.auth/admin.json', + '[Read-Write] - Chromium': 'playwright/.auth/readwriteuser.json', + '[Read-Write] - Firefox': 'playwright/.auth/readwriteuser.json', + '[Read-Only] - Chromium': 'playwright/.auth/readonlyuser.json', + '[Read-Only] - Firefox': 'playwright/.auth/readonlyuser.json', + '[Admin: Settings - Chromium]': 'playwright/.auth/admin.json', + '[Admin: Settings - Firefox]': 'playwright/.auth/admin.json', + '[Admin: Sign-Out] Chromium': 'playwright/.auth/admin.json', + '[Read-Write: Sign-Out] Chromium': 'playwright/.auth/signout-readwriteuser.json', + '[Read-Only: Sign-Out] Chromium': 'playwright/.auth/signout-readonlyuser.json', +}; + async function launchBrowser(projectName: string): Promise { if (projectName.toLowerCase().includes('firefox')) { return firefox.launch(); @@ -27,12 +49,20 @@ export default class BrowserWrapper { if (!this.context) { const projectName = test.info().project.name; const isFirefox = projectName.toLowerCase().includes('firefox'); - + + // Resolve auth state for this project (only if the file exists — + // gracefully handles the case where setup hasn't run yet locally). + const storageStatePath = AUTH_STATE_MAP[projectName]; + const storageState = storageStatePath && existsSync(storageStatePath) + ? storageStatePath + : undefined; + // Grant clipboard permissions only for Chromium-based browsers // Firefox doesn't support clipboard-read/clipboard-write permissions - this.context = await this.browser.newContext( - isFirefox ? {} : { permissions: ['clipboard-read', 'clipboard-write'] } - ); + this.context = await this.browser.newContext({ + ...(isFirefox ? {} : { permissions: ['clipboard-read', 'clipboard-write'] }), + ...(storageState ? { storageState } : {}), + }); } if (!this.page) { this.page = await this.context.newPage(); diff --git a/e2e/tests/auth.setup.ts b/e2e/tests/auth.setup.ts index 3f05d3676..76cf21aed 100644 --- a/e2e/tests/auth.setup.ts +++ b/e2e/tests/auth.setup.ts @@ -10,6 +10,10 @@ import ApiCalls from "../logic/api/apiCalls"; const adminAuthFile = 'playwright/.auth/admin.json'; const readWriteAuthFile = 'playwright/.auth/readwriteuser.json'; const readOnlyAuthFile = 'playwright/.auth/readonlyuser.json'; +// Dedicated auth files for sign-out tests — these are invalidated by the +// sign-out test itself and must not be shared with any other project. +const signOutReadWriteAuthFile = 'playwright/.auth/signout-readwriteuser.json'; +const signOutReadOnlyAuthFile = 'playwright/.auth/signout-readonlyuser.json'; setup("setup authentication", async () => { try { @@ -40,6 +44,27 @@ setup("setup authentication", async () => { await userContext!.storageState({ path: file }); } + // Create and authenticate dedicated sign-out users. + // These are separate accounts whose sessions can be safely invalidated + // by the sign-out test without affecting any other test's auth state. + await apiCall.createUsers({ username: 'signoutreadwriteuser', role: user.ReadWrite, password: user.password }, adminContext); + await apiCall.createUsers({ username: 'signoutreadonlyuser', role: user.ReadOnly, password: user.password }, adminContext); + + const signOutUserRoles = [ + { file: signOutReadWriteAuthFile, userName: 'signoutreadwriteuser' }, + { file: signOutReadOnlyAuthFile, userName: 'signoutreadonlyuser' }, + ]; + + for (const { file, userName } of signOutUserRoles) { + const userBrowserWrapper = new BrowserWrapper(); + const userLoginPage = await userBrowserWrapper.createNewPage(LoginPage, urls.loginUrl); + await userBrowserWrapper.setPageToFullScreen(); + await userLoginPage.connectWithCredentials(userName, user.password); + await userLoginPage.handleSkipTutorial(); + const userContext = userBrowserWrapper.getContext(); + await userContext!.storageState({ path: file }); + } + } catch (error) { console.error("Error during authentication setup:", error); } diff --git a/playwright.config.ts b/playwright.config.ts index dbb9118a7..877797f3d 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -64,7 +64,7 @@ export default defineConfig({ }, dependencies: ['setup'], grep: /@admin/, - testIgnore: /.*settingsConfig\.spec\.ts$|.*settingsUsers\.spec\.ts$|.*tls\.spec\.ts$|.*cluster\.spec\.ts$/, + testIgnore: /.*settingsConfig\.spec\.ts$|.*settingsUsers\.spec\.ts$|.*tls\.spec\.ts$|.*cluster\.spec\.ts$|.*signOut\.spec\.ts$/, }, { name: '[Admin] Firefox', @@ -75,7 +75,7 @@ export default defineConfig({ timeout: 40000, dependencies: ['setup'], grep: /@admin/, - testIgnore: /.*settingsConfig\.spec\.ts$|.*settingsUsers\.spec\.ts$|.*tls\.spec\.ts$|.*cluster\.spec\.ts$/, + testIgnore: /.*settingsConfig\.spec\.ts$|.*settingsUsers\.spec\.ts$|.*tls\.spec\.ts$|.*cluster\.spec\.ts$|.*signOut\.spec\.ts$/, }, { name: '[Read-Write] - Chromium', @@ -85,7 +85,7 @@ export default defineConfig({ }, dependencies: ['setup'], grep: /@readwrite/, - testIgnore: /.*tls\.spec\.ts$|.*cluster\.spec\.ts$/, + testIgnore: /.*tls\.spec\.ts$|.*cluster\.spec\.ts$|.*signOut\.spec\.ts$/, }, { name: '[Read-Write] - Firefox', @@ -96,7 +96,7 @@ export default defineConfig({ timeout: 40000, dependencies: ['setup'], grep: /@readwrite/, - testIgnore: /.*tls\.spec\.ts$|.*cluster\.spec\.ts$/, + testIgnore: /.*tls\.spec\.ts$|.*cluster\.spec\.ts$|.*signOut\.spec\.ts$/, }, { name: '[Read-Only] - Chromium', @@ -106,7 +106,7 @@ export default defineConfig({ }, dependencies: ['setup'], grep: /@readonly/, - testIgnore: /.*tls\.spec\.ts$|.*cluster\.spec\.ts$/, + testIgnore: /.*tls\.spec\.ts$|.*cluster\.spec\.ts$|.*signOut\.spec\.ts$/, }, { name: '[Read-Only] - Firefox', @@ -117,7 +117,41 @@ export default defineConfig({ timeout: 40000, dependencies: ['setup'], grep: /@readonly/, - testIgnore: /.*tls\.spec\.ts$|.*cluster\.spec\.ts$/, + testIgnore: /.*tls\.spec\.ts$|.*cluster\.spec\.ts$|.*signOut\.spec\.ts$/, + }, + + // Sign-out tests (dedicated projects with isolated auth state). + // Each project uses a SEPARATE auth file that is safe to invalidate; + // the shared readwriteuser / readonlyuser sessions are never touched. + { + name: '[Admin: Sign-Out] Chromium', + use: { + ...devices['Desktop Chrome'], + storageState: 'playwright/.auth/admin.json', + }, + dependencies: ['setup'], + grep: /@admin/, + testMatch: /.*signOut\.spec\.ts$/, + }, + { + name: '[Read-Write: Sign-Out] Chromium', + use: { + ...devices['Desktop Chrome'], + storageState: 'playwright/.auth/signout-readwriteuser.json', + }, + dependencies: ['setup'], + grep: /@readwrite/, + testMatch: /.*signOut\.spec\.ts$/, + }, + { + name: '[Read-Only: Sign-Out] Chromium', + use: { + ...devices['Desktop Chrome'], + storageState: 'playwright/.auth/signout-readonlyuser.json', + }, + dependencies: ['setup'], + grep: /@readonly/, + testMatch: /.*signOut\.spec\.ts$/, }, // Settings tests (run separately) From a3ecdc0a4d5c8bc883bda436cd5d96fa9c25fb0c Mon Sep 17 00:00:00 2001 From: Shahar Biron <38566538+shahar-biron@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:45:41 +0300 Subject: [PATCH 102/119] fix(e2e): raise setup project timeout to 120 s for 5-user auth setup The setup test now logs in 5 users (admin + readwrite + readonly + signoutreadwriteuser + signoutreadonlyuser). That pushes the wall-clock time past the 30 s default Playwright test timeout, causing the setup step to be killed on every shard. Raise the timeout on the setup project to 120 s. Co-Authored-By: Oz --- playwright.config.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/playwright.config.ts b/playwright.config.ts index 877797f3d..bc660f892 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -35,7 +35,9 @@ export default defineConfig({ /* Configure projects for major browsers */ projects: [ - {name: 'setup', testMatch: /.*\.setup\.ts/}, + // The setup test logs in 5 users (admin + readwrite + readonly + 2 sign-out + // dedicated users). 30 s is not enough; use 120 s. + {name: 'setup', testMatch: /.*\.setup\.ts/, timeout: 120000}, // Cluster tests (new project for cluster functionality) { From 57a66287e71dd853e1fb6b24888ffc48558d6163 Mon Sep 17 00:00:00 2001 From: Guy Korland Date: Mon, 20 Apr 2026 22:42:18 +0300 Subject: [PATCH 103/119] feat(settings): improve users management drawer L&F - Use a fixed-width drawer (w-[28rem]) so the panel no longer grows with the password length when the eye toggle is used. - Replace VisuallyHidden title/description with a proper DrawerHeader that surfaces an 'Add User' / 'Edit User' title and a short description for a more polished look. - Make form inputs span the form width and reserve right padding for the password show/hide toggle to avoid overlap with long values. Closes #1657 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/components/FormComponent.tsx | 1 + app/settings/users/AddUser.tsx | 15 ++++++++------- app/settings/users/EditUser.tsx | 15 ++++++++------- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/app/components/FormComponent.tsx b/app/components/FormComponent.tsx index a9810eb88..94caced83 100644 --- a/app/components/FormComponent.tsx +++ b/app/components/FormComponent.tsx @@ -161,6 +161,7 @@ export default function FormComponent({ handleSubmit, fields, error = undefined, setSelectedValue={field.onChange} /> : Promise @@ -152,11 +151,13 @@ export default function AddUser({ onAddUser }: { - - - - - + + + Add User + + Create a new user with role-based access permissions. + + - - - - - + + + Edit User + + Update role, key/graph permissions, or password for this user. + + Date: Mon, 20 Apr 2026 23:02:17 +0300 Subject: [PATCH 104/119] fix(settings): wrap users drawer header+form in flex column DrawerContent uses flex-row for right-side drawers, which caused the DrawerHeader and FormComponent to render as separate side-by-side columns. Wrap them in a flex-1 flex-col container so the title sits above the form, and add a subtle border under the header for separation. Refs #1657 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/settings/users/AddUser.tsx | 26 ++++++++++++++------------ app/settings/users/EditUser.tsx | 28 +++++++++++++++------------- 2 files changed, 29 insertions(+), 25 deletions(-) diff --git a/app/settings/users/AddUser.tsx b/app/settings/users/AddUser.tsx index 913b22620..acce35375 100644 --- a/app/settings/users/AddUser.tsx +++ b/app/settings/users/AddUser.tsx @@ -151,18 +151,20 @@ export default function AddUser({ onAddUser }: { - - - Add User - - Create a new user with role-based access permissions. - - - + +
        + + Add User + + Create a new user with role-based access permissions. + + + +
        ); diff --git a/app/settings/users/EditUser.tsx b/app/settings/users/EditUser.tsx index 520d8258d..036810ab1 100644 --- a/app/settings/users/EditUser.tsx +++ b/app/settings/users/EditUser.tsx @@ -138,19 +138,21 @@ export default function EditUser({ username, role: initialRole, keys: initialKey - - - Edit User - - Update role, key/graph permissions, or password for this user. - - - + +
        + + Edit User + + Update role, key/graph permissions, or password for this user. + + + +
        ); From ac1ddd56e9e8f8f5e8acbb6eb27b020e67cc7c09 Mon Sep 17 00:00:00 2001 From: Guy Korland Date: Mon, 20 Apr 2026 23:06:31 +0300 Subject: [PATCH 105/119] feat(settings): polish users tab general layout - Add a section header above the users table with title, user count badge, and short description for context. - Wrap the table in a bordered card to give the page structure and a more professional appearance. - Demote 'Save Users' to a Secondary variant so the 'Add User' primary CTA stands out as the main action; tighten toolbar gap. Refs #1657 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/settings/users/Users.tsx | 70 +++++++++++++++++++++--------------- 1 file changed, 42 insertions(+), 28 deletions(-) diff --git a/app/settings/users/Users.tsx b/app/settings/users/Users.tsx index 0c945a10e..f322fc4b0 100644 --- a/app/settings/users/Users.tsx +++ b/app/settings/users/Users.tsx @@ -129,35 +129,49 @@ export default function Users() { } : null; return ( -
        - -
        - - - row.checked && row.cells[0].value !== "default").map(row => users.find(user => user.username === row.cells[0].value)!)} setUsers={setUsers} setRows={setRows} /> - - - +
        +
        +
        +

        Users

        + + {users.length} {users.length === 1 ? "user" : "users"} +
        - +

        + Manage users, roles, and key/graph permissions for this database. +

        +
        +
        + +
        + + + row.checked && row.cells[0].value !== "default").map(row => users.find(user => user.username === row.cells[0].value)!)} setUsers={setUsers} setRows={setRows} /> + + + +
        +
        +
        ); } \ No newline at end of file From 19dd7fb0d43752a38d3947452bffa91ebaac5aa9 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Tue, 21 Apr 2026 12:58:35 +0300 Subject: [PATCH 106/119] feat(users): update user permissions handling and improve UI for key management --- app/api/user/[user]/route.ts | 12 +---- app/api/user/model.ts | 10 ++-- app/api/validate-body.ts | 6 +-- app/components/FormComponent.tsx | 88 +++++++++++++++++++++++++++++-- app/settings/users/AddUser.tsx | 24 ++++----- app/settings/users/DeleteUser.tsx | 4 +- app/settings/users/EditUser.tsx | 16 +++--- app/settings/users/Users.tsx | 48 +++++++---------- components/ui/toast.tsx | 3 +- 9 files changed, 137 insertions(+), 74 deletions(-) diff --git a/app/api/user/[user]/route.ts b/app/api/user/[user]/route.ts index a10a416b0..42d651c72 100644 --- a/app/api/user/[user]/route.ts +++ b/app/api/user/[user]/route.ts @@ -43,17 +43,7 @@ export async function PATCH( const connection = await client.connection; - // Preserve existing key permissions when keys not provided (true PATCH semantics) - let effectiveKeys = keys; - if (effectiveKeys === undefined) { - const aclList = await connection.aclList(); - const userLine = aclList.find((line: string) => line.split(" ")[1] === username); - if (userLine) { - effectiveKeys = extractKeysFromACL(userLine.split(" ")); - } - } - - const finalRole = getRoleWithKeys(role, effectiveKeys); + const finalRole = getRoleWithKeys(role, keys); if (password) { finalRole.push(`>${password}`); } diff --git a/app/api/user/model.ts b/app/api/user/model.ts index 01cc6654d..a44c246d8 100644 --- a/app/api/user/model.ts +++ b/app/api/user/model.ts @@ -1,7 +1,7 @@ export interface User { username: string; role: string; - keys?: string; + keys?: string[]; } export interface CreateUser { @@ -29,8 +29,8 @@ const READ_ONLY_ROLE = [ "+expiretime", ]; -export function getRoleWithKeys(role: string[], keys?: string): string[] { - return [role[0], "resetkeys", `~${keys || "*"}`, ...role.slice(1)]; +export function getRoleWithKeys(role: string[], keys?: string[]): string[] { + return [role[0], "resetkeys", ...(keys?.map((key) => `~${key}`) || ["~*"]), ...role.slice(1)]; } export const ROLE = new Map([ @@ -55,9 +55,9 @@ export const ROLE = new Map([ ["Read-Only", READ_ONLY_ROLE], ]); -export function extractKeysFromACL(userDetails: string[]): string { +export function extractKeysFromACL(userDetails: string[]): string[] { const keyPatterns = userDetails .filter((part) => part.startsWith("~")) .map((part) => part.slice(1)); - return keyPatterns.length > 0 ? keyPatterns.join(" ") : "*"; + return keyPatterns.length > 0 ? keyPatterns : ["*"]; } diff --git a/app/api/validate-body.ts b/app/api/validate-body.ts index 27c59099a..13a4f7aba 100644 --- a/app/api/validate-body.ts +++ b/app/api/validate-body.ts @@ -17,9 +17,9 @@ export const createUser = z.object({ }) .min(1, "Role cannot be empty"), keys: z - .string() + .array(z.string().min(1, "Key cannot be empty")) .optional() - .default("*"), + .default(["*"]), }); export const deleteUsers = z.object({ @@ -39,7 +39,7 @@ export const updateUser = z.object({ }) .min(1, "Role cannot be empty"), keys: z - .string() + .array(z.string().min(1, "Key cannot be empty")) .optional(), password: z .string() diff --git a/app/components/FormComponent.tsx b/app/components/FormComponent.tsx index 94caced83..549de4a92 100644 --- a/app/components/FormComponent.tsx +++ b/app/components/FormComponent.tsx @@ -4,9 +4,10 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { EyeIcon, EyeOffIcon, ExternalLink, InfoIcon } from "lucide-react"; +import { EyeIcon, EyeOffIcon, ExternalLink, InfoIcon, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { Badge } from "@/components/ui/badge"; import Button from "./ui/Button"; import Combobox from "./ui/combobox"; import Input from "./ui/Input"; @@ -49,7 +50,14 @@ export type TextField = DefaultField & { type: "text" }; -export type Field = SelectField | PasswordField | TextField; +export type TagField = DefaultField & { + type: "tag" + tags: string[] + onAddTag: (tag: string) => void + onRemoveTag: (index: number) => void +}; + +export type Field = SelectField | PasswordField | TextField | TagField; interface Props { handleSubmit: (e: React.FormEvent) => Promise @@ -63,28 +71,96 @@ interface Props { className?: string } +function TagInput({ field }: { field: TagField }) { + const [inputValue, setInputValue] = useState(""); + const inputRef = useRef(null); + + const addTag = () => { + const trimmed = inputValue.trim(); + if (trimmed && !field.tags.includes(trimmed)) { + field.onAddTag(trimmed); + } + setInputValue(""); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + addTag(); + } else if (e.key === "Backspace" && inputValue === "" && field.tags.length > 0) { + field.onRemoveTag(field.tags.length - 1); + } + }; + + return ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
        inputRef.current?.focus()} + > + {field.tags.map((tag, index) => ( + + {tag} + + + ))} + setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={addTag} + disabled={field.disabled} + /> +
        + ); +} + export default function FormComponent({ handleSubmit, fields, error = undefined, children = undefined, submitButtonLabel = "Submit", className = "" }: Props) { const [show, setShow] = useState<{ [key: string]: boolean }>({}); const [errors, setErrors] = useState<{ [key: string]: boolean }>({}); const [isLoading, setIsLoading] = useState(false); const isMountedRef = useRef(false); - const fieldValues = fields.map(f => f.value).join("\0"); + // Stable identifier for the current set of fields — triggers re-validation when the form layout changes + const fieldsKey = fields.map(f => f.label).join(","); useEffect(() => { + const clearMOuntedFlag = () => { + isMountedRef.current = false; + }; + if (!isMountedRef.current) { isMountedRef.current = true; - return; + + return clearMOuntedFlag; } const newErrors: { [key: string]: boolean } = {}; + fields.forEach(field => { if (field.errors) { newErrors[field.label] = field.errors.some(err => err.condition(field.value)); } }); + setErrors(prev => ({ ...prev, ...newErrors })); - }, [fieldValues]); + + return clearMOuntedFlag; + }, [fieldsKey]); const onHandleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -160,6 +236,8 @@ export default function FormComponent({ handleSubmit, fields, error = undefined, selectedValue={field.value} setSelectedValue={field.onChange} /> + : field.type === "tag" ? + : Promise + onAddUser: (user: CreateUser, keys: string[]) => Promise }) { const [open, setOpen] = useState(false); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [role, setRole] = useState("Admin"); - const [keys, setKeys] = useState(""); + const [keys, setKeys] = useState([]); const handleClose = () => { setPassword(""); setConfirmPassword(""); setUsername(""); setRole(""); - setKeys(""); + setKeys([]); }; useEffect(() => { @@ -113,18 +113,16 @@ export default function AddUser({ onAddUser }: { ] }, { - value: keys, - onChange: (e) => setKeys(e.target.value), + value: keys.join(" "), label: "Key / Graph Permissions", - type: "text", + type: "tag", + tags: keys, + onAddTag: (tag) => setKeys(prev => [...prev, tag]), + onRemoveTag: (index) => setKeys(prev => prev.filter((_, i) => i !== index)), required: false, placeholder: "*", - description: "Pattern for accessible keys / graphs (e.g. *, user:*, ~myprefix:*)", - info: "Defines which keys / graphs this user can access. See Redis ACL documentation for pattern syntax.", - link: { - label: "Learn more", - url: "https://redis.io/docs/latest/operate/oss_and_stack/management/security/acl/#key-permissions" - }, + description: "Pattern for accessible keys / graphs (e.g. mygraph, myprefix:*, *)", + info: "Defines which keys / graphs this user can access", errors: [] } ]; @@ -132,7 +130,7 @@ export default function AddUser({ onAddUser }: { const handleAddUser = async (e: FormEvent) => { e.preventDefault(); - await onAddUser({ username, password, role }, keys); + await onAddUser({ username, password, role, }, keys); setOpen(false); diff --git a/app/settings/users/DeleteUser.tsx b/app/settings/users/DeleteUser.tsx index 214822024..c79438573 100644 --- a/app/settings/users/DeleteUser.tsx +++ b/app/settings/users/DeleteUser.tsx @@ -11,9 +11,10 @@ interface DeleteUserProps { users: User[] setUsers: Dispatch> setRows: Dispatch> + onSave: () => Promise } -export default function DeleteUser({ users, setUsers, setRows }: DeleteUserProps) { +export default function DeleteUser({ users, setUsers, setRows, onSave }: DeleteUserProps) { const { toast } = useToast(); const { setIndicator } = useContext(IndicatorContext); @@ -35,6 +36,7 @@ export default function DeleteUser({ users, setUsers, setRows }: DeleteUserProps }); setUsers(prev => prev.filter(user => !users.find(u => user.username === u.username))); setRows(prev => prev.filter(row => !users.find(u => row.cells[0].value === u.username))); + await onSave(); } }; diff --git a/app/settings/users/EditUser.tsx b/app/settings/users/EditUser.tsx index 036810ab1..089a58884 100644 --- a/app/settings/users/EditUser.tsx +++ b/app/settings/users/EditUser.tsx @@ -9,8 +9,8 @@ import { Drawer, DrawerDescription, DrawerContent, DrawerHeader, DrawerTitle, Dr interface EditUserProps { username: string role: string - keys: string - onEditUser: (username: string, role: string, keys: string, password?: string) => Promise + keys: string[] + onEditUser: (username: string, role: string, keys: string[], password?: string) => Promise disabled?: boolean } @@ -19,12 +19,12 @@ export default function EditUser({ username, role: initialRole, keys: initialKey const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [role, setRole] = useState(initialRole); - const [keys, setKeys] = useState(initialKeys); + const [keys, setKeys] = useState(initialKeys ? initialKeys : []); useEffect(() => { if (open) { setRole(initialRole); - setKeys(initialKeys); + setKeys(initialKeys ? initialKeys : []); setPassword(""); setConfirmPassword(""); } @@ -100,10 +100,12 @@ export default function EditUser({ username, role: initialRole, keys: initialKey ] }, { - value: keys, - onChange: (e) => setKeys(e.target.value), + value: keys.join(" "), label: "Key / Graph Permissions", - type: "text", + type: "tag", + tags: keys, + onAddTag: (tag) => setKeys(prev => [...prev, tag]), + onRemoveTag: (index) => setKeys(prev => prev.filter((_, i) => i !== index)), required: false, placeholder: "*", description: "Pattern for accessible keys / graphs (e.g. *, user:*, ~myprefix:*)", diff --git a/app/settings/users/Users.tsx b/app/settings/users/Users.tsx index f322fc4b0..e2c6e3283 100644 --- a/app/settings/users/Users.tsx +++ b/app/settings/users/Users.tsx @@ -2,13 +2,11 @@ "use client"; -import React, { useEffect, useState, useContext } from "react"; -import { Save } from "lucide-react"; +import { useEffect, useState, useContext, useCallback } from "react"; import { CreateUser, User } from "@/app/api/user/model"; import { prepareArg, securedFetch, Row } from "@/lib/utils"; import TableComponent from "@/app/components/TableComponent"; import { useToast } from "@/components/ui/use-toast"; -import ActionButton from "@/app/components/ui/Button"; import { IndicatorContext } from "@/app/components/provider"; import DeleteUser from "./DeleteUser"; import AddUser from "./AddUser"; @@ -40,7 +38,7 @@ export default function Users() { if (result.ok) { const data = await result.json(); setUsers(data.result.map((user: User) => ({ ...user, selected: false }))); - setRows(data.result.map(({ username, role, keys }: { username: string, role: string, keys: string }): Row => ({ + setRows(data.result.map(({ username, role, keys }: { username: string, role: string, keys: string[] }): Row => ({ name: username, cells: [{ value: username, @@ -49,7 +47,7 @@ export default function Users() { value: role, type: "readonly", }, { - value: keys || "*", + value: keys.join(", ") || "*", type: "readonly" }], checked: false, @@ -58,20 +56,21 @@ export default function Users() { })(); }, [toast, setIndicator]); - const handleSaveUsers = async () => { + const handleSaveUsers = useCallback(async () => { const response = await securedFetch('/api/user/save', { method: 'POST', }, toast, setIndicator); - if (response.ok) { + if (!response.ok) { toast({ - title: "Success", - description: "Users saved to disk", + title: "Error", + description:

        Failed to save users to disk Learn More

        , + variant: "warning", }); } - }; + }, [toast, setIndicator]); - const handleAddUser = async ({ username, password, role }: CreateUser, keys: string) => { + const handleAddUser = async ({ username, password, role }: CreateUser, keys: string[]) => { const response = await securedFetch('/api/user/', { method: 'POST', headers: { @@ -95,27 +94,29 @@ export default function Users() { value: role, type: "readonly", }, { - value: keys || "*", + value: keys.join(", ") || "*", type: "readonly" }], checked: false, }] as Row[]); + await handleSaveUsers(); } }; - const handleEditUser = async (username: string, role: string, keys: string, password?: string) => { + const handleEditUser = async (username: string, role: string, keys: string[], password?: string) => { const result = await securedFetch(`api/user/${prepareArg(username)}`, { method: 'PATCH', body: JSON.stringify({ role, keys, password }) }, toast, setIndicator); if (result.ok) { - setUsers(prev => prev.map(u => u.username === username ? { ...u, role, keys: keys || "*" } : u)); - setRows(prev => prev.map((row): Row => row.cells[0].value === username ? { ...row, cells: [row.cells[0], { ...row.cells[1], value: role }, { ...row.cells[2], value: keys || "*" }] } : row)); + setUsers(prev => prev.map(u => u.username === username ? { ...u, role, keys: keys || ["*"] } : u)); + setRows(prev => prev.map((row): Row => row.cells[0].value === username ? { ...row, cells: [row.cells[0], { ...row.cells[1], value: role }, { ...row.cells[2], value: keys.join(", ") || "*" }] } : row)); toast({ title: "Success", description: `${username} updated successfully`, }); + await handleSaveUsers(); } return result.ok; @@ -125,7 +126,7 @@ export default function Users() { const selectedUserData = checkedRows.length === 1 ? { username: checkedRows[0].cells[0].value as string, role: checkedRows[0].cells[1].value as string, - keys: checkedRows[0].cells[2].value as string, + keys: (checkedRows[0].cells[2].value as string).split(", ").map(key => key.trim()), } : null; return ( @@ -133,7 +134,7 @@ export default function Users() {

        Users

        - + {users.length} {users.length === 1 ? "user" : "users"}
        @@ -155,20 +156,11 @@ export default function Users() { - row.checked && row.cells[0].value !== "default").map(row => users.find(user => user.username === row.cells[0].value)!)} setUsers={setUsers} setRows={setRows} /> - - - + row.checked && row.cells[0].value !== "default").map(row => users.find(user => user.username === row.cells[0].value)!)} setUsers={setUsers} setRows={setRows} onSave={handleSaveUsers} />
        diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx index 7b26f633f..0e87d9668 100644 --- a/components/ui/toast.tsx +++ b/components/ui/toast.tsx @@ -30,7 +30,8 @@ const toastVariants = cva( default: "bg-background", destructive: "destructive group border-destructive bg-destructive text-destructive-foreground", - }, + warning: "group border-yellow-600 bg-yellow-600 text-yellow-50", + }, }, defaultVariants: { variant: "default", From f4c4822eba0833f5ddad97bb8085642e4d137eb8 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Tue, 21 Apr 2026 13:22:13 +0300 Subject: [PATCH 107/119] fix(users): update key handling to support arrays and set default role in user creation --- app/api/user/model.test.ts | 28 ++++++++++++++-------------- app/api/user/model.ts | 2 +- app/settings/users/AddUser.tsx | 2 +- app/settings/users/Users.tsx | 11 ++++++----- e2e/logic/POM/settingsUsersPage.ts | 11 ++++++++++- e2e/logic/api/apiCalls.ts | 2 +- e2e/tests/settingsUsers.spec.ts | 2 +- 7 files changed, 34 insertions(+), 24 deletions(-) diff --git a/app/api/user/model.test.ts b/app/api/user/model.test.ts index 76c41e0c3..a5efb8d32 100644 --- a/app/api/user/model.test.ts +++ b/app/api/user/model.test.ts @@ -1,6 +1,6 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { getRoleWithKeys, extractKeysFromACL, ROLE } from "./model.ts"; +import { getRoleWithKeys, extractKeysFromACL, ROLE } from "./model"; // --------------------------------------------------------------------------- // getRoleWithKeys @@ -16,14 +16,14 @@ describe("getRoleWithKeys", () => { it("inserts resetkeys and the supplied key pattern", () => { const role = ROLE.get("Read-Write")!; - const result = getRoleWithKeys(role, "myprefix:*"); + const result = getRoleWithKeys(role, ["myprefix:*"]); assert.equal(result[1], "resetkeys"); assert.equal(result[2], "~myprefix:*"); }); it("inserts resetkeys and key pattern for Read-Only role", () => { const role = ROLE.get("Read-Only")!; - const result = getRoleWithKeys(role, "test:*"); + const result = getRoleWithKeys(role, ["test:*"]); assert.equal(result[1], "resetkeys"); assert.equal(result[2], "~test:*"); }); @@ -37,7 +37,7 @@ describe("getRoleWithKeys", () => { it("preserves all remaining role entries after the key pattern", () => { const role = ROLE.get("Read-Only")!; - const result = getRoleWithKeys(role, "ns:*"); + const result = getRoleWithKeys(role, ["ns:*"]); // role[0] = "on", then "resetkeys", then "~ns:*", then role.slice(1) const expected = ["on", "resetkeys", "~ns:*", ...role.slice(1)]; assert.deepEqual(result, expected); @@ -47,7 +47,7 @@ describe("getRoleWithKeys", () => { // update to a narrower pattern because aclSetUser merges key patterns. it("includes resetkeys so stale wildcard ~* cannot shadow a narrower update", () => { const role = ROLE.get("Read-Write")!; - const result = getRoleWithKeys(role, "app:*"); + const result = getRoleWithKeys(role, ["app:*"]); // resetkeys must appear before the new ~pattern so Redis/FalkorDB clears // existing key patterns first. const resetkeysIdx = result.indexOf("resetkeys"); @@ -63,28 +63,28 @@ describe("getRoleWithKeys", () => { describe("extractKeysFromACL", () => { it("returns the key pattern from a typical ACL line", () => { const parts = ["user", "alice", "on", "~myprefix:*", "resetchannels", "-@all"]; - assert.equal(extractKeysFromACL(parts), "myprefix:*"); + assert.deepEqual(extractKeysFromACL(parts), ["myprefix:*"]); }); - it("returns * when no ~ pattern is present", () => { + it("returns [*] when no ~ pattern is present", () => { const parts = ["user", "alice", "on", "resetchannels", "-@all"]; - assert.equal(extractKeysFromACL(parts), "*"); + assert.deepEqual(extractKeysFromACL(parts), ["*"]); }); - it("returns * when the only pattern is ~*", () => { + it("returns [*] when the only pattern is ~*", () => { const parts = ["user", "alice", "on", "~*", "resetchannels", "-@all"]; - assert.equal(extractKeysFromACL(parts), "*"); + assert.deepEqual(extractKeysFromACL(parts), ["*"]); }); - it("joins multiple key patterns with a space", () => { + it("returns multiple key patterns as an array", () => { const parts = ["user", "alice", "on", "~ns1:*", "~ns2:*", "-@all"]; - assert.equal(extractKeysFromACL(parts), "ns1:* ns2:*"); + assert.deepEqual(extractKeysFromACL(parts), ["ns1:*", "ns2:*"]); }); it("strips the ~ prefix correctly", () => { const parts = ["user", "bob", "on", "~test:*", "-@all"]; const result = extractKeysFromACL(parts); - assert.ok(!result.startsWith("~"), "result must not start with ~"); - assert.equal(result, "test:*"); + assert.ok(!result[0].startsWith("~"), "result must not start with ~"); + assert.deepEqual(result, ["test:*"]); }); }); diff --git a/app/api/user/model.ts b/app/api/user/model.ts index a44c246d8..8779e0083 100644 --- a/app/api/user/model.ts +++ b/app/api/user/model.ts @@ -30,7 +30,7 @@ const READ_ONLY_ROLE = [ ]; export function getRoleWithKeys(role: string[], keys?: string[]): string[] { - return [role[0], "resetkeys", ...(keys?.map((key) => `~${key}`) || ["~*"]), ...role.slice(1)]; + return [role[0], "resetkeys", ...(keys?.length ? keys.map((key) => `~${key}`) : ["~*"]), ...role.slice(1)]; } export const ROLE = new Map([ diff --git a/app/settings/users/AddUser.tsx b/app/settings/users/AddUser.tsx index 57118e811..1a8972f81 100644 --- a/app/settings/users/AddUser.tsx +++ b/app/settings/users/AddUser.tsx @@ -23,7 +23,7 @@ export default function AddUser({ onAddUser }: { setPassword(""); setConfirmPassword(""); setUsername(""); - setRole(""); + setRole("Admin"); setKeys([]); }; diff --git a/app/settings/users/Users.tsx b/app/settings/users/Users.tsx index e2c6e3283..2d2a96128 100644 --- a/app/settings/users/Users.tsx +++ b/app/settings/users/Users.tsx @@ -84,7 +84,7 @@ export default function Users() { title: "Success", description: "User added successfully", }); - setUsers(prev => [...prev, { username, role, selected: false }]); + setUsers(prev => [...prev, { username, role, keys, selected: false }]); setRows(prev => [...prev, { name: username, cells: [{ @@ -123,10 +123,11 @@ export default function Users() { }; const checkedRows = rows.filter(row => row.checked); - const selectedUserData = checkedRows.length === 1 ? { - username: checkedRows[0].cells[0].value as string, - role: checkedRows[0].cells[1].value as string, - keys: (checkedRows[0].cells[2].value as string).split(", ").map(key => key.trim()), + const selectedUser = checkedRows.length === 1 ? users.find(u => u.username === checkedRows[0].cells[0].value) : null; + const selectedUserData = selectedUser ? { + username: selectedUser.username, + role: selectedUser.role, + keys: selectedUser.keys || ["*"], } : null; return ( diff --git a/e2e/logic/POM/settingsUsersPage.ts b/e2e/logic/POM/settingsUsersPage.ts index 9f6152877..ec7864eb7 100644 --- a/e2e/logic/POM/settingsUsersPage.ts +++ b/e2e/logic/POM/settingsUsersPage.ts @@ -311,9 +311,18 @@ export default class SettingsUsersPage extends BasePage { } async fillEditKeysField(keys: string): Promise { + // Remove all existing tags by clicking their remove buttons + const removeButtons = this.page.locator("button[aria-label^='Remove ']"); + while (await removeButtons.count() > 0) { + await removeButtons.first().click(); + } + // Type the new key and press Enter to commit the tag await interactWhenVisible( this.editKeysField, - (el) => el.fill(keys), + async (el) => { + await el.fill(keys); + await el.press("Enter"); + }, "edit keys input" ); } diff --git a/e2e/logic/api/apiCalls.ts b/e2e/logic/api/apiCalls.ts index 37415a799..8dd838efe 100644 --- a/e2e/logic/api/apiCalls.ts +++ b/e2e/logic/api/apiCalls.ts @@ -425,7 +425,7 @@ export default class ApiCalls { } } - async updateUser(username: string, data: { role: string; keys?: string; password?: string }): Promise { + async updateUser(username: string, data: { role: string; keys?: string[]; password?: string }): Promise { try { const result = await patchRequest( `${urls.api.settingsUser}${encodeURIComponent(username)}`, diff --git a/e2e/tests/settingsUsers.spec.ts b/e2e/tests/settingsUsers.spec.ts index a8a29f3fa..5acd1ca21 100644 --- a/e2e/tests/settingsUsers.spec.ts +++ b/e2e/tests/settingsUsers.spec.ts @@ -208,7 +208,7 @@ test.describe('@Config Settings users tests', () => { test("@admin API Test: Update user with password and keys via PATCH", async () => { const username = getRandomString('user'); await apiCall.createUsers({ username, password: user.password, role: user.ReadWrite }); - await apiCall.updateUser(username, { role: user.ReadWrite, keys: "test:*", password: "NewPass1@" }); + await apiCall.updateUser(username, { role: user.ReadWrite, keys: ["test:*"], password: "NewPass1@" }); const settingsUsersPage = await browser.createNewPage(SettingsUsersPage, urls.settingsUrl); await settingsUsersPage.navigateToUserTab(); const newKeys = await settingsUsersPage.getUserKeys(username); From 14255aec1a26f034818b8c6b786d6344fa429f46 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Tue, 21 Apr 2026 13:28:13 +0300 Subject: [PATCH 108/119] fix(FormComponent): enhance tag input handling to support multiple tags and improve cleanup logic --- app/components/FormComponent.tsx | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/app/components/FormComponent.tsx b/app/components/FormComponent.tsx index 549de4a92..0dd142e8f 100644 --- a/app/components/FormComponent.tsx +++ b/app/components/FormComponent.tsx @@ -75,18 +75,20 @@ function TagInput({ field }: { field: TagField }) { const [inputValue, setInputValue] = useState(""); const inputRef = useRef(null); - const addTag = () => { - const trimmed = inputValue.trim(); - if (trimmed && !field.tags.includes(trimmed)) { - field.onAddTag(trimmed); - } + const addTags = (value: string) => { + const parts = value.split(",").map(p => p.trim().replace(/^~/, "")).filter(Boolean); + parts.forEach(part => { + if (!field.tags.includes(part)) { + field.onAddTag(part); + } + }); setInputValue(""); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" || e.key === ",") { e.preventDefault(); - addTag(); + addTags(inputValue); } else if (e.key === "Backspace" && inputValue === "" && field.tags.length > 0) { field.onRemoveTag(field.tags.length - 1); } @@ -122,7 +124,7 @@ function TagInput({ field }: { field: TagField }) { placeholder={field.tags.length === 0 ? (field.placeholder || "Type and press Enter") : ""} onChange={(e) => setInputValue(e.target.value)} onKeyDown={handleKeyDown} - onBlur={addTag} + onBlur={() => addTags(inputValue)} disabled={field.disabled} />
        @@ -137,16 +139,17 @@ export default function FormComponent({ handleSubmit, fields, error = undefined, // Stable identifier for the current set of fields — triggers re-validation when the form layout changes const fieldsKey = fields.map(f => f.label).join(","); + const fieldValues = fields.map(f => f.value).join("\0"); useEffect(() => { - const clearMOuntedFlag = () => { + const clearMountedFlag = () => { isMountedRef.current = false; }; if (!isMountedRef.current) { isMountedRef.current = true; - return clearMOuntedFlag; + return clearMountedFlag; } const newErrors: { [key: string]: boolean } = {}; @@ -159,8 +162,8 @@ export default function FormComponent({ handleSubmit, fields, error = undefined, setErrors(prev => ({ ...prev, ...newErrors })); - return clearMOuntedFlag; - }, [fieldsKey]); + return clearMountedFlag; + }, [fieldsKey, fieldValues]); const onHandleSubmit = async (e: React.FormEvent) => { e.preventDefault(); From b2e6f50bd5da51643496e0bb791fb2be1295bf2b Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Tue, 21 Apr 2026 13:53:20 +0300 Subject: [PATCH 109/119] fix(AddUser): reset role state to empty string on close and update tag removal logic to be scoped --- app/settings/users/AddUser.tsx | 4 ++-- e2e/logic/POM/settingsUsersPage.ts | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/settings/users/AddUser.tsx b/app/settings/users/AddUser.tsx index 1a8972f81..f13ffadfb 100644 --- a/app/settings/users/AddUser.tsx +++ b/app/settings/users/AddUser.tsx @@ -16,14 +16,14 @@ export default function AddUser({ onAddUser }: { const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); - const [role, setRole] = useState("Admin"); + const [role, setRole] = useState(""); const [keys, setKeys] = useState([]); const handleClose = () => { setPassword(""); setConfirmPassword(""); setUsername(""); - setRole("Admin"); + setRole(""); setKeys([]); }; diff --git a/e2e/logic/POM/settingsUsersPage.ts b/e2e/logic/POM/settingsUsersPage.ts index ec7864eb7..068edf1e7 100644 --- a/e2e/logic/POM/settingsUsersPage.ts +++ b/e2e/logic/POM/settingsUsersPage.ts @@ -311,8 +311,9 @@ export default class SettingsUsersPage extends BasePage { } async fillEditKeysField(keys: string): Promise { - // Remove all existing tags by clicking their remove buttons - const removeButtons = this.page.locator("button[aria-label^='Remove ']"); + // Remove all existing tags by clicking their remove buttons (scoped to tag input container) + const keysContainer = this.editKeysField.locator('..'); + const removeButtons = keysContainer.locator("button[aria-label^='Remove ']"); while (await removeButtons.count() > 0) { await removeButtons.first().click(); } From 6cb34d77dcb9ed455e2fa97ede39e1a8359c442f Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Tue, 21 Apr 2026 14:51:08 +0300 Subject: [PATCH 110/119] chore: bump version to 2.0.1 in package.json and package-lock.json --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7f2661ad0..a1000024c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "falkordb-browser", - "version": "1.9.4", + "version": "2.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "falkordb-browser", - "version": "1.9.4", + "version": "2.0.1", "dependencies": { "@falkordb/canvas": "^0.0.45", "@falkordb/text-to-cypher": "^0.1.13", diff --git a/package.json b/package.json index cf2174e70..b0e86fea4 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "falkordb-browser", "author": "FalkorDB", "description": "FalkorDB Browser", - "version": "1.9.4", + "version": "2.0.1", "private": true, "engines": { "node": ">=20.9.0" From 47c9459ae9f92e99630085de603c0f507356c311 Mon Sep 17 00:00:00 2001 From: Barak Bar Orion Date: Thu, 23 Apr 2026 08:12:33 +0300 Subject: [PATCH 111/119] chore(deps): combine dependabot dependency updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump the following dependencies: Production: - react-hook-form: 7.72.1 → 7.73.1 - tailwindcss: 4.2.2 → 4.2.3 - @tailwindcss/postcss: 4.2.2 → 4.2.3 Development: - @typescript-eslint/eslint-plugin: 8.58.2 → 8.59.0 - @typescript-eslint/parser: 8.58.1 → 8.59.0 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package-lock.json | 797 ++++++++++++++++++++++++++++++++++++---------- package.json | 10 +- 2 files changed, 642 insertions(+), 165 deletions(-) diff --git a/package-lock.json b/package-lock.json index a1000024c..9f426c69a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,13 +64,13 @@ "react-dom": "^19.2.5", "react-dropzone": "^15.0.0", "react-gtm-module": "^2.0.11", - "react-hook-form": "^7.72.1", + "react-hook-form": "^7.73.1", "react-json-tree": "^0.20.0", "react-resizable-panels": "^4.9.0", "swagger-ui-react": "^5.32.4", "swr": "^2.4.1", "tailwind-merge": "^3.5.0", - "tailwindcss": "^4.2.2", + "tailwindcss": "^4.2.3", "uuid": "^14.0.0", "vaul": "^1.1.2", "yaml": "^2.8.3", @@ -78,7 +78,7 @@ }, "devDependencies": { "@playwright/test": "^1.59.1", - "@tailwindcss/postcss": "^4.2.2", + "@tailwindcss/postcss": "^4.2.3", "@types/cytoscape-fcose": "^2.2.4", "@types/lodash": "^4.17.24", "@types/node": "^25.5.2", @@ -87,8 +87,8 @@ "@types/react-dom": "^19.2.3", "@types/react-gtm-module": "^2.0.4", "@types/swagger-ui-react": "^5.18.0", - "@typescript-eslint/eslint-plugin": "^8.58.2", - "@typescript-eslint/parser": "^8.58.1", + "@typescript-eslint/eslint-plugin": "^8.59.0", + "@typescript-eslint/parser": "^8.59.0", "eslint": "^10.2.1", "eslint-config-airbnb": "^19.0.4", "eslint-config-airbnb-typescript": "^18.0.0", @@ -3731,9 +3731,9 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", - "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", + "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==", "dev": true, "license": "MIT", "dependencies": { @@ -3743,37 +3743,37 @@ "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.2.2" + "tailwindcss": "4.2.4" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", - "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz", + "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==", "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.2", - "@tailwindcss/oxide-darwin-arm64": "4.2.2", - "@tailwindcss/oxide-darwin-x64": "4.2.2", - "@tailwindcss/oxide-freebsd-x64": "4.2.2", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", - "@tailwindcss/oxide-linux-x64-musl": "4.2.2", - "@tailwindcss/oxide-wasm32-wasi": "4.2.2", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + "@tailwindcss/oxide-android-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-x64": "4.2.4", + "@tailwindcss/oxide-freebsd-x64": "4.2.4", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-x64-musl": "4.2.4", + "@tailwindcss/oxide-wasm32-wasi": "4.2.4", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", - "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz", + "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", "cpu": [ "arm64" ], @@ -3788,9 +3788,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", - "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz", + "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==", "cpu": [ "arm64" ], @@ -3805,9 +3805,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", - "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz", + "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==", "cpu": [ "x64" ], @@ -3822,9 +3822,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", - "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz", + "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==", "cpu": [ "x64" ], @@ -3839,9 +3839,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", - "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz", + "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==", "cpu": [ "arm" ], @@ -3856,13 +3856,16 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", - "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz", + "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3873,13 +3876,16 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", - "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz", + "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3890,13 +3896,16 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", - "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz", + "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3907,13 +3916,16 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", - "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz", + "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3924,9 +3936,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", - "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz", + "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -3953,74 +3965,10 @@ "node": ">=14.0.0" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.8.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.1.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.8.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { - "version": "2.8.1", - "dev": true, - "inBundle": true, - "license": "0BSD", - "optional": true - }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", - "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", + "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==", "cpu": [ "arm64" ], @@ -4035,9 +3983,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", - "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz", + "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==", "cpu": [ "x64" ], @@ -4052,17 +4000,17 @@ } }, "node_modules/@tailwindcss/postcss": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.2.tgz", - "integrity": "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.4.tgz", + "integrity": "sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg==", "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.2.2", - "@tailwindcss/oxide": "4.2.2", + "@tailwindcss/node": "4.2.4", + "@tailwindcss/oxide": "4.2.4", "postcss": "^8.5.6", - "tailwindcss": "4.2.2" + "tailwindcss": "4.2.4" } }, "node_modules/@tree-sitter-grammars/tree-sitter-yaml": { @@ -4538,17 +4486,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz", - "integrity": "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz", + "integrity": "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.58.2", - "@typescript-eslint/type-utils": "8.58.2", - "@typescript-eslint/utils": "8.58.2", - "@typescript-eslint/visitor-keys": "8.58.2", + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/type-utils": "8.59.0", + "@typescript-eslint/utils": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -4561,22 +4509,176 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.58.2", + "@typescript-eslint/parser": "^8.59.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/project-service": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.0.tgz", + "integrity": "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.0", + "@typescript-eslint/types": "^8.59.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.0.tgz", + "integrity": "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.0.tgz", + "integrity": "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.0.tgz", + "integrity": "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.0.tgz", + "integrity": "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.0", + "@typescript-eslint/tsconfig-utils": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.0.tgz", + "integrity": "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.0.tgz", + "integrity": "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@typescript-eslint/parser": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.2.tgz", - "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.0.tgz", + "integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.58.2", - "@typescript-eslint/types": "8.58.2", - "@typescript-eslint/typescript-estree": "8.58.2", - "@typescript-eslint/visitor-keys": "8.58.2", + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3" }, "engines": { @@ -4591,6 +4693,136 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/project-service": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.0.tgz", + "integrity": "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.0", + "@typescript-eslint/types": "^8.59.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.0.tgz", + "integrity": "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.0.tgz", + "integrity": "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.0.tgz", + "integrity": "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.0.tgz", + "integrity": "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.0", + "@typescript-eslint/tsconfig-utils": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.0.tgz", + "integrity": "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@typescript-eslint/project-service": { "version": "8.58.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.2.tgz", @@ -4649,16 +4881,116 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.2.tgz", - "integrity": "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.0.tgz", + "integrity": "sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.2", - "@typescript-eslint/typescript-estree": "8.58.2", - "@typescript-eslint/utils": "8.58.2", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0", + "@typescript-eslint/utils": "8.59.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/project-service": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.0.tgz", + "integrity": "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.0", + "@typescript-eslint/types": "^8.59.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.0.tgz", + "integrity": "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.0.tgz", + "integrity": "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.0.tgz", + "integrity": "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.0.tgz", + "integrity": "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.0", + "@typescript-eslint/tsconfig-utils": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "engines": { @@ -4668,11 +5000,65 @@ "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/utils": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.0.tgz", + "integrity": "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.0.tgz", + "integrity": "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@typescript-eslint/types": { "version": "8.58.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz", @@ -9630,6 +10016,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9651,6 +10040,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9672,6 +10064,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9693,6 +10088,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -11104,9 +11502,9 @@ "license": "MIT" }, "node_modules/react-hook-form": { - "version": "7.72.1", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.72.1.tgz", - "integrity": "sha512-RhwBoy2ygeVZje+C+bwJ8g0NjTdBmDlJvAUHTxRjTmSUKPYsKfMphkS2sgEMotsY03bP358yEYlnUeZy//D9Ig==", + "version": "7.73.1", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.73.1.tgz", + "integrity": "sha512-VAfVYOPcx3piiEVQy95vyFmBwbVUsP/AUIN+mpFG8h11yshDd444nn0VyfaGWSRnhOLVgiDu7HIuBtAIzxn9dA==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -12373,15 +12771,15 @@ } }, "node_modules/tailwindcss": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", - "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", + "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", "license": "MIT" }, "node_modules/tapable": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", - "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true, "license": "MIT", "engines": { @@ -12716,6 +13114,85 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz", + "integrity": "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/type-utils": "8.58.2", + "@typescript-eslint/utils": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.58.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.2.tgz", + "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/type-utils": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.2.tgz", + "integrity": "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/utils": "8.58.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", diff --git a/package.json b/package.json index b0e86fea4..d4ffe9e69 100644 --- a/package.json +++ b/package.json @@ -71,13 +71,13 @@ "react-dom": "^19.2.5", "react-dropzone": "^15.0.0", "react-gtm-module": "^2.0.11", - "react-hook-form": "^7.72.1", + "react-hook-form": "^7.73.1", "react-json-tree": "^0.20.0", "react-resizable-panels": "^4.9.0", "swagger-ui-react": "^5.32.4", "swr": "^2.4.1", "tailwind-merge": "^3.5.0", - "tailwindcss": "^4.2.2", + "tailwindcss": "^4.2.3", "uuid": "^14.0.0", "vaul": "^1.1.2", "yaml": "^2.8.3", @@ -85,7 +85,7 @@ }, "devDependencies": { "@playwright/test": "^1.59.1", - "@tailwindcss/postcss": "^4.2.2", + "@tailwindcss/postcss": "^4.2.3", "@types/cytoscape-fcose": "^2.2.4", "@types/lodash": "^4.17.24", "@types/node": "^25.5.2", @@ -94,8 +94,8 @@ "@types/react-dom": "^19.2.3", "@types/react-gtm-module": "^2.0.4", "@types/swagger-ui-react": "^5.18.0", - "@typescript-eslint/eslint-plugin": "^8.58.2", - "@typescript-eslint/parser": "^8.58.1", + "@typescript-eslint/eslint-plugin": "^8.59.0", + "@typescript-eslint/parser": "^8.59.0", "eslint": "^10.2.1", "eslint-config-airbnb": "^19.0.4", "eslint-config-airbnb-typescript": "^18.0.0", From aa43a24ba01bc7664182a45d2ae5b27d92a3a2df Mon Sep 17 00:00:00 2001 From: Barak Bar Orion Date: Thu, 23 Apr 2026 08:27:00 +0300 Subject: [PATCH 112/119] chore: update Node engine minimum to >=20.19.0 eslint-visitor-keys@5.0.1 (transitive dependency of @typescript-eslint/* and eslint) requires Node ^20.19.0 || ^22.13.0 || >=24. Update the engines field to reflect the actual minimum requirement. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d4ffe9e69..4b75a4b2a 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "version": "2.0.1", "private": true, "engines": { - "node": ">=20.9.0" + "node": ">=20.19.0" }, "scripts": { "dev": "next dev", From 7fa9dc7047fe37622ff85e6e53ce481b4824f313 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Thu, 23 Apr 2026 11:41:15 +0300 Subject: [PATCH 113/119] fix: update key pattern descriptions for clarity in AddUser and EditUser components --- app/settings/users/AddUser.tsx | 2 +- app/settings/users/EditUser.tsx | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/app/settings/users/AddUser.tsx b/app/settings/users/AddUser.tsx index f13ffadfb..e583b2d5f 100644 --- a/app/settings/users/AddUser.tsx +++ b/app/settings/users/AddUser.tsx @@ -121,7 +121,7 @@ export default function AddUser({ onAddUser }: { onRemoveTag: (index) => setKeys(prev => prev.filter((_, i) => i !== index)), required: false, placeholder: "*", - description: "Pattern for accessible keys / graphs (e.g. mygraph, myprefix:*, *)", + description: "Pattern for accessible keys / graphs (e.g. mygraph, myprefix*, *)", info: "Defines which keys / graphs this user can access", errors: [] } diff --git a/app/settings/users/EditUser.tsx b/app/settings/users/EditUser.tsx index 089a58884..329e3b19e 100644 --- a/app/settings/users/EditUser.tsx +++ b/app/settings/users/EditUser.tsx @@ -108,12 +108,8 @@ export default function EditUser({ username, role: initialRole, keys: initialKey onRemoveTag: (index) => setKeys(prev => prev.filter((_, i) => i !== index)), required: false, placeholder: "*", - description: "Pattern for accessible keys / graphs (e.g. *, user:*, ~myprefix:*)", - info: "Defines which keys / graphs this user can access. See Redis ACL documentation for pattern syntax.", - link: { - label: "Learn more", - url: "https://redis.io/docs/latest/operate/oss_and_stack/management/security/acl/#key-permissions" - }, + description: "Pattern for accessible keys / graphs (e.g. *, mygraph, myprefix*)", + info: "Defines which keys / graphs this user can access", errors: [] } ]; From d8d0f1ade7a739b90eef845790496c46c613c306 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Thu, 23 Apr 2026 12:10:29 +0300 Subject: [PATCH 114/119] fix(FormComponent): optimize error handling by re-validating only on layout changes --- app/components/FormComponent.tsx | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/app/components/FormComponent.tsx b/app/components/FormComponent.tsx index 0dd142e8f..db69b99cf 100644 --- a/app/components/FormComponent.tsx +++ b/app/components/FormComponent.tsx @@ -136,34 +136,34 @@ export default function FormComponent({ handleSubmit, fields, error = undefined, const [errors, setErrors] = useState<{ [key: string]: boolean }>({}); const [isLoading, setIsLoading] = useState(false); const isMountedRef = useRef(false); + const prevFieldsKeyRef = useRef(null); // Stable identifier for the current set of fields — triggers re-validation when the form layout changes const fieldsKey = fields.map(f => f.label).join(","); - const fieldValues = fields.map(f => f.value).join("\0"); useEffect(() => { - const clearMountedFlag = () => { - isMountedRef.current = false; - }; - if (!isMountedRef.current) { isMountedRef.current = true; - - return clearMountedFlag; + prevFieldsKeyRef.current = fieldsKey; + return; } - const newErrors: { [key: string]: boolean } = {}; + // Only re-validate when the form layout changes (e.g. switching login mode), + // not on mount or on every value change + if (prevFieldsKeyRef.current !== fieldsKey) { + prevFieldsKeyRef.current = fieldsKey; - fields.forEach(field => { - if (field.errors) { - newErrors[field.label] = field.errors.some(err => err.condition(field.value)); - } - }); + const newErrors: { [key: string]: boolean } = {}; - setErrors(prev => ({ ...prev, ...newErrors })); + fields.forEach(field => { + if (field.errors) { + newErrors[field.label] = field.errors.some(err => err.condition(field.value)); + } + }); - return clearMountedFlag; - }, [fieldsKey, fieldValues]); + setErrors(prev => ({ ...prev, ...newErrors })); + } + }, [fieldsKey, fields]); const onHandleSubmit = async (e: React.FormEvent) => { e.preventDefault(); From 9e835cf18c90e6d49adbd1262f794dda3713df48 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Thu, 23 Apr 2026 12:27:21 +0300 Subject: [PATCH 115/119] fix: improve tag handling in TagInput and update user role handling in AddUser and EditUser components --- app/components/FormComponent.tsx | 28 ++++++++++++++++------------ app/settings/users/AddUser.tsx | 11 +++++------ app/settings/users/EditUser.tsx | 10 +++++++--- app/settings/users/Users.tsx | 2 ++ 4 files changed, 30 insertions(+), 21 deletions(-) diff --git a/app/components/FormComponent.tsx b/app/components/FormComponent.tsx index db69b99cf..c974baee9 100644 --- a/app/components/FormComponent.tsx +++ b/app/components/FormComponent.tsx @@ -77,8 +77,10 @@ function TagInput({ field }: { field: TagField }) { const addTags = (value: string) => { const parts = value.split(",").map(p => p.trim().replace(/^~/, "")).filter(Boolean); + const seen = new Set(field.tags); parts.forEach(part => { - if (!field.tags.includes(part)) { + if (!seen.has(part)) { + seen.add(part); field.onAddTag(part); } }); @@ -103,17 +105,19 @@ function TagInput({ field }: { field: TagField }) { {field.tags.map((tag, index) => ( {tag} - + {!field.disabled && ( + + )} ))} Promise + onAddUser: (user: CreateUser, keys: string[]) => Promise }) { const [open, setOpen] = useState(false); const [username, setUsername] = useState(""); @@ -130,11 +130,10 @@ export default function AddUser({ onAddUser }: { const handleAddUser = async (e: FormEvent) => { e.preventDefault(); - await onAddUser({ username, password, role, }, keys); - - setOpen(false); - - handleClose(); + const ok = await onAddUser({ username, password, role, }, keys); + if (ok) { + setOpen(false); + } }; return ( diff --git a/app/settings/users/EditUser.tsx b/app/settings/users/EditUser.tsx index 329e3b19e..cd1c7f18d 100644 --- a/app/settings/users/EditUser.tsx +++ b/app/settings/users/EditUser.tsx @@ -21,14 +21,17 @@ export default function EditUser({ username, role: initialRole, keys: initialKey const [role, setRole] = useState(initialRole); const [keys, setKeys] = useState(initialKeys ? initialKeys : []); + const [prevOpen, setPrevOpen] = useState(false); + useEffect(() => { - if (open) { + if (open && !prevOpen) { setRole(initialRole); setKeys(initialKeys ? initialKeys : []); setPassword(""); setConfirmPassword(""); } - }, [open, initialRole, initialKeys]); + setPrevOpen(open); + }, [open, prevOpen, initialRole, initialKeys]); const fields: Field[] = [ { @@ -117,7 +120,8 @@ export default function EditUser({ username, role: initialRole, keys: initialKey const handleEditUser = async (e: FormEvent) => { e.preventDefault(); - const ok = await onEditUser(username, role, keys, password || undefined); + const normalizedKeys = keys.length === 0 ? ["*"] : keys; + const ok = await onEditUser(username, role, normalizedKeys, password || undefined); if (ok) { setOpen(false); } diff --git a/app/settings/users/Users.tsx b/app/settings/users/Users.tsx index 2d2a96128..c6a02256c 100644 --- a/app/settings/users/Users.tsx +++ b/app/settings/users/Users.tsx @@ -101,6 +101,8 @@ export default function Users() { }] as Row[]); await handleSaveUsers(); } + + return response.ok; }; const handleEditUser = async (username: string, role: string, keys: string[], password?: string) => { From f2b9b712c903fd55649316a6543c6d00e6a027df Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Thu, 23 Apr 2026 12:37:09 +0300 Subject: [PATCH 116/119] fix: normalize keys in AddUser component before adding user --- app/components/FormComponent.tsx | 2 +- app/settings/users/AddUser.tsx | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/components/FormComponent.tsx b/app/components/FormComponent.tsx index c974baee9..48a441067 100644 --- a/app/components/FormComponent.tsx +++ b/app/components/FormComponent.tsx @@ -77,7 +77,7 @@ function TagInput({ field }: { field: TagField }) { const addTags = (value: string) => { const parts = value.split(",").map(p => p.trim().replace(/^~/, "")).filter(Boolean); - const seen = new Set(field.tags); + const seen = new Set(field.tags.map(t => t.replace(/^~/, ""))); parts.forEach(part => { if (!seen.has(part)) { seen.add(part); diff --git a/app/settings/users/AddUser.tsx b/app/settings/users/AddUser.tsx index 2bb5f8a76..0062c9e79 100644 --- a/app/settings/users/AddUser.tsx +++ b/app/settings/users/AddUser.tsx @@ -130,7 +130,8 @@ export default function AddUser({ onAddUser }: { const handleAddUser = async (e: FormEvent) => { e.preventDefault(); - const ok = await onAddUser({ username, password, role, }, keys); + const normalizedKeys = keys.length === 0 ? ["*"] : keys; + const ok = await onAddUser({ username, password, role, }, normalizedKeys); if (ok) { setOpen(false); } From 2996c3f5aee7abcf849076a9e4367fe7508fa580 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Thu, 23 Apr 2026 13:11:19 +0300 Subject: [PATCH 117/119] fix(tests): enhance timeout settings for node movement tests and improve chat toggle button rendering check --- e2e/tests/canvas.spec.ts | 8 +++++--- e2e/tests/chat.spec.ts | 3 ++- e2e/tests/settingsTokens.spec.ts | 4 +++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/e2e/tests/canvas.spec.ts b/e2e/tests/canvas.spec.ts index 653adb7cf..7d16af2e6 100644 --- a/e2e/tests/canvas.spec.ts +++ b/e2e/tests/canvas.spec.ts @@ -164,7 +164,8 @@ test.describe('Canvas Tests', () => { await apicalls.removeGraph(graphName); }); - test(`@readwrite moving a node to another node's position while animation is off should place them at the same position`, async () => { + test(`@readwrite moving a node to another node's position while animation is off should place them at the same position`, async ({ }, testInfo) => { + testInfo.setTimeout(60000); const graphName = getRandomString('graph'); await apicalls.addGraph(graphName); const graph = await browser.createNewPage(GraphPage, urls.graphUrl); @@ -186,7 +187,8 @@ test.describe('Canvas Tests', () => { await apicalls.removeGraph(graphName); }); - test(`@readwrite moving a node to another node's position while animation is on should push them apart`, async () => { + test(`@readwrite moving a node to another node's position while animation is on should push them apart`, async ({ }, testInfo) => { + testInfo.setTimeout(60000); const graphName = getRandomString('graph'); await apicalls.addGraph(graphName); @@ -200,7 +202,7 @@ test.describe('Canvas Tests', () => { const fromX = initNodes[0].screenX; const fromY = initNodes[0].screenY; - const toX = initNodes[1].screenX;; + const toX = initNodes[1].screenX; const toY = initNodes[1].screenY; await graph.changeNodePosition(fromX, fromY, toX, toY); await graph.waitForScaleToStabilize(); diff --git a/e2e/tests/chat.spec.ts b/e2e/tests/chat.spec.ts index a5fdda56b..b8df32ffb 100644 --- a/e2e/tests/chat.spec.ts +++ b/e2e/tests/chat.spec.ts @@ -24,7 +24,8 @@ test.describe("Chat Feature Tests", () => { const chat = await browser.createNewPage(ChatComponent, urls.graphUrl); await browser.setPageToFullScreen(); - // Verify chat toggle button is disabled when no graph is selected + // Wait for the chat toggle button to render before checking state + await chat.waitForChatToggleButton(); const isChatButtonVisible = await chat.isChatToggleButtonVisible(); expect(isChatButtonVisible).toBe(true); const isChatButtonDisabled = await chat.isChatToggleButtonDisabled(); diff --git a/e2e/tests/settingsTokens.spec.ts b/e2e/tests/settingsTokens.spec.ts index 9b2ccf858..403c202d1 100644 --- a/e2e/tests/settingsTokens.spec.ts +++ b/e2e/tests/settingsTokens.spec.ts @@ -40,6 +40,9 @@ test.describe("@Tokens Personal Access Tokens Tests", () => { expect(token).not.toBeNull(); expect(token?.length).toBeGreaterThan(0); + + // Dismiss the token display dialog before verifying the token in the list + await settingsTokensPage.dismissTokenDisplay(); expect(await settingsTokensPage.verifyTokenExists(tokenName)).toBe(true); // Verify expiration if applicable @@ -49,7 +52,6 @@ test.describe("@Tokens Personal Access Tokens Tests", () => { } // Cleanup - await settingsTokensPage.dismissTokenDisplay(); await settingsTokensPage.revokeToken(tokenName); }); }); From 3dd2c7a85f9ef71f5f68ac52a31754f54ac038ca Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Thu, 23 Apr 2026 14:08:14 +0300 Subject: [PATCH 118/119] fix(tests): enhance interaction visibility checks with timeout and retry parameters --- e2e/infra/utils.ts | 6 ++++-- e2e/logic/POM/settingsTokensPage.ts | 5 ++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/e2e/infra/utils.ts b/e2e/infra/utils.ts index e06b71f1a..27f113ef6 100644 --- a/e2e/infra/utils.ts +++ b/e2e/infra/utils.ts @@ -209,9 +209,11 @@ export function getRandomString(prefix = "", delimiter = "_"): string { export async function interactWhenVisible( element: Locator, action: (el: Locator) => Promise, - name: string + name: string, + time?: number, + retry?: number ): Promise { - const isVisible = await waitForElementToBeVisible(element); + const isVisible = await waitForElementToBeVisible(element, time, retry); if (!isVisible) throw new Error(`${name} is not visible!`); return action(element); } diff --git a/e2e/logic/POM/settingsTokensPage.ts b/e2e/logic/POM/settingsTokensPage.ts index 5922971c6..2e7f06189 100644 --- a/e2e/logic/POM/settingsTokensPage.ts +++ b/e2e/logic/POM/settingsTokensPage.ts @@ -92,7 +92,9 @@ export default class SettingsTokensPage extends HeaderComponent { await interactWhenVisible( this.tokensTab, (el) => el.click(), - "tokens tab" + "tokens tab", + 1000, + 15 ); } @@ -222,6 +224,7 @@ export default class SettingsTokensPage extends HeaderComponent { // Actions async navigateToTokensTab(): Promise { + await this.waitForPageIdle(); await this.clickTokensTab(); await this.waitFor(500); } From c77e0dc0785d03ba7c8d77c6a60a152b4471c791 Mon Sep 17 00:00:00 2001 From: Shahar Biron <38566538+shahar-biron@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:21:31 +0300 Subject: [PATCH 119/119] fix(token-storage): use FalkorDB when PAT_FALKORDB_HOST is set; atomic FileTokenStorage writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the @readonly token persistence failures in CI: The parallel Playwright test workers (4 workers, 2 with @readonly) were concurrently reading and writing the same FileTokenStorage JSON file. A non-atomic writeFile during a concurrent read caused JSON.parse to fail; readTokens() silently returned []. This made getPasswordFromTokenDB throw, triggering SESSION_INVALID + X-Session-Invalid:1, which caused the client to auto-signOut. The signOut event then permanently deleted the session credentialRef from the file, breaking every subsequent test that relied on the readonlyuser session. Fix 1 — StorageFactory: use FalkorDB when PAT_FALKORDB_HOST is set. The CI already runs a dedicated PAT FalkorDB container on port 6380 and sets PAT_FALKORDB_HOST=localhost, but StorageFactory only switched to FalkorDB when API_TOKEN_FALKORDB_URL was present. Now it also switches when PAT_FALKORDB_HOST is configured, matching the intent expressed by the PAT_FALKORDB_* env vars. FalkorDB handles concurrent access natively. Fix 2 — FileTokenStorage: atomic writes + retry reads. For local development (no PAT FalkorDB configured), writeTokens now writes to a .tmp file and atomically renames it so readers never see a partial write. readTokens retries up to 3 times with 50 ms back-off before giving up, to handle the brief rename window. Co-Authored-By: Oz --- lib/token-storage/FileTokenStorage.ts | 39 +++++++++++++++++++-------- lib/token-storage/StorageFactory.ts | 18 +++++++++---- 2 files changed, 41 insertions(+), 16 deletions(-) diff --git a/lib/token-storage/FileTokenStorage.ts b/lib/token-storage/FileTokenStorage.ts index c18b50bc7..26dfd07f7 100644 --- a/lib/token-storage/FileTokenStorage.ts +++ b/lib/token-storage/FileTokenStorage.ts @@ -39,37 +39,54 @@ class FileTokenStorage implements ITokenStorage { } /** - * Read all tokens from file + * Read all tokens from file. + * Retries up to 3 times on JSON parse failure to handle the brief window + * where a concurrent atomic write (rename) is in progress. */ private async readTokens(): Promise { await this.ensureFileExists(); - try { - const content = await fs.readFile(this.filePath, 'utf8'); - const data = JSON.parse(content); - return data.tokens || []; - } catch (error) { - // eslint-disable-next-line no-console - console.error('Failed to read tokens from file:', error); - return []; + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + const content = await fs.readFile(this.filePath, 'utf8'); + const data = JSON.parse(content); + return data.tokens || []; + } catch (error) { + if (attempt < 2) { + // Brief wait before retry — allows an in-progress atomic rename to + // complete so the next read sees a consistent file. + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { setTimeout(resolve, 50); }); + } else { + // eslint-disable-next-line no-console + console.error('Failed to read tokens from file after retries:', error); + } + } } + return []; } /** - * Write tokens to file + * Write tokens to file atomically: write to a temp file first, then rename. + * fs.rename is atomic on POSIX systems (same filesystem), so readers + * always see either the old or the new complete file — never a partial write. */ private async writeTokens(tokens: TokenData[]): Promise { await this.ensureFileExists(); + const tmpPath = `${this.filePath}.tmp`; try { await fs.writeFile( - this.filePath, + tmpPath, JSON.stringify({ tokens }, null, 2), 'utf8' ); + await fs.rename(tmpPath, this.filePath); } catch (error) { // eslint-disable-next-line no-console console.error('Failed to write tokens to file:', error); + // Clean up temp file if rename failed + try { await fs.unlink(tmpPath); } catch { /* ignore */ } throw new Error('Failed to save tokens'); } } diff --git a/lib/token-storage/StorageFactory.ts b/lib/token-storage/StorageFactory.ts index da16299f3..2c350121e 100644 --- a/lib/token-storage/StorageFactory.ts +++ b/lib/token-storage/StorageFactory.ts @@ -22,15 +22,23 @@ class StorageFactory { * * Storage selection logic: * - If API_TOKEN_FALKORDB_URL is set → FalkorDBTokenStorage - * - Otherwise → FileTokenStorage (default) + * - If PAT_FALKORDB_HOST is set → FalkorDBTokenStorage (uses PAT_FALKORDB_* vars) + * - Otherwise → FileTokenStorage (default, single-process dev only) + * + * NOTE: FileTokenStorage is NOT safe for concurrent use by multiple + * processes or parallel test workers. Always configure one of the + * FalkorDB env vars in multi-process environments (CI, production). */ static getStorage(): ITokenStorage { if (this.instance) { return this.instance; } - // Check if FalkorDB URL is configured - if (process.env.API_TOKEN_FALKORDB_URL) { + // Use FalkorDB when either the full URL or the host env var is present. + // PAT_FALKORDB_HOST is already used by falkordb-client.ts as the fallback + // connection target, so if it is set the PAT FalkorDB is intentionally + // configured and we should store tokens there instead of in a local file. + if (process.env.API_TOKEN_FALKORDB_URL || process.env.PAT_FALKORDB_HOST) { // eslint-disable-next-line no-console console.log('Using FalkorDB storage for API tokens'); this.instance = new FalkorDBTokenStorage(); @@ -57,14 +65,14 @@ class StorageFactory { * Check if using FalkorDB storage */ static isFalkorDBStorage(): boolean { - return !!process.env.API_TOKEN_FALKORDB_URL; + return !!(process.env.API_TOKEN_FALKORDB_URL || process.env.PAT_FALKORDB_HOST); } /** * Check if using file storage */ static isFileStorage(): boolean { - return !process.env.API_TOKEN_FALKORDB_URL; + return !(process.env.API_TOKEN_FALKORDB_URL || process.env.PAT_FALKORDB_HOST); } }