diff --git a/.github/workflows/helm-chart.yml b/.github/workflows/helm-chart.yml index ad76205a0..2930adaf8 100644 --- a/.github/workflows/helm-chart.yml +++ b/.github/workflows/helm-chart.yml @@ -235,7 +235,7 @@ jobs: cat installation-instructions.txt - name: Upload package as artifact - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: helm-chart path: .helm-charts/*.tgz diff --git a/.github/workflows/nextjs.yml b/.github/workflows/nextjs.yml index ea01803da..c3d5b90b6 100644 --- a/.github/workflows/nextjs.yml +++ b/.github/workflows/nextjs.yml @@ -43,7 +43,7 @@ jobs: cache: npm - name: Restore cache - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | .next/cache @@ -73,7 +73,7 @@ jobs: uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 - name: Build Docker image - uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7 with: context: . file: ./Dockerfile @@ -111,7 +111,7 @@ jobs: fi - name: Upload Trivy results to GitHub Security tab - uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4 + uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 if: always() with: sarif_file: 'trivy-results.sarif' @@ -132,7 +132,7 @@ jobs: - name: Comment PR with Trivy results if: github.event_name == 'pull_request' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const fs = require('fs'); @@ -157,22 +157,26 @@ jobs: comment.body.includes('🔒 Trivy Security Scan Results') ); - if (botComment) { - // Update existing comment - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: botComment.id, - body: comment - }); - } else { - // Create new comment - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: comment - }); + try { + if (botComment) { + // Update existing comment + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: comment + }); + } else { + // Create new comment + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: comment + }); + } + } catch (error) { + console.error('Error posting comment:', error); } - name: Enforce HIGH/CRITICAL vulnerability threshold diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index f0516f120..5df8453f9 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -41,7 +41,7 @@ jobs: cache: 'npm' - name: Cache node modules id: cache-npm - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | ~/.npm @@ -87,7 +87,7 @@ jobs: - name: Build application run: npm run build - name: Upload build output - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: nextjs-build-${{ github.run_id }} path: .next/ @@ -122,7 +122,7 @@ jobs: node-version: 24 cache: 'npm' - name: Restore dependencies cache - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | ~/.npm @@ -164,7 +164,7 @@ jobs: --reporter=dot,list - name: Upload test results if: always() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: playwright-results-shard-${{ matrix.shard }}-${{ github.run_id }} path: test-results/ @@ -172,7 +172,7 @@ jobs: if-no-files-found: ignore - name: Upload HTML report if: always() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: playwright-report-shard-${{ matrix.shard }}-${{ github.run_id }} path: playwright-report/ @@ -180,7 +180,7 @@ jobs: if-no-files-found: ignore - name: Upload server logs if: always() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: nextjs-logs-shard-${{ matrix.shard }}-${{ github.run_id }} path: nextjs.log @@ -200,7 +200,7 @@ jobs: node-version: 24 cache: 'npm' - name: Restore dependencies cache - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | ~/.npm @@ -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 !(); @@ -60,7 +71,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.SKIP_SERVER_IDENTITY_CHECK === "true" ? { checkServerIdentity: () => undefined } : {}), ca: !credentials.ca || credentials.ca === "undefined" ? undefined @@ -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,23 @@ async function tryJWTAuthentication(): Promise<{ client: FalkorDB; user: Authent return null; } + // Resolve password server-side from Token DB (never from the JWT payload). + // 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) { + 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); + return null; + } + // Try to reuse existing connection (performance optimization) let client = connections.get(payload.sub); @@ -253,7 +281,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,12 +302,8 @@ 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); - // Create new connection with retrieved password const { client: reconnectedClient } = await newClient( { @@ -305,7 +332,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) { @@ -320,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", @@ -344,12 +380,55 @@ 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, + 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 + 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 +451,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 +475,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 +485,42 @@ 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; + + // 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.deleteToken(credentialRef); + } catch (e) { + // eslint-disable-next-line no-console + console.warn("Failed to delete 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 +547,54 @@ 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. + // 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({ + // 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) { + 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.", code: "SESSION_INVALID" }, + { + status: 401, + headers: { + ...getCorsHeaders(request), + "X-Session-Invalid": "1", + }, + } + ); + } + } + } 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 read JWT for session credential lookup:", err); + } + + const userWithPassword: AuthenticatedUserWithPassword = { ...user, password }; + let connection = connections.get(id); // Health check: if connection exists, verify it's still alive @@ -445,7 +604,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 +627,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 +635,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..381b9e251 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,42 @@ 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; + kind?: 'session' | 'pat'; +}): 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), + kind: params.kind ?? 'pat', + }); +} diff --git a/app/api/auth/tokens/credentials/route.ts b/app/api/auth/tokens/credentials/route.ts index c756e2b49..62d137b31 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, + const expiresAtUnix = expirationTime ?? -1; + + 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/app/api/auth/tokens/route.ts b/app/api/auth/tokens/route.ts index 31070a429..9157215d2 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,27 @@ 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); - + // 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 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"; + const expiresAtUnix = expirationTime ?? -1; - 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 diff --git a/app/api/graph/[graph]/[element]/[key]/route.ts b/app/api/graph/[graph]/[element]/[key]/route.ts index bba29a607..690cbc2b7 100644 --- a/app/api/graph/[graph]/[element]/[key]/route.ts +++ b/app/api/graph/[graph]/[element]/[key]/route.ts @@ -24,9 +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 isReadOnly = request.nextUrl.searchParams.get("readOnly") === "true"; 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 (isReadOnly) await graph.roQuery(query, { params: { id: elementId, value } }); else await graph.query(query, { params: { id: elementId, value } }); @@ -85,10 +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 isReadOnly = request.nextUrl.searchParams.get("readOnly") === "true"; 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 (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 54ed88d64..16bca15a8 100644 --- a/app/api/graph/[graph]/[element]/label/route.ts +++ b/app/api/graph/[graph]/[element]/label/route.ts @@ -22,9 +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 isReadOnly = request.nextUrl.searchParams.get("readOnly") === "true"; 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 (isReadOnly) await graph.roQuery(query, { params: { id: elementId } }); else await graph.query(query, { params: { id: elementId } }); @@ -75,9 +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 isReadOnly = request.nextUrl.searchParams.get("readOnly") === "true"; 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 (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 0fb692cd2..91f4c3f7e 100644 --- a/app/api/graph/[graph]/[element]/route.ts +++ b/app/api/graph/[graph]/[element]/route.ts @@ -23,9 +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 isReadOnly = request.nextUrl.searchParams.get("readOnly") === "true"; try { const graph = client.selectGraph(graphId); @@ -35,8 +36,7 @@ export async function GET( WHERE ID(n) = $id RETURN *`; - const result = - user.role === "Read-Only" + const result = isReadOnly ? await graph.roQuery(query, { params: { id: elementId } }) : await graph.query(query, { params: { id: elementId } }); @@ -68,8 +68,9 @@ export async function POST( return session; } - const { client, user } = session; + const { client } = session; const { graph: graphId } = await params; + const isReadOnly = request.nextUrl.searchParams.get("readOnly") === "true"; try { const body = await request.json(); @@ -119,8 +120,7 @@ export async function POST( }); } - const result = - user.role === "Read-Only" + const result = isReadOnly ? await graph.roQuery(query, { params: queryParams }) : await graph.query(query, { params: queryParams }); @@ -152,9 +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 isReadOnly = request.nextUrl.searchParams.get("readOnly") === "true"; try { const body = await request.json(); @@ -175,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") + 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 bd1d8acf7..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,18 +20,27 @@ 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, user } = session; + const { client } = session; const { graph: graphId } = await params; + 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); + 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 79da4afd3..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,18 +22,27 @@ 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, user } = session; + const { client } = session; const { graph: graphId } = await params; + 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); + 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 6192825d0..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,11 +15,20 @@ 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, user } = session; + const { client } = session; const { graph: graphId } = await params; + const isReadOnly = request.nextUrl.searchParams.get("readOnly") === "true"; try { const graph = client.selectGraph(graphId); @@ -29,10 +38,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, isReadOnly); // Execute edges count query - const edgesResult = await runQuery(graph, edgesQuery, user.role); + 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 b5e487eb4..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,6 +22,7 @@ export async function GET( | "(label)" | "(relationship type)" | undefined; + const isReadOnly = request.nextUrl.searchParams.get("readOnly") === "true"; try { const getQuery = () => { @@ -41,8 +42,7 @@ export async function GET( const graph = client.selectGraph(graphId); - const result = - user.role === "Read-Only" + 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 d40b40313..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) }); @@ -60,14 +60,15 @@ export async function POST( return session; } - const { client, user } = session; + const { client } = session; const { graph: graphId } = await params; + const isReadOnly = request.nextUrl.searchParams.get("readOnly") === "true"; try { const graph = client.selectGraph(graphId); - if (user.role === "Read-Only") await graph.roQuery("RETURN 1"); + if (isReadOnly) await graph.roQuery("RETURN 1"); else await graph.query("RETURN 1"); return NextResponse.json( @@ -156,13 +157,22 @@ 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, 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 isReadOnly = request.nextUrl.searchParams.get("readOnly") === "true"; try { if (!query) throw new Error("Missing parameter query"); @@ -170,8 +180,7 @@ export async function GET( const graph = client.selectGraph(graphId); - const result = - user.role === "Read-Only" + const result = isReadOnly ? await graph.roQuery(query, { TIMEOUT: timeout }) : await graph.query(query, { TIMEOUT: timeout }); diff --git a/app/api/graph/model.ts b/app/api/graph/model.ts index 17da5f4e8..0f3f1d816 100644 --- a/app/api/graph/model.ts +++ b/app/api/graph/model.ts @@ -687,16 +687,19 @@ export class Graph { return undefined; } - public extendCell(cell: any, collapsed: boolean, isSchema: boolean) { + public async extendCell(cell: any, collapsed: boolean, isSchema: boolean) { if (cell.nodes) { - return [ - ...cell.nodes.map((node: any) => + const nodes = await Promise.all( + cell.nodes.map((node: any) => this.extendNode(node, collapsed, isSchema) - ), - ...cell.edges.map((edge: any) => + ) + ); + const edges = await Promise.all( + (cell.edges ?? []).map((edge: any) => this.extendEdge(edge, collapsed, isSchema) - ), - ] as (Node | Link)[]; + ) + ); + return [...nodes, ...edges].filter((el): el is Node | Link => el !== undefined); } if (cell.relationshipType) { 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/schema/[schema]/[element]/[key]/_route.ts b/app/api/schema/[schema]/[element]/[key]/_route.ts index 97dae4e98..fdee1083a 100644 --- a/app/api/schema/[schema]/[element]/[key]/_route.ts +++ b/app/api/schema/[schema]/[element]/[key]/_route.ts @@ -18,7 +18,7 @@ export async function POST( return session; } - const { client, user } = session; + const { client } = session; const { schema, element, key } = await params; const schemaName = `${schema}_schema`; const elementId = Number(element); @@ -42,7 +42,9 @@ export async function POST( ? `MATCH (n) WHERE ID(n) = $id SET n.${formattedKey} = $value` : `MATCH ()-[e]->() WHERE ID(e) = $id SET e.${formattedKey} = $value`; - if (user.role === "Read-Only") + const isReadOnly = request.nextUrl.searchParams.get("readOnly") === "true"; + + if (isReadOnly) await graph.roQuery(query, { params: { id: elementId, value: formattedValue }, }); @@ -80,7 +82,7 @@ export async function DELETE( return session; } - const { client, user } = session; + const { client } = session; const { schema, element, key } = await params; const schemaName = `${schema}_schema`; const elementId = Number(element); @@ -103,7 +105,9 @@ 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") + const isReadOnly = request.nextUrl.searchParams.get("readOnly") === "true"; + + if (isReadOnly) await graph.roQuery(query, { params: { id: elementId } }); else await graph.query(query, { params: { id: elementId } }); diff --git a/app/api/schema/[schema]/[element]/_route.ts b/app/api/schema/[schema]/[element]/_route.ts index 8898787bc..13e929375 100644 --- a/app/api/schema/[schema]/[element]/_route.ts +++ b/app/api/schema/[schema]/[element]/_route.ts @@ -23,10 +23,11 @@ export async function GET( return session; } - const { client, user } = session; + const { client } = session; const { schema: schemaName, element } = await params; const schemaId = `${schemaName}_schema`; const elementId = Number(element); + const isReadOnly = request.nextUrl.searchParams.get("readOnly") === "true"; try { const schema = client.selectGraph(schemaId); @@ -37,7 +38,7 @@ export async function GET( RETURN e, n`; const result = - user.role === "Read-Only" + isReadOnly ? await schema.roQuery(query, { params: { id: elementId } }) : await schema.query(query, { params: { id: elementId } }); @@ -69,7 +70,7 @@ export async function POST( return session; } - const { client, user } = session; + const { client } = session; const { schema } = await params; const schemaName = `${schema}_schema`; const body = await request.json(); @@ -81,6 +82,7 @@ export async function POST( } const { type, label, attributes, selectedNodes } = validation.data; + const isReadOnly = request.nextUrl.searchParams.get("readOnly") === "true"; try { if (!type) { @@ -122,7 +124,7 @@ export async function POST( } const result = - user.role === "Read-Only" + isReadOnly ? await graph.roQuery(query, { params: queryParams }) : await graph.query(query, { params: queryParams }); @@ -153,7 +155,7 @@ export async function DELETE( return session; } - const { client, user } = session; + const { client } = session; const { schema, element } = await params; const schemaName = `${schema}_schema`; const elementId = Number(element); @@ -166,6 +168,7 @@ export async function DELETE( } const { type } = validation.data; + const isReadOnly = request.nextUrl.searchParams.get("readOnly") === "true"; try { const graph = client.selectGraph(schemaName); @@ -173,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") + if (isReadOnly) await graph.roQuery(query, { params: { id: elementId } }); else await graph.query(query, { params: { id: elementId } }); diff --git a/app/api/schema/[schema]/[element]/label/_route.ts b/app/api/schema/[schema]/[element]/label/_route.ts index 36459cfa0..4ceb7ca0e 100644 --- a/app/api/schema/[schema]/[element]/label/_route.ts +++ b/app/api/schema/[schema]/[element]/label/_route.ts @@ -18,7 +18,7 @@ export async function DELETE( return session; } - const { client, user } = session; + const { client } = session; const { schema, element } = await params; const elementId = Number(element); const schemaName = `${schema}_schema`; @@ -36,8 +36,9 @@ export async function DELETE( const { label } = validation.data; const query = `MATCH (n) WHERE ID(n) = $id REMOVE n:${label}`; const graph = client.selectGraph(schemaName); + const isReadOnly = request.nextUrl.searchParams.get("readOnly") === "true"; - if (user.role === "Read-Only") + if (isReadOnly) await graph.roQuery(query, { params: { id: elementId } }); else await graph.query(query, { params: { id: elementId } }); @@ -71,7 +72,7 @@ export async function POST( return session; } - const { client, user } = session; + const { client } = session; const { schema, element } = await params; const elementId = Number(element); const schemaName = `${schema}_schema`; @@ -89,8 +90,9 @@ export async function POST( const { label } = validation.data; const query = `MATCH (n) WHERE ID(n) = $id SET n:${label}`; const graph = client.selectGraph(schemaName); + const isReadOnly = request.nextUrl.searchParams.get("readOnly") === "true"; - if (user.role === "Read-Only") + if (isReadOnly) await graph.roQuery(query, { params: { id: elementId } }); else await graph.query(query, { params: { id: elementId } }); diff --git a/app/api/schema/[schema]/_route.ts b/app/api/schema/[schema]/_route.ts index 1795a8034..080a8253f 100644 --- a/app/api/schema/[schema]/_route.ts +++ b/app/api/schema/[schema]/_route.ts @@ -13,10 +13,11 @@ export async function GET( return session; } - const { client, user } = session; + const { client } = session; const { schema } = await params; const schemaName = `${schema}_schema`; const create = request.nextUrl.searchParams.get("create"); + const isReadOnly = request.nextUrl.searchParams.get("readOnly") === "true"; try { const schemas = await client.list(); @@ -29,7 +30,7 @@ export async function GET( const graph = client.selectGraph(schemaName); const result = - user.role === "Read-Only" + isReadOnly ? await graph.roQuery( "MATCH (n) OPTIONAL MATCH (n)-[e]-(m) RETURN * LIMIT 100" ) diff --git a/app/api/schema/[schema]/count/_route.ts b/app/api/schema/[schema]/count/_route.ts index 21ec58f5f..d760d2322 100644 --- a/app/api/schema/[schema]/count/_route.ts +++ b/app/api/schema/[schema]/count/_route.ts @@ -18,9 +18,10 @@ export async function GET( throw new Error(await session.text()); } - const { client, user } = session; + const { client } = session; const { schema } = await params; const schemaName = `${schema}_schema`; + const isReadOnly = request.nextUrl.searchParams.get("readOnly") === "true"; try { const graph = client.selectGraph(schemaName); @@ -30,10 +31,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, isReadOnly); // Execute edges count query - const edgesResult = await runQuery(graph, edgesQuery, user.role); + const edgesResult = await runQuery(graph, edgesQuery, isReadOnly); if (!nodesResult || !edgesResult) throw new Error("Something went wrong"); diff --git a/app/api/swagger/swagger-spec.ts b/app/api/swagger/swagger-spec.ts index 618018b84..c598880ff 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,57 @@ 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). Must be at least 8 characters with uppercase, lowercase, digit, and special character." + } + }, + required: ["role"] + } + } + } + }, responses: { "200": { - description: "User role 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 cbd303c2f..42d651c72 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, extractKeysFromACL } 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,17 @@ 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 connection = await client.connection; + + const finalRole = getRoleWithKeys(role, keys); + if (password) { + finalRole.push(`>${password}`); + } + 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.test.ts b/app/api/user/model.test.ts new file mode 100644 index 000000000..a5efb8d32 --- /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"; + +// --------------------------------------------------------------------------- +// 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.deepEqual(extractKeysFromACL(parts), ["myprefix:*"]); + }); + + it("returns [*] when no ~ pattern is present", () => { + const parts = ["user", "alice", "on", "resetchannels", "-@all"]; + assert.deepEqual(extractKeysFromACL(parts), ["*"]); + }); + + it("returns [*] when the only pattern is ~*", () => { + const parts = ["user", "alice", "on", "~*", "resetchannels", "-@all"]; + assert.deepEqual(extractKeysFromACL(parts), ["*"]); + }); + + it("returns multiple key patterns as an array", () => { + const parts = ["user", "alice", "on", "~ns1:*", "~ns2:*", "-@all"]; + 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[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 3aebc0f86..8779e0083 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 { @@ -11,7 +12,6 @@ export interface CreateUser { const READ_ONLY_ROLE = [ "on", - "~*", "resetchannels", "-@all", "+graph.explain", @@ -29,8 +29,12 @@ const READ_ONLY_ROLE = [ "+expiretime", ]; +export function getRoleWithKeys(role: string[], keys?: string[]): string[] { + return [role[0], "resetkeys", ...(keys?.length ? keys.map((key) => `~${key}`) : ["~*"]), ...role.slice(1)]; +} + export const ROLE = new Map([ - ["Admin", ["on", "~*", "&*", "+@all"]], + ["Admin", ["on", "&*", "+@all"]], [ "Read-Write", [ @@ -50,3 +54,10 @@ export const ROLE = new Map([ ], ["Read-Only", READ_ONLY_ROLE], ]); + +export function extractKeysFromACL(userDetails: string[]): string[] { + const keyPatterns = userDetails + .filter((part) => part.startsWith("~")) + .map((part) => part.slice(1)); + return keyPatterns.length > 0 ? keyPatterns : ["*"]; +} 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/utils.ts b/app/api/utils.ts index 1ccaf3cb9..009db838d 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) => { - const result = role === "Read-Only" ? 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; }; @@ -87,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/app/api/validate-body.ts b/app/api/validate-body.ts index 312c11fe6..13a4f7aba 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 + .array(z.string().min(1, "Key cannot be empty")) + .optional() + .default(["*"]), }); export const deleteUsers = z.object({ @@ -28,12 +32,18 @@ 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 + .array(z.string().min(1, "Key cannot be empty")) + .optional(), + password: z + .string() + .optional(), }); // Schema (graph schema) schemas @@ -305,6 +315,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..73099788c --- /dev/null +++ b/app/api/validate-url/route.ts @@ -0,0 +1,41 @@ +import { FalkorDB } from "falkordb"; +import { NextRequest, NextResponse } from "next/server"; +import { validateBody, validateUrl } from "../validate-body"; + + +export async function POST(request: NextRequest) { + let body: unknown; + + try { + body = await request.json(); + } catch { + return NextResponse.json( + { message: "Invalid JSON" }, + { status: 400 } + ); + } + + 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 { + 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/CreateGraph.tsx b/app/components/CreateGraph.tsx index fb1b313e7..3346c7d4b 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 { isReadOnly } = 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)}${isReadOnly ? '?readOnly=true' : ''}`, { method: "POST", }, toast, setIndicator); diff --git a/app/components/CypherEditor.tsx b/app/components/CypherEditor.tsx index 5692d4fd5..31daaae90 100644 --- a/app/components/CypherEditor.tsx +++ b/app/components/CypherEditor.tsx @@ -4,17 +4,18 @@ "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"; -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"; 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 +219,7 @@ export default function CypherEditor({ graph, graphName, historyQuery, maximize, const { indicator, setIndicator } = useContext(IndicatorContext); const { tutorialOpen } = useContext(BrowserSettingsContext); const { udfList } = useContext(UDFContext); + const { isReadOnly } = useContext(ConnectionContext); const { toast } = useToast(); const editorRef = useRef(null); @@ -230,6 +232,7 @@ export default function CypherEditor({ graph, graphName, historyQuery, maximize, const graphNameRef = useRef(graphName); const queryRef = useRef(historyQuery.query); const tutorialOpenRef = useRef(tutorialOpen); + const isReadOnlyRef = useRef(isReadOnly); const monacoRef = useRef(null); const [lineNumber, setLineNumber] = useState(1); @@ -268,6 +271,10 @@ export default function CypherEditor({ graph, graphName, historyQuery, maximize, graphIdRef.current = graph.Id; }, [graph.Id]); + useEffect(() => { + isReadOnlyRef.current = isReadOnly; + }, [isReadOnly]); + useEffect(() => { if (!containerRef.current) return; @@ -294,7 +301,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 readOnlyParam = isReadOnlyRef.current ? '&readOnly=true' : ''; + const result = await securedFetch(`api/graph/${graphIdRef.current}/info?type=${prepareArg(detail)}${readOnlyParam}`, { method: 'GET', }, toast, setIndicator); @@ -341,7 +349,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() : []; @@ -640,6 +648,25 @@ export default function CypherEditor({ graph, graphName, historyQuery, maximize, } + + + + + + + + + + {"Run (Enter) | History (Arrow Up/Down) | Insert new line (Shift + Enter)"} + + + + )} + + ))} + setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={() => addTags(inputValue)} + 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 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(","); + + useEffect(() => { + if (!isMountedRef.current) { + isMountedRef.current = true; + prevFieldsKeyRef.current = fieldsKey; + return; + } + + // 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; + + 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 })); + } + }, [fieldsKey, fields]); const onHandleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -93,7 +199,7 @@ export default function FormComponent({ handleSubmit, fields, error = undefined, fields.map((field) => { const passwordType = show[field.label] ? "text" : "password"; return ( -
+
{ @@ -108,7 +214,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/components/Header.tsx b/app/components/Header.tsx index bc917925f..13c505143 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -1,43 +1,12 @@ -/* eslint-disable react/require-default-props */ - -'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 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 { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; 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; +import { ConnectionContext, IndicatorContext } from "./provider"; +import { useCallback, useContext, useEffect, useState } from "react"; +import { useSession } from "next-auth/react"; +import { Copy, Loader2 } from "lucide-react"; +import { useToast } from "@/components/ui/use-toast"; +import { cn, securedFetch } from "@/lib/utils"; /** * Format version number to include dots (e.g., "11111" -> "1.11.11") @@ -59,45 +28,34 @@ function formatVersion(version: string | undefined): string { return version; } -export default function Header({ onSetGraphName, graphNames, graphName, onOpenPanel, panelOpen, showUDF }: Props) { - - const { indicator } = useContext(IndicatorContext); +export default function Header() { + const { indicator, setIndicator } = useContext(IndicatorContext); const { connectionType, connectionInfo, dbVersion } = useContext(ConnectionContext); - const { setPanel, panel } = useContext(PanelContext); + const { status, data: session } = useSession(); + const { toast } = useToast(); - const { theme, setTheme } = useTheme(); - const { currentTheme } = getTheme(theme); - const { data: session } = useSession(); - const pathname = usePathname(); - const router = useRouter(); + const [usedMemory, setUsedMemory] = useState(null); - const [mounted, setMounted] = useState(false); - const [openTooltip, setOpenTooltip] = useState(null); - const closeTimeoutRef = useRef | null>(null); - const { toast } = useToast(); + useEffect(() => { + setUsedMemory(null); + (async () => { + if (status !== "authenticated") return; + + const result = await securedFetch("/api/info?section=memory", { + method: "GET" + }, toast, setIndicator); - const openTip = useCallback((name: string) => { - if (closeTimeoutRef.current) { - clearTimeout(closeTimeoutRef.current); - closeTimeoutRef.current = null; - } - setOpenTooltip(name); - }, []); + if (!result.ok) return; - const closeTip = useCallback(() => { - closeTimeoutRef.current = setTimeout(() => { - setOpenTooltip(null); - closeTimeoutRef.current = null; - }, 150); - }, []); + const data = (await result.json()).result; - useEffect(() => { - return () => { - if (closeTimeoutRef.current) { - clearTimeout(closeTimeoutRef.current); - } - }; - }, []); + const match = data.match(/used_memory_human:(\S+)/); + + if (!match) return; + + setUsedMemory(match[1]); + })(); + }, [toast, setIndicator, connectionType, connectionInfo]); const handleCopy = useCallback((text: string) => { if (!navigator.clipboard?.writeText) { @@ -109,396 +67,124 @@ export default function Header({ onSetGraphName, graphNames, graphName, onOpenPa .catch(() => toast({ title: "Failed to copy", variant: "destructive" })); }, [toast]); - 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 ( -
-
+
+
+ +

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

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

v{formatVersion(dbVersion)}

+
+ } +
+ + + + + +

Used Memory

+
+
{ - mounted && currentTheme && - - FalkorDB Logo - + usedMemory !== null ? +

{usedMemory}

+ : } +
+
-

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

+
+ + {connectionType === "Standalone" && "Single"} + {connectionType === "Sentinel" && "Sentinel"} + {connectionType === "Cluster" && "Cluster"} +
-

User Name

+
+

Deployment type: {connectionType}

+

Status: {indicator}

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

v{formatVersion(dbVersion)}

-
- -

FalkorDB Server Version

-
-
- } -
- - -
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}

- } -
-
-
- - -
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})

} -
- } -
-
-
- - -
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}

+
+ { + session?.user && +
+ + {connectionType !== "Standalone" ? ( + + +
- - -
-
-
- {/* - - } - { - type === "Graph" && graphName && - - } - { - showCreate && - - - - } - /> - } + + )} +
+ + + ) : ( +

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

+ )}
-
-
- - -

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} - -
-
+ } + ); -} +} \ No newline at end of file diff --git a/app/components/Navbar.tsx b/app/components/Navbar.tsx new file mode 100644 index 000000000..288c8197a --- /dev/null +++ b/app/components/Navbar.tsx @@ -0,0 +1,208 @@ +/* eslint-disable react/require-default-props */ + +'use client'; + +import { ArrowUpRight, FileCode, LogOut, Monitor, Moon, Sun, Settings, FunctionSquare, GitGraph } from "lucide-react"; +import { useState, useEffect } from "react"; +import Image from "next/image"; +import { cn, getTheme } from "@/lib/utils"; +import { useRouter, usePathname } from "next/navigation"; +import { signOut } 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 { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import Link from "next/link"; +import { useTheme } from "next-themes"; +import Button from "./ui/Button"; + +interface Props { + 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({ showUDF }: Props) { + + const { theme, setTheme } = useTheme(); + const { currentTheme } = getTheme(theme); + const pathname = usePathname(); + const router = useRouter(); + + const [mounted, setMounted] = useState(false); + + const type = getPathType(pathname); + + useEffect(() => { + setMounted(true); + }, []); + + const separator =
; + + return ( +
+
+ { + mounted && currentTheme && + + FalkorDB Logo + + } +
+ + { + showUDF ? + : null + } + +
+
+
+ + + e.preventDefault()} asChild> + + + + + + + + Documentation + + + + + + + + + API Documentation + + + + + + + + + + Get Support + + + + + + + + + + } + {separator} + +
+
+ ); +} diff --git a/app/components/PaginationList.tsx b/app/components/PaginationList.tsx index 13db8806a..c30249318 100644 --- a/app/components/PaginationList.tsx +++ b/app/components/PaginationList.tsx @@ -126,14 +126,15 @@ interface Props { afterSearchCallback: (newFilteredList: T[]) => void isSelected: (item: T) => boolean 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 } -export default function PaginationList({ list, onClick, dataTestId, afterSearchCallback, isSelected, isDeleteSelected, onToggleFav, label, isLoading, className, children, searchRef }: Props) { +export default function PaginationList({ list, onClick, onDoubleClick, dataTestId, afterSearchCallback, isSelected, isDeleteSelected, onToggleFav, label, isLoading, className, children, searchRef }: Props) { const [filteredList, setFilteredList] = useState([...list]); const [hoverIndex, setHoverIndex] = useState(0); @@ -217,7 +218,7 @@ export default function PaginationList({ list, onClick, dataTest {children}
} data-testid={`${label}Search`} className="w-full bg-background text-foreground" value={search} @@ -323,6 +324,11 @@ export default function PaginationList({ list, onClick, dataTest onClick={(e) => { onClick(text, e); }} + onDoubleClick={(e) => { + if (onDoubleClick) { + onDoubleClick(text, e); + } + }} onContextMenu={(e) => { e.preventDefault(); const syntheticEvent = { diff --git a/app/components/TableComponent.tsx b/app/components/TableComponent.tsx index 8e38bb9fa..c5c213a63 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", @@ -29,7 +31,7 @@ interface Props { itemHeightExpandMultiple?: number itemWidth?: number valueClassName?: string - inputRef?: React.RefObject, + inputRef?: React.RefObject, children?: React.ReactNode, setRows?: Dispatch>, className?: string @@ -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]); @@ -246,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; @@ -391,7 +390,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 +405,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 +446,11 @@ export default function TableComponent({ { setRows ? - + 0 && filteredRows.every(row => row.checked)} onCheckedChange={() => { const checked = filteredRows.every(row => row.checked); @@ -467,9 +467,9 @@ export default function TableComponent({ : null } - +
-

Index

+

Index

{ isObjectType && <> @@ -478,7 +478,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 +491,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 +516,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}-${i}`} >
-

{header}

+

{header.name}

{ isObjectType &&
@@ -604,29 +608,33 @@ 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 +655,14 @@ export default function TableComponent({ return ( - +
+ +
); } @@ -669,7 +677,6 @@ export default function TableComponent({ key={cellKey} >
{ @@ -678,7 +685,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,7 +730,7 @@ export default function TableComponent({ : cell.type === "text" && } className="grow" value={newValue} onChange={(e) => setNewValue(e.target.value)} @@ -783,7 +790,7 @@ export default function TableComponent({ :
-

{cell.value}

+

{cell.value}

{cell.value} diff --git a/app/components/provider.ts b/app/components/provider.ts index 5356ea3ed..13d45537b 100644 --- a/app/components/provider.ts +++ b/app/components/provider.ts @@ -148,6 +148,8 @@ type GraphContextType = { cooldownTicks: number | undefined; isLoading: boolean; setIsLoading: (loading: boolean) => void; + expand: boolean; + setExpand: Dispatch>; }; type SchemaContextType = { @@ -172,6 +174,8 @@ type IndicatorContextType = { type PanelContextType = { panel: Panel; setPanel: Dispatch>; + panelOpen: boolean; + onTogglePanel: () => void; }; type QueryLoadingContextType = { @@ -206,6 +210,7 @@ type ConnectionContextType = { setConnectionInfo: Dispatch>; dbVersion: string; setDbVersion: Dispatch>; + isReadOnly: boolean; }; type UDFContextType = { @@ -350,6 +355,8 @@ export const GraphContext = createContext({ cooldownTicks: undefined, isLoading: false, setIsLoading: () => { }, + expand: true, + setExpand: () => { }, }); export const SchemaContext = createContext({ @@ -389,6 +396,8 @@ export const IndicatorContext = createContext({ export const PanelContext = createContext({ panel: undefined, setPanel: () => { }, + panelOpen: false, + onTogglePanel: () => { }, }); export const QueryLoadingContext = createContext({ @@ -423,6 +432,7 @@ export const ConnectionContext = createContext({ setConnectionInfo: () => { }, dbVersion: "", setDbVersion: () => { }, + isReadOnly: false, }); export const UDFContext = createContext({ diff --git a/app/components/ui/Input.tsx b/app/components/ui/Input.tsx index e3aa674a2..6b97ef303 100644 --- a/app/components/ui/Input.tsx +++ b/app/components/ui/Input.tsx @@ -10,7 +10,7 @@ interface Props extends React.InputHTMLAttributes { className?: string; } -const Input = forwardRef(({ +const Input = forwardRef(({ className, ...props }, ref) => ( diff --git a/app/globals.css b/app/globals.css index f7f055133..d36348cca 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%; } @@ -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 { @@ -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,7 +166,7 @@ } .light ::-webkit-scrollbar-thumb { - background: #666666; + background: #888888; } .dark ::-webkit-scrollbar-thumb { diff --git a/app/graph/Chat.tsx b/app/graph/Chat.tsx index 4d6678ca0..667b7448a 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"; @@ -359,7 +359,7 @@ export default function Chat({ onClose }: Props) { messages[messages.length - 1].type === "Status" && messages[messages.length - 1] === message && } -

{message.content}

+

{message.content}

; return index !== undefined ? ( @@ -392,14 +392,14 @@ export default function Chat({ onClose }: Props) { queryCollapse[i] ? ( -

{message.content}

+

{message.content}

{message.content}
) : ( -
+                                    
                                         {message.content}
                                     
) @@ -432,7 +432,7 @@ export default function Chat({ onClose }: Props) { ); default: return ( -

{message.content}

+

{message.content}

); } }; @@ -449,8 +449,8 @@ export default function Chat({ onClose }: Props) {
-

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}

      (undefined); const labelsListRef = useRef(null); const { toast } = useToast(); - const { data: session } = useSession(); const [labelsHover, setLabelsHover] = useState(false); const [label, setLabel] = useState([]); @@ -76,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`, { + const result = await securedFetch(`api/graph/${prepareArg(graph.Id)}/${node.id}/label${isReadOnly ? '?readOnly=true' : ''}`, { method: "POST", body: JSON.stringify({ label: newLabel @@ -124,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`, { + const result = await securedFetch(`api/graph/${prepareArg(graph.Id)}/${node.id}/label${isReadOnly ? '?readOnly=true' : ''}`, { method: "DELETE", body: JSON.stringify({ label: removeLabel @@ -163,7 +162,7 @@ export default function DataPanel({ object, onClose, setLabels, canvasRef }: Pro }; return ( -
      +
      + } { historyQuery ? <> @@ -445,16 +473,7 @@ export default function Selector -
      - - {separator} +
      { + const index = historyQuery.queries.findIndex(q => q.text === counter); + setHistoryQuery(prev => ({ + ...prev, + counter: index + 1 + })); + setTab("text"); + try { + setIsLoading(true); + if (counter.trim()) { + await runQuery!(counter.trim()); + } + setQueriesOpen(false); + } finally { + setIsLoading(false); } }} searchRef={searchQueryRef} @@ -710,15 +747,71 @@ export default function Selector
      - {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 &&
      "labels" in e) ? setIsAddEdge : undefined} canvasRef={canvasRef} + setExpand={() => { }} + expand={true} isLoadingSchema={!!isCanvasLoading} isAddNode={isAddNode} isAddEdge={isAddEdge} diff --git a/app/graph/controls.tsx b/app/graph/controls.tsx index 517a6c2bb..00d3acf5e 100644 --- a/app/graph/controls.tsx +++ b/app/graph/controls.tsx @@ -39,15 +39,16 @@ export default function Controls({ return ( -
      +
      { graph.getElements().length > 0 &&
      - {cooldownTicks === undefined ? : } + {cooldownTicks === undefined ? : } { @@ -57,40 +58,43 @@ export default function Controls({
      -

      Animation Control

      +

      {cooldownTicks === undefined ? "Pause animation" : "Resume animation"}

      } - - - +
      +
      + + + +
      ); } \ No newline at end of file diff --git a/app/graph/graphInfo.tsx b/app/graph/graphInfo.tsx index 1838d906e..f657a7348 100644 --- a/app/graph/graphInfo.tsx +++ b/app/graph/graphInfo.tsx @@ -1,12 +1,20 @@ import { Dispatch, SetStateAction, useContext, useEffect, useState } from "react"; -import { Loader2, X, Palette, Network, Search } from "lucide-react"; +import { Loader2, X, Palette, Play, Plus, 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 { Popover, PopoverContent, PopoverTrigger, PopoverClose } from "@/components/ui/popover"; +import { cn, formatName, InfoLabel } from "@/lib/utils"; import Button from "../components/ui/Button"; -import { BrowserSettingsContext, GraphContext, QueryLoadingContext } from "../components/provider"; +import { BrowserSettingsContext, ConnectionContext, GraphContext, QueryLoadingContext } from "../components/provider"; import CustomizeStylePanel from "./CustomizeStylePanel"; import Input from "../components/ui/Input"; +import SelectGraph from "./selectGraph"; +import { Graph } from "../api/graph/model"; +import CreateGraph from "../components/CreateGraph"; + +/** Escape a Cypher identifier by wrapping it in backticks (doubles any internal backticks). */ +function escapeIdentifier(id: string): string { + return `\`${id.replace(/`/g, '``')}\``; +} /** * Render a side panel showing graph metadata and interactive controls to run representative queries. @@ -15,9 +23,10 @@ import Input from "../components/ui/Input"; * @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 } = 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); const [nodesSearch, setNodesSearch] = useState(""); const [edgesSearch, setEdgesSearch] = useState(""); @@ -28,7 +37,7 @@ export default function GraphInfoPanel({ onClose, customizingLabel, setCustomizi useEffect(() => { setPropertyKeysSearch(""); }, [PropertyKeys, maxItemsForSearch]); return ( -
      +
      { !customizingLabel ? ( <> @@ -39,57 +48,78 @@ export default function GraphInfoPanel({ onClose, customizingLabel, setCustomizi > -
      -

      Graph Info

      - +
      +

      Graph Info

      +
      -
      -

      Graph 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 && -
      -
      -

      Memory Usage:

      +
      +

      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

      +

      Nodes

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

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

      - {nodesCount.toLocaleString()} + {nodesCount?.toLocaleString() || 0}
      : @@ -97,12 +127,12 @@ export default function GraphInfoPanel({ onClose, customizingLabel, setCustomizi { Labels.size > maxItemsForSearch &&
      - + setNodesSearch(e.target.value)} className="w-1 grow" />
      }
      -
        +
        • - - - Customize Style - - + + + + + + + + + +
        • ); })}
      -
      +
      -

      Edges

      +

      Edges

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

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

      - {edgesCount.toLocaleString()} + {edgesCount?.toLocaleString() || 0}
      : @@ -176,12 +216,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 +280,30 @@ 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 bc5175348..b508d6b20 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"; @@ -22,8 +22,8 @@ const CreateElementPanel = dynamicImport(() => import("./CreateElementPanel"), { const Selector = dynamicImport(() => import("./Selector"), { ssr: false, - loading: () =>
          -
          + loading: () =>
          +
          @@ -51,6 +51,7 @@ export default function Page() { const { tutorialOpen } = useContext(BrowserSettingsContext); const { isQueryLoading, setIsQueryLoading } = useContext(QueryLoadingContext); const { setData, canvasRef } = useContext(ForceGraphContext); + const { isReadOnly } = useContext(ConnectionContext); const { graph, setGraph, @@ -82,6 +83,7 @@ export default function Page() { const panelRef = useRef(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); @@ -90,30 +92,19 @@ export default function Page() { setIsCollapsed(size.asPercentage === 0); }, []); + const panelSizes: Record = { + data: { size: "200px", min: "200px" }, + add: { size: "30%", min: "25%" }, + }; + const getPanelSize = useCallback(() => { - switch (panel) { - case "data": - return "200px"; - case "add": - return "30%"; - case "chat": - return "40%"; - 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 "45%"; - default: - return "0%"; - } + if (!panel) return "0%"; + return panelSizes[panel]?.min ?? "0%"; }, [panel]); useEffect(() => { @@ -132,12 +123,6 @@ export default function Page() { } currentPanel.collapse(); - if (panel !== "chat") return; - - setSelectedElements([]); - setIsAddNode(false); - setIsAddEdge(false); - }, [getPanelSize, panel]); useEffect(() => { @@ -158,7 +143,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 readOnlyParam = isReadOnly ? '&readOnly=true' : ''; + const result = await securedFetch(`/api/graph/${graphName}/info?type=${type}${readOnlyParam}`, { method: "GET", }, toast, setIndicator); @@ -169,7 +155,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, isReadOnly), [setIndicator, toast, isReadOnly]); useEffect(() => { if (!graphName) return undefined; @@ -246,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(); @@ -276,7 +259,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 readOnlyParam = isReadOnly ? '?readOnly=true' : ''; + const result = await securedFetch(`api/graph/${prepareArg(graphName)}/${fakeId}${readOnlyParam}`, { method: "POST", body: JSON.stringify({ attributes, @@ -317,8 +301,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 readOnlyParam = isReadOnly ? '?readOnly=true' : ''; + const result = await securedFetch(`api/graph/${prepareArg(graph.Id)}/${prepareArg(element.id.toString())}${readOnlyParam}`, { method: "DELETE", body: JSON.stringify({ type }) }, toast, setIndicator); @@ -377,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; @@ -428,7 +406,7 @@ export default function Page() { }, [graphName, panel, handleSetSelectedElements, setPanel, isAddNode, selectedElements, handleCreateElement, setLabels, canvasRef]); return ( -
          +
          - + 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 a938f86a2..1ee794411 100644 --- a/app/graph/selectGraph.tsx +++ b/app/graph/selectGraph.tsx @@ -7,16 +7,15 @@ 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 } 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"; 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 { @@ -44,6 +43,7 @@ interface Props { export default function SelectGraph({ options, setOptions, selectedValue, setSelectedValue, type, setGraph }: Props) { const { indicator, setIndicator } = useContext(IndicatorContext); + const { isReadOnly } = useContext(ConnectionContext); const { settings: { contentPersistenceSettings: { @@ -85,7 +85,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 readOnlyParam = isReadOnly ? '?readOnly=true' : ''; + const result = await getSSEGraphResult(`api/graph/${prepareArg(opt)}/count/nodes${readOnlyParam}`, toast, setIndicator) as { nodes?: number }; if (result.nodes == null || !Number.isFinite(Number(result.nodes))) return ""; @@ -93,12 +94,13 @@ export default function SelectGraph({ options, setOptions, selectedValue, setSel } catch { return ""; } - }, [toast, setIndicator]); + }, [toast, setIndicator, isReadOnly]); const loadEdgesCount = useCallback((opt: string) => async () => { try { - const result = await getSSEGraphResult(`api/graph/${prepareArg(opt)}/count/edges`, toast, setIndicator) as { edges?: number }; + const readOnlyParam = isReadOnly ? '?readOnly=true' : ''; + const result = await getSSEGraphResult(`api/graph/${prepareArg(opt)}/count/edges${readOnlyParam}`, toast, setIndicator) as { edges?: number }; if (result.edges == null || !Number.isFinite(Number(result.edges))) return ""; @@ -106,7 +108,7 @@ export default function SelectGraph({ options, setOptions, selectedValue, setSel } catch { return ""; } - }, [toast, setIndicator]); + }, [toast, setIndicator, isReadOnly]); const handleSetOption = useCallback(async (option: string, optionName: string) => { const result = await securedFetch( @@ -213,7 +215,7 @@ export default function SelectGraph({ options, setOptions, selectedValue, setSel - } - /> } - { - suggestions.length > 0 && -
          -
            + { + expand && graph.getElements().length > 0 && !isLoading && + setSearchElement(e.target.value)} onKeyDown={(e) => { if (e.key === 'Escape') { e.preventDefault(); @@ -205,7 +172,6 @@ export default function Toolbar({ const index = suggestionIndex === suggestions.length - 1 ? 0 : suggestionIndex + 1; setSuggestionIndex(index); scrollToSuggestion(index); - } if (e.key === 'ArrowUp') { @@ -215,110 +181,126 @@ 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()} + /> + } + { + expand && 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 + &&
          • + } +
          +
          + } +
          { - graphName && session?.user.role !== "Read-Only" && + graphName && !isReadOnly && <> - - { - (hasLimitWarning || hasLimitChangeWarning) ? - - : null - }
          - +