Per User Dark Mode Preference (#151507)

## Summary

Allow user's to set their desired theme on their User Profile

## How to test

Login as a non-cloud user, navigate to User Profile:
<img width="1051" alt="Screenshot 2023-02-28 at 1 40 34 PM"
src="https://user-images.githubusercontent.com/21210601/221948512-a3e9b485-d3fa-4646-ae7d-63a68777cf19.png">

## Release Note
Users can now select their theme preference for Kibana in their User
Profile

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Michael Marcialis <michael.l.marcialis@gmail.com>
This commit is contained in:
Kurt 2023-04-25 15:19:20 -04:00 committed by GitHub
parent 5fb93a1ea2
commit b66df8774a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
61 changed files with 1271 additions and 109 deletions

3
.github/CODEOWNERS vendored
View file

@ -286,6 +286,9 @@ packages/core/usage-data/core-usage-data-base-server-internal @elastic/kibana-co
packages/core/usage-data/core-usage-data-server @elastic/kibana-core
packages/core/usage-data/core-usage-data-server-internal @elastic/kibana-core
packages/core/usage-data/core-usage-data-server-mocks @elastic/kibana-core
packages/core/user-settings/core-user-settings-server @elastic/platform-security
packages/core/user-settings/core-user-settings-server-internal @elastic/platform-security
packages/core/user-settings/core-user-settings-server-mocks @elastic/platform-security
x-pack/plugins/cross_cluster_replication @elastic/platform-deployment-management
packages/kbn-crypto @elastic/kibana-security
packages/kbn-crypto-browser @elastic/kibana-core

View file

@ -331,6 +331,9 @@
"@kbn/core-usage-data-base-server-internal": "link:packages/core/usage-data/core-usage-data-base-server-internal",
"@kbn/core-usage-data-server": "link:packages/core/usage-data/core-usage-data-server",
"@kbn/core-usage-data-server-internal": "link:packages/core/usage-data/core-usage-data-server-internal",
"@kbn/core-user-settings-server": "link:packages/core/user-settings/core-user-settings-server",
"@kbn/core-user-settings-server-internal": "link:packages/core/user-settings/core-user-settings-server-internal",
"@kbn/core-user-settings-server-mocks": "link:packages/core/user-settings/core-user-settings-server-mocks",
"@kbn/cross-cluster-replication-plugin": "link:x-pack/plugins/cross_cluster_replication",
"@kbn/crypto": "link:packages/kbn-crypto",
"@kbn/crypto-browser": "link:packages/kbn-crypto-browser",

View file

@ -25,6 +25,7 @@ import type { InternalStatusServiceSetup } from '@kbn/core-status-server-interna
import type { InternalUiSettingsServiceSetup } from '@kbn/core-ui-settings-server-internal';
import type { InternalCoreUsageDataSetup } from '@kbn/core-usage-data-base-server-internal';
import type { InternalCustomBrandingSetup } from '@kbn/core-custom-branding-server-internal';
import type { InternalUserSettingsServiceSetup } from '@kbn/core-user-settings-server-internal';
/** @internal */
export interface InternalCoreSetup {
@ -47,4 +48,5 @@ export interface InternalCoreSetup {
deprecations: InternalDeprecationsServiceSetup;
coreUsageData: InternalCoreUsageDataSetup;
customBranding: InternalCustomBrandingSetup;
userSettings: InternalUserSettingsServiceSetup;
}

View file

@ -32,7 +32,8 @@
"@kbn/core-usage-data-base-server-internal",
"@kbn/core-usage-data-server",
"@kbn/core-custom-branding-server-internal",
"@kbn/core-custom-branding-server"
"@kbn/core-custom-branding-server",
"@kbn/core-user-settings-server-internal"
],
"exclude": [
"target/**/*",

View file

@ -26,6 +26,7 @@ import { executionContextServiceMock } from '@kbn/core-execution-context-server-
import { coreUsageDataServiceMock } from '@kbn/core-usage-data-server-mocks';
import { customBrandingServiceMock } from '@kbn/core-custom-branding-server-mocks';
import { createCoreStartMock } from './core_start.mock';
import { userSettingsServiceMock } from '@kbn/core-user-settings-server-mocks';
type CoreSetupMockType = MockedKeys<CoreSetup> & {
elasticsearch: ReturnType<typeof elasticsearchServiceMock.createSetup>;
@ -53,6 +54,7 @@ export function createCoreSetupMock({
analytics: analyticsServiceMock.createAnalyticsServiceSetup(),
capabilities: capabilitiesServiceMock.createSetupContract(),
customBranding: customBrandingServiceMock.createSetupContract(),
userSettings: userSettingsServiceMock.createSetupContract(),
docLinks: docLinksServiceMock.createSetupContract(),
elasticsearch: elasticsearchServiceMock.createSetup(),
http: httpMock,

View file

@ -25,6 +25,7 @@ import { statusServiceMock } from '@kbn/core-status-server-mocks';
import { uiSettingsServiceMock } from '@kbn/core-ui-settings-server-mocks';
import { coreUsageDataServiceMock } from '@kbn/core-usage-data-server-mocks';
import { customBrandingServiceMock } from '@kbn/core-custom-branding-server-mocks';
import { userSettingsServiceMock } from '@kbn/core-user-settings-server-mocks';
export function createInternalCoreSetupMock() {
const setupDeps = {
@ -47,6 +48,7 @@ export function createInternalCoreSetupMock() {
executionContext: executionContextServiceMock.createInternalSetupContract(),
coreUsageData: coreUsageDataServiceMock.createSetupContract(),
customBranding: customBrandingServiceMock.createSetupContract(),
userSettings: userSettingsServiceMock.createSetupContract(),
};
return setupDeps;
}

View file

@ -34,6 +34,7 @@
"@kbn/core-http-request-handler-context-server",
"@kbn/core-logging-server-mocks",
"@kbn/core-custom-branding-server-mocks",
"@kbn/core-user-settings-server-mocks",
],
"exclude": [
"target/**/*",

View file

@ -23,6 +23,7 @@ import { StatusServiceSetup } from '@kbn/core-status-server';
import { UiSettingsServiceSetup } from '@kbn/core-ui-settings-server';
import { CoreUsageDataSetup } from '@kbn/core-usage-data-server';
import { CustomBrandingSetup } from '@kbn/core-custom-branding-server';
import { UserSettingsServiceSetup } from '@kbn/core-user-settings-server';
import { CoreStart } from './core_start';
/**
@ -64,6 +65,8 @@ export interface CoreSetup<TPluginsStart extends object = object, TStart = unkno
status: StatusServiceSetup;
/** {@link UiSettingsServiceSetup} */
uiSettings: UiSettingsServiceSetup;
/** {@link UserSettingsServiceSetup} */
userSettings: UserSettingsServiceSetup;
/** {@link DeprecationsServiceSetup} */
deprecations: DeprecationsServiceSetup;
/** {@link StartServicesAccessor} */

View file

@ -28,7 +28,8 @@
"@kbn/core-status-server",
"@kbn/core-ui-settings-server",
"@kbn/core-usage-data-server",
"@kbn/core-custom-branding-server"
"@kbn/core-custom-branding-server",
"@kbn/core-user-settings-server"
],
"exclude": [
"target/**/*",

View file

@ -260,6 +260,9 @@ export function createPluginSetupContext<TPlugin, TPluginDependencies>(
register: deps.uiSettings.register,
registerGlobal: deps.uiSettings.registerGlobal,
},
userSettings: {
setUserProfileSettings: deps.userSettings.setUserProfileSettings,
},
getStartServices: () => plugin.startDependencies,
deprecations: deps.deprecations.getRegistry(plugin.name),
coreUsageData: {

View file

@ -19,6 +19,7 @@ import type { UiPlugins } from '@kbn/core-plugins-base-server-internal';
import { httpServiceMock, httpServerMock } from '@kbn/core-http-server-mocks';
import { uiSettingsServiceMock } from '@kbn/core-ui-settings-server-mocks';
import { bootstrapRendererFactory, BootstrapRenderer } from './bootstrap_renderer';
import { userSettingsServiceMock } from '@kbn/core-user-settings-server-mocks';
const createPackageInfo = (parts: Partial<PackageInfo> = {}): PackageInfo => ({
branch: 'master',
@ -41,12 +42,14 @@ describe('bootstrapRenderer', () => {
let renderer: BootstrapRenderer;
let uiPlugins: UiPlugins;
let packageInfo: PackageInfo;
let userSettingsService: ReturnType<typeof userSettingsServiceMock.createSetupContract>;
beforeEach(() => {
auth = httpServiceMock.createAuth();
uiSettingsClient = uiSettingsServiceMock.createClient();
uiPlugins = createUiPlugins();
packageInfo = createPackageInfo();
userSettingsService = userSettingsServiceMock.createSetupContract();
getThemeTagMock.mockReturnValue('v8light');
getPluginsBundlePathsMock.mockReturnValue(new Map());
@ -88,7 +91,7 @@ describe('bootstrapRenderer', () => {
expect(uiSettingsClient.get).toHaveBeenCalledWith('theme:darkMode');
});
it('calls getThemeTag with the correct parameters', async () => {
it('calls getThemeTag with the values from the UiSettingsClient when the UserSettingsService is not provided', async () => {
uiSettingsClient.get.mockResolvedValue(true);
const request = httpServerMock.createKibanaRequest();
@ -104,6 +107,58 @@ describe('bootstrapRenderer', () => {
darkMode: true,
});
});
it('calls getThemeTag with values from the UserSettingsService when provided', async () => {
userSettingsService.getUserSettingDarkMode.mockReturnValueOnce(true);
renderer = bootstrapRendererFactory({
auth,
packageInfo,
uiPlugins,
serverBasePath: '/base-path',
userSettingsService,
});
uiSettingsClient.get.mockResolvedValue(false);
const request = httpServerMock.createKibanaRequest();
await renderer({
request,
uiSettingsClient,
});
expect(getThemeTagMock).toHaveBeenCalledTimes(1);
expect(getThemeTagMock).toHaveBeenCalledWith({
themeVersion: 'v8',
darkMode: true,
});
});
it('calls getThemeTag with values from the UiSettingsClient when values from UserSettingsService are `undefined`', async () => {
userSettingsService.getUserSettingDarkMode.mockReturnValueOnce(undefined);
renderer = bootstrapRendererFactory({
auth,
packageInfo,
uiPlugins,
serverBasePath: '/base-path',
userSettingsService,
});
uiSettingsClient.get.mockResolvedValue(false);
const request = httpServerMock.createKibanaRequest();
await renderer({
request,
uiSettingsClient,
});
expect(getThemeTagMock).toHaveBeenCalledTimes(1);
expect(getThemeTagMock).toHaveBeenCalledWith({
themeVersion: 'v8',
darkMode: false,
});
});
});
describe('when the auth status is `unknown`', () => {

View file

@ -12,6 +12,7 @@ import { ThemeVersion } from '@kbn/ui-shared-deps-npm';
import type { KibanaRequest, HttpAuth } from '@kbn/core-http-server';
import type { IUiSettingsClient } from '@kbn/core-ui-settings-server';
import type { UiPlugins } from '@kbn/core-plugins-base-server-internal';
import { InternalUserSettingsServiceSetup } from '@kbn/core-user-settings-server-internal';
import { getPluginsBundlePaths } from './get_plugin_bundle_paths';
import { getJsDependencyPaths } from './get_js_dependency_paths';
import { getThemeTag } from './get_theme_tag';
@ -25,6 +26,7 @@ interface FactoryOptions {
packageInfo: PackageInfo;
uiPlugins: UiPlugins;
auth: HttpAuth;
userSettingsService?: InternalUserSettingsServiceSetup;
}
interface RenderedOptions {
@ -43,6 +45,7 @@ export const bootstrapRendererFactory: BootstrapRendererFactory = ({
serverBasePath,
uiPlugins,
auth,
userSettingsService,
}) => {
const isAuthenticated = (request: KibanaRequest) => {
const { status: authStatus } = auth.get(request);
@ -56,7 +59,16 @@ export const bootstrapRendererFactory: BootstrapRendererFactory = ({
try {
const authenticated = isAuthenticated(request);
darkMode = authenticated ? await uiSettingsClient.get('theme:darkMode') : false;
if (authenticated) {
const userSettingDarkMode = await userSettingsService?.getUserSettingDarkMode(request);
if (userSettingDarkMode) {
darkMode = userSettingDarkMode;
} else {
darkMode = await uiSettingsClient.get('theme:darkMode');
}
}
} catch (e) {
// just use the default values in case of connectivity issues with ES
}

View file

@ -184,6 +184,84 @@ function renderTestCases(
});
}
function renderDarkModeTestCases(
getRender: () => Promise<
[
InternalRenderingServicePreboot['render'] | InternalRenderingServiceSetup['render'],
typeof mockRenderingPrebootDeps | typeof mockRenderingSetupDeps
]
>
) {
describe('render() Dark Mode tests', () => {
let uiSettings: {
client: ReturnType<typeof uiSettingsServiceMock.createClient>;
globalClient: ReturnType<typeof uiSettingsServiceMock.createClient>;
};
beforeEach(async () => {
uiSettings = {
client: uiSettingsServiceMock.createClient(),
globalClient: uiSettingsServiceMock.createClient(),
};
uiSettings.client.getRegistered.mockReturnValue({
registered: { name: 'title' },
});
});
describe('Dark Mode', () => {
it('UserSettings value should override the space setting', async () => {
mockRenderingSetupDeps.userSettings.getUserSettingDarkMode.mockReturnValueOnce(
Promise.resolve(true)
);
getSettingValueMock.mockImplementation((settingName: string) => {
if (settingName === 'theme:darkMode') {
return false;
}
return settingName;
});
const settings = { 'theme:darkMode': { userValue: false } };
uiSettings.client.getUserProvided.mockResolvedValue(settings);
const [render] = await getRender();
await render(createKibanaRequest(), uiSettings);
expect(getStylesheetPathsMock).toHaveBeenCalledWith({
darkMode: true,
themeVersion: 'v8',
basePath: '/mock-server-basepath',
buildNum: expect.any(Number),
});
});
it('Space setting value should be used if UsersSettings value is undefined', async () => {
mockRenderingSetupDeps.userSettings.getUserSettingDarkMode.mockReturnValueOnce(
Promise.resolve(undefined)
);
getSettingValueMock.mockImplementation((settingName: string) => {
if (settingName === 'theme:darkMode') {
return false;
}
return settingName;
});
const settings = { 'theme:darkMode': { userValue: false } };
uiSettings.client.getUserProvided.mockResolvedValue(settings);
const [render] = await getRender();
await render(createKibanaRequest(), uiSettings);
expect(getStylesheetPathsMock).toHaveBeenCalledWith({
darkMode: false,
themeVersion: 'v8',
basePath: '/mock-server-basepath',
buildNum: expect.any(Number),
});
});
});
});
}
describe('RenderingService', () => {
let service: RenderingService;
@ -231,5 +309,9 @@ describe('RenderingService', () => {
await service.preboot(mockRenderingPrebootDeps);
return [(await service.setup(mockRenderingSetupDeps)).render, mockRenderingSetupDeps];
});
renderDarkModeTestCases(async () => {
await service.preboot(mockRenderingPrebootDeps);
return [(await service.setup(mockRenderingSetupDeps)).render, mockRenderingSetupDeps];
});
});
});

View file

@ -18,6 +18,7 @@ import type { KibanaRequest, HttpAuth } from '@kbn/core-http-server';
import type { IUiSettingsClient } from '@kbn/core-ui-settings-server';
import type { UiPlugins } from '@kbn/core-plugins-base-server-internal';
import { CustomBranding } from '@kbn/core-custom-branding-common';
import { UserProvidedValues } from '@kbn/core-ui-settings-common';
import { Template } from './views';
import {
IRenderOptions,
@ -34,7 +35,12 @@ import type { InternalRenderingRequestHandlerContext } from './internal_types';
type RenderOptions =
| RenderingSetupDeps
| (RenderingPrebootDeps & { status?: never; elasticsearch?: never; customBranding?: never });
| (RenderingPrebootDeps & {
status?: never;
elasticsearch?: never;
customBranding?: never;
userSettings?: never;
});
/** @internal */
export class RenderingService {
@ -67,6 +73,7 @@ export class RenderingService {
status,
uiPlugins,
customBranding,
userSettings,
}: RenderingSetupDeps): Promise<InternalRenderingServiceSetup> {
registerBootstrapRoute({
router: http.createRouter<InternalRenderingRequestHandlerContext>(''),
@ -75,11 +82,19 @@ export class RenderingService {
serverBasePath: http.basePath.serverBasePath,
packageInfo: this.coreContext.env.packageInfo,
auth: http.auth,
userSettingsService: userSettings,
}),
});
return {
render: this.render.bind(this, { elasticsearch, http, uiPlugins, status, customBranding }),
render: this.render.bind(this, {
elasticsearch,
http,
uiPlugins,
status,
customBranding,
userSettings,
}),
};
}
@ -92,7 +107,7 @@ export class RenderingService {
},
{ isAnonymousPage = false, vars, includeExposedConfigKeys }: IRenderOptions = {}
) {
const { elasticsearch, http, uiPlugins, status, customBranding } = renderOptions;
const { elasticsearch, http, uiPlugins, status, customBranding, userSettings } = renderOptions;
const env = {
mode: this.coreContext.env.mode,
@ -101,14 +116,29 @@ export class RenderingService {
const buildNum = env.packageInfo.buildNum;
const basePath = http.basePath.get(request);
const { serverBasePath, publicBaseUrl } = http.basePath;
let settingsUserValues: Record<string, UserProvidedValues> = {};
let globalSettingsUserValues: Record<string, UserProvidedValues> = {};
if (!isAnonymousPage) {
const userValues = await Promise.all([
uiSettings.client?.getUserProvided(),
uiSettings.globalClient?.getUserProvided(),
]);
settingsUserValues = userValues[0];
globalSettingsUserValues = userValues[1];
}
const settings = {
defaults: uiSettings.client?.getRegistered() ?? {},
user: isAnonymousPage ? {} : await uiSettings.client?.getUserProvided(),
user: settingsUserValues,
};
const globalSettings = {
defaults: uiSettings.globalClient?.getRegistered() ?? {},
user: isAnonymousPage ? {} : await uiSettings.globalClient?.getUserProvided(),
user: globalSettingsUserValues,
};
let clusterInfo = {};
let branding: CustomBranding = {};
try {
@ -129,7 +159,20 @@ export class RenderingService {
// swallow error
}
const darkMode = getSettingValue('theme:darkMode', settings, Boolean);
let userSettingDarkMode: boolean | undefined;
if (!isAnonymousPage) {
userSettingDarkMode = await userSettings?.getUserSettingDarkMode(request);
}
let darkMode: boolean;
if (userSettingDarkMode) {
darkMode = userSettingDarkMode;
} else {
darkMode = getSettingValue('theme:darkMode', settings, Boolean);
}
const themeVersion: ThemeVersion = 'v8';
const stylesheetPaths = getStylesheetPaths({

View file

@ -11,6 +11,7 @@ import { httpServiceMock } from '@kbn/core-http-server-mocks';
import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks';
import { statusServiceMock } from '@kbn/core-status-server-mocks';
import { customBrandingServiceMock } from '@kbn/core-custom-branding-server-mocks';
import { userSettingsServiceMock } from '@kbn/core-user-settings-server-mocks';
const context = mockCoreContext.create();
const httpPreboot = httpServiceMock.createInternalPrebootContract();
@ -18,6 +19,7 @@ const httpSetup = httpServiceMock.createInternalSetupContract();
const status = statusServiceMock.createInternalSetupContract();
const elasticsearch = elasticsearchServiceMock.createInternalSetup();
const customBranding = customBrandingServiceMock.createSetupContract();
const userSettings = userSettingsServiceMock.createSetupContract();
function createUiPlugins() {
return {
@ -38,4 +40,5 @@ export const mockRenderingSetupDeps = {
uiPlugins: createUiPlugins(),
customBranding,
status,
userSettings,
};

View file

@ -20,6 +20,7 @@ import type { IUiSettingsClient } from '@kbn/core-ui-settings-server';
import type { UiPlugins } from '@kbn/core-plugins-base-server-internal';
import type { InternalCustomBrandingSetup } from '@kbn/core-custom-branding-server-internal';
import type { CustomBranding } from '@kbn/core-custom-branding-common';
import type { InternalUserSettingsServiceSetup } from '@kbn/core-user-settings-server-internal';
/** @internal */
export interface RenderingMetadata {
@ -48,6 +49,7 @@ export interface RenderingSetupDeps {
status: InternalStatusServiceSetup;
uiPlugins: UiPlugins;
customBranding: InternalCustomBrandingSetup;
userSettings: InternalUserSettingsServiceSetup;
}
/** @internal */

View file

@ -37,6 +37,8 @@
"@kbn/core-custom-branding-server-internal",
"@kbn/core-custom-branding-common",
"@kbn/core-custom-branding-server-mocks",
"@kbn/core-user-settings-server-mocks",
"@kbn/core-user-settings-server-internal",
],
"exclude": [
"target/**/*",

View file

@ -65,6 +65,11 @@ jest.doMock('@kbn/core-custom-branding-server-internal', () => ({
CustomBrandingService: jest.fn(() => mockCustomBrandingService),
}));
export const mockUserSettingsService = userSettingsServiceMock.create();
jest.doMock('@kbn/core-user-settings-server-internal', () => ({
UserSettingsService: jest.fn(() => mockUserSettingsService),
}));
export const mockEnsureValidConfiguration = jest.fn();
jest.doMock('@kbn/core-config-server-internal', () => ({
ensureValidConfiguration: mockEnsureValidConfiguration,
@ -134,6 +139,7 @@ jest.doMock('@kbn/core-deprecations-server-internal', () => ({
}));
import { docLinksServiceMock } from '@kbn/core-doc-links-server-mocks';
import { userSettingsServiceMock } from '@kbn/core-user-settings-server-mocks';
export const mockDocLinksService = docLinksServiceMock.create();
jest.doMock('@kbn/core-doc-links-server-internal', () => ({

View file

@ -26,6 +26,7 @@ import {
mockDeprecationService,
mockDocLinksService,
mockCustomBrandingService,
mockUserSettingsService,
} from './server.test.mocks';
import { BehaviorSubject } from 'rxjs';
@ -113,6 +114,7 @@ test('sets up services on "setup"', async () => {
expect(mockDeprecationService.setup).not.toHaveBeenCalled();
expect(mockDocLinksService.setup).not.toHaveBeenCalled();
expect(mockCustomBrandingService.setup).not.toHaveBeenCalled();
expect(mockUserSettingsService.setup).not.toHaveBeenCalled();
await server.setup();
@ -130,6 +132,7 @@ test('sets up services on "setup"', async () => {
expect(mockDeprecationService.setup).toHaveBeenCalledTimes(1);
expect(mockDocLinksService.setup).toHaveBeenCalledTimes(1);
expect(mockCustomBrandingService.setup).toHaveBeenCalledTimes(1);
expect(mockUserSettingsService.setup).toHaveBeenCalledTimes(1);
});
test('injects legacy dependency to context#setup()', async () => {

View file

@ -34,6 +34,7 @@ import { CoreUsageDataService } from '@kbn/core-usage-data-server-internal';
import { StatusService } from '@kbn/core-status-server-internal';
import { UiSettingsService } from '@kbn/core-ui-settings-server-internal';
import { CustomBrandingService } from '@kbn/core-custom-branding-server-internal';
import { UserSettingsService } from '@kbn/core-user-settings-server-internal';
import {
CoreRouteHandlerContext,
PrebootCoreRouteHandlerContext,
@ -98,6 +99,7 @@ export class Server {
private readonly prebootService: PrebootService;
private readonly docLinks: DocLinksService;
private readonly customBranding: CustomBrandingService;
private readonly userSettingsService: UserSettingsService;
private readonly savedObjectsStartPromise: Promise<SavedObjectsServiceStart>;
private resolveSavedObjectsStartPromise?: (value: SavedObjectsServiceStart) => void;
@ -145,6 +147,7 @@ export class Server {
this.prebootService = new PrebootService(core);
this.docLinks = new DocLinksService(core);
this.customBranding = new CustomBrandingService(core);
this.userSettingsService = new UserSettingsService(core);
this.savedObjectsStartPromise = new Promise((resolve) => {
this.resolveSavedObjectsStartPromise = resolve;
@ -301,6 +304,7 @@ export class Server {
});
const customBrandingSetup = this.customBranding.setup();
const userSettingsServiceSetup = this.userSettingsService.setup();
const renderingSetup = await this.rendering.setup({
elasticsearch: elasticsearchServiceSetup,
@ -308,6 +312,7 @@ export class Server {
status: statusSetup,
uiPlugins,
customBranding: customBrandingSetup,
userSettings: userSettingsServiceSetup,
});
const httpResourcesSetup = this.httpResources.setup({
@ -337,6 +342,7 @@ export class Server {
metrics: metricsSetup,
deprecations: deprecationsSetup,
coreUsageData: coreUsageDataSetup,
userSettings: userSettingsServiceSetup,
};
const pluginsSetup = await this.plugins.setup(coreSetup);

View file

@ -69,6 +69,8 @@
"@kbn/core-custom-branding-server-mocks",
"@kbn/repo-packages",
"@kbn/core-node-server",
"@kbn/core-user-settings-server-internal",
"@kbn/core-user-settings-server-mocks",
],
"exclude": [
"target/**/*",

View file

@ -34,7 +34,7 @@ export async function getUpgradeableConfig({
}: {
savedObjectsClient: SavedObjectsClientContract;
version: string;
type: 'config' | 'config-global';
type: 'config' | 'config-global' | 'config-user';
}) {
// attempt to find a config we can upgrade
const { saved_objects: savedConfigs } =

View file

@ -56,7 +56,7 @@ export interface UiSettingsServiceStart {
* Creates a {@link IUiSettingsClient} with provided *scoped* saved objects client.
*
* This should only be used in the specific case where the client needs to be accessed
* from outside of the scope of a {@link RequestHandler}.
* from outside the scope of a {@link RequestHandler}.
*
* @example
* ```ts
@ -72,7 +72,7 @@ export interface UiSettingsServiceStart {
* Creates a global {@link IUiSettingsClient} with provided *scoped* saved objects client.
*
* This should only be used in the specific case where the client needs to be accessed
* from outside of the scope of a {@link RequestHandler}.
* from outside the scope of a {@link RequestHandler}.
*
* @example
* ```ts

View file

@ -12,7 +12,7 @@
],
"kbn_references": [
"@kbn/core-saved-objects-api-server",
"@kbn/core-ui-settings-common"
"@kbn/core-ui-settings-common",
],
"exclude": [
"target/**/*",

View file

@ -0,0 +1,3 @@
# @kbn/core-user-settings-server-internal
Contains the implementation and internal types of the server-side `userSettings` service.

View file

@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { UserSettingsService } from './user_settings_service';
export type { InternalUserSettingsServiceSetup } from './user_settings_service';

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test/jest_node',
rootDir: '../../../..',
roots: ['<rootDir>/packages/core/user-settings/core-user-settings-server-internal'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/core-user-settings-server-internal",
"owner": "@elastic/platform-security",
}

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/core-user-settings-server-internal",
"private": true,
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0"
}

View file

@ -0,0 +1,24 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node"
]
},
"kbn_references": [
"@kbn/core-base-server-mocks",
"@kbn/core-http-server",
"@kbn/core-base-server-internal",
"@kbn/logging",
"@kbn/core-http-server-mocks",
"@kbn/core-user-settings-server",
],
"include": [
"**/*.ts",
],
"exclude": [
"target/**/*",
]
}

View file

@ -0,0 +1,82 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { mockCoreContext } from '@kbn/core-base-server-mocks';
import { UserSettingsService } from './user_settings_service';
import { httpServerMock } from '@kbn/core-http-server-mocks';
describe('#setup', () => {
const coreContext: ReturnType<typeof mockCoreContext.create> = mockCoreContext.create();
const { createKibanaRequest } = httpServerMock;
it('fetches userSettings when client is set and returns `true` when `darkMode` is set to `dark`', async () => {
const service = new UserSettingsService(coreContext);
const { setUserProfileSettings, getUserSettingDarkMode } = service.setup();
const userProfileContract = {
get: jest.fn().mockReturnValueOnce(Promise.resolve({ darkMode: 'dark' })),
};
setUserProfileSettings(userProfileContract);
const kibanaRequest = createKibanaRequest();
const darkMode = await getUserSettingDarkMode(kibanaRequest);
expect(darkMode).toEqual(true);
expect(userProfileContract.get).toHaveBeenCalledTimes(1);
expect(userProfileContract.get).toHaveBeenCalledWith(kibanaRequest);
});
it('fetches userSettings when client is set and returns `false` when `darkMode` is set to `light`', async () => {
const service = new UserSettingsService(coreContext);
const { setUserProfileSettings, getUserSettingDarkMode } = service.setup();
const userProfileContract = {
get: jest.fn().mockReturnValueOnce(Promise.resolve({ darkMode: 'light' })),
};
setUserProfileSettings(userProfileContract);
const kibanaRequest = createKibanaRequest();
const darkMode = await getUserSettingDarkMode(kibanaRequest);
expect(darkMode).toEqual(false);
expect(userProfileContract.get).toHaveBeenCalledTimes(1);
expect(userProfileContract.get).toHaveBeenCalledWith(kibanaRequest);
});
it('fetches userSettings when client is set and returns `undefined` when `darkMode` is set to `` (the default value)', async () => {
const service = new UserSettingsService(coreContext);
const { setUserProfileSettings, getUserSettingDarkMode } = service.setup();
const userProfileContract = {
get: jest.fn().mockReturnValueOnce(Promise.resolve({ darkMode: '' })),
};
setUserProfileSettings(userProfileContract);
const kibanaRequest = createKibanaRequest();
const darkMode = await getUserSettingDarkMode(kibanaRequest);
expect(darkMode).toEqual(undefined);
expect(userProfileContract.get).toHaveBeenCalledTimes(1);
expect(userProfileContract.get).toHaveBeenCalledWith(kibanaRequest);
});
it('does not fetch userSettings when client is not set, returns `undefined`, and logs a debug statement', async () => {
const service = new UserSettingsService(coreContext);
const { getUserSettingDarkMode } = service.setup();
const kibanaRequest = createKibanaRequest();
const darkMode = await getUserSettingDarkMode(kibanaRequest);
expect(darkMode).toEqual(undefined);
expect(coreContext.logger.get().debug).toHaveBeenCalledWith(
'UserProfileSettingsClient not set'
);
});
});

View file

@ -0,0 +1,64 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { CoreContext } from '@kbn/core-base-server-internal';
import { Logger } from '@kbn/logging';
import { KibanaRequest } from '@kbn/core-http-server';
import { UserProfileSettingsClientContract } from '@kbn/core-user-settings-server';
/**
* @internal
*/
export interface InternalUserSettingsServiceSetup {
setUserProfileSettings: (client: UserProfileSettingsClientContract) => void;
getUserSettingDarkMode: (request: KibanaRequest) => Promise<boolean | undefined>;
}
export class UserSettingsService {
private logger: Logger;
private client?: UserProfileSettingsClientContract;
constructor(coreContext: CoreContext) {
this.logger = coreContext.logger.get('user-settings-service');
}
public setup(): InternalUserSettingsServiceSetup {
return {
setUserProfileSettings: (client: UserProfileSettingsClientContract) => {
this.client = client;
},
getUserSettingDarkMode: async (request: KibanaRequest) => {
const userSettings = await this.getSettings(request);
return this.getUserSettingDarkMode(userSettings);
},
};
}
private async getSettings(request: KibanaRequest): Promise<Record<string, string>> {
let result = {};
if (this.client) {
result = (await this.client.get(request)) as Record<string, string>;
} else {
this.logger.debug('UserProfileSettingsClient not set');
}
return result;
}
private async getUserSettingDarkMode(
userSettings: Record<string, string>
): Promise<boolean | undefined> {
let result;
if (userSettings?.darkMode) {
result = userSettings.darkMode.toUpperCase() === 'DARK';
}
return result;
}
}

View file

@ -0,0 +1,3 @@
# @kbn/core-custom-branding-server-mocks
Contains the mocks for Core's internal `userSettings` server-side service.

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { userSettingsServiceMock } from './src/user_settings_service.mock';

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test/jest_node',
rootDir: '../../../..',
roots: ['<rootDir>/packages/core/user-settings/core-user-settings-server-mocks'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/core-user-settings-server-mocks",
"owner": "@elastic/platform-security",
}

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/core-user-settings-server-mocks",
"private": true,
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0"
}

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { UserSettingsService } from '@kbn/core-user-settings-server-internal';
export const serviceContractMock = (): jest.Mocked<UserSettingsService> => {
return {
setup: jest.fn(),
} as unknown as jest.Mocked<UserSettingsService>;
};

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { serviceContractMock } from './service_contract.mock';
const createSetupContractMock = () => {
return {
setUserProfileSettings: jest.fn(),
getUserSettingDarkMode: jest.fn(),
};
};
const createMock = () => {
const mocked = serviceContractMock();
mocked.setup.mockReturnValue(createSetupContractMock());
// mocked.start.mockReturnValue(createStartContractMock());
return mocked;
};
export const userSettingsServiceMock = {
create: createMock,
createSetupContract: createSetupContractMock,
};

View file

@ -0,0 +1,19 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node"
]
},
"kbn_references": [
"@kbn/core-user-settings-server-internal",
],
"include": [
"**/*.ts",
],
"exclude": [
"target/**/*",
]
}

View file

@ -0,0 +1,3 @@
# @kbn/core-user-settings-server
Contains the public types of Core's server-side `userSettings` service.

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export type { UserSettingsServiceSetup, UserProfileSettingsClientContract } from './types';

View file

@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/core-user-settings-server",
"owner": "@elastic/platform-security",
}

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/core-user-settings-server",
"private": true,
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0"
}

View file

@ -0,0 +1,18 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"node"
]
},
"kbn_references": [
"@kbn/core-http-server",
],
"include": [
"**/*.ts",
],
"exclude": [
"target/**/*",
]
}

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { KibanaRequest } from '@kbn/core-http-server';
/** @public */
export interface UserSettingsServiceSetup {
setUserProfileSettings: (client: UserProfileSettingsClientContract) => void;
}
export interface UserProfileSettingsClientContract {
get: (request: KibanaRequest) => Promise<Record<string, string>>;
}

View file

@ -84,7 +84,10 @@ export async function mountManagementSection(
<Route path="/">
<Settings
history={params.history}
enableSaving={{ namespace: canSaveAdvancedSettings, global: canSaveGlobalSettings }}
enableSaving={{
namespace: canSaveAdvancedSettings,
global: canSaveGlobalSettings,
}}
enableShowing={{ namespace: true, global: canShowGlobalSettings }}
toasts={notifications.toasts}
docLinks={docLinks.links}

View file

@ -566,6 +566,12 @@
"@kbn/core-usage-data-server-internal/*": ["packages/core/usage-data/core-usage-data-server-internal/*"],
"@kbn/core-usage-data-server-mocks": ["packages/core/usage-data/core-usage-data-server-mocks"],
"@kbn/core-usage-data-server-mocks/*": ["packages/core/usage-data/core-usage-data-server-mocks/*"],
"@kbn/core-user-settings-server": ["packages/core/user-settings/core-user-settings-server"],
"@kbn/core-user-settings-server/*": ["packages/core/user-settings/core-user-settings-server/*"],
"@kbn/core-user-settings-server-internal": ["packages/core/user-settings/core-user-settings-server-internal"],
"@kbn/core-user-settings-server-internal/*": ["packages/core/user-settings/core-user-settings-server-internal/*"],
"@kbn/core-user-settings-server-mocks": ["packages/core/user-settings/core-user-settings-server-mocks"],
"@kbn/core-user-settings-server-mocks/*": ["packages/core/user-settings/core-user-settings-server-mocks/*"],
"@kbn/cross-cluster-replication-plugin": ["x-pack/plugins/cross_cluster_replication"],
"@kbn/cross-cluster-replication-plugin/*": ["x-pack/plugins/cross_cluster_replication/*"],
"@kbn/crypto": ["packages/kbn-crypto"],

View file

@ -90,6 +90,13 @@ export interface UserProfileAvatarData {
imageUrl?: string;
}
/**
* User settings stored in the data object of the User Profile
*/
export interface UserSettingsData {
darkMode?: string;
}
/**
* Extended user information returned in user profile (both basic and security related properties).
*/

View file

@ -13,7 +13,7 @@ import type { CoreStart } from '@kbn/core/public';
import { i18n } from '@kbn/i18n';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import type { UserProfileAvatarData } from '../../common';
import type { UserProfileData } from '../../common';
import { canUserHaveProfile } from '../../common/model';
import { useCurrentUser, useUserProfile } from '../components';
import { Breadcrumb } from '../components/breadcrumb';
@ -23,7 +23,7 @@ export const AccountManagementPage: FunctionComponent = () => {
const { services } = useKibana<CoreStart>();
const currentUser = useCurrentUser();
const userProfile = useUserProfile<{ avatar: UserProfileAvatarData }>('avatar');
const userProfile = useUserProfile<UserProfileData>('avatar,userSettings');
// If we fail to load profile, we treat it as a failure _only_ if user is supposed
// to have a profile. For example, anonymous and users authenticated via

View file

@ -75,6 +75,9 @@ describe('useUserProfileForm', () => {
"imageUrl": "",
"initials": "fn",
},
"userSettings": Object {
"darkMode": "",
},
},
"user": Object {
"email": "email",
@ -230,4 +233,78 @@ describe('useUserProfileForm', () => {
expect(testWrapper.exists('UserAvatar')).toBeFalsy();
});
});
describe('Dark Mode Form', () => {
it('should display if the User is not a cloud user', () => {
const data: UserProfileData = {};
const nonCloudUser = mockAuthenticatedUser({ elastic_cloud_user: false });
const testWrapper = mount(
<Providers
services={coreStart}
theme$={theme$}
history={history}
authc={authc}
securityApiClients={{
userProfiles: new UserProfileAPIClient(coreStart.http),
users: new UserAPIClient(coreStart.http),
}}
>
<UserProfile user={nonCloudUser} data={data} />
</Providers>
);
expect(testWrapper.exists('[data-test-subj="darkModeButton"]')).toBeTruthy();
});
it('should not display if the User is a cloud user', () => {
const data: UserProfileData = {};
const cloudUser = mockAuthenticatedUser({ elastic_cloud_user: true });
const testWrapper = mount(
<Providers
services={coreStart}
theme$={theme$}
history={history}
authc={authc}
securityApiClients={{
userProfiles: new UserProfileAPIClient(coreStart.http),
users: new UserAPIClient(coreStart.http),
}}
>
<UserProfile user={cloudUser} data={data} />
</Providers>
);
expect(testWrapper.exists('[data-test-subj="darkModeButton"]')).toBeFalsy();
});
it('should add special toast after submitting form successfully since darkMode requires a refresh', async () => {
const data: UserProfileData = {};
const { result } = renderHook(() => useUserProfileForm({ user, data }), { wrapper });
await act(async () => {
await result.current.submitForm();
});
expect(coreStart.notifications.toasts.addSuccess).toHaveBeenNthCalledWith(
1,
{ title: 'Profile updated' },
{}
);
await act(async () => {
await result.current.setFieldValue('data.userSettings.darkMode', 'dark');
await result.current.submitForm();
});
expect(coreStart.notifications.toasts.addSuccess).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ title: 'Profile updated' }),
expect.objectContaining({ toastLifeTimeMs: 300000 })
);
});
});
});

View file

@ -29,10 +29,10 @@ import type { FunctionComponent } from 'react';
import React, { useRef, useState } from 'react';
import useUpdateEffect from 'react-use/lib/useUpdateEffect';
import type { CoreStart } from '@kbn/core/public';
import type { CoreStart, ToastInput, ToastOptions } from '@kbn/core/public';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { toMountPoint, useKibana } from '@kbn/kibana-react-plugin/public';
import { UserAvatar } from '@kbn/user-profile-components';
import type { AuthenticatedUser, UserProfileAvatarData } from '../../../common';
@ -42,6 +42,7 @@ import {
getUserAvatarColor,
getUserAvatarInitials,
} from '../../../common/model';
import type { UserSettingsData } from '../../../common/model/user_profile';
import { useSecurityApiClients } from '../../components';
import { Breadcrumb } from '../../components/breadcrumb';
import {
@ -60,6 +61,7 @@ export interface UserProfileProps {
user: AuthenticatedUser;
data?: {
avatar?: UserProfileAvatarData;
userSettings?: UserSettingsData;
};
}
@ -74,6 +76,9 @@ export interface UserProfileFormValues {
color: string;
imageUrl: string;
};
userSettings: {
darkMode: string;
};
};
avatarType: 'initials' | 'image';
}
@ -137,6 +142,91 @@ function UserDetailsEditor({ user }: { user: AuthenticatedUser }) {
);
}
function UserSettingsEditor({ formik }: { formik: ReturnType<typeof useUserProfileForm> }) {
if (!formik.values.data) {
return null;
}
return (
<EuiDescribedFormGroup
fullWidth
fieldFlexItemProps={{ style: { alignSelf: 'flex-start' } }}
title={
<h2>
<FormattedMessage
id="xpack.security.accountManagement.userProfile.userSettingsTitle"
defaultMessage="Theme"
/>
</h2>
}
description={
<FormattedMessage
id="xpack.security.accountManagement.userProfile.themeFormGroupDescription"
defaultMessage="Select the appearance of your interface."
/>
}
>
<FormRow
name="data.userSettings.darkMode"
label={
<FormLabel for="data.userSettings.darkMode">
<FormattedMessage
id="xpack.security.accountManagement.userProfile.userSettings.theme"
defaultMessage="Mode"
/>
</FormLabel>
}
fullWidth
>
<EuiButtonGroup
legend={i18n.translate(
'xpack.security.accountManagement.userProfile.userSettings.themeGroupDescription',
{
defaultMessage: 'Elastic theme',
}
)}
buttonSize="m"
data-test-subj="darkModeButton"
idSelected={formik.values.data.userSettings.darkMode}
options={[
{
id: '',
label: (
<FormattedMessage
id="xpack.security.accountManagement.userProfile.defaultModeButton"
defaultMessage="Space default"
/>
),
},
{
id: 'light',
label: (
<FormattedMessage
id="xpack.security.accountManagement.userProfile.lightModeButton"
defaultMessage="Light"
/>
),
iconType: 'sun',
},
{
id: 'dark',
label: (
<FormattedMessage
id="xpack.security.accountManagement.userProfile.darkModeButton"
defaultMessage="Dark"
/>
),
iconType: 'moon',
},
]}
onChange={(id: string) => formik.setFieldValue('data.userSettings.darkMode', id)}
isFullWidth
/>
</FormRow>
</EuiDescribedFormGroup>
);
}
function UserAvatarEditor({
user,
formik,
@ -498,81 +588,83 @@ export const UserProfile: FunctionComponent<UserProfileProps> = ({ user, data })
}
return (
<FormikProvider value={formik}>
<FormChangesProvider value={formChanges}>
<Breadcrumb
text={i18n.translate('xpack.security.accountManagement.userProfile.title', {
defaultMessage: 'Profile',
})}
>
{showChangePasswordForm ? (
<ChangePasswordModal
username={user.username}
onCancel={() => setShowChangePasswordForm(false)}
onSuccess={() => setShowChangePasswordForm(false)}
/>
) : null}
<EuiPageTemplate
className="eui-fullHeight"
pageHeader={{
pageTitle: (
<FormattedMessage
id="xpack.security.accountManagement.userProfile.title"
defaultMessage="Profile"
/>
),
pageTitleProps: { id: titleId },
rightSideItems: rightSideItems.reverse().map((item) => (
<EuiDescriptionList
textStyle="reverse"
listItems={[
{
title: (
<EuiText color={euiTheme.colors.darkestShade} size="s">
<EuiFlexGroup responsive={false} alignItems="center" gutterSize="none">
<EuiFlexItem grow={false}>{item.title}</EuiFlexItem>
<EuiFlexItem grow={false} style={{ marginLeft: '0.33em' }}>
<EuiIconTip type="questionInCircle" content={item.helpText} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiText>
),
description: (
<span data-test-subj={item.testSubj}>
{item.description || (
<EuiText color={euiTheme.colors.disabledText} size="s">
<FormattedMessage
id="xpack.security.accountManagement.userProfile.noneProvided"
defaultMessage="None provided"
/>
</EuiText>
)}
</span>
),
},
]}
compressed
/>
)),
}}
bottomBar={formChanges.count > 0 ? <SaveChangesBottomBar /> : null}
bottomBarProps={{ paddingSize: 'm', position: 'fixed' }}
restrictWidth={1000}
<>
<FormikProvider value={formik}>
<FormChangesProvider value={formChanges}>
<Breadcrumb
text={i18n.translate('xpack.security.accountManagement.userProfile.title', {
defaultMessage: 'Profile',
})}
>
<Form aria-labelledby={titleId}>
<UserDetailsEditor user={user} />
{isCloudUser ? null : <UserAvatarEditor user={user} formik={formik} />}
<UserPasswordEditor
user={user}
onShowPasswordForm={() => setShowChangePasswordForm(true)}
{showChangePasswordForm ? (
<ChangePasswordModal
username={user.username}
onCancel={() => setShowChangePasswordForm(false)}
onSuccess={() => setShowChangePasswordForm(false)}
/>
</Form>
<EuiSpacer />
</EuiPageTemplate>
</Breadcrumb>
</FormChangesProvider>
</FormikProvider>
) : null}
<EuiPageTemplate
className="eui-fullHeight"
pageHeader={{
pageTitle: (
<FormattedMessage
id="xpack.security.accountManagement.userProfile.title"
defaultMessage="Profile"
/>
),
pageTitleProps: { id: titleId },
rightSideItems: rightSideItems.reverse().map((item) => (
<EuiDescriptionList
textStyle="reverse"
listItems={[
{
title: (
<EuiText color={euiTheme.colors.darkestShade} size="s">
<EuiFlexGroup responsive={false} alignItems="center" gutterSize="none">
<EuiFlexItem grow={false}>{item.title}</EuiFlexItem>
<EuiFlexItem grow={false} style={{ marginLeft: '0.33em' }}>
<EuiIconTip type="questionInCircle" content={item.helpText} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiText>
),
description: (
<span data-test-subj={item.testSubj}>
{item.description || (
<EuiText color={euiTheme.colors.disabledText} size="s">
<FormattedMessage
id="xpack.security.accountManagement.userProfile.noneProvided"
defaultMessage="None provided"
/>
</EuiText>
)}
</span>
),
},
]}
compressed
/>
)),
}}
bottomBar={formChanges.count > 0 ? <SaveChangesBottomBar /> : null}
bottomBarProps={{ paddingSize: 'm', position: 'fixed' }}
restrictWidth={1000}
>
<Form aria-labelledby={titleId}>
<UserDetailsEditor user={user} />
{isCloudUser ? null : <UserAvatarEditor user={user} formik={formik} />}
<UserPasswordEditor
user={user}
onShowPasswordForm={() => setShowChangePasswordForm(true)}
/>
{isCloudUser ? null : <UserSettingsEditor formik={formik} />}
</Form>
</EuiPageTemplate>
</Breadcrumb>
</FormChangesProvider>
</FormikProvider>
</>
);
};
@ -592,12 +684,16 @@ export function useUserProfileForm({ user, data }: UserProfileProps) {
color: data.avatar?.color || getUserAvatarColor(user),
imageUrl: data.avatar?.imageUrl || '',
},
userSettings: {
darkMode: data.userSettings?.darkMode || '',
},
}
: undefined,
avatarType: data?.avatar?.imageUrl ? 'image' : 'initials',
});
const [validateOnBlurOrChange, setValidateOnBlurOrChange] = useState(false);
const formik = useFormik<UserProfileFormValues>({
onSubmit: async (values) => {
const submitActions = [];
@ -639,12 +735,59 @@ export function useUserProfileForm({ user, data }: UserProfileProps) {
return;
}
let isRefreshRequired = false;
if (initialValues.data?.userSettings.darkMode !== values.data?.userSettings.darkMode) {
isRefreshRequired = true;
}
resetInitialValues(values);
services.notifications.toasts.addSuccess(
i18n.translate('xpack.security.accountManagement.userProfile.submitSuccessTitle', {
let successToastInput: ToastInput = {
title: i18n.translate('xpack.security.accountManagement.userProfile.submitSuccessTitle', {
defaultMessage: 'Profile updated',
})
);
}),
};
let successToastOptions: ToastOptions = {};
if (isRefreshRequired) {
successToastOptions = {
toastLifeTimeMs: 1000 * 60 * 5,
};
successToastInput = {
...successToastInput,
text: toMountPoint(
<EuiFlexGroup justifyContent="flexEnd" gutterSize="s">
<EuiFlexItem grow={false}>
<p>
{i18n.translate(
'xpack.security.accountManagement.userProfile.requiresPageReloadToastDescription',
{
defaultMessage:
'One or more settings require you to reload the page to take effect.',
}
)}
</p>
<EuiButton
size="s"
onClick={() => window.location.reload()}
data-test-subj="windowReloadButton"
>
{i18n.translate(
'xpack.security.accountManagement.userProfile.requiresPageReloadToastButtonLabel',
{
defaultMessage: 'Reload page',
}
)}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
),
};
}
services.notifications.toasts.addSuccess(successToastInput, successToastOptions);
},
initialValues,
enableReinitialize: true,

View file

@ -16,6 +16,7 @@ import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks';
import { ConfigSchema } from './config';
import type { PluginSetupDependencies, PluginStartDependencies } from './plugin';
import { SecurityPlugin } from './plugin';
import { userProfileServiceMock } from './user_profile/user_profile_service.mock';
describe('Security Plugin', () => {
let plugin: SecurityPlugin;
@ -36,7 +37,9 @@ describe('Security Plugin', () => {
)
);
mockCoreSetup = coreMock.createSetup();
mockCoreSetup = coreMock.createSetup({
pluginStartContract: { userProfiles: userProfileServiceMock.createStart() },
});
mockCoreSetup.http.getServerInfo.mockReturnValue({
hostname: 'localhost',
name: 'kibana',

View file

@ -59,6 +59,9 @@ import { setupSpacesClient } from './spaces';
import { registerSecurityUsageCollector } from './usage_collector';
import { UserProfileService } from './user_profile';
import type { UserProfileServiceStart, UserProfileServiceStartInternal } from './user_profile';
import { UserProfileSettingsClient } from './user_profile/user_profile_settings_client';
import type { UserSettingServiceStart } from './user_profile/user_setting_service';
import { UserSettingService } from './user_profile/user_setting_service';
export type SpacesService = Pick<
SpacesPluginSetup['spacesService'],
@ -195,6 +198,9 @@ export class SecurityPlugin
private readonly userProfileService: UserProfileService;
private userProfileStart?: UserProfileServiceStartInternal;
private readonly userSettingService: UserSettingService;
private userSettingServiceStart?: UserSettingServiceStart;
private readonly getUserProfileService = () => {
if (!this.userProfileStart) {
throw new Error(`userProfileStart is not registered!`);
@ -222,11 +228,15 @@ export class SecurityPlugin
this.userProfileService = new UserProfileService(
this.initializerContext.logger.get('user-profile')
);
this.userSettingService = new UserSettingService(
this.initializerContext.logger.get('user-settings')
);
this.analyticsService = new AnalyticsService(this.initializerContext.logger.get('analytics'));
}
public setup(
core: CoreSetup<PluginStartDependencies>,
core: CoreSetup<PluginStartDependencies, SecurityPluginStart>,
{ features, licensing, taskManager, usageCollection, spaces }: PluginSetupDependencies
) {
this.kibanaIndexName = core.savedObjects.getDefaultIndex();
@ -245,10 +255,25 @@ export class SecurityPlugin
const kibanaIndexName = this.getKibanaIndexName();
// A subset of `start` services we need during `setup`.
const startServicesPromise = core.getStartServices().then(([coreServices, depsServices]) => ({
elasticsearch: coreServices.elasticsearch,
features: depsServices.features,
}));
const startServicesPromise = core
.getStartServices()
.then(([coreServices, depsServices, startServices]) => ({
elasticsearch: coreServices.elasticsearch,
features: depsServices.features,
userProfiles: startServices.userProfiles,
}));
/**
* Once the UserProfileServiceStart is available, use it to start the SecurityPlugin > UserSettingService.
*
* Then the UserProfileSettingsClient is created with the SecurityPlugin > UserSettingServiceStart and set on
* the Core > UserSettingsServiceSetup
*/
startServicesPromise.then(({ userProfiles }) => {
this.userSettingServiceStart = this.userSettingService.start(userProfiles);
const client = new UserProfileSettingsClient(this.userSettingServiceStart);
core.userSettings.setUserProfileSettings(client);
});
const { license } = this.securityLicenseService.setup({
license$: licensing.license$,
@ -381,6 +406,7 @@ export class SecurityPlugin
this.session = session;
this.userProfileStart = this.userProfileService.start({ clusterClient, session });
this.userSettingServiceStart = this.userSettingService.start(this.userProfileStart);
const config = this.getConfig();
this.authenticationStart = this.authenticationService.start({

View file

@ -6,6 +6,7 @@
*/
export { UserProfileService } from './user_profile_service';
export type {
UserProfileServiceStart,
UserProfileServiceStartInternal,

View file

@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { httpServerMock } from '@kbn/core-http-server-mocks';
import { UserProfileSettingsClient } from './user_profile_settings_client';
import type { UserSettingServiceStart } from './user_setting_service';
describe('UserProfileSettingsClient', () => {
let mockRequest: ReturnType<typeof httpServerMock.createKibanaRequest>;
let client: UserProfileSettingsClient;
beforeEach(() => {
const userSettingsServiceStart = {
getCurrentUserProfileSettings: jest.fn(),
} as jest.Mocked<UserSettingServiceStart>;
userSettingsServiceStart.getCurrentUserProfileSettings.mockResolvedValue({ darkMode: 'dark' });
client = new UserProfileSettingsClient(userSettingsServiceStart);
});
describe('#get', () => {
it('should return user settings', async () => {
const userSettings = await client.get(mockRequest);
expect(userSettings).toEqual({ darkMode: 'dark' });
});
});
});

View file

@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { KibanaRequest } from '@kbn/core-http-server';
import type { UserProfileSettingsClientContract } from '@kbn/core-user-settings-server';
import type { UserSettingServiceStart } from './user_setting_service';
/**
* A wrapper client around {@link UserSettingServiceStart} that exposes a method to get the current user's profile
*/
export class UserProfileSettingsClient implements UserProfileSettingsClientContract {
private userSettingsServiceStart: UserSettingServiceStart;
constructor(userSettingsServiceStart: UserSettingServiceStart) {
this.userSettingsServiceStart = userSettingsServiceStart;
}
/**
* Returns the current user's user profile settings
*
* @param request the KibanaRequest that is required to get the current user and their settings
*/
async get(request: KibanaRequest): Promise<Record<string, string>> {
return await this.userSettingsServiceStart.getCurrentUserProfileSettings(request);
}
}

View file

@ -0,0 +1,53 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { KibanaRequest } from '@kbn/core-http-server';
import type { Logger } from '@kbn/logging';
import type { UserProfileGetCurrentParams, UserProfileServiceStart } from './user_profile_service';
export interface UserSettingServiceStart {
/**
* Returns the currently signed-in user's settings from their User Profile
*
* @param request the KibanaRequest that is required to get the current user and their settings
*/
getCurrentUserProfileSettings(request: KibanaRequest): Promise<Record<string, string>>;
}
/**
* A service that wraps the {@link UserProfileServiceStart} so that only the 'getCurrent' method is made available
*/
export class UserSettingService {
private readonly logger: Logger;
constructor(logger: Logger) {
this.logger = logger;
}
start(userProfileServiceStart: UserProfileServiceStart): UserSettingServiceStart {
return {
getCurrentUserProfileSettings: async (request) => {
const params: UserProfileGetCurrentParams = {
request,
dataPath: 'userSettings',
};
const currentUserProfile = await userProfileServiceStart.getCurrent(params);
let result = {} as Record<string, string>;
if (currentUserProfile?.data?.userSettings) {
result = currentUserProfile?.data?.userSettings as Record<string, string>;
} else {
this.logger.debug('User Settings not found.');
}
return result;
},
};
}
}

View file

@ -0,0 +1,143 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { SecurityGetUserProfileResponse } from '@elastic/elasticsearch/lib/api/types';
import {
elasticsearchServiceMock,
httpServerMock,
loggingSystemMock,
} from '@kbn/core/server/mocks';
import type { UserProfileWithSecurity } from '../../common';
import { licenseMock } from '../../common/licensing/index.mock';
import { userProfileMock } from '../../common/model/user_profile.mock';
import { authorizationMock } from '../authorization/index.mock';
import { sessionMock } from '../session_management/session.mock';
import type { UserProfileServiceStart } from './user_profile_service';
import { UserProfileService } from './user_profile_service';
import { UserSettingService } from './user_setting_service';
const logger = loggingSystemMock.createLogger();
describe('UserSettingService', () => {
let mockStartParams: {
clusterClient: ReturnType<typeof elasticsearchServiceMock.createClusterClient>;
session: ReturnType<typeof sessionMock.create>;
};
let mockAuthz: ReturnType<typeof authorizationMock.create>;
let userProfileService: UserProfileService;
let userSettingsService: UserSettingService;
let userProfileServiceStart: UserProfileServiceStart;
beforeEach(() => {
mockStartParams = {
clusterClient: elasticsearchServiceMock.createClusterClient(),
session: sessionMock.create(),
};
mockAuthz = authorizationMock.create();
userProfileService = new UserProfileService(logger);
userSettingsService = new UserSettingService(logger);
userProfileService.setup({
authz: mockAuthz,
license: licenseMock.create({ allowUserProfileCollaboration: true }),
});
userProfileServiceStart = userProfileService.start(mockStartParams);
});
afterEach(() => {
logger.error.mockClear();
});
it('should expose correct start contract', () => {
const userSettingServiceStart = userSettingsService.start(userProfileServiceStart);
expect(userSettingServiceStart).toMatchInlineSnapshot(`
Object {
"getCurrentUserProfileSettings": [Function],
}
`);
});
describe('#getCurrentUserProfileSettings', () => {
let mockUserProfile: UserProfileWithSecurity;
let mockRequest: ReturnType<typeof httpServerMock.createKibanaRequest>;
beforeEach(() => {
mockRequest = httpServerMock.createKibanaRequest();
});
it('returns user settings data', async () => {
mockUserProfile = userProfileMock.createWithSecurity({
uid: 'UID',
user: {
username: 'user-1',
full_name: 'full-name-1',
realm_name: 'some-realm',
realm_domain: 'some-domain',
roles: ['role-1'],
},
data: {
kibana: {
userSettings: {
darkMode: 'dark',
},
},
},
});
mockStartParams.clusterClient.asInternalUser.security.getUserProfile.mockResolvedValue({
profiles: [mockUserProfile],
} as unknown as SecurityGetUserProfileResponse);
mockStartParams.session.get.mockResolvedValue({
error: null,
value: sessionMock.createValue({ userProfileId: mockUserProfile.uid }),
});
userProfileServiceStart = userProfileService.start(mockStartParams);
const userSettingServiceStart = userSettingsService.start(userProfileServiceStart);
await expect(
userSettingServiceStart.getCurrentUserProfileSettings(mockRequest)
).resolves.toEqual({ darkMode: 'dark' });
});
it('logs a warning and returns ', async () => {
mockUserProfile = userProfileMock.createWithSecurity({
uid: 'UID',
user: {
username: 'user-1',
full_name: 'full-name-1',
realm_name: 'some-realm',
realm_domain: 'some-domain',
roles: ['role-1'],
},
data: {},
});
mockStartParams.clusterClient.asInternalUser.security.getUserProfile.mockResolvedValue({
profiles: [mockUserProfile],
} as unknown as SecurityGetUserProfileResponse);
mockStartParams.session.get.mockResolvedValue({
error: null,
value: sessionMock.createValue({ userProfileId: mockUserProfile.uid }),
});
userProfileServiceStart = userProfileService.start(mockStartParams);
const userSettingServiceStart = userSettingsService.start(userProfileServiceStart);
await expect(
userSettingServiceStart.getCurrentUserProfileSettings(mockRequest)
).resolves.toEqual({});
expect(logger.debug).toHaveBeenCalledWith('User Settings not found.');
});
});
});

View file

@ -58,6 +58,9 @@
"@kbn/ecs",
"@kbn/safer-lodash-set",
"@kbn/shared-ux-router",
"@kbn/core-http-server",
"@kbn/core-http-server-mocks",
"@kbn/core-user-settings-server",
],
"exclude": [
"target/**/*",

View file

@ -3865,6 +3865,18 @@
version "0.0.0"
uid ""
"@kbn/core-user-settings-server-internal@link:packages/core/user-settings/core-user-settings-server-internal":
version "0.0.0"
uid ""
"@kbn/core-user-settings-server-mocks@link:packages/core/user-settings/core-user-settings-server-mocks":
version "0.0.0"
uid ""
"@kbn/core-user-settings-server@link:packages/core/user-settings/core-user-settings-server":
version "0.0.0"
uid ""
"@kbn/core@link:src/core":
version "0.0.0"
uid ""
@ -11007,11 +11019,16 @@ balanced-match@^2.0.0:
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-2.0.0.tgz#dc70f920d78db8b858535795867bf48f820633d9"
integrity sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==
base64-js@1.3.1, base64-js@^1.0.2, base64-js@^1.1.2, base64-js@^1.2.0, base64-js@^1.3.0, base64-js@^1.3.1:
base64-js@1.3.1, base64-js@^1.0.2, base64-js@^1.2.0, base64-js@^1.3.0, base64-js@^1.3.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1"
integrity sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==
base64-js@^1.1.2:
version "1.5.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
base64url@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/base64url/-/base64url-3.0.1.tgz#6399d572e2bc3f90a9a8b22d5dbb0a32d33f788d"
@ -12487,13 +12504,20 @@ content-type@~1.0.4:
resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b"
integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==
convert-source-map@1.X, convert-source-map@^1.1.0, convert-source-map@^1.4.0, convert-source-map@^1.5.0, convert-source-map@^1.5.1, convert-source-map@^1.6.0, convert-source-map@^1.7.0:
convert-source-map@1.X, convert-source-map@^1.1.0, convert-source-map@^1.4.0, convert-source-map@^1.5.0, convert-source-map@^1.6.0, convert-source-map@^1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.7.0.tgz#17a2cb882d7f77d3490585e2ce6c524424a3a442"
integrity sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==
dependencies:
safe-buffer "~5.1.1"
convert-source-map@^1.5.1:
version "1.8.0"
resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.8.0.tgz#f3373c32d21b4d780dd8004514684fb791ca4369"
integrity sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==
dependencies:
safe-buffer "~5.1.1"
convert-source-map@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a"
@ -15231,11 +15255,16 @@ esrecurse@^4.1.0, esrecurse@^4.3.0:
dependencies:
estraverse "^5.2.0"
estraverse@^4.1.1, estraverse@^4.2.0:
estraverse@^4.1.1:
version "4.2.0"
resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.2.0.tgz#0dee3fed31fcd469618ce7342099fc1afa0bdb13"
integrity sha1-De4/7TH81GlhjOc0IJn8GvoL2xM=
estraverse@^4.2.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d"
integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==
estraverse@^5.1.0, estraverse@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.2.0.tgz#307df42547e6cc7324d3cf03c155d5cdb8c53880"
@ -21959,11 +21988,16 @@ object-identity-map@^1.0.2:
dependencies:
object.entries "^1.1.0"
object-inspect@^1.11.0, object-inspect@^1.6.0, object-inspect@^1.7.0, object-inspect@^1.9.0:
object-inspect@^1.11.0, object-inspect@^1.7.0, object-inspect@^1.9.0:
version "1.11.0"
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.11.0.tgz#9dceb146cedd4148a0d9e51ab88d34cf509922b1"
integrity sha512-jp7ikS6Sd3GxQfZJPyH3cjcbJF6GZPClgdV+EFygjFLQ5FmW/dRUnTd9PQ9k0JhoNDabWFbpF1yCdSWCC6gexg==
object-inspect@^1.6.0:
version "1.12.2"
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.2.tgz#c0641f26394532f28ab8d796ab954e43c009a8ea"
integrity sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==
object-is@^1.0.1, object-is@^1.0.2, object-is@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.2.tgz#c5d2e87ff9e119f78b7a088441519e2eec1573b6"
@ -26402,9 +26436,9 @@ state-toggle@^1.0.0:
integrity sha1-0g+aYWu08MO5i5GSLSW2QKorxCU=
static-eval@^2.0.5:
version "2.0.5"
resolved "https://registry.yarnpkg.com/static-eval/-/static-eval-2.0.5.tgz#f0782e66999c4b3651cda99d9ce59c507d188f71"
integrity sha512-nNbV6LbGtMBgv7e9LFkt5JV8RVlRsyJrphfAt9tOtBBW/SfnzZDf2KnS72an8e434A+9e/BmJuTxeGPvrAK7KA==
version "2.1.0"
resolved "https://registry.yarnpkg.com/static-eval/-/static-eval-2.1.0.tgz#a16dbe54522d7fa5ef1389129d813fd47b148014"
integrity sha512-agtxZ/kWSsCkI5E4QifRwsaPs0P0JmZV6dkLz6ILYfFYQGn+5plctanRN+IC8dJRiFkyXHrwEE3W9Wmx67uDbw==
dependencies:
escodegen "^1.11.1"