diff --git a/CHANGELOG.md b/CHANGELOG.md index 00ba2867e1..77720bd2cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,16 @@ - Bump Node.js version to 14.16.0 (PR[#3214](https://github.com/scality/metalk8s/pull/3214)) +- Introduces a `shell-ui` project that groups various UI components to be reused by +solutions UIs (PR[#3106](https://github.com/scality/metalk8s/pull/3106)) + +- Move the navbar component to `shell-ui` to enable its reuse by solutions UIs +(PR[#3110](https://github.com/scality/metalk8s/pull/3110)) + +- Add a static user/groups mapping configuration as part of `shell-ui` configuration to +allow solutions UIs displaying features according to some user groups +(PR[#3154](https://github.com/scality/metalk8s/pull/3154)) + ## Release 2.8.1 (in development) ### Enhancements diff --git a/docs/operation/cluster_and_service_configuration.rst b/docs/operation/cluster_and_service_configuration.rst index 729266a716..7247ea1977 100644 --- a/docs/operation/cluster_and_service_configuration.rst +++ b/docs/operation/cluster_and_service_configuration.rst @@ -39,7 +39,7 @@ The default configuration values for Dex are specified below: .. literalinclude:: ../../salt/metalk8s/addons/dex/config/dex.yaml.j2 :language: yaml - :lines: 3-42,45- + :lines: 14-42,45- See :ref:`csc-dex-customization` for Dex configuration customizations. diff --git a/salt/metalk8s/addons/dex/config/dex.yaml.j2 b/salt/metalk8s/addons/dex/config/dex.yaml.j2 index 3e43b2763a..f737334ba3 100644 --- a/salt/metalk8s/addons/dex/config/dex.yaml.j2 +++ b/salt/metalk8s/addons/dex/config/dex.yaml.j2 @@ -1,5 +1,16 @@ #!jinja|yaml +{%- set metalk8s_ui_defaults = salt.slsutil.renderer( + 'salt://metalk8s/addons/ui/config/metalk8s-ui-config.yaml', saltenv=saltenv + ) +%} + +{%- set metalk8s_ui_config = salt.metalk8s_service_configuration.get_service_conf( + 'metalk8s-ui', 'metalk8s-ui-config', metalk8s_ui_defaults + ) +%} + + # Defaults for configuration of Dex (OIDC) apiVersion: addons.metalk8s.scality.com/v1alpha2 kind: DexConfig @@ -54,7 +65,7 @@ spec: - id: metalk8s-ui name: MetalK8s UI redirectURIs: - - https://{{ grains.metalk8s.control_plane_ip }}:8443/ + - https://{{ grains.metalk8s.control_plane_ip }}:8443/{{ metalk8s_ui_config.spec.basePath.lstrip('/') }} secret: ybrMJpVMQxsiZw26MhJzCjA2ut - id: grafana-ui name: Grafana UI diff --git a/salt/metalk8s/addons/ui/config/metalk8s-shell-ui-config.yaml.j2 b/salt/metalk8s/addons/ui/config/metalk8s-shell-ui-config.yaml.j2 index 90b5c30fc5..0b2a0c1fea 100644 --- a/salt/metalk8s/addons/ui/config/metalk8s-shell-ui-config.yaml.j2 +++ b/salt/metalk8s/addons/ui/config/metalk8s-shell-ui-config.yaml.j2 @@ -2,6 +2,20 @@ {%- set dex_defaults = salt.slsutil.renderer('salt://metalk8s/addons/dex/config/dex.yaml.j2', saltenv=saltenv) %} {%- set dex = salt.metalk8s_service_configuration.get_service_conf('metalk8s-auth', 'metalk8s-dex-config', dex_defaults) %} +{%- set metalk8s_ui_defaults = salt.slsutil.renderer( + 'salt://metalk8s/addons/ui/config/metalk8s-ui-config.yaml', saltenv=saltenv + ) +%} + +{%- set metalk8s_ui_config = salt.metalk8s_service_configuration.get_service_conf( + 'metalk8s-ui', 'metalk8s-ui-config', metalk8s_ui_defaults + ) +%} + +{%- set normalized_base_path = metalk8s_ui_config.spec.basePath.strip('/') %} + +{%- set metalk8s_ui_url = "https://" ~ grains.metalk8s.control_plane_ip ~ + ":8443" ~ ('/' ~ normalized_base_path if normalized_base_path else '') ~ '/' %} # Defaults for shell UI configuration apiVersion: addons.metalk8s.scality.com/v1alpha1 @@ -9,7 +23,7 @@ kind: ShellUIConfig spec: oidc: providerUrl: "/oidc" - redirectUrl: "https://{{ grains.metalk8s.control_plane_ip }}:8443/" + redirectUrl: "https://{{ grains.metalk8s.control_plane_ip }}:8443/{{ metalk8s_ui_config.spec.basePath.lstrip('/') }}" clientId: "metalk8s-ui" responseType: "id_token" scopes: "openid profile email groups offline_access audience:server:client_id:oidc-auth-client" @@ -25,17 +39,16 @@ spec: canChangeTheme: false options: main: - "https://{{ grains.metalk8s.control_plane_ip }}:8443/": + "{{ metalk8s_ui_url }}": en: "Platform" fr: "Plateforme" groups: [metalk8s:admin] - activeIfMatches: "https://{{ grains.metalk8s.control_plane_ip }}:8443/(?!alerts).*" - "https://{{ grains.metalk8s.control_plane_ip }}:8443/alerts": + activeIfMatches: "{{ metalk8s_ui_url }}(?!alerts|docs).*" + "{{ metalk8s_ui_url }}alerts": en: "Alerts" fr: "Alertes" groups: [metalk8s:admin] subLogin: - "https://{{ grains.metalk8s.control_plane_ip }}:8443/docs": + "{{ metalk8s_ui_url }}docs": en: "Documentation" fr: "Documentation" - diff --git a/salt/metalk8s/addons/ui/deployed/ingress.sls b/salt/metalk8s/addons/ui/deployed/ingress.sls index f4adf238b1..36d7987126 100644 --- a/salt/metalk8s/addons/ui/deployed/ingress.sls +++ b/salt/metalk8s/addons/ui/deployed/ingress.sls @@ -10,6 +10,9 @@ ) %} +{%- set stripped_base_path = metalk8s_ui_config.spec.basePath.strip('/') %} +{%- set normalized_base_path = '/' ~ stripped_base_path %} + apiVersion: networking.k8s.io/v1beta1 kind: Ingress metadata: @@ -92,23 +95,16 @@ spec: rules: - http: paths: - - path: {{ metalk8s_ui_config.spec.basePath }} - backend: - serviceName: metalk8s-ui - servicePort: 80 - - path: /config.json - backend: - serviceName: metalk8s-ui - servicePort: 80 - - path: /brand - backend: - serviceName: metalk8s-ui - servicePort: 80 - - path: /static - backend: - serviceName: metalk8s-ui - servicePort: 80 - - path: /manifest.json +{% for path in [ + "/brand", + "/config.json", + "/manifest.json", + "/shell", + "/static", + normalized_base_path +] %} + - path: {{ path }} backend: serviceName: metalk8s-ui servicePort: 80 +{% endfor %} diff --git a/salt/metalk8s/addons/ui/deployed/ui.sls.in b/salt/metalk8s/addons/ui/deployed/ui.sls.in index 038922f679..7306911ca5 100644 --- a/salt/metalk8s/addons/ui/deployed/ui.sls.in +++ b/salt/metalk8s/addons/ui/deployed/ui.sls.in @@ -26,6 +26,9 @@ include: ) %} +{%- set stripped_base_path = metalk8s_ui_config.spec.basePath.strip('/') %} +{%- set normalized_base_path = '/' ~ stripped_base_path %} + Create metalk8s-ui deployment: metalk8s_kubernetes.object_present: - name: salt://{{ slspath }}/files/metalk8s-ui-deployment.yaml.j2 @@ -69,7 +72,7 @@ Create metalk8s-ui ConfigMap: "url_alertmanager": "/api/alertmanager", "url_navbar": "/shell/solution-ui-navbar.@@ShellUIVersion.js", "url_navbar_config": "/shell/config.json", - "ui_base_path": "{{ metalk8s_ui_config.spec.basePath }}" + "ui_base_path": "{{ normalized_base_path }}" } Create shell-ui ConfigMap: diff --git a/shell-ui/babel.config.js b/shell-ui/babel.config.js index 05ac9182f0..7256daad41 100644 --- a/shell-ui/babel.config.js +++ b/shell-ui/babel.config.js @@ -1,16 +1,36 @@ -if (process.env.NODE_ENV === "test") { +if (process.env.NODE_ENV === 'test') { module.exports = { - "presets": ["@babel/preset-env", "@babel/preset-flow", ["@babel/preset-react", { - "runtime": "automatic" - }]], - "plugins": ["@babel/plugin-transform-modules-commonjs"] - } + presets: [ + '@babel/preset-env', + '@babel/preset-flow', + [ + '@babel/preset-react', + { + runtime: 'automatic', + }, + ], + ], + plugins: ['@babel/plugin-transform-modules-commonjs'], + }; } else { module.exports = { - "presets": ["@babel/preset-env", "@babel/preset-flow", ["@babel/preset-react", { - "runtime": "automatic" - }]] - } - + plugins: [ + [ + 'babel-plugin-styled-components', + { + namespace: 'shell-ui', + }, + ], + ], + presets: [ + '@babel/preset-env', + '@babel/preset-flow', + [ + '@babel/preset-react', + { + runtime: 'automatic', + }, + ], + ], + }; } - diff --git a/shell-ui/package.json b/shell-ui/package.json index b69e5c2ac9..714ca0de0d 100644 --- a/shell-ui/package.json +++ b/shell-ui/package.json @@ -20,6 +20,7 @@ "@testing-library/user-event": "^13.0.10", "babel-jest": "^26.6.3", "babel-loader": "^8.2.2", + "babel-plugin-styled-components": "^1.12.0", "css-loader": "^5.0.1", "flow-bin": "^0.143.1", "html-webpack-plugin": "^4.5.1", diff --git a/shell-ui/src/Navbar.spec.js b/shell-ui/src/Navbar.spec.js index b04e5fb216..bcb5856579 100644 --- a/shell-ui/src/Navbar.spec.js +++ b/shell-ui/src/Navbar.spec.js @@ -1,9 +1,10 @@ import { setupServer } from 'msw/node'; import { rest } from 'msw'; import { screen, render, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event' +import userEvent from '@testing-library/user-event'; import './index'; import { waitForLoadingToFinish } from './__TESTS__/utils'; +import { jest } from '@jest/globals'; const server = setupServer( rest.get( @@ -43,6 +44,25 @@ const server = setupServer( ), ); +function mockOidcReact() { + const {jest} = require('@jest/globals'); + + const original = jest.requireActual('oidc-react'); + return { + ...original, //Pass down all the exported objects + useAuth: () => ({ + userData: { + profile: { + groups: ['group1'], + email: 'test@test.invalid', + name: 'user', + }, + }, + }) + } +} +jest.mock('oidc-react', () => mockOidcReact()); + const mockOIDCProvider = () => { // This is a hack to workarround the following issue : MSW return lower cased content-type header, // oidc-client is internally using XMLHttpRequest to perform queries and retrieve response header Content-Type using 'XMLHttpRequest.prototype.getResponseHeader'. @@ -62,6 +82,9 @@ describe('navbar', () => { jest.useFakeTimers(); beforeAll(() => server.listen()); + beforeEach(() => { + jest.resetModules(); + }); afterEach(() => server.resetHandlers()); it('should display a loading state when resolving its configuration', () => { @@ -87,7 +110,7 @@ describe('navbar', () => { return res(ctx.status(500)); }), ); - + render( { it('should display expected selected menu when it matches by exact loaction (default behavior)', async () => { //S mockOIDCProvider(); + //E render( { it('should display expected selected menu when it matches by regex', async () => { //S mockOIDCProvider(); + //E render( { it('should set the language of the navbar', async () => { //S mockOIDCProvider(); + //E render( { }); expect(platformEntry).toBeInTheDocument(); expect(localStorage.setItem).toBeCalledWith('lang', 'fr'); - + //C userEvent.click(screen.getByText('en')); }); @@ -224,6 +250,7 @@ describe('navbar', () => { mockOIDCProvider(); + render( { mockOIDCProvider(); + render( { expect(screen.queryByText(/Test/i)).not.toBeInTheDocument(); }); + it('should display a restrained menu when an user is authorized', async () => { + //S + + mockOIDCProvider(); + + render( + , + ); + //E + await waitForLoadingToFinish(); + //V + expect(screen.queryByText(/Platform/i)).toBeInTheDocument(); + expect(screen.queryByText(/Test/i)).toBeInTheDocument(); + }); + afterAll(() => server.close()); }); diff --git a/shell-ui/src/auth/permissionUtils.js b/shell-ui/src/auth/permissionUtils.js index ca95d8237d..fc875d74c0 100644 --- a/shell-ui/src/auth/permissionUtils.js +++ b/shell-ui/src/auth/permissionUtils.js @@ -11,7 +11,7 @@ export const isEntryAccessibleByTheUser = ( userGroups: string[], ): boolean => { return ( - pathDescription.groups?.every((group) => userGroups.includes(group)) ?? + pathDescription.groups?.some((group) => userGroups.includes(group)) ?? true ); };