mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
🧭 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:
parent
ef3bf0a932
commit
7aa8d49f3a
21 changed files with 717 additions and 30 deletions
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
202
client/src/routes/__tests__/useAuthRedirect.spec.tsx
Normal file
202
client/src/routes/__tests__/useAuthRedirect.spec.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue