diff --git a/api/server/middleware/roles/capabilities.js b/api/server/middleware/roles/capabilities.js index f2b1c5dd1c..059f8c8930 100644 --- a/api/server/middleware/roles/capabilities.js +++ b/api/server/middleware/roles/capabilities.js @@ -3,26 +3,28 @@ const { getUserPrincipals, hasAnyConfigReadAccess, hasCapabilityForPrincipals, - getHeldCapabilities, + getHeldCapabilities: getHeldCapabilitiesForPrincipals, } = require('~/models'); const { hasCapability, requireCapability, hasConfigCapability, + getHeldCapabilities, hasAnyConfigReadAccess: checkAnyConfigReadAccess, getReadableConfigSections, } = generateCapabilityCheck({ getUserPrincipals, hasAnyConfigReadAccess, hasCapabilityForPrincipals, - getHeldCapabilities, + getHeldCapabilities: getHeldCapabilitiesForPrincipals, }); module.exports = { hasCapability, requireCapability, hasConfigCapability, + getHeldCapabilities, capabilityContextMiddleware, hasAnyConfigReadAccess: checkAnyConfigReadAccess, getReadableConfigSections, diff --git a/api/server/routes/__tests__/share.spec.js b/api/server/routes/__tests__/share.spec.js index 774ee4737a..5f7dc7c5bd 100644 --- a/api/server/routes/__tests__/share.spec.js +++ b/api/server/routes/__tests__/share.spec.js @@ -8,10 +8,15 @@ const mockUpdateSharedLinkPermissionsExpiration = jest.fn(); const mockSharedLinksAccess = jest.fn((_req, _res, next) => next()); const mockSharedLinkConfigMiddleware = jest.fn((_req, _res, next) => next()); let mockShareTenantId; +const mockHasCapability = jest.fn(); +const mockHasConfigCapability = jest.fn(); +const mockGetSharedLangfuseSessionUrl = jest.fn(); const mockBuildSharedLinkStartupPayload = jest.fn(); const mockCanAccessSharedLink = jest.fn((req, _res, next) => { req.shareResourceId = 'resource-123'; req.shareTenantId = mockShareTenantId; + req.shareConversationId = 'conversation-owner-123'; + req.shareOwnerId = 'owner-123'; next(); }); const mockGetAppConfig = jest.fn(); @@ -93,6 +98,11 @@ jest.mock('@librechat/api', () => ({ buildShareFileEtag: (file) => `"share-${file.file_id}-${file.previewRevision ?? 0}-${file.bytes ?? 0}-${file.filepath ?? ''}"`, MAX_SHARED_LINK_SEARCH_LENGTH: 256, + createSharedLangfuseSessionResolver: jest.fn( + () => + (...args) => + mockGetSharedLangfuseSessionUrl(...args), + ), isContentFilterError: jest.fn( (error) => error?.code === 'content_filter_block' || error?.code === 'content_filter_uninspectable', @@ -105,6 +115,7 @@ jest.mock('@librechat/data-schemas', () => ({ runAsSystem: jest.fn((fn) => fn()), tenantStorage: { run: jest.fn((_ctx, fn) => fn()) }, SYSTEM_TENANT_ID: '__SYSTEM__', + SystemCapabilities: { ACCESS_ADMIN: 'access:admin' }, })); jest.mock('librechat-data-provider', () => ({ @@ -151,9 +162,15 @@ jest.mock('~/models', () => ({ getSharedLink: jest.fn(), getSharedLinkFile: jest.fn(), backfillSharedLinkFiles: jest.fn(), + getMessages: jest.fn(), getRoleByName: jest.fn(), })); +jest.mock('~/server/middleware/roles/capabilities', () => ({ + hasCapability: (...args) => mockHasCapability(...args), + hasConfigCapability: (...args) => mockHasConfigCapability(...args), +})); + const mockGetStrategyFunctions = jest.fn(); jest.mock('~/server/services/Files/strategies', () => ({ getStrategyFunctions: (...args) => mockGetStrategyFunctions(...args), @@ -233,6 +250,7 @@ const buildApp = ({ user = { id: 'user-123' }, filters, messageFilter, + langfuse, } = {}) => { const app = express(); app.use(express.json()); @@ -242,6 +260,7 @@ const buildApp = ({ interfaceConfig: { retentionMode }, ...(filters == null ? {} : { filters }), ...(messageFilter == null ? {} : { messageFilter }), + ...(langfuse == null ? {} : { langfuse }), }; next(); }); @@ -278,6 +297,9 @@ describe('share routes', () => { beforeEach(() => { jest.clearAllMocks(); mockShareTenantId = undefined; + mockHasCapability.mockResolvedValue(false); + mockHasConfigCapability.mockResolvedValue(false); + mockGetSharedLangfuseSessionUrl.mockResolvedValue(null); mockGetAppConfig.mockResolvedValue({ interfaceConfig: { privacyPolicy: { externalUrl: 'https://example.com/privacy' }, @@ -353,6 +375,88 @@ describe('share routes', () => { expect(mockAssertSharedFileMetadataAllowed).not.toHaveBeenCalled(); }); + it('includes the source conversation session for an admin in the share tenant', async () => { + mockShareTenantId = 'tenant-abc'; + mockGetSharedLangfuseSessionUrl.mockResolvedValue( + 'https://cloud.langfuse.com/project/project-1/sessions/conversation-owner-123', + ); + mockSharedMessagesResult({ shareId: 'share-123', messages: [] }); + const langfuse = { enabled: true, destination: 'eu', projectId: 'project-1' }; + + const response = await request( + buildApp({ + user: { id: 'admin-123', role: 'ADMIN', tenantId: 'tenant-abc' }, + langfuse, + }), + ).get('/api/share/share-123'); + + expect(response.status).toBe(200); + expect(response.body.langfuseSessionUrl).toBe( + 'https://cloud.langfuse.com/project/project-1/sessions/conversation-owner-123', + ); + expect(mockGetSharedLangfuseSessionUrl).toHaveBeenCalledWith({ + viewer: expect.objectContaining({ id: 'admin-123', tenantId: 'tenant-abc' }), + shareTenantId: 'tenant-abc', + shareConversationId: 'conversation-owner-123', + shareOwnerId: 'owner-123', + config: langfuse, + }); + }); + + it('omits the session link when the shared-session resolver denies access', async () => { + mockShareTenantId = 'tenant-abc'; + mockSharedMessagesResult({ shareId: 'share-123', messages: [] }); + + const response = await request( + buildApp({ user: { id: 'user-123', role: 'USER', tenantId: 'tenant-abc' } }), + ).get('/api/share/share-123'); + + expect(response.status).toBe(200); + expect(response.body).not.toHaveProperty('langfuseSessionUrl'); + }); + + it('serves the shared chat when session-link resolution fails', async () => { + mockShareTenantId = 'tenant-abc'; + mockGetSharedLangfuseSessionUrl.mockRejectedValue(new Error('Langfuse lookup failed')); + mockSharedMessagesResult({ shareId: 'share-123', messages: [] }); + + const response = await request( + buildApp({ user: { id: 'admin-123', role: 'ADMIN', tenantId: 'tenant-abc' } }), + ).get('/api/share/share-123'); + + expect(response.status).toBe(200); + expect(response.body).not.toHaveProperty('langfuseSessionUrl'); + expect(logger.warn).toHaveBeenCalledWith( + '[share] Failed to resolve Langfuse session link:', + expect.any(Error), + ); + }); + + it('resolves the session link while loading the shared snapshot', async () => { + let resolveShare; + let markShareStarted; + const shareStarted = new Promise((resolve) => { + markShareStarted = resolve; + }); + const pendingShare = new Promise((resolve) => { + resolveShare = resolve; + }); + getSharedMessages.mockImplementationOnce(() => { + markShareStarted(); + return pendingShare; + }); + mockGetSharedLangfuseSessionUrl.mockResolvedValue(null); + + const responsePromise = request(buildApp()) + .get('/api/share/share-123') + .then((response) => response); + await shareStarted; + + expect(mockGetSharedLangfuseSessionUrl).toHaveBeenCalledTimes(1); + resolveShare({ shareId: 'share-123', messages: [] }); + await expect(responsePromise).resolves.toMatchObject({ status: 200 }); + }); + it('normalizes shared-link list parameters without double-decoding search text', async () => { getSharedLinks.mockResolvedValue({ links: [], hasNextPage: false }); mockParseSharedLinksPageSize.mockReturnValueOnce(100); diff --git a/api/server/routes/share.js b/api/server/routes/share.js index 4a327d825c..d9278a65df 100644 --- a/api/server/routes/share.js +++ b/api/server/routes/share.js @@ -20,6 +20,7 @@ const { isValidSharedLinksCursor, MAX_SHARED_LINK_SEARCH_LENGTH, createSharedLinkConfigMiddleware, + createSharedLangfuseSessionResolver, } = require('@librechat/api'); const { logger, @@ -38,6 +39,7 @@ const { getSharedLink, getSharedLinkFile, backfillSharedLinkFiles, + getMessages, getRoleByName, } = require('~/models'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); @@ -48,11 +50,17 @@ const { createForkLimiters } = require('~/server/middleware/limiters'); const optionalShareFileAuth = require('~/server/middleware/optionalShareFileAuth'); const optionalJwtAuth = require('~/server/middleware/optionalJwtAuth'); const requireJwtAuth = require('~/server/middleware/requireJwtAuth'); +const { getHeldCapabilities } = require('~/server/middleware/roles/capabilities'); const configMiddleware = require('~/server/middleware/config/app'); const { getAppConfig } = require('~/server/services/Config/app'); const router = express.Router(); const sharedLinkConfigMiddleware = createSharedLinkConfigMiddleware({ getAppConfig }); +const getSharedLangfuseSessionUrl = createSharedLangfuseSessionResolver({ + getHeldCapabilities, + getMessages, +}); + const SHARE_SERVICE_ERROR_STATUS = { INVALID_PARAMS: 400, TARGET_MESSAGE_NOT_FOUND: 400, @@ -335,15 +343,31 @@ if (allowSharedLinks) { sharedFileMetadata: true, legacyPii: req.config?.messageFilter?.pii, }); - const share = await getSharedMessages(req.params.shareId, req.shareResourceId, { + const sharePromise = getSharedMessages(req.params.shareId, req.shareResourceId, { // Viewer-independent: the per-link choice (stored on the share) decides // file inclusion; only a global env kill switch can force it off here. snapshotFiles: !isFileSnapshotKillSwitchActive(), preflight: contentPreflight, }); + const langfuseSessionPromise = getSharedLangfuseSessionUrl({ + viewer: req.user, + shareTenantId: req.shareTenantId, + shareConversationId: req.shareConversationId, + shareOwnerId: req.shareOwnerId, + config: req.config?.langfuse, + }).catch((error) => { + logger.warn('[share] Failed to resolve Langfuse session link:', error); + return null; + }); + const [share, langfuseSessionUrl] = await Promise.all([ + sharePromise, + langfuseSessionPromise, + ]); if (share) { res.set('Cache-Control', 'private, no-store'); - res.status(200).json(share); + res + .status(200) + .json(langfuseSessionUrl == null ? share : { ...share, langfuseSessionUrl }); } else { res.status(404).end(); } diff --git a/client/src/common/types.ts b/client/src/common/types.ts index ada674c6ac..1249bab3a2 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -491,6 +491,7 @@ export type TAuthContext = { user: t.TUser | undefined; token: string | undefined; isAuthenticated: boolean; + isAuthReady: boolean; error: string | undefined; login: (data: t.TLoginUser) => void; logout: (redirect?: string) => void; @@ -508,6 +509,7 @@ export type TUserContext = { export type TAuthConfig = { loginRedirect: string; test?: boolean; + optional?: boolean; }; export type IconProps = Pick & diff --git a/client/src/components/Share/ShareView.spec.tsx b/client/src/components/Share/ShareView.spec.tsx new file mode 100644 index 0000000000..7b650efc1f --- /dev/null +++ b/client/src/components/Share/ShareView.spec.tsx @@ -0,0 +1,37 @@ +import { render, screen } from '@testing-library/react'; +import { ShareHeader } from './ShareView'; + +const defaultProps = { + title: 'Shared conversation', + formattedDate: 'August 27, 2026', + theme: 'system', + langcode: 'en-US', + settingsLabel: 'Settings', + continueLabel: 'Continue chat', + langfuseSessionLabel: 'View session in Langfuse', + isContinuing: false, + onContinue: jest.fn(), + onThemeChange: jest.fn(), + onLangChange: jest.fn(), +}; + +describe('ShareHeader', () => { + it('shows the Langfuse session as an external link when supplied', () => { + const url = 'https://cloud.langfuse.com/project/project-1/sessions/conversation-1'; + + render(); + + const link = screen.getByRole('link', { name: 'View session in Langfuse' }); + expect(link).toHaveAttribute('href', url); + expect(link).toHaveAttribute('target', '_blank'); + expect(link.parentElement).toHaveClass('flex-wrap'); + }); + + it('omits the Langfuse action when the server does not supply a session', () => { + render(); + + expect( + screen.queryByRole('link', { name: 'View session in Langfuse' }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Share/ShareView.tsx b/client/src/components/Share/ShareView.tsx index c76bdf4566..6f80b62797 100644 --- a/client/src/components/Share/ShareView.tsx +++ b/client/src/components/Share/ShareView.tsx @@ -2,9 +2,9 @@ import { memo, useState, useCallback, useContext } from 'react'; import Cookies from 'js-cookie'; import { buildTree } from 'librechat-data-provider'; import { useParams, useNavigate } from 'react-router-dom'; -import { CalendarDays, Settings, MessageSquarePlus } from 'lucide-react'; import { useRecoilState, useRecoilValue, useRecoilCallback } from 'recoil'; import { useGetSharedMessages } from 'librechat-data-provider/react-query'; +import { CalendarDays, ExternalLink, Settings, MessageSquarePlus } from 'lucide-react'; import { Spinner, Button, @@ -21,12 +21,12 @@ import { } from '@librechat/client'; import SharedSubagentActivityDialog from '~/components/Chat/Subagents/SharedSubagentActivityDialog'; import { cn, DEFAULT_APP_TITLE, getResponseStatus, selectActiveBranchTail } from '~/utils'; +import { useLocalize, useDocumentTitle, useAuthContext } from '~/hooks'; import { ThemeSelector, LangSelector } from '~/components/Appearance'; import { ShareMessagesProvider } from './ShareMessagesProvider'; import { useForkSharedConvoMutation } from '~/data-provider'; import { useGetSharedStartupConfig } from '~/data-provider'; import { ShareArtifactsContainer } from './ShareArtifacts'; -import { useLocalize, useDocumentTitle } from '~/hooks'; import { ShareContext } from '~/Providers'; import MessagesView from './MessagesView'; import Footer from '../Chat/Footer'; @@ -39,10 +39,13 @@ function SharedView() { const localize = useLocalize(); const navigate = useNavigate(); const { showToast } = useToastContext(); + const { isAuthReady } = useAuthContext(); const { theme, setTheme } = useContext(ThemeContext); const { shareId } = useParams(); - const { data: config } = useGetSharedStartupConfig(shareId); - const { data, isLoading, refetch } = useGetSharedMessages(shareId ?? ''); + const { data: config } = useGetSharedStartupConfig(shareId, { enabled: isAuthReady }); + const { data, isLoading, refetch } = useGetSharedMessages(shareId ?? '', { + enabled: isAuthReady, + }); const dataTree = data && buildTree({ messages: data.messages }); const messagesTree = dataTree?.length === 0 ? null : (dataTree ?? null); @@ -170,7 +173,7 @@ function SharedView() { ); let content: JSX.Element; - if (isLoading) { + if (!isAuthReady || isLoading) { content = (
@@ -188,6 +191,8 @@ function SharedView() { onLangChange={handleLangChange} settingsLabel={localize('com_nav_settings')} continueLabel={localize('com_ui_continue_chat')} + langfuseSessionLabel={localize('com_ui_langfuse_view_session')} + langfuseSessionUrl={data.langfuseSessionUrl} onContinue={handleContinue} isContinuing={forkShare.isLoading} /> @@ -275,19 +280,23 @@ interface ShareHeaderProps { langcode: string; settingsLabel: string; continueLabel: string; + langfuseSessionLabel: string; + langfuseSessionUrl?: string; isContinuing: boolean; onContinue: () => void; onThemeChange: (value: string) => void; onLangChange: (value: string) => void; } -function ShareHeader({ +export function ShareHeader({ title, formattedDate, theme, langcode, settingsLabel, continueLabel, + langfuseSessionLabel, + langfuseSessionUrl, isContinuing, onContinue, onThemeChange, @@ -318,7 +327,19 @@ function ShareHeader({ )}
-
+
+ {langfuseSessionUrl && ( + + )}