🧭 fix: Add Base Path Support for Login/Register and Image Paths (#10116)

* fix: add basePath pattern to support login/register and image paths

* Fix linter errors

* refactor: Update import statements for getBasePath and isEnabled, and add path utility functions with tests

- Refactored imports in addImages.js and StableDiffusion.js to use getBasePath from '@librechat/api'.
- Consolidated isEnabled and getBasePath imports in validateImageRequest.js.
- Introduced new path utility functions in path.ts and corresponding unit tests in path.spec.ts to validate base path extraction logic.

* fix: Update domain server base URL in MarkdownComponents and refactor authentication redirection logic

- Changed the domain server base URL in MarkdownComponents.tsx to use the API base URL.
- Refactored the useAuthRedirect hook to utilize React Router's navigate for redirection instead of window.location, ensuring a smoother SPA experience.
- Added unit tests for the useAuthRedirect hook to verify authentication redirection behavior.

* test: Mock isEnabled in validateImages.spec.js for improved test isolation

- Updated validateImages.spec.js to mock the isEnabled function from @librechat/api, ensuring that tests can run independently of the actual implementation.
- Cleared the DOMAIN_CLIENT environment variable before tests to avoid interference with basePath resolution.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
catmeme 2025-11-21 11:25:14 -05:00 committed by GitHub
parent ef3bf0a932
commit 7aa8d49f3a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 717 additions and 30 deletions

View file

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { ErrorTypes } from 'librechat-data-provider';
import { ErrorTypes, registerPage } from 'librechat-data-provider';
import { OpenIDIcon, useToastContext } from '@librechat/client';
import { useOutletContext, useSearchParams } from 'react-router-dom';
import type { TLoginLayoutContext } from '~/common';
@ -104,7 +104,7 @@ function Login() {
{' '}
{localize('com_auth_no_account')}{' '}
<a
href="/register"
href={registerPage()}
className="inline-flex p-1 text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
>
{localize('com_auth_sign_up')}

View file

@ -4,6 +4,7 @@ import { Turnstile } from '@marsidev/react-turnstile';
import { ThemeContext, Spinner, Button, isDark } from '@librechat/client';
import { useNavigate, useOutletContext, useLocation } from 'react-router-dom';
import { useRegisterUserMutation } from 'librechat-data-provider/react-query';
import { loginPage } from 'librechat-data-provider';
import type { TRegisterUser, TError } from 'librechat-data-provider';
import type { TLoginLayoutContext } from '~/common';
import { useLocalize, TranslationKeys } from '~/hooks';
@ -213,7 +214,7 @@ const Registration: React.FC = () => {
<p className="my-4 text-center text-sm font-light text-gray-700 dark:text-white">
{localize('com_auth_already_have_account')}{' '}
<a
href="/login"
href={loginPage()}
aria-label="Login"
className="inline-flex p-1 text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
>

View file

@ -3,6 +3,7 @@ import { useState, ReactNode } from 'react';
import { Spinner, Button } from '@librechat/client';
import { useOutletContext } from 'react-router-dom';
import { useRequestPasswordResetMutation } from 'librechat-data-provider/react-query';
import { loginPage } from 'librechat-data-provider';
import type { TRequestPasswordReset, TRequestPasswordResetResponse } from 'librechat-data-provider';
import type { TLoginLayoutContext } from '~/common';
import type { FC } from 'react';
@ -26,7 +27,7 @@ const ResetPasswordBodyText = () => {
<p>{localize('com_auth_reset_password_if_email_exists')}</p>
<a
className="inline-flex text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
href="/login"
href={loginPage()}
>
{localize('com_auth_back_to_login')}
</a>
@ -134,7 +135,7 @@ function RequestPasswordReset() {
{isLoading ? <Spinner /> : localize('com_auth_continue')}
</Button>
<a
href="/login"
href={loginPage()}
className="block text-center text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
>
{localize('com_auth_back_to_login')}

View file

@ -1,6 +1,7 @@
import React, { useState, useRef, useMemo } from 'react';
import { Skeleton } from '@librechat/client';
import { LazyLoadImage } from 'react-lazy-load-image-component';
import { apiBaseUrl } from 'librechat-data-provider';
import { cn, scaleImage } from '~/utils';
import DialogImage from './DialogImage';
@ -36,6 +37,24 @@ const Image = ({
const handleImageLoad = () => setIsLoaded(true);
// Fix image path to include base path for subdirectory deployments
const absoluteImageUrl = useMemo(() => {
if (!imagePath) return imagePath;
// If it's already an absolute URL or doesn't start with /images/, return as is
if (
imagePath.startsWith('http') ||
imagePath.startsWith('data:') ||
!imagePath.startsWith('/images/')
) {
return imagePath;
}
// Get the base URL and prepend it to the image path
const baseURL = apiBaseUrl();
return `${baseURL}${imagePath}`;
}, [imagePath]);
const { width: scaledWidth, height: scaledHeight } = useMemo(
() =>
scaleImage({
@ -48,7 +67,7 @@ const Image = ({
const downloadImage = async () => {
try {
const response = await fetch(imagePath);
const response = await fetch(absoluteImageUrl);
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.status}`);
}
@ -67,7 +86,7 @@ const Image = ({
} catch (error) {
console.error('Download failed:', error);
const link = document.createElement('a');
link.href = imagePath;
link.href = absoluteImageUrl;
link.download = altText || 'image.png';
document.body.appendChild(link);
link.click();
@ -97,7 +116,7 @@ const Image = ({
'opacity-100 transition-opacity duration-100',
isLoaded ? 'opacity-100' : 'opacity-0',
)}
src={imagePath}
src={absoluteImageUrl}
style={{
width: `${scaledWidth}`,
height: 'auto',
@ -117,7 +136,7 @@ const Image = ({
<DialogImage
isOpen={isOpen}
onOpenChange={setIsOpen}
src={imagePath}
src={absoluteImageUrl}
downloadImage={downloadImage}
args={args}
/>

View file

@ -14,7 +14,7 @@ import { ArtifactProvider, CodeBlockProvider } from '~/Providers';
import MarkdownErrorBoundary from './MarkdownErrorBoundary';
import { langSubset, preprocessLaTeX } from '~/utils';
import { unicodeCitation } from '~/components/Web';
import { code, a, p } from './MarkdownComponents';
import { code, a, p, img } from './MarkdownComponents';
import store from '~/store';
type TContentProps = {
@ -81,6 +81,7 @@ const Markdown = memo(({ content = '', isLatestMessage }: TContentProps) => {
code,
a,
p,
img,
artifact: Artifact,
citation: Citation,
'highlighted-text': HighlightedText,

View file

@ -1,7 +1,7 @@
import React, { memo, useMemo, useRef, useEffect } from 'react';
import { useRecoilValue } from 'recoil';
import { useToastContext } from '@librechat/client';
import { PermissionTypes, Permissions, dataService } from 'librechat-data-provider';
import { PermissionTypes, Permissions, apiBaseUrl } from 'librechat-data-provider';
import CodeBlock from '~/components/Messages/Content/CodeBlock';
import useHasAccess from '~/hooks/Roles/useHasAccess';
import { useFileDownload } from '~/data-provider';
@ -135,7 +135,7 @@ export const a: React.ElementType = memo(({ href, children }: TAnchorProps) => {
props.onClick = handleDownload;
props.target = '_blank';
const domainServerBaseUrl = dataService.getDomainServerBaseUrl();
const domainServerBaseUrl = `${apiBaseUrl()}/api`;
return (
<a
@ -158,3 +158,31 @@ type TParagraphProps = {
export const p: React.ElementType = memo(({ children }: TParagraphProps) => {
return <p className="mb-2 whitespace-pre-wrap">{children}</p>;
});
type TImageProps = {
src?: string;
alt?: string;
title?: string;
className?: string;
style?: React.CSSProperties;
};
export const img: React.ElementType = memo(({ src, alt, title, className, style }: TImageProps) => {
// Get the base URL from the API endpoints
const baseURL = apiBaseUrl();
// If src starts with /images/, prepend the base URL
const fixedSrc = useMemo(() => {
if (!src) return src;
// If it's already an absolute URL or doesn't start with /images/, return as is
if (src.startsWith('http') || src.startsWith('data:') || !src.startsWith('/images/')) {
return src;
}
// Prepend base URL to the image path
return `${baseURL}${src}`;
}, [src, baseURL]);
return <img src={fixedSrc} alt={alt} title={title} className={className} style={style} />;
});

View file

@ -6,7 +6,7 @@ import supersub from 'remark-supersub';
import ReactMarkdown from 'react-markdown';
import rehypeHighlight from 'rehype-highlight';
import type { PluggableList } from 'unified';
import { code, codeNoExecution, a, p } from './MarkdownComponents';
import { code, codeNoExecution, a, p, img } from './MarkdownComponents';
import { CodeBlockProvider, ArtifactProvider } from '~/Providers';
import MarkdownErrorBoundary from './MarkdownErrorBoundary';
import { langSubset } from '~/utils';
@ -44,6 +44,7 @@ const MarkdownLite = memo(
code: codeExecution ? code : codeNoExecution,
a,
p,
img,
} as {
[nodeType: string]: React.ElementType;
}

View file

@ -0,0 +1,202 @@
/* eslint-disable i18next/no-literal-string */
import React from 'react';
import { render, waitFor } from '@testing-library/react';
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
import useAuthRedirect from '../useAuthRedirect';
import { useAuthContext } from '~/hooks';
// Polyfill Request for React Router in test environment
if (typeof Request === 'undefined') {
global.Request = class Request {
constructor(
public url: string,
public init?: RequestInit,
) {}
} as any;
}
jest.mock('~/hooks', () => ({
useAuthContext: jest.fn(),
}));
/**
* TestComponent that uses the useAuthRedirect hook and exposes its return value
*/
function TestComponent() {
const result = useAuthRedirect();
// Expose result for assertions
(window as any).__testResult = result;
return <div data-testid="test-component">Test Component</div>;
}
/**
* Creates a test router with optional basename to verify navigation works correctly
* with subdirectory deployments (e.g., /librechat)
*/
const createTestRouter = (basename = '/') => {
// When using basename, initialEntries must include the basename
const initialEntry = basename === '/' ? '/' : `${basename}/`;
return createMemoryRouter(
[
{
path: '/',
element: <TestComponent />,
},
{
path: '/login',
element: <div data-testid="login-page">Login Page</div>,
},
],
{
basename,
initialEntries: [initialEntry],
},
);
};
describe('useAuthRedirect', () => {
beforeEach(() => {
(window as any).__testResult = undefined;
});
afterEach(() => {
jest.clearAllMocks();
(window as any).__testResult = undefined;
});
it('should not redirect when user is authenticated', async () => {
(useAuthContext as jest.Mock).mockReturnValue({
user: { id: '123', email: 'test@example.com' },
isAuthenticated: true,
});
const router = createTestRouter();
const { getByTestId } = render(<RouterProvider router={router} />);
expect(router.state.location.pathname).toBe('/');
expect(getByTestId('test-component')).toBeInTheDocument();
// Wait for the timeout (300ms) plus a buffer
await new Promise((resolve) => setTimeout(resolve, 400));
// Should still be on home page, not redirected
expect(router.state.location.pathname).toBe('/');
expect(getByTestId('test-component')).toBeInTheDocument();
});
it('should redirect to /login when user is not authenticated', async () => {
(useAuthContext as jest.Mock).mockReturnValue({
user: null,
isAuthenticated: false,
});
const router = createTestRouter();
const { getByTestId, queryByTestId } = render(<RouterProvider router={router} />);
expect(router.state.location.pathname).toBe('/');
expect(getByTestId('test-component')).toBeInTheDocument();
// Wait for the redirect to happen (300ms timeout + navigation)
await waitFor(
() => {
expect(router.state.location.pathname).toBe('/login');
expect(getByTestId('login-page')).toBeInTheDocument();
expect(queryByTestId('test-component')).not.toBeInTheDocument();
},
{ timeout: 1000 },
);
// Verify navigation used replace (history has only 1 entry)
// This prevents users from hitting back to return to protected pages
expect(router.state.historyAction).toBe('REPLACE');
});
it('should respect router basename when redirecting (subdirectory deployment)', async () => {
(useAuthContext as jest.Mock).mockReturnValue({
user: null,
isAuthenticated: false,
});
// Test with basename="/librechat" (simulates subdirectory deployment)
const router = createTestRouter('/librechat');
const { getByTestId } = render(<RouterProvider router={router} />);
// Full pathname includes basename
expect(router.state.location.pathname).toBe('/librechat/');
// Wait for the redirect - router handles basename internally
await waitFor(
() => {
// Router state pathname includes the full path with basename
expect(router.state.location.pathname).toBe('/librechat/login');
expect(getByTestId('login-page')).toBeInTheDocument();
},
{ timeout: 1000 },
);
// The key point: navigate('/login', { replace: true }) works correctly with basename
// The router automatically prepends the basename to create the full URL
expect(router.state.historyAction).toBe('REPLACE');
});
it('should use React Router navigate (not window.location) for SPA experience', async () => {
(useAuthContext as jest.Mock).mockReturnValue({
user: null,
isAuthenticated: false,
});
const router = createTestRouter('/librechat');
const { getByTestId } = render(<RouterProvider router={router} />);
await waitFor(
() => {
expect(router.state.location.pathname).toBe('/librechat/login');
expect(getByTestId('login-page')).toBeInTheDocument();
},
{ timeout: 1000 },
);
// The fact that navigation worked within the router proves we're using
// navigate() and not window.location.href (which would cause a full reload
// and break the test entirely). This maintains the SPA experience.
expect(router.state.location.pathname).toBe('/librechat/login');
});
it('should clear timeout on unmount', async () => {
(useAuthContext as jest.Mock).mockReturnValue({
user: null,
isAuthenticated: false,
});
const router = createTestRouter();
const { unmount } = render(<RouterProvider router={router} />);
// Unmount immediately before timeout fires
unmount();
// Wait past the timeout period
await new Promise((resolve) => setTimeout(resolve, 400));
// Should still be at home, not redirected (timeout was cleared)
expect(router.state.location.pathname).toBe('/');
});
it('should return user and isAuthenticated values', async () => {
const mockUser = { id: '123', email: 'test@example.com' };
(useAuthContext as jest.Mock).mockReturnValue({
user: mockUser,
isAuthenticated: true,
});
const router = createTestRouter();
render(<RouterProvider router={router} />);
await waitFor(() => {
const testResult = (window as any).__testResult;
expect(testResult).toBeDefined();
expect(testResult.user).toEqual(mockUser);
expect(testResult.isAuthenticated).toBe(true);
});
});
});