feat(ui)#10274: UI - allow to configure OM logo (#10316)

* feat(ui)#10274: UI - allow to configure OM logo

* chore: use logo config to render the brand and login page logo

* fix : cy tests

* doc: update the doc

* chore: remove check for filePath logo location type

* test: add unit test

* - removed FilePath
- Added configs to docker

* address comment

* remove config from test yaml

* update logo aspect ratio

* remove type

* chore: update ui logic as per backend config

* update doc

* address comments

* Revert "address comments"

This reverts commit c4f9da88918343cab08cd5e603cba29bd05b2ec9.

* address comments

---------

Co-authored-by: mohitdeuex <mohit.y@deuexsolutions.com>
This commit is contained in:
Sachin Chaurasiya 2023-03-01 14:09:44 +05:30 committed by GitHub
parent 754074f1be
commit 4ce038557e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 279 additions and 64 deletions

View File

@ -267,13 +267,10 @@ email:
applicationConfig:
logoConfig:
logoLocationType: ${OM_LOGO_LOCATION_TYPE:-openmetadata} #either "openmetadata' or { "url" or "filePath" , based on this specify either '*AbsoluteFilePath' or '*LogoUrlPath' }
loginPageLogoAbsoluteFilePath: ${OM_LOGO_LOGIN_LOCATION_FILE_PATH:-""} #login page logo , work in "filePath" mode
loginPageLogoUrlPath: ${OM_LOGO_LOGIN_LOCATION_URL_PATH:-""} #login page logo , work in "url" mode
navBarLogoAbsoluteFilePath: ${OM_LOGO_NAVBAR_LOCATION_FILE_PATH:-""} #nav bar logo , work in "filePath" mode
navBarLogoUrlPath: ${OM_LOGO_NAVBAR_LOCATION_URL_PATH:-""} #nav bar logo , work in "url" mode
customLogoUrlPath: ${OM_CUSTOM_LOGO_URL_PATH:-""} #login page logo
customMonogramUrlPath: ${OM_CUSTOM_MONOGRAM_URL_PATH:-""} #nav bar logo
loginConfig:
maxLoginFailAttempts: ${OM_MAX_FAILED_LOGIN_ATTEMPTS:-3}
accessBlockTime: ${OM_LOGIN_ACCESS_BLOCKTIME:-600}
accessBlockTime: ${OM_LOGIN_ACCESS_BLOCK_TIME:-600}
jwtTokenExpiryTime: ${OM_JWT_EXPIRY_TIME:-3600}

View File

@ -109,6 +109,12 @@ services:
ELASTICSEARCH_PASSWORD: ${ELASTICSEARCH_PASSWORD:-""}
# Heap OPTS Configurations
OPENMETADATA_HEAP_OPTS: ${OPENMETADATA_HEAP_OPTS:--Xmx1G -Xms1G}
# Application Config
CUSTOM_LOGO_URL_PATH: ${CUSTOM_LOGO_URL_PATH:-""}
CUSTOM_MONOGRAM_URL_PATH: ${CUSTOM_MONOGRAM_URL_PATH:-""}
OM_MAX_FAILED_LOGIN_ATTEMPTS: ${OM_MAX_FAILED_LOGIN_ATTEMPTS:-3}
OM_LOGIN_ACCESS_BLOCK_TIME: ${OM_LOGIN_ACCESS_BLOCK_TIME:-600}
OM_JWT_EXPIRY_TIME: ${OM_JWT_EXPIRY_TIME:-3600}
expose:
- 8585
- 8586

View File

@ -108,6 +108,12 @@ services:
ELASTICSEARCH_PASSWORD: ${ELASTICSEARCH_PASSWORD:-""}
# Heap OPTS Configurations
OPENMETADATA_HEAP_OPTS: ${OPENMETADATA_HEAP_OPTS:--Xmx1G -Xms1G}
# Application Config
CUSTOM_LOGO_URL_PATH: ${CUSTOM_LOGO_URL_PATH:-""}
CUSTOM_MONOGRAM_URL_PATH: ${CUSTOM_MONOGRAM_URL_PATH:-""}
OM_MAX_FAILED_LOGIN_ATTEMPTS: ${OM_MAX_FAILED_LOGIN_ATTEMPTS:-3}
OM_LOGIN_ACCESS_BLOCK_TIME: ${OM_LOGIN_ACCESS_BLOCK_TIME:-600}
OM_JWT_EXPIRY_TIME: ${OM_JWT_EXPIRY_TIME:-3600}
expose:
- 8585
- 8586

View File

@ -101,6 +101,12 @@ services:
ELASTICSEARCH_PASSWORD: ${ELASTICSEARCH_PASSWORD:-""}
# Heap OPTS Configurations
OPENMETADATA_HEAP_OPTS: ${OPENMETADATA_HEAP_OPTS:--Xmx1G -Xms1G}
# Application Config
CUSTOM_LOGO_URL_PATH: ${CUSTOM_LOGO_URL_PATH:-""}
CUSTOM_MONOGRAM_URL_PATH: ${CUSTOM_MONOGRAM_URL_PATH:-""}
OM_MAX_FAILED_LOGIN_ATTEMPTS: ${OM_MAX_FAILED_LOGIN_ATTEMPTS:-3}
OM_LOGIN_ACCESS_BLOCK_TIME: ${OM_LOGIN_ACCESS_BLOCK_TIME:-600}
OM_JWT_EXPIRY_TIME: ${OM_JWT_EXPIRY_TIME:-3600}
expose:
- 8585

View File

@ -99,6 +99,12 @@ services:
ELASTICSEARCH_PASSWORD: ${ELASTICSEARCH_PASSWORD:-""}
# Heap OPTS Configurations
OPENMETADATA_HEAP_OPTS: ${OPENMETADATA_HEAP_OPTS:--Xmx1G -Xms1G}
# Application Config
CUSTOM_LOGO_URL_PATH: ${CUSTOM_LOGO_URL_PATH:-""}
CUSTOM_MONOGRAM_URL_PATH: ${CUSTOM_MONOGRAM_URL_PATH:-""}
OM_MAX_FAILED_LOGIN_ATTEMPTS: ${OM_MAX_FAILED_LOGIN_ATTEMPTS:-3}
OM_LOGIN_ACCESS_BLOCK_TIME: ${OM_LOGIN_ACCESS_BLOCK_TIME:-600}
OM_JWT_EXPIRY_TIME: ${OM_JWT_EXPIRY_TIME:-3600}
expose:
- 8585

View File

@ -3,6 +3,8 @@ title: How to change the Login Page and Nav Bar Logo
slug: /how-to-guides/custom-logo/how-to-add-custom-logo
---
# How to add a custom logo for the application
To change the Logo for the application, we need to update logo at two locations.
1. Login Page
@ -13,36 +15,24 @@ To change the Logo for the application, we need to update logo at two locations.
<Image src="/images/how-to-guides/custom-logo/nav-Bar-Logo.png" alt="navBar-image"/>
# How to add a custom logo for the application
### Step 1: Get the image size as per the following formats.
- Login Page (1 px x 2px)
- Navigation Bar (1 px x 2px)
- Monogram aspect ratio should be 1:1 and Recommended size should be 30 x 30 px
- Logo aspect ratio should be 5:2 and Recommended size should be 150 x 60 px
### Step 2: Configure 'openmetadata.yaml' or the corresponding environment variables
```yaml
applicationConfig:
logoConfig:
logoLocationType: ${OM_LOGO_LOCATION_TYPE:-openmetadata} #either "openmetadata' or { "url" or "filePath" , based on this specify either '*AbsoluteFilePath' or '*LogoUrlPath' }
loginPageLogoAbsoluteFilePath: ${OM_LOGO_LOGIN_LOCATION_FILE_PATH:-""} #login page logo , work in "filePath" mode
loginPageLogoUrlPath: ${OM_LOGO_LOGIN_LOCATION_URL_PATH:-""} #login page logo , work in "url" mode
navBarLogoAbsoluteFilePath: ${OM_LOGO_NAVBAR_LOCATION_FILE_PATH:-""} #nav bar logo , work in "filePath" mode
navBarLogoUrlPath: ${OM_LOGO_NAVBAR_LOCATION_URL_PATH:-""} #nav bar logo , work in "url" mode
customLogoUrlPath: ${OM_CUSTOM_LOGO_URL_PATH:-""} #login page logo
customMonogramUrlPath: ${OM_CUSTOM_MONOGRAM_URL_PATH:-""} #nav bar logo
```
1. `logoLocationType` set to 'openmetadata'
- In this case it will take the default OM logo, we don't need to configure anything else.
1. `customLogoUrlPath`
2. `logoLocationType` set to 'filePath'
- URL path for the login page logo.
- In this case it will take the custom logo from the specified absolute paths, need to configure following as well.
- `loginPageLogoAbsoluteFilePath` -> This is the path for Login Page Logo Image.
- `navBarLogoAbsoluteFilePath` -> This is the path for Navigation Bar Logo Image.
2. `customMonogramUrlPath`
3. `logoLocationType` set to 'url'
- In this case it will take the custom logo from the specified url, need to configure following as well.
- `loginPageLogoUrlPath` -> This is the url for Login Page Logo Image.
- `navBarLogoUrlPath` -> This is the url for Navigation Bar Logo Image.
- URL path for the navbar logo.

View File

@ -193,11 +193,8 @@ email:
applicationConfig:
logoConfig:
logoLocationType: "openmetadata"
loginPageLogoAbsoluteFilePath: ""
loginPageLogoUrlPath: ""
navBarLogoAbsoluteFilePath: ""
navBarLogoUrlPath: ""
customLogoUrlPath: ""
customMonogramUrlPath: ""
loginConfig:
maxLoginFailAttempts: 3
accessBlockTime: 600

View File

@ -11,29 +11,15 @@
"type": "object",
"javaType": "org.openmetadata.api.configuration.LogoConfiguration",
"properties": {
"logoLocationType": {
"description": "Type of Logo Location",
"type": "string",
"enum": ["openmetadata","filePath","url"]
},
"loginPageLogoAbsoluteFilePath": {
"description": "Login Page Absolute File Path For Logo Image",
"type": "string"
},
"loginPageLogoUrlPath": {
"customLogoUrlPath": {
"description": "Login Page Logo Image Url",
"type": "string"
},
"navBarLogoAbsoluteFilePath": {
"description": "Navigation Bar Absolute File Path For Logo Image",
"type": "string"
},
"navBarLogoUrlPath": {
"customMonogramUrlPath": {
"description": "Navigation Bar Logo Image Url",
"type": "string"
}
},
"required": ["logoLocationType"],
"additionalProperties": false
},
"loginConfiguration": {

View File

@ -12,6 +12,7 @@
*/
import Appbar from 'components/app-bar/Appbar';
import ApplicationConfigProvider from 'components/ApplicationConfigProvider/ApplicationConfigProvider';
import { AuthProvider } from 'components/authentication/auth-provider/AuthProvider';
import ErrorBoundry from 'components/ErrorBoundry/ErrorBoundry';
import GlobalSearchProvider from 'components/GlobalSearchProvider/GlobalSearchProvider';
@ -36,18 +37,20 @@ const App: FunctionComponent = () => {
<I18nextProvider i18n={i18n}>
<ErrorBoundry>
<AuthProvider childComponentType={AppRouter}>
<HelmetProvider>
<WebAnalyticsProvider>
<PermissionProvider>
<WebSocketProvider>
<GlobalSearchProvider>
<Appbar />
<AppRouter />
</GlobalSearchProvider>
</WebSocketProvider>
</PermissionProvider>
</WebAnalyticsProvider>
</HelmetProvider>
<ApplicationConfigProvider>
<HelmetProvider>
<WebAnalyticsProvider>
<PermissionProvider>
<WebSocketProvider>
<GlobalSearchProvider>
<Appbar />
<AppRouter />
</GlobalSearchProvider>
</WebSocketProvider>
</PermissionProvider>
</WebAnalyticsProvider>
</HelmetProvider>
</ApplicationConfigProvider>
</AuthProvider>
</ErrorBoundry>
</I18nextProvider>

View File

@ -0,0 +1,77 @@
/*
* Copyright 2023 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { act, render, screen } from '@testing-library/react';
import React from 'react';
import { getApplicationConfig } from 'rest/miscAPI';
import ApplicationConfigProvider, {
useApplicationConfigProvider,
} from './ApplicationConfigProvider';
const mockApplicationConfig = {
logoConfig: {
customLogoUrlPath: 'https://customlink.source',
customMonogramUrlPath: 'https://customlink.source',
},
loginConfig: {
maxLoginFailAttempts: 3,
accessBlockTime: 600,
jwtTokenExpiryTime: 3600,
},
};
jest.mock('rest/miscAPI', () => ({
getApplicationConfig: jest
.fn()
.mockImplementation(() => Promise.resolve(mockApplicationConfig)),
}));
describe('ApplicationConfigProvider', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders the children components', async () => {
await act(async () => {
render(
<ApplicationConfigProvider>
<div>Test Children</div>
</ApplicationConfigProvider>
);
});
expect(screen.getByText('Test Children')).toBeInTheDocument();
});
it('fetch the application config on mount and set in the context', async () => {
function TestComponent() {
const { logoConfig } = useApplicationConfigProvider();
return <div>{logoConfig?.customLogoUrlPath}</div>;
}
await act(async () => {
render(
<ApplicationConfigProvider>
<TestComponent />
</ApplicationConfigProvider>
);
});
expect(
await screen.findByText('https://customlink.source')
).toBeInTheDocument();
expect(getApplicationConfig).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,63 @@
/*
* Copyright 2023 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ApplicationConfiguration } from 'generated/configuration/applicationConfiguration';
import React, {
createContext,
FC,
ReactNode,
useContext,
useEffect,
useState,
} from 'react';
import { getApplicationConfig } from 'rest/miscAPI';
export const ApplicationConfigContext = createContext<ApplicationConfiguration>(
{} as ApplicationConfiguration
);
export const useApplicationConfigProvider = () =>
useContext(ApplicationConfigContext);
interface ApplicationConfigProviderProps {
children: ReactNode;
}
const ApplicationConfigProvider: FC<ApplicationConfigProviderProps> = ({
children,
}) => {
const [applicationConfig, setApplicationConfig] =
useState<ApplicationConfiguration>({} as ApplicationConfiguration);
const fetchApplicationConfig = async () => {
try {
const response = await getApplicationConfig();
setApplicationConfig(response);
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
}
};
useEffect(() => {
fetchApplicationConfig();
}, []);
return (
<ApplicationConfigContext.Provider value={{ ...applicationConfig }}>
{children}
</ApplicationConfigContext.Provider>
);
};
export default ApplicationConfigProvider;

View File

@ -12,6 +12,7 @@
*/
import { Badge, Dropdown, Image, Input, Select, Space, Tooltip } from 'antd';
import { useApplicationConfigProvider } from 'components/ApplicationConfigProvider/ApplicationConfigProvider';
import { CookieStorage } from 'cookie-storage';
import i18next from 'i18next';
import { debounce, toString } from 'lodash';
@ -20,6 +21,7 @@ import { useTranslation } from 'react-i18next';
import { NavLink, useHistory } from 'react-router-dom';
import AppState from '../../AppState';
import Logo from '../../assets/svg/logo-monogram.svg';
import {
NOTIFICATION_READ_TIMER,
ROUTES,
@ -73,6 +75,8 @@ const NavBar = ({
handleKeyDown,
handleOnClick,
}: NavBarProps) => {
const { logoConfig } = useApplicationConfigProvider();
// get current user details
const currentUser = useMemo(
() => AppState.getCurrentUserDetails(),
@ -295,16 +299,23 @@ const NavBar = ({
[AppState]
);
const brandLogoUrl = useMemo(() => {
return logoConfig?.customMonogramUrlPath ?? Logo;
}, [logoConfig]);
return (
<>
<div className="tw-h-16 tw-py-3 tw-border-b-2 tw-border-separator tw-bg-white">
<div className="tw-flex tw-items-center tw-flex-row tw-justify-between tw-flex-nowrap tw-px-6">
<div className="tw-flex tw-items-center tw-flex-row tw-justify-between tw-flex-nowrap">
<NavLink className="tw-flex-shrink-0" id="openmetadata_logo" to="/">
<SVGIcons
<Image
alt="OpenMetadata Logo"
data-testid="image"
fallback={Logo}
height={30}
icon={Icons.LOGO_SMALL}
preview={false}
src={brandLogoUrl}
width={25}
/>
</NavLink>

View File

@ -11,7 +11,12 @@
* limitations under the License.
*/
import { findByTestId, findByText, render } from '@testing-library/react';
import {
findByTestId,
findByText,
render,
screen,
} from '@testing-library/react';
import { useAuthContext } from 'components/authentication/auth-provider/AuthProvider';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
@ -26,6 +31,18 @@ jest.mock('react-router-dom', () => ({
jest.mock('components/authentication/auth-provider/AuthProvider', () => ({
useAuthContext: jest.fn(),
}));
jest.mock(
'components/ApplicationConfigProvider/ApplicationConfigProvider',
() => ({
useApplicationConfigProvider: jest.fn().mockImplementation(() => ({
logoConfig: {
customLogoUrlPath: 'https://customlink.source',
customMonogramUrlPath: 'https://customlink.source',
},
})),
})
);
jest.mock(
'components/containers/PageContainer',
@ -112,4 +129,23 @@ describe('Test SigninPage Component', () => {
expect(signinButton).toBeInTheDocument();
});
it('Page should render the correct logo image', async () => {
mockUseAuthContext.mockReturnValue({
isAuthDisabled: false,
authConfig: { provider: 'custom-oidc', providerName: 'Custom OIDC' },
onLoginHandler: jest.fn(),
onLogoutHandler: jest.fn(),
});
render(<SigninPage />, {
wrapper: MemoryRouter,
});
const brandLogoImage = await screen.findByTestId('brand-logo-image');
const logoImage = brandLogoImage.querySelector('img') as HTMLImageElement;
expect(brandLogoImage).toBeInTheDocument();
expect(logoImage.src).toEqual('https://customlink.source/');
});
});

View File

@ -11,8 +11,19 @@
* limitations under the License.
*/
import { Button, Col, Divider, Form, Input, Row, Typography } from 'antd';
import {
Button,
Col,
Divider,
Form,
Image,
Input,
Row,
Typography,
} from 'antd';
import Logo from 'assets/svg/logo.svg';
import classNames from 'classnames';
import { useApplicationConfigProvider } from 'components/ApplicationConfigProvider/ApplicationConfigProvider';
import { useAuthContext } from 'components/authentication/auth-provider/AuthProvider';
import { useBasicAuth } from 'components/authentication/auth-provider/basic-auth.provider';
import Loader from 'components/Loader/Loader';
@ -32,6 +43,7 @@ import './login.style.less';
import LoginCarousel from './LoginCarousel';
const SigninPage = () => {
const { logoConfig } = useApplicationConfigProvider();
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
@ -66,6 +78,10 @@ const SigninPage = () => {
return isAuthDisabled || isAuthenticated;
}, [isAuthDisabled, isAuthenticated]);
const brandLogoUrl = useMemo(() => {
return logoConfig?.customLogoUrlPath ?? Logo;
}, [logoConfig]);
const isTokenExpired = () => {
const token = localState.getOidcToken();
if (token) {
@ -194,7 +210,14 @@ const SigninPage = () => {
className={classNames('mt-24 text-center flex-center flex-col', {
'sso-container': !isAuthProviderBasic,
})}>
<SVGIcons alt="OpenMetadata Logo" icon={Icons.LOGO} width="152" />
<Image
alt="OpenMetadata Logo"
data-testid="brand-logo-image"
fallback={Logo}
preview={false}
src={brandLogoUrl}
width={152}
/>
<Typography.Text className="mt-8 w-80 text-xl font-medium text-grey-muted">
{t('message.om-description')}{' '}
</Typography.Text>

View File

@ -14,6 +14,7 @@
import { AxiosResponse } from 'axios';
import { Edge } from 'components/EntityLineage/EntityLineage.interface';
import { ExploreSearchIndex } from 'components/Explore/explore.interface';
import { ApplicationConfiguration } from 'generated/configuration/applicationConfiguration';
import { AuthorizerConfiguration } from 'generated/configuration/authorizerConfiguration';
import { SearchIndex } from '../enums/search.enum';
import { AuthenticationConfiguration } from '../generated/configuration/authenticationConfiguration';
@ -71,6 +72,13 @@ export const fetchAuthenticationConfig = async () => {
return response.data;
};
export const getApplicationConfig = async () => {
const response = await APIClient.get<ApplicationConfiguration>(
'/system/config/applicationConfig'
);
return response.data;
};
export const fetchAuthorizerConfig = async () => {
const response = await APIClient.get<AuthorizerConfiguration>(