mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
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:
parent
5fb93a1ea2
commit
b66df8774a
61 changed files with 1271 additions and 109 deletions
3
.github/CODEOWNERS
vendored
3
.github/CODEOWNERS
vendored
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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/**/*",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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/**/*",
|
||||
|
|
|
@ -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} */
|
||||
|
|
|
@ -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/**/*",
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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`', () => {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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];
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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/**/*",
|
||||
|
|
|
@ -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', () => ({
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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/**/*",
|
||||
|
|
|
@ -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 } =
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
],
|
||||
"kbn_references": [
|
||||
"@kbn/core-saved-objects-api-server",
|
||||
"@kbn/core-ui-settings-common"
|
||||
"@kbn/core-ui-settings-common",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
# @kbn/core-user-settings-server-internal
|
||||
|
||||
Contains the implementation and internal types of the server-side `userSettings` service.
|
|
@ -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';
|
|
@ -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'],
|
||||
};
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"type": "shared-common",
|
||||
"id": "@kbn/core-user-settings-server-internal",
|
||||
"owner": "@elastic/platform-security",
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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/**/*",
|
||||
]
|
||||
}
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
# @kbn/core-custom-branding-server-mocks
|
||||
|
||||
Contains the mocks for Core's internal `userSettings` server-side service.
|
|
@ -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';
|
|
@ -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'],
|
||||
};
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"type": "shared-common",
|
||||
"id": "@kbn/core-user-settings-server-mocks",
|
||||
"owner": "@elastic/platform-security",
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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>;
|
||||
};
|
|
@ -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,
|
||||
};
|
|
@ -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/**/*",
|
||||
]
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
# @kbn/core-user-settings-server
|
||||
|
||||
Contains the public types of Core's server-side `userSettings` service.
|
|
@ -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';
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"type": "shared-common",
|
||||
"id": "@kbn/core-user-settings-server",
|
||||
"owner": "@elastic/platform-security",
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "target/types",
|
||||
"types": [
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"kbn_references": [
|
||||
"@kbn/core-http-server",
|
||||
],
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
]
|
||||
}
|
|
@ -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>>;
|
||||
}
|
|
@ -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}
|
||||
|
|
|
@ -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"],
|
||||
|
|
|
@ -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).
|
||||
*/
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
export { UserProfileService } from './user_profile_service';
|
||||
|
||||
export type {
|
||||
UserProfileServiceStart,
|
||||
UserProfileServiceStartInternal,
|
||||
|
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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.');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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/**/*",
|
||||
|
|
48
yarn.lock
48
yarn.lock
|
@ -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"
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue