From aea01f0bc5ce560bd9c589df8997786cd1c6452d Mon Sep 17 00:00:00 2001 From: Yuichi Oneda Date: Wed, 11 Sep 2024 06:34:25 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=80=20feat:=20Banner=20(#3952)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Add banner schema and model * feat: Add optional JwtAuth To handle the conditional logic with and without authentication within the model. * feat: Add an endpoint to retrieve a banner * feat: Add implementation for client to use banner and access API * feat: Display a banner on UI * feat: Script for updating and deleting banners * style: Update banner style * fix: Adjust the height when the banner is displayed * fix: failed specs --- api/models/Banner.js | 27 ++++ api/models/schema/banner.js | 36 +++++ api/server/index.js | 1 + api/server/middleware/optionalJwtAuth.js | 17 ++ api/server/routes/banner.js | 15 ++ api/server/routes/index.js | 2 + client/src/components/Auth/AuthLayout.tsx | 2 + .../components/Auth/__tests__/Login.spec.tsx | 10 ++ .../Auth/__tests__/LoginForm.spec.tsx | 10 ++ .../Auth/__tests__/Registration.spec.tsx | 9 ++ client/src/components/Banners/Banner.tsx | 48 ++++++ client/src/components/Banners/index.ts | 1 + client/src/components/ui/OGDialogTemplate.tsx | 2 +- client/src/routes/Root.tsx | 6 +- client/src/store/banner.ts | 5 + client/src/store/index.ts | 3 +- config/delete-banner.js | 61 ++++++++ config/helpers.js | 22 +++ config/update-banner.js | 147 ++++++++++++++++++ package.json | 2 + packages/data-provider/src/api-endpoints.ts | 1 + packages/data-provider/src/data-service.ts | 4 + packages/data-provider/src/keys.ts | 1 + .../src/react-query/react-query-service.ts | 11 ++ packages/data-provider/src/schemas.ts | 11 ++ packages/data-provider/src/types.ts | 3 + 26 files changed, 453 insertions(+), 4 deletions(-) create mode 100644 api/models/Banner.js create mode 100644 api/models/schema/banner.js create mode 100644 api/server/middleware/optionalJwtAuth.js create mode 100644 api/server/routes/banner.js create mode 100644 client/src/components/Banners/Banner.tsx create mode 100644 client/src/components/Banners/index.ts create mode 100644 client/src/store/banner.ts create mode 100644 config/delete-banner.js create mode 100644 config/update-banner.js diff --git a/api/models/Banner.js b/api/models/Banner.js new file mode 100644 index 000000000..8d439dae2 --- /dev/null +++ b/api/models/Banner.js @@ -0,0 +1,27 @@ +const Banner = require('./schema/banner'); +const logger = require('~/config/winston'); +/** + * Retrieves the current active banner. + * @returns {Promise} The active banner object or null if no active banner is found. + */ +const getBanner = async (user) => { + try { + const now = new Date(); + const banner = await Banner.findOne({ + displayFrom: { $lte: now }, + $or: [{ displayTo: { $gte: now } }, { displayTo: null }], + type: 'banner', + }).lean(); + + if (!banner || banner.isPublic || user) { + return banner; + } + + return null; + } catch (error) { + logger.error('[getBanners] Error getting banners', error); + throw new Error('Error getting banners'); + } +}; + +module.exports = { getBanner }; diff --git a/api/models/schema/banner.js b/api/models/schema/banner.js new file mode 100644 index 000000000..7fd86c1b6 --- /dev/null +++ b/api/models/schema/banner.js @@ -0,0 +1,36 @@ +const mongoose = require('mongoose'); + +const bannerSchema = mongoose.Schema( + { + bannerId: { + type: String, + required: true, + }, + message: { + type: String, + required: true, + }, + displayFrom: { + type: Date, + required: true, + default: Date.now, + }, + displayTo: { + type: Date, + }, + type: { + type: String, + enum: ['banner', 'popup'], + default: 'banner', + }, + isPublic: { + type: Boolean, + default: false, + }, + }, + + { timestamps: true }, +); + +const Banner = mongoose.model('Banner', bannerSchema); +module.exports = Banner; diff --git a/api/server/index.js b/api/server/index.js index 47ce354f2..8c4d3250f 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -106,6 +106,7 @@ const startServer = async () => { app.use('/api/share', routes.share); app.use('/api/roles', routes.roles); app.use('/api/agents', routes.agents); + app.use('/api/banner', routes.banner); app.use('/api/bedrock', routes.bedrock); app.use('/api/tags', routes.tags); diff --git a/api/server/middleware/optionalJwtAuth.js b/api/server/middleware/optionalJwtAuth.js new file mode 100644 index 000000000..8aa1c27e0 --- /dev/null +++ b/api/server/middleware/optionalJwtAuth.js @@ -0,0 +1,17 @@ +const passport = require('passport'); + +// This middleware does not require authentication, +// but if the user is authenticated, it will set the user object. +const optionalJwtAuth = (req, res, next) => { + passport.authenticate('jwt', { session: false }, (err, user) => { + if (err) { + return next(err); + } + if (user) { + req.user = user; + } + next(); + })(req, res, next); +}; + +module.exports = optionalJwtAuth; diff --git a/api/server/routes/banner.js b/api/server/routes/banner.js new file mode 100644 index 000000000..cf7eafd01 --- /dev/null +++ b/api/server/routes/banner.js @@ -0,0 +1,15 @@ +const express = require('express'); + +const { getBanner } = require('~/models/Banner'); +const optionalJwtAuth = require('~/server/middleware/optionalJwtAuth'); +const router = express.Router(); + +router.get('/', optionalJwtAuth, async (req, res) => { + try { + res.status(200).send(await getBanner(req.user)); + } catch (error) { + res.status(500).json({ message: 'Error getting banner' }); + } +}); + +module.exports = router; diff --git a/api/server/routes/index.js b/api/server/routes/index.js index 3790aacd2..4aba91e95 100644 --- a/api/server/routes/index.js +++ b/api/server/routes/index.js @@ -24,6 +24,7 @@ const edit = require('./edit'); const keys = require('./keys'); const user = require('./user'); const ask = require('./ask'); +const banner = require('./banner'); module.exports = { ask, @@ -52,4 +53,5 @@ module.exports = { assistants, categories, staticRoute, + banner, }; diff --git a/client/src/components/Auth/AuthLayout.tsx b/client/src/components/Auth/AuthLayout.tsx index 9f222a110..0ff99f357 100644 --- a/client/src/components/Auth/AuthLayout.tsx +++ b/client/src/components/Auth/AuthLayout.tsx @@ -3,6 +3,7 @@ import { BlinkAnimation } from './BlinkAnimation'; import { TStartupConfig } from 'librechat-data-provider'; import SocialLoginRender from './SocialLoginRender'; import { ThemeSelector } from '~/components/ui'; +import { Banner } from '../Banners'; import Footer from './Footer'; const ErrorRender = ({ children }: { children: React.ReactNode }) => ( @@ -56,6 +57,7 @@ function AuthLayout({ return (
+
Logo diff --git a/client/src/components/Auth/__tests__/Login.spec.tsx b/client/src/components/Auth/__tests__/Login.spec.tsx index 71c3d6592..288d79685 100644 --- a/client/src/components/Auth/__tests__/Login.spec.tsx +++ b/client/src/components/Auth/__tests__/Login.spec.tsx @@ -54,6 +54,11 @@ const setup = ({ }, }, useGetStartupConfigReturnValue = mockStartupConfig, + useGetBannerQueryReturnValue = { + isLoading: false, + isError: false, + data: {}, + }, } = {}) => { const mockUseLoginUser = jest .spyOn(mockDataProvider, 'useLoginUserMutation') @@ -71,6 +76,10 @@ const setup = ({ .spyOn(mockDataProvider, 'useRefreshTokenMutation') //@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult .mockReturnValue(useRefreshTokenMutationReturnValue); + const mockUseGetBannerQuery = jest + .spyOn(mockDataProvider, 'useGetBannerQuery') + //@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult + .mockReturnValue(useGetBannerQueryReturnValue); const mockUseOutletContext = jest.spyOn(reactRouter, 'useOutletContext').mockReturnValue({ startupConfig: useGetStartupConfigReturnValue.data, }); @@ -93,6 +102,7 @@ const setup = ({ mockUseOutletContext, mockUseGetStartupConfig, mockUseRefreshTokenMutation, + mockUseGetBannerQuery, }; }; diff --git a/client/src/components/Auth/__tests__/LoginForm.spec.tsx b/client/src/components/Auth/__tests__/LoginForm.spec.tsx index d30b709eb..eca8e6ef5 100644 --- a/client/src/components/Auth/__tests__/LoginForm.spec.tsx +++ b/client/src/components/Auth/__tests__/LoginForm.spec.tsx @@ -59,6 +59,11 @@ const setup = ({ isError: false, data: mockStartupConfig, }, + useGetBannerQueryReturnValue = { + isLoading: false, + isError: false, + data: {}, + }, } = {}) => { const mockUseLoginUser = jest .spyOn(mockDataProvider, 'useLoginUserMutation') @@ -76,11 +81,16 @@ const setup = ({ .spyOn(mockDataProvider, 'useRefreshTokenMutation') //@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult .mockReturnValue(useRefreshTokenMutationReturnValue); + const mockUseGetBannerQuery = jest + .spyOn(mockDataProvider, 'useGetBannerQuery') + //@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult + .mockReturnValue(useGetBannerQueryReturnValue); return { mockUseLoginUser, mockUseGetUserQuery, mockUseGetStartupConfig, mockUseRefreshTokenMutation, + mockUseGetBannerQuery, }; }; diff --git a/client/src/components/Auth/__tests__/Registration.spec.tsx b/client/src/components/Auth/__tests__/Registration.spec.tsx index 16d276175..0a9b6d4da 100644 --- a/client/src/components/Auth/__tests__/Registration.spec.tsx +++ b/client/src/components/Auth/__tests__/Registration.spec.tsx @@ -50,6 +50,11 @@ const setup = ({ user: {}, }, }, + useGetBannerQueryReturnValue = { + isLoading: false, + isError: false, + data: {}, + }, useGetStartupConfigReturnValue = mockStartupConfig, } = {}) => { const mockUseRegisterUserMutation = jest @@ -71,6 +76,10 @@ const setup = ({ const mockUseOutletContext = jest.spyOn(reactRouter, 'useOutletContext').mockReturnValue({ startupConfig: useGetStartupConfigReturnValue.data, }); + const mockUseGetBannerQuery = jest + .spyOn(mockDataProvider, 'useGetBannerQuery') + //@ts-ignore - we don't need all parameters of the QueryObserverSuccessResult + .mockReturnValue(useGetBannerQueryReturnValue); const renderResult = render( void }) => { + const { data: banner } = useGetBannerQuery(); + const [hideBannerHint, setHideBannerHint] = useRecoilState(store.hideBannerHint); + const bannerRef = useRef(null); + + useEffect(() => { + if (onHeightChange && bannerRef.current) { + onHeightChange(bannerRef.current.offsetHeight); + } + }, [banner, hideBannerHint, onHeightChange]); + + if (!banner || (banner.bannerId && hideBannerHint.includes(banner.bannerId))) { + return null; + } + + const onClick = () => { + setHideBannerHint([...hideBannerHint, banner.bannerId]); + if (onHeightChange) { + onHeightChange(0); // Reset height when banner is closed + } + }; + + return ( +
+
+ +
+ ); +}; diff --git a/client/src/components/Banners/index.ts b/client/src/components/Banners/index.ts new file mode 100644 index 000000000..f4930c071 --- /dev/null +++ b/client/src/components/Banners/index.ts @@ -0,0 +1 @@ +export { Banner } from './Banner'; diff --git a/client/src/components/ui/OGDialogTemplate.tsx b/client/src/components/ui/OGDialogTemplate.tsx index 740fd390b..471d8bad9 100644 --- a/client/src/components/ui/OGDialogTemplate.tsx +++ b/client/src/components/ui/OGDialogTemplate.tsx @@ -74,7 +74,7 @@ const OGDialogTemplate = forwardRef((props: DialogTemplateProps, ref: Ref{leftButtons != null ? leftButtons : null}
{showCancelButton && ( - + {Cancel} )} diff --git a/client/src/routes/Root.tsx b/client/src/routes/Root.tsx index 41fc3f242..6a06323f8 100644 --- a/client/src/routes/Root.tsx +++ b/client/src/routes/Root.tsx @@ -8,6 +8,7 @@ import { AgentsMapContext, AssistantsMapContext, FileMapContext, SearchContext } import { useAuthContext, useAssistantsMap, useAgentsMap, useFileMap, useSearch } from '~/hooks'; import { Nav, MobileNav } from '~/components/Nav'; import TermsAndConditionsModal from '~/components/ui/TermsAndConditionsModal'; +import { Banner } from '~/components/Banners'; export default function Root() { const { isAuthenticated, logout, token } = useAuthContext(); @@ -16,6 +17,7 @@ export default function Root() { const savedNavVisible = localStorage.getItem('navVisible'); return savedNavVisible !== null ? JSON.parse(savedNavVisible) : true; }); + const [bannerHeight, setBannerHeight] = useState(0); const search = useSearch({ isAuthenticated }); const fileMap = useFileMap({ isAuthenticated }); @@ -24,7 +26,6 @@ export default function Root() { const [showTerms, setShowTerms] = useState(false); const { data: config } = useGetStartupConfig(); - const { data: termsData } = useUserTermsQuery({ enabled: isAuthenticated && !!config?.interface?.termsOfService?.modalAcceptance, }); @@ -54,7 +55,8 @@ export default function Root() { -
+ +