diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md index 403d8594999a7..8f6f1f6c98ab2 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md @@ -88,6 +88,7 @@ readonly links: { readonly usersAccess: string; }; readonly workplaceSearch: { + readonly apiKeys: string; readonly box: string; readonly confluenceCloud: string; readonly confluenceServer: string; diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md index 131d4452c980c..a9828f04672e9 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md @@ -17,5 +17,5 @@ export interface DocLinksStart | --- | --- | --- | | [DOC\_LINK\_VERSION](./kibana-plugin-core-public.doclinksstart.doc_link_version.md) | string | | | [ELASTIC\_WEBSITE\_URL](./kibana-plugin-core-public.doclinksstart.elastic_website_url.md) | string | | -| [links](./kibana-plugin-core-public.doclinksstart.links.md) | { readonly settings: string; readonly elasticStackGetStarted: string; readonly upgrade: { readonly upgradingElasticStack: string; }; readonly apm: { readonly kibanaSettings: string; readonly supportedServiceMaps: string; readonly customLinks: string; readonly droppedTransactionSpans: string; readonly upgrading: string; readonly metaData: string; }; readonly canvas: { readonly guide: string; }; readonly cloud: { readonly indexManagement: string; }; readonly dashboard: { readonly guide: string; readonly drilldowns: string; readonly drilldownsTriggerPicker: string; readonly urlDrilldownTemplateSyntax: string; readonly urlDrilldownVariables: string; }; readonly discover: Record<string, string>; readonly filebeat: { readonly base: string; readonly installation: string; readonly configuration: string; readonly elasticsearchOutput: string; readonly elasticsearchModule: string; readonly startup: string; readonly exportedFields: string; readonly suricataModule: string; readonly zeekModule: string; }; readonly auditbeat: { readonly base: string; readonly auditdModule: string; readonly systemModule: string; }; readonly metricbeat: { readonly base: string; readonly configure: string; readonly httpEndpoint: string; readonly install: string; readonly start: string; }; readonly appSearch: { readonly apiRef: string; readonly apiClients: string; readonly apiKeys: string; readonly authentication: string; readonly crawlRules: string; readonly curations: string; readonly duplicateDocuments: string; readonly entryPoints: string; readonly guide: string; readonly indexingDocuments: string; readonly indexingDocumentsSchema: string; readonly logSettings: string; readonly metaEngines: string; readonly precisionTuning: string; readonly relevanceTuning: string; readonly resultSettings: string; readonly searchUI: string; readonly security: string; readonly synonyms: string; readonly webCrawler: string; readonly webCrawlerEventLogs: string; }; readonly enterpriseSearch: { readonly configuration: string; readonly licenseManagement: string; readonly mailService: string; readonly usersAccess: string; }; readonly workplaceSearch: { readonly box: string; readonly confluenceCloud: string; readonly confluenceServer: string; readonly customSources: string; readonly customSourcePermissions: string; readonly documentPermissions: string; readonly dropbox: string; readonly externalIdentities: string; readonly gitHub: string; readonly gettingStarted: string; readonly gmail: string; readonly googleDrive: string; readonly indexingSchedule: string; readonly jiraCloud: string; readonly jiraServer: string; readonly oneDrive: string; readonly permissions: string; readonly salesforce: string; readonly security: string; readonly serviceNow: string; readonly sharePoint: string; readonly slack: string; readonly synch: string; readonly zendesk: string; }; readonly heartbeat: { readonly base: string; }; readonly libbeat: { readonly getStarted: string; }; readonly logstash: { readonly base: string; }; readonly functionbeat: { readonly base: string; }; readonly winlogbeat: { readonly base: string; }; readonly aggs: { readonly composite: string; readonly composite\_missing\_bucket: string; readonly date\_histogram: string; readonly date\_range: string; readonly date\_format\_pattern: string; readonly filter: string; readonly filters: string; readonly geohash\_grid: string; readonly histogram: string; readonly ip\_range: string; readonly range: string; readonly significant\_terms: string; readonly terms: string; readonly terms\_doc\_count\_error: string; readonly avg: string; readonly avg\_bucket: string; readonly max\_bucket: string; readonly min\_bucket: string; readonly sum\_bucket: string; readonly cardinality: string; readonly count: string; readonly cumulative\_sum: string; readonly derivative: string; readonly geo\_bounds: string; readonly geo\_centroid: string; readonly max: string; readonly median: string; readonly min: string; readonly moving\_avg: string; readonly percentile\_ranks: string; readonly serial\_diff: string; readonly std\_dev: string; readonly sum: string; readonly top\_hits: string; }; readonly runtimeFields: { readonly overview: string; readonly mapping: string; }; readonly scriptedFields: { readonly scriptFields: string; readonly scriptAggs: string; readonly painless: string; readonly painlessApi: string; readonly painlessLangSpec: string; readonly painlessSyntax: string; readonly painlessWalkthrough: string; readonly luceneExpressions: string; }; readonly search: { readonly sessions: string; readonly sessionLimits: string; }; readonly indexPatterns: { readonly introduction: string; readonly fieldFormattersNumber: string; readonly fieldFormattersString: string; readonly runtimeFields: string; }; readonly addData: string; readonly kibana: string; readonly upgradeAssistant: { readonly overview: string; readonly batchReindex: string; readonly remoteReindex: string; }; readonly rollupJobs: string; readonly elasticsearch: Record<string, string>; readonly siem: { readonly privileges: string; readonly guide: string; readonly gettingStarted: string; readonly ml: string; readonly ruleChangeLog: string; readonly detectionsReq: string; readonly networkMap: string; readonly troubleshootGaps: string; }; readonly securitySolution: { readonly trustedApps: string; }; readonly query: { readonly eql: string; readonly kueryQuerySyntax: string; readonly luceneQuerySyntax: string; readonly percolate: string; readonly queryDsl: string; }; readonly date: { readonly dateMath: string; readonly dateMathIndexNames: string; }; readonly management: Record<string, string>; readonly ml: Record<string, string>; readonly transforms: Record<string, string>; readonly visualize: Record<string, string>; readonly apis: Readonly<{ bulkIndexAlias: string; byteSizeUnits: string; createAutoFollowPattern: string; createFollower: string; createIndex: string; createSnapshotLifecyclePolicy: string; createRoleMapping: string; createRoleMappingTemplates: string; createRollupJobsRequest: string; createApiKey: string; createPipeline: string; createTransformRequest: string; cronExpressions: string; executeWatchActionModes: string; indexExists: string; openIndex: string; putComponentTemplate: string; painlessExecute: string; painlessExecuteAPIContexts: string; putComponentTemplateMetadata: string; putSnapshotLifecyclePolicy: string; putIndexTemplateV1: string; putWatch: string; simulatePipeline: string; timeUnits: string; updateTransform: string; }>; readonly observability: Readonly<{ guide: string; infrastructureThreshold: string; logsThreshold: string; metricsThreshold: string; monitorStatus: string; monitorUptime: string; tlsCertificate: string; uptimeDurationAnomaly: string; }>; readonly alerting: Record<string, string>; readonly maps: Readonly<{ guide: string; importGeospatialPrivileges: string; gdalTutorial: string; }>; readonly monitoring: Record<string, string>; readonly security: Readonly<{ apiKeyServiceSettings: string; clusterPrivileges: string; elasticsearchSettings: string; elasticsearchEnableSecurity: string; elasticsearchEnableApiKeys: string; indicesPrivileges: string; kibanaTLS: string; kibanaPrivileges: string; mappingRoles: string; mappingRolesFieldRules: string; runAsPrivilege: string; }>; readonly spaces: Readonly<{ kibanaLegacyUrlAliases: string; kibanaDisableLegacyUrlAliasesApi: string; }>; readonly watcher: Record<string, string>; readonly ccs: Record<string, string>; readonly plugins: Record<string, string>; readonly snapshotRestore: Record<string, string>; readonly ingest: Record<string, string>; readonly fleet: Readonly<{ beatsAgentComparison: string; guide: string; fleetServer: string; fleetServerAddFleetServer: string; settings: string; settingsFleetServerHostSettings: string; settingsFleetServerProxySettings: string; troubleshooting: string; elasticAgent: string; datastreams: string; datastreamsNamingScheme: string; installElasticAgent: string; installElasticAgentStandalone: string; upgradeElasticAgent: string; upgradeElasticAgent712lower: string; learnMoreBlog: string; apiKeysLearnMore: string; onPremRegistry: string; }>; readonly ecs: { readonly guide: string; }; readonly clients: { readonly guide: string; readonly goOverview: string; readonly javaIndex: string; readonly jsIntro: string; readonly netGuide: string; readonly perlGuide: string; readonly phpGuide: string; readonly pythonGuide: string; readonly rubyOverview: string; readonly rustGuide: string; }; readonly endpoints: { readonly troubleshooting: string; }; } | | +| [links](./kibana-plugin-core-public.doclinksstart.links.md) | { readonly settings: string; readonly elasticStackGetStarted: string; readonly upgrade: { readonly upgradingElasticStack: string; }; readonly apm: { readonly kibanaSettings: string; readonly supportedServiceMaps: string; readonly customLinks: string; readonly droppedTransactionSpans: string; readonly upgrading: string; readonly metaData: string; }; readonly canvas: { readonly guide: string; }; readonly cloud: { readonly indexManagement: string; }; readonly dashboard: { readonly guide: string; readonly drilldowns: string; readonly drilldownsTriggerPicker: string; readonly urlDrilldownTemplateSyntax: string; readonly urlDrilldownVariables: string; }; readonly discover: Record<string, string>; readonly filebeat: { readonly base: string; readonly installation: string; readonly configuration: string; readonly elasticsearchOutput: string; readonly elasticsearchModule: string; readonly startup: string; readonly exportedFields: string; readonly suricataModule: string; readonly zeekModule: string; }; readonly auditbeat: { readonly base: string; readonly auditdModule: string; readonly systemModule: string; }; readonly metricbeat: { readonly base: string; readonly configure: string; readonly httpEndpoint: string; readonly install: string; readonly start: string; }; readonly appSearch: { readonly apiRef: string; readonly apiClients: string; readonly apiKeys: string; readonly authentication: string; readonly crawlRules: string; readonly curations: string; readonly duplicateDocuments: string; readonly entryPoints: string; readonly guide: string; readonly indexingDocuments: string; readonly indexingDocumentsSchema: string; readonly logSettings: string; readonly metaEngines: string; readonly recisionTuning: string; readonly relevanceTuning: string; readonly resultSettings: string; readonly searchUI: string; readonly security: string; readonly synonyms: string; readonly webCrawler: string; readonly webCrawlerEventLogs: string; }; readonly enterpriseSearch: { readonly configuration: string; readonly licenseManagement: string; readonly mailService: string; readonly usersAccess: string; }; readonly workplaceSearch: { readonly apiKeys: string; readonly box: string; readonly confluenceCloud: string; readonly confluenceServer: string; readonly customSources: string; readonly customSourcePermissions: string; readonly documentPermissions: string; readonly dropbox: string; readonly externalIdentities: string; readonly gitHub: string; readonly gettingStarted: string; readonly gmail: string; readonly googleDrive: string; readonly indexingSchedule: string; readonly jiraCloud: string; readonly jiraServer: string; readonly oneDrive: string; readonly permissions: string; readonly salesforce: string; readonly security: string; readonly serviceNow: string; readonly sharePoint: string; readonly slack: string; readonly synch: string; readonly zendesk: string; }; readonly heartbeat: { readonly base: string; }; readonly libbeat: { readonly getStarted: string; }; readonly logstash: { readonly base: string; }; readonly functionbeat: { readonly base: string; }; readonly winlogbeat: { readonly base: string; }; readonly aggs: { readonly composite: string; readonly composite\_missing\_bucket: string; readonly date\_histogram: string; readonly date\_range: string; readonly date\_format\_pattern: string; readonly filter: string; readonly filters: string; readonly geohash\_grid: string; readonly histogram: string; readonly ip\_range: string; readonly range: string; readonly significant\_terms: string; readonly terms: string; readonly terms\_doc\_count\_error: string; readonly avg: string; readonly avg\_bucket: string; readonly max\_bucket: string; readonly min\_bucket: string; readonly sum\_bucket: string; readonly cardinality: string; readonly count: string; readonly cumulative\_sum: string; readonly derivative: string; readonly geo\_bounds: string; readonly geo\_centroid: string; readonly max: string; readonly median: string; readonly min: string; readonly moving\_avg: string; readonly percentile\_ranks: string; readonly serial\_diff: string; readonly std\_dev: string; readonly sum: string; readonly top\_hits: string; }; readonly runtimeFields: { readonly overview: string; readonly mapping: string; }; readonly scriptedFields: { readonly scriptFields: string; readonly scriptAggs: string; readonly painless: string; readonly painlessApi: string; readonly painlessLangSpec: string; readonly painlessSyntax: string; readonly painlessWalkthrough: string; readonly luceneExpressions: string; }; readonly search: { readonly sessions: string; readonly sessionLimits: string; }; readonly indexPatterns: { readonly introduction: string; readonly fieldFormattersNumber: string; readonly fieldFormattersString: string; readonly runtimeFields: string; }; readonly addData: string; readonly kibana: string; readonly upgradeAssistant: { readonly overview: string; readonly batchReindex: string; readonly remoteReindex: string; }; readonly rollupJobs: string; readonly elasticsearch: Record<string, string>; readonly siem: { readonly privileges: string; readonly guide: string; readonly gettingStarted: string; readonly ml: string; readonly ruleChangeLog: string; readonly detectionsReq: string; readonly networkMap: string; readonly troubleshootGaps: string; }; readonly securitySolution: { readonly trustedApps: string; }; readonly query: { readonly eql: string; readonly kueryQuerySyntax: string; readonly luceneQuerySyntax: string; readonly percolate: string; readonly queryDsl: string; }; readonly date: { readonly dateMath: string; readonly dateMathIndexNames: string; }; readonly management: Record<string, string>; readonly ml: Record<string, string>; readonly transforms: Record<string, string>; readonly visualize: Record<string, string>; readonly apis: Readonly<{ bulkIndexAlias: string; byteSizeUnits: string; createAutoFollowPattern: string; createFollower: string; createIndex: string; createSnapshotLifecyclePolicy: string; createRoleMapping: string; createRoleMappingTemplates: string; createRollupJobsRequest: string; createApiKey: string; createPipeline: string; createTransformRequest: string; cronExpressions: string; executeWatchActionModes: string; indexExists: string; openIndex: string; putComponentTemplate: string; painlessExecute: string; painlessExecuteAPIContexts: string; putComponentTemplateMetadata: string; putSnapshotLifecyclePolicy: string; putIndexTemplateV1: string; putWatch: string; simulatePipeline: string; timeUnits: string; updateTransform: string; }>; readonly observability: Readonly<{ guide: string; infrastructureThreshold: string; logsThreshold: string; metricsThreshold: string; monitorStatus: string; monitorUptime: string; tlsCertificate: string; uptimeDurationAnomaly: string; }>; readonly alerting: Record<string, string>; readonly maps: Readonly<{ guide: string; importGeospatialPrivileges: string; gdalTutorial: string; }>; readonly monitoring: Record<string, string>; readonly security: Readonly<{ apiKeyServiceSettings: string; clusterPrivileges: string; elasticsearchSettings: string; elasticsearchEnableSecurity: string; elasticsearchEnableApiKeys: string; indicesPrivileges: string; kibanaTLS: string; kibanaPrivileges: string; mappingRoles: string; mappingRolesFieldRules: string; runAsPrivilege: string; }>; readonly spaces: Readonly<{ kibanaLegacyUrlAliases: string; kibanaDisableLegacyUrlAliasesApi: string; }>; readonly watcher: Record<string, string>; readonly ccs: Record<string, string>; readonly plugins: Record<string, string>; readonly snapshotRestore: Record<string, string>; readonly ingest: Record<string, string>; readonly fleet: Readonly<{ beatsAgentComparison: string; guide: string; fleetServer: string; fleetServerAddFleetServer: string; settings: string; settingsFleetServerHostSettings: string; settingsFleetServerProxySettings: string; troubleshooting: string; elasticAgent: string; datastreams: string; datastreamsNamingScheme: string; installElasticAgent: string; installElasticAgentStandalone: string; upgradeElasticAgent: string; upgradeElasticAgent712lower: string; learnMoreBlog: string; apiKeysLearnMore: string; onPremRegistry: string; }>; readonly ecs: { readonly guide: string; }; readonly clients: { readonly guide: string; readonly goOverview: string; readonly javaIndex: string; readonly jsIntro: string; readonly netGuide: string; readonly perlGuide: string; readonly phpGuide: string; readonly pythonGuide: string; readonly rubyOverview: string; readonly rustGuide: string; }; readonly endpoints: { readonly troubleshooting: string; }; } | | diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 24c085ef64de3..692367cd0f580 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -113,6 +113,7 @@ export class DocLinksService { usersAccess: `${ENTERPRISE_SEARCH_DOCS}users-access.html`, }, workplaceSearch: { + apiKeys: `${WORKPLACE_SEARCH_DOCS}workplace-search-api-authentication.html`, box: `${WORKPLACE_SEARCH_DOCS}workplace-search-box-connector.html`, confluenceCloud: `${WORKPLACE_SEARCH_DOCS}workplace-search-confluence-cloud-connector.html`, confluenceServer: `${WORKPLACE_SEARCH_DOCS}workplace-search-confluence-server-connector.html`, @@ -671,6 +672,7 @@ export interface DocLinksStart { readonly usersAccess: string; }; readonly workplaceSearch: { + readonly apiKeys: string; readonly box: string; readonly confluenceCloud: string; readonly confluenceServer: string; diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 30225acb3dd8d..08d41ab1301b0 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -571,6 +571,7 @@ export interface DocLinksStart { readonly usersAccess: string; }; readonly workplaceSearch: { + readonly apiKeys: string; readonly box: string; readonly confluenceCloud: string; readonly confluenceServer: string; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts b/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts index 8cd5b86314a2c..376941b018f6a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts @@ -34,6 +34,7 @@ class DocLinks { public enterpriseSearchMailService: string; public enterpriseSearchUsersAccess: string; public licenseManagement: string; + public workplaceSearchApiKeys: string; public workplaceSearchBox: string; public workplaceSearchConfluenceCloud: string; public workplaceSearchConfluenceServer: string; @@ -86,6 +87,7 @@ class DocLinks { this.enterpriseSearchMailService = ''; this.enterpriseSearchUsersAccess = ''; this.licenseManagement = ''; + this.workplaceSearchApiKeys = ''; this.workplaceSearchBox = ''; this.workplaceSearchConfluenceCloud = ''; this.workplaceSearchConfluenceServer = ''; @@ -139,6 +141,7 @@ class DocLinks { this.enterpriseSearchMailService = docLinks.links.enterpriseSearch.mailService; this.enterpriseSearchUsersAccess = docLinks.links.enterpriseSearch.usersAccess; this.licenseManagement = docLinks.links.enterpriseSearch.licenseManagement; + this.workplaceSearchApiKeys = docLinks.links.workplaceSearch.apiKeys; this.workplaceSearchBox = docLinks.links.workplaceSearch.box; this.workplaceSearchConfluenceCloud = docLinks.links.workplaceSearch.confluenceCloud; this.workplaceSearchConfluenceServer = docLinks.links.workplaceSearch.confluenceServer; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx index 65a6a798b032a..01df4bdd02d55 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.test.tsx @@ -49,6 +49,11 @@ describe('useWorkplaceSearchNav', () => { name: 'Users and roles', href: '/users_and_roles', }, + { + id: 'apiKeys', + name: 'API keys', + href: '/api_keys', + }, { id: 'security', name: 'Security', diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx index 7dc005a56bf10..05ec569dcd292 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx @@ -10,6 +10,7 @@ import { EuiSideNavItemType } from '@elastic/eui'; import { generateNavLink } from '../../../shared/layout'; import { NAV } from '../../constants'; import { + API_KEYS_PATH, SOURCES_PATH, SECURITY_PATH, USERS_AND_ROLES_PATH, @@ -47,6 +48,11 @@ export const useWorkplaceSearchNav = () => { name: NAV.ROLE_MAPPINGS, ...generateNavLink({ to: USERS_AND_ROLES_PATH }), }, + { + id: 'apiKeys', + name: NAV.API_KEYS, + ...generateNavLink({ to: API_KEYS_PATH }), + }, { id: 'security', name: NAV.SECURITY, diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts index 43da4ccef223a..2de3ad91e7f37 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts @@ -42,6 +42,9 @@ export const NAV = { ROLE_MAPPINGS: i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.roleMappings', { defaultMessage: 'Users and roles', }), + API_KEYS: i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.apiKeys', { + defaultMessage: 'API keys', + }), SECURITY: i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.security', { defaultMessage: 'Security', }), @@ -329,6 +332,20 @@ export const SOURCE_OBJ_TYPES = { ), }; +export const API_KEYS_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.apiKeysTitle', + { + defaultMessage: 'API keys', + } +); + +export const API_KEY_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.apiKeyLabel', + { + defaultMessage: 'API key', + } +); + export const GITHUB_LINK_TITLE = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.sources.applicationLinkTitles.github', { @@ -863,3 +880,14 @@ export const PLATINUM_FEATURE = i18n.translate( defaultMessage: 'Platinum feature', } ); + +export const COPY_TOOLTIP = i18n.translate('xpack.enterpriseSearch.workplaceSearch.copy.tooltip', { + defaultMessage: 'Copy to clipboard', +}); + +export const COPIED_TOOLTIP = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.copied.tooltip', + { + defaultMessage: 'Copied!', + } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx index 2b24e09f96315..e7ffabd54a88c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx @@ -28,11 +28,13 @@ import { PRIVATE_SOURCES_PATH, ORG_SETTINGS_PATH, USERS_AND_ROLES_PATH, + API_KEYS_PATH, SECURITY_PATH, PERSONAL_SETTINGS_PATH, PERSONAL_PATH, } from './routes'; import { AccountSettings } from './views/account_settings'; +import { ApiKeys } from './views/api_keys'; import { SourcesRouter } from './views/content_sources'; import { SourceAdded } from './views/content_sources/components/source_added'; import { ErrorState } from './views/error_state'; @@ -133,6 +135,9 @@ export const WorkplaceSearchConfigured: React.FC = (props) => { + + + diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts index 5f3c79f9432e7..828cc0e749b07 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts @@ -53,6 +53,8 @@ export const SEARCH_AUTHORIZE_PATH = `${PERSONAL_PATH}/authorize_search`; export const USERS_AND_ROLES_PATH = '/users_and_roles'; +export const API_KEYS_PATH = '/api_keys'; + export const SECURITY_PATH = '/security'; export const GROUPS_PATH = '/groups'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts index 0fa8c00409d1a..0c4d6d986caab 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts @@ -295,3 +295,9 @@ export interface WSRoleMapping extends RoleMapping { allGroups: boolean; groups: RoleGroup[]; } + +export interface ApiToken { + key?: string; + id?: string; + name: string; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/api_keys/api_keys.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/api_keys/api_keys.test.tsx new file mode 100644 index 0000000000000..caea725ca67a9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/api_keys/api_keys.test.tsx @@ -0,0 +1,102 @@ +/* + * 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 { setMockValues, setMockActions } from '../../../__mocks__/kea_logic'; +import { unmountHandler } from '../../../__mocks__/shallow_useeffect.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiEmptyPrompt, EuiCopy } from '@elastic/eui'; + +import { DEFAULT_META } from '../../../shared/constants'; +import { externalUrl } from '../../../shared/enterprise_search_url'; + +import { ApiKeys } from './api_keys'; +import { ApiKeyFlyout } from './components/api_key_flyout'; +import { ApiKeysList } from './components/api_keys_list'; + +describe('ApiKeys', () => { + const fetchApiKeys = jest.fn(); + const resetApiKeys = jest.fn(); + const showApiKeysForm = jest.fn(); + const apiToken = { + id: '1', + name: 'test', + key: 'foo', + }; + + const values = { + apiKeyFormVisible: false, + meta: DEFAULT_META, + dataLoading: false, + apiTokens: [apiToken], + }; + + beforeEach(() => { + jest.clearAllMocks(); + setMockValues(values); + setMockActions({ + fetchApiKeys, + resetApiKeys, + showApiKeysForm, + }); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(ApiKeysList)).toHaveLength(1); + }); + + it('renders EuiEmptyPrompt when no api keys present', () => { + setMockValues({ ...values, apiTokens: [] }); + const wrapper = shallow(); + + expect(wrapper.find(ApiKeysList)).toHaveLength(0); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + }); + + it('fetches data on mount', () => { + shallow(); + + expect(fetchApiKeys).toHaveBeenCalledTimes(1); + }); + + it('calls resetApiKeys on unmount', () => { + shallow(); + unmountHandler(); + + expect(resetApiKeys).toHaveBeenCalledTimes(1); + }); + + it('renders the API endpoint and a button to copy it', () => { + externalUrl.enterpriseSearchUrl = 'http://localhost:3002'; + const copyMock = jest.fn(); + const wrapper = shallow(); + // We wrap children in a div so that `shallow` can render it. + const copyEl = shallow(
{wrapper.find(EuiCopy).props().children(copyMock)}
); + + expect(copyEl.find('EuiButtonIcon').props().onClick).toEqual(copyMock); + expect(copyEl.text().replace('', '')).toEqual('http://localhost:3002'); + }); + + it('will render ApiKeyFlyout if apiKeyFormVisible is true', () => { + setMockValues({ ...values, apiKeyFormVisible: true }); + const wrapper = shallow(); + + expect(wrapper.find(ApiKeyFlyout)).toHaveLength(1); + }); + + it('will NOT render ApiKeyFlyout if apiKeyFormVisible is false', () => { + setMockValues({ ...values, apiKeyFormVisible: false }); + const wrapper = shallow(); + + expect(wrapper.find(ApiKeyFlyout)).toHaveLength(0); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/api_keys/api_keys.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/api_keys/api_keys.tsx new file mode 100644 index 0000000000000..dd20020c619c8 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/api_keys/api_keys.tsx @@ -0,0 +1,109 @@ +/* + * 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 React, { useEffect } from 'react'; + +import { useActions, useValues } from 'kea'; + +import { + EuiButton, + EuiTitle, + EuiPanel, + EuiCopy, + EuiButtonIcon, + EuiSpacer, + EuiEmptyPrompt, +} from '@elastic/eui'; + +import { docLinks } from '../../../shared/doc_links'; +import { externalUrl } from '../../../shared/enterprise_search_url/external_url'; + +import { WorkplaceSearchPageTemplate } from '../../components/layout'; +import { NAV, API_KEYS_TITLE } from '../../constants'; + +import { ApiKeysLogic } from './api_keys_logic'; +import { ApiKeyFlyout } from './components/api_key_flyout'; +import { ApiKeysList } from './components/api_keys_list'; +import { + API_KEYS_EMPTY_TITLE, + API_KEYS_EMPTY_BODY, + API_KEYS_EMPTY_BUTTON_LABEL, + CREATE_KEY_BUTTON_LABEL, + ENDPOINT_TITLE, + COPIED_TOOLTIP, + COPY_API_ENDPOINT_BUTTON_LABEL, +} from './constants'; + +export const ApiKeys: React.FC = () => { + const { fetchApiKeys, resetApiKeys, showApiKeyForm } = useActions(ApiKeysLogic); + + const { meta, dataLoading, apiKeyFormVisible, apiTokens } = useValues(ApiKeysLogic); + + useEffect(() => { + fetchApiKeys(); + return resetApiKeys; + }, [meta.page.current]); + + const hasApiKeys = apiTokens.length > 0; + + const addKeyButton = ( + + {CREATE_KEY_BUTTON_LABEL} + + ); + + const emptyPrompt = ( + {API_KEYS_EMPTY_TITLE}} + body={API_KEYS_EMPTY_BODY} + actions={ + + {API_KEYS_EMPTY_BUTTON_LABEL} + + } + /> + ); + + return ( + + {apiKeyFormVisible && } + + +

{ENDPOINT_TITLE}

+
+ + {(copy) => ( + <> + + {externalUrl.enterpriseSearchUrl} + + )} + +
+ + {hasApiKeys ? : emptyPrompt} +
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/api_keys/api_keys_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/api_keys/api_keys_logic.test.ts new file mode 100644 index 0000000000000..a02b1578bd38a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/api_keys/api_keys_logic.test.ts @@ -0,0 +1,491 @@ +/* + * 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 { + LogicMounter, + mockFlashMessageHelpers, + mockHttpValues, +} from '../../../__mocks__/kea_logic'; + +import { nextTick } from '@kbn/test/jest'; + +import { DEFAULT_META } from '../../../shared/constants'; +import { itShowsServerErrorAsFlashMessage } from '../../../test_helpers'; + +import { ApiKeysLogic } from './api_keys_logic'; + +describe('ApiKeysLogic', () => { + const { mount } = new LogicMounter(ApiKeysLogic); + const { http } = mockHttpValues; + const { clearFlashMessages, flashSuccessToast } = mockFlashMessageHelpers; + + const DEFAULT_VALUES = { + dataLoading: true, + apiTokens: [], + meta: DEFAULT_META, + nameInputBlurred: false, + activeApiToken: { + name: '', + }, + activeApiTokenRawName: '', + apiKeyFormVisible: false, + apiTokenNameToDelete: '', + deleteModalVisible: false, + formErrors: [], + }; + + const newToken = { + id: '1', + name: 'myToken', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('has expected default values', () => { + mount(); + expect(ApiKeysLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('actions', () => { + describe('onApiTokenCreateSuccess', () => { + const values = { + ...DEFAULT_VALUES, + apiTokens: expect.any(Array), + activeApiToken: expect.any(Object), + activeApiTokenRawName: expect.any(String), + apiKeyFormVisible: expect.any(Boolean), + formErrors: expect.any(Array), + }; + + describe('apiTokens', () => { + const existingToken = { + name: 'some_token', + }; + + it('should add the provided token to the apiTokens list', () => { + mount({ + apiTokens: [existingToken], + }); + + ApiKeysLogic.actions.onApiTokenCreateSuccess(newToken); + expect(ApiKeysLogic.values).toEqual({ + ...values, + apiTokens: [existingToken, newToken], + }); + }); + }); + + describe('activeApiToken', () => { + it('should reset to the default value, which effectively clears out the current form', () => { + mount({ + activeApiToken: newToken, + }); + + ApiKeysLogic.actions.onApiTokenCreateSuccess(newToken); + expect(ApiKeysLogic.values).toEqual({ + ...values, + activeApiToken: DEFAULT_VALUES.activeApiToken, + }); + }); + }); + + describe('activeApiTokenRawName', () => { + it('should reset to the default value, which effectively clears out the current form', () => { + mount({ + activeApiTokenRawName: 'foo', + }); + + ApiKeysLogic.actions.onApiTokenCreateSuccess(newToken); + expect(ApiKeysLogic.values).toEqual({ + ...values, + activeApiTokenRawName: DEFAULT_VALUES.activeApiTokenRawName, + }); + }); + }); + + describe('apiKeyFormVisible', () => { + it('should reset to the default value, which closes the api key form', () => { + mount({ + apiKeyFormVisible: true, + }); + + ApiKeysLogic.actions.onApiTokenCreateSuccess(newToken); + expect(ApiKeysLogic.values).toEqual({ + ...values, + apiKeyFormVisible: false, + }); + }); + }); + + describe('deleteModalVisible', () => { + const tokenName = 'my-token'; + + it('should set deleteModalVisible to true and set apiTokenNameToDelete', () => { + ApiKeysLogic.actions.stageTokenNameForDeletion(tokenName); + + expect(ApiKeysLogic.values).toEqual({ + ...values, + deleteModalVisible: true, + apiTokenNameToDelete: tokenName, + }); + }); + + it('should set deleteModalVisible to false and reset apiTokenNameToDelete', () => { + mount({ + deleteModalVisible: true, + apiTokenNameToDelete: tokenName, + }); + ApiKeysLogic.actions.hideDeleteModal(); + + expect(ApiKeysLogic.values).toEqual(values); + }); + }); + + describe('formErrors', () => { + it('should reset `formErrors`', () => { + mount({ + formErrors: ['I am an error'], + }); + + ApiKeysLogic.actions.onApiTokenCreateSuccess(newToken); + expect(ApiKeysLogic.values).toEqual({ + ...values, + formErrors: [], + }); + }); + }); + }); + + describe('onApiTokenError', () => { + const values = { + ...DEFAULT_VALUES, + formErrors: expect.any(Array), + }; + + describe('formErrors', () => { + it('should set `formErrors`', () => { + mount({ + formErrors: ['I am an error'], + }); + + ApiKeysLogic.actions.onApiTokenError(['I am the NEW error']); + expect(ApiKeysLogic.values).toEqual({ + ...values, + formErrors: ['I am the NEW error'], + }); + }); + }); + }); + + describe('setApiKeysData', () => { + const meta = { + page: { + current: 1, + size: 1, + total_pages: 1, + total_results: 1, + }, + }; + + const values = { + ...DEFAULT_VALUES, + dataLoading: false, + apiTokens: expect.any(Array), + meta: expect.any(Object), + }; + + describe('apiTokens', () => { + it('should be set', () => { + mount(); + + ApiKeysLogic.actions.setApiKeysData(meta, [newToken, newToken]); + expect(ApiKeysLogic.values).toEqual({ + ...values, + apiTokens: [newToken, newToken], + }); + }); + }); + + describe('meta', () => { + it('should be set', () => { + mount(); + + ApiKeysLogic.actions.setApiKeysData(meta, [newToken, newToken]); + expect(ApiKeysLogic.values).toEqual({ + ...values, + meta, + }); + }); + }); + }); + + describe('setNameInputBlurred', () => { + const values = { + ...DEFAULT_VALUES, + nameInputBlurred: expect.any(Boolean), + }; + + describe('nameInputBlurred', () => { + it('should set this value', () => { + mount({ + nameInputBlurred: false, + }); + + ApiKeysLogic.actions.setNameInputBlurred(true); + expect(ApiKeysLogic.values).toEqual({ + ...values, + nameInputBlurred: true, + }); + }); + }); + }); + + describe('setApiKeyName', () => { + const values = { + ...DEFAULT_VALUES, + activeApiToken: expect.any(Object), + activeApiTokenRawName: expect.any(String), + }; + + describe('activeApiToken', () => { + it('update the name property on the activeApiToken, formatted correctly', () => { + mount({ + activeApiToken: { + ...newToken, + name: 'bar', + }, + }); + + ApiKeysLogic.actions.setApiKeyName('New Name'); + expect(ApiKeysLogic.values).toEqual({ + ...values, + activeApiToken: { ...newToken, name: 'new-name' }, + }); + }); + }); + + describe('activeApiTokenRawName', () => { + it('updates the raw name, with no formatting applied', () => { + mount(); + + ApiKeysLogic.actions.setApiKeyName('New Name'); + expect(ApiKeysLogic.values).toEqual({ + ...values, + activeApiTokenRawName: 'New Name', + }); + }); + }); + }); + + describe('showApiKeyForm', () => { + const values = { + ...DEFAULT_VALUES, + activeApiToken: expect.any(Object), + activeApiTokenRawName: expect.any(String), + formErrors: expect.any(Array), + apiKeyFormVisible: expect.any(Boolean), + }; + + describe('apiKeyFormVisible', () => { + it('should toggle `apiKeyFormVisible`', () => { + mount({ + apiKeyFormVisible: false, + }); + + ApiKeysLogic.actions.showApiKeyForm(); + expect(ApiKeysLogic.values).toEqual({ + ...values, + apiKeyFormVisible: true, + }); + }); + }); + + describe('formErrors', () => { + it('should reset `formErrors`', () => { + mount({ + formErrors: ['I am an error'], + }); + + ApiKeysLogic.actions.showApiKeyForm(); + expect(ApiKeysLogic.values).toEqual({ + ...values, + formErrors: [], + }); + }); + }); + + describe('listener side-effects', () => { + it('should clear flashMessages whenever the api key form flyout is opened', () => { + ApiKeysLogic.actions.showApiKeyForm(); + expect(clearFlashMessages).toHaveBeenCalled(); + }); + }); + }); + + describe('hideApiKeyForm', () => { + const values = { + ...DEFAULT_VALUES, + apiKeyFormVisible: expect.any(Boolean), + activeApiTokenRawName: expect.any(String), + }; + + describe('activeApiTokenRawName', () => { + it('resets this value', () => { + mount({ + activeApiTokenRawName: 'foo', + }); + + ApiKeysLogic.actions.hideApiKeyForm(); + expect(ApiKeysLogic.values).toEqual({ + ...values, + activeApiTokenRawName: '', + }); + }); + }); + + describe('apiKeyFormVisible', () => { + it('resets this value', () => { + mount({ + apiKeyFormVisible: true, + }); + + ApiKeysLogic.actions.hideApiKeyForm(); + expect(ApiKeysLogic.values).toEqual({ + ...values, + apiKeyFormVisible: false, + }); + }); + }); + }); + + describe('resetApiKeys', () => { + const values = { + ...DEFAULT_VALUES, + formErrors: expect.any(Array), + }; + + describe('formErrors', () => { + it('should reset', () => { + mount({ + formErrors: ['I am an error'], + }); + + ApiKeysLogic.actions.resetApiKeys(); + expect(ApiKeysLogic.values).toEqual({ + ...values, + formErrors: [], + }); + }); + }); + }); + + describe('onPaginate', () => { + it('should set meta.page.current', () => { + mount({ meta: DEFAULT_META }); + + ApiKeysLogic.actions.onPaginate(5); + expect(ApiKeysLogic.values).toEqual({ + ...DEFAULT_VALUES, + meta: { + page: { + ...DEFAULT_META.page, + current: 5, + }, + }, + }); + }); + }); + }); + + describe('listeners', () => { + describe('fetchApiKeys', () => { + const meta = { + page: { + current: 1, + size: 1, + total_pages: 1, + total_results: 1, + }, + }; + const results: object[] = []; + + it('will call an API endpoint and set the results with the `setApiKeysData` action', async () => { + mount(); + jest.spyOn(ApiKeysLogic.actions, 'setApiKeysData').mockImplementationOnce(() => {}); + http.get.mockReturnValue(Promise.resolve({ meta, results })); + + ApiKeysLogic.actions.fetchApiKeys(); + expect(http.get).toHaveBeenCalledWith('/internal/workplace_search/api_keys', { + query: { + 'page[current]': 1, + 'page[size]': 10, + }, + }); + await nextTick(); + expect(ApiKeysLogic.actions.setApiKeysData).toHaveBeenCalledWith(meta, results); + }); + + itShowsServerErrorAsFlashMessage(http.get, () => { + mount(); + ApiKeysLogic.actions.fetchApiKeys(); + }); + }); + + describe('deleteApiKey', () => { + const tokenName = 'abc123'; + + it('will call an API endpoint and re-fetch the api keys list', async () => { + mount(); + jest.spyOn(ApiKeysLogic.actions, 'fetchApiKeys').mockImplementationOnce(() => {}); + http.delete.mockReturnValue(Promise.resolve()); + + ApiKeysLogic.actions.stageTokenNameForDeletion(tokenName); + ApiKeysLogic.actions.deleteApiKey(); + expect(http.delete).toHaveBeenCalledWith( + `/internal/workplace_search/api_keys/${tokenName}` + ); + await nextTick(); + + expect(ApiKeysLogic.actions.fetchApiKeys).toHaveBeenCalled(); + expect(flashSuccessToast).toHaveBeenCalled(); + }); + + itShowsServerErrorAsFlashMessage(http.delete, () => { + mount(); + ApiKeysLogic.actions.deleteApiKey(); + }); + }); + + describe('onApiFormSubmit', () => { + it('calls a POST API endpoint that creates a new token if the active token does not exist yet', async () => { + const createdToken = { + name: 'new-key', + }; + mount({ + activeApiToken: createdToken, + }); + jest.spyOn(ApiKeysLogic.actions, 'onApiTokenCreateSuccess'); + http.post.mockReturnValue(Promise.resolve(createdToken)); + + ApiKeysLogic.actions.onApiFormSubmit(); + expect(http.post).toHaveBeenCalledWith('/internal/workplace_search/api_keys', { + body: JSON.stringify(createdToken), + }); + await nextTick(); + expect(ApiKeysLogic.actions.onApiTokenCreateSuccess).toHaveBeenCalledWith(createdToken); + expect(flashSuccessToast).toHaveBeenCalled(); + }); + + itShowsServerErrorAsFlashMessage(http.post, () => { + mount(); + ApiKeysLogic.actions.onApiFormSubmit(); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/api_keys/api_keys_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/api_keys/api_keys_logic.ts new file mode 100644 index 0000000000000..ca3662a8784c8 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/api_keys/api_keys_logic.ts @@ -0,0 +1,213 @@ +/* + * 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 { kea, MakeLogicType } from 'kea'; + +import { Meta } from '../../../../../common/types'; +import { DEFAULT_META } from '../../../shared/constants'; +import { + clearFlashMessages, + flashSuccessToast, + flashAPIErrors, +} from '../../../shared/flash_messages'; +import { HttpLogic } from '../../../shared/http'; +import { updateMetaPageIndex } from '../../../shared/table_pagination'; + +import { ApiToken } from '../../types'; + +import { CREATE_MESSAGE, DELETE_MESSAGE } from './constants'; + +const formatApiName = (rawName: string): string => + rawName + .trim() + .replace(/[^a-zA-Z0-9]+/g, '-') // Replace all special/non-alphanumerical characters with dashes + .replace(/^[-]+|[-]+$/g, '') // Strip all leading and trailing dashes + .toLowerCase(); + +export const defaultApiToken: ApiToken = { + name: '', +}; + +interface ApiKeysLogicActions { + onApiTokenCreateSuccess(apiToken: ApiToken): ApiToken; + onApiTokenError(formErrors: string[]): string[]; + setApiKeysData(meta: Meta, apiTokens: ApiToken[]): { meta: Meta; apiTokens: ApiToken[] }; + setNameInputBlurred(isBlurred: boolean): boolean; + setApiKeyName(name: string): string; + showApiKeyForm(): void; + hideApiKeyForm(): { value: boolean }; + resetApiKeys(): { value: boolean }; + fetchApiKeys(): void; + onPaginate(newPageIndex: number): { newPageIndex: number }; + deleteApiKey(): void; + onApiFormSubmit(): void; + stageTokenNameForDeletion(tokenName: string): string; + hideDeleteModal(): void; +} + +interface ApiKeysLogicValues { + activeApiToken: ApiToken; + activeApiTokenRawName: string; + apiTokens: ApiToken[]; + dataLoading: boolean; + formErrors: string[]; + meta: Meta; + nameInputBlurred: boolean; + apiKeyFormVisible: boolean; + deleteModalVisible: boolean; + apiTokenNameToDelete: string; +} + +export const ApiKeysLogic = kea>({ + path: ['enterprise_search', 'workplace_search', 'api_keys_logic'], + actions: () => ({ + onApiTokenCreateSuccess: (apiToken) => apiToken, + onApiTokenError: (formErrors) => formErrors, + setApiKeysData: (meta, apiTokens) => ({ meta, apiTokens }), + setNameInputBlurred: (nameInputBlurred) => nameInputBlurred, + setApiKeyName: (name) => name, + showApiKeyForm: true, + hideApiKeyForm: false, + resetApiKeys: false, + fetchApiKeys: true, + onPaginate: (newPageIndex) => ({ newPageIndex }), + deleteApiKey: true, + stageTokenNameForDeletion: (tokenName) => tokenName, + hideDeleteModal: true, + onApiFormSubmit: () => null, + }), + reducers: () => ({ + dataLoading: [ + true, + { + setApiKeysData: () => false, + }, + ], + apiTokens: [ + [], + { + setApiKeysData: (_, { apiTokens }) => apiTokens, + onApiTokenCreateSuccess: (apiTokens, apiToken) => [...apiTokens, apiToken], + }, + ], + meta: [ + DEFAULT_META, + { + setApiKeysData: (_, { meta }) => meta, + onPaginate: (state, { newPageIndex }) => updateMetaPageIndex(state, newPageIndex), + }, + ], + nameInputBlurred: [ + false, + { + setNameInputBlurred: (_, nameInputBlurred) => nameInputBlurred, + }, + ], + activeApiToken: [ + defaultApiToken, + { + onApiTokenCreateSuccess: () => defaultApiToken, + hideApiKeyForm: () => defaultApiToken, + setApiKeyName: (activeApiToken, name) => ({ ...activeApiToken, name: formatApiName(name) }), + }, + ], + activeApiTokenRawName: [ + '', + { + setApiKeyName: (_, activeApiTokenRawName) => activeApiTokenRawName, + hideApiKeyForm: () => '', + onApiTokenCreateSuccess: () => '', + }, + ], + apiKeyFormVisible: [ + false, + { + showApiKeyForm: () => true, + hideApiKeyForm: () => false, + onApiTokenCreateSuccess: () => false, + }, + ], + deleteModalVisible: [ + false, + { + stageTokenNameForDeletion: () => true, + hideDeleteModal: () => false, + }, + ], + apiTokenNameToDelete: [ + '', + { + stageTokenNameForDeletion: (_, tokenName) => tokenName, + hideDeleteModal: () => '', + }, + ], + formErrors: [ + [], + { + onApiTokenError: (_, formErrors) => formErrors, + onApiTokenCreateSuccess: () => [], + showApiKeyForm: () => [], + resetApiKeys: () => [], + }, + ], + }), + listeners: ({ actions, values }) => ({ + showApiKeyForm: () => { + clearFlashMessages(); + }, + fetchApiKeys: async () => { + try { + const { http } = HttpLogic.values; + const { meta } = values; + const query = { + 'page[current]': meta.page.current, + 'page[size]': meta.page.size, + }; + const response = await http.get<{ meta: Meta; results: ApiToken[] }>( + '/internal/workplace_search/api_keys', + { query } + ); + actions.setApiKeysData(response.meta, response.results); + } catch (e) { + flashAPIErrors(e); + } + }, + deleteApiKey: async () => { + const { apiTokenNameToDelete } = values; + + try { + const { http } = HttpLogic.values; + await http.delete(`/internal/workplace_search/api_keys/${apiTokenNameToDelete}`); + + actions.fetchApiKeys(); + flashSuccessToast(DELETE_MESSAGE(apiTokenNameToDelete)); + } catch (e) { + flashAPIErrors(e); + } finally { + actions.hideDeleteModal(); + } + }, + onApiFormSubmit: async () => { + const { name } = values.activeApiToken; + + const data: ApiToken = { + name, + }; + + try { + const { http } = HttpLogic.values; + const body = JSON.stringify(data); + + const response = await http.post('/internal/workplace_search/api_keys', { body }); + actions.onApiTokenCreateSuccess(response); + flashSuccessToast(CREATE_MESSAGE(name)); + } catch (e) { + flashAPIErrors(e); + } + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/api_keys/components/api_key.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/api_keys/components/api_key.test.tsx new file mode 100644 index 0000000000000..d99ab3f260c77 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/api_keys/components/api_key.test.tsx @@ -0,0 +1,59 @@ +/* + * 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 React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiButtonIcon } from '@elastic/eui'; + +import { ApiKey } from './api_key'; + +describe('ApiKey', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const props = { + copy: jest.fn(), + toggleIsHidden: jest.fn(), + isHidden: true, + text: 'some-api-key', + }; + + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiButtonIcon).length).toEqual(2); + }); + + it('will call copy when the first button is clicked', () => { + const wrapper = shallow(); + wrapper.find(EuiButtonIcon).first().simulate('click'); + expect(props.copy).toHaveBeenCalled(); + }); + + it('will call hide when the second button is clicked', () => { + const wrapper = shallow(); + wrapper.find(EuiButtonIcon).last().simulate('click'); + expect(props.toggleIsHidden).toHaveBeenCalled(); + }); + + it('will render the "eye" icon when isHidden is true', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiButtonIcon).last().prop('iconType')).toBe('eye'); + }); + + it('will render the "eyeClosed" icon when isHidden is false', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiButtonIcon).last().prop('iconType')).toBe('eyeClosed'); + }); + + it('will render the provided text', () => { + const wrapper = shallow(); + expect(wrapper.text()).toContain('some-api-key'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/api_keys/components/api_key.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/api_keys/components/api_key.tsx new file mode 100644 index 0000000000000..0ea24d9b684ea --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/api_keys/components/api_key.tsx @@ -0,0 +1,42 @@ +/* + * 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 React from 'react'; + +import { EuiButtonIcon } from '@elastic/eui'; + +import { SHOW_API_KEY_LABEL, HIDE_API_KEY_LABEL, COPY_API_KEY_BUTTON_LABEL } from '../constants'; + +interface Props { + copy: () => void; + toggleIsHidden: () => void; + isHidden: boolean; + text: React.ReactNode; +} + +export const ApiKey: React.FC = ({ copy, toggleIsHidden, isHidden, text }) => { + const hideIcon = isHidden ? 'eye' : 'eyeClosed'; + const hideIconLabel = isHidden ? SHOW_API_KEY_LABEL : HIDE_API_KEY_LABEL; + + return ( + <> + + + {text} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/api_keys/components/api_key_flyout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/api_keys/components/api_key_flyout.test.tsx new file mode 100644 index 0000000000000..e31ae94e968ce --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/api_keys/components/api_key_flyout.test.tsx @@ -0,0 +1,94 @@ +/* + * 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 { setMockValues, setMockActions } from '../../../../__mocks__/kea_logic'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiFlyout, EuiForm, EuiFieldText, EuiFormRow } from '@elastic/eui'; + +import { ApiKeyFlyout } from './api_key_flyout'; + +describe('ApiKeyFlyout', () => { + const setNameInputBlurred = jest.fn(); + const setApiKeyName = jest.fn(); + const onApiFormSubmit = jest.fn(); + const hideApiKeyForm = jest.fn(); + + const apiKey = { + id: '123', + name: 'test', + }; + + const values = { + activeApiToken: apiKey, + }; + + beforeEach(() => { + setMockValues(values); + setMockActions({ + setNameInputBlurred, + setApiKeyName, + onApiFormSubmit, + hideApiKeyForm, + }); + }); + + it('renders', () => { + const wrapper = shallow(); + const flyout = wrapper.find(EuiFlyout); + + expect(flyout).toHaveLength(1); + expect(flyout.prop('onClose')).toEqual(hideApiKeyForm); + }); + + it('calls onApiTokenChange on form submit', () => { + const wrapper = shallow(); + const preventDefault = jest.fn(); + wrapper.find(EuiForm).simulate('submit', { preventDefault }); + + expect(preventDefault).toHaveBeenCalled(); + expect(onApiFormSubmit).toHaveBeenCalled(); + }); + + it('shows help text if the raw name does not match the expected name', () => { + setMockValues({ + ...values, + activeApiToken: { name: 'my-api-key' }, + activeApiTokenRawName: 'my api key!!', + }); + const wrapper = shallow(); + + expect(wrapper.find(EuiFormRow).prop('helpText')).toEqual('Your key will be named: my-api-key'); + }); + + it('controls the input value', () => { + setMockValues({ + ...values, + activeApiTokenRawName: 'test', + }); + const wrapper = shallow(); + + expect(wrapper.find(EuiFieldText).prop('value')).toEqual('test'); + }); + + it('calls setApiKeyName when the input value is changed', () => { + const wrapper = shallow(); + wrapper.find(EuiFieldText).simulate('change', { target: { value: 'changed' } }); + + expect(setApiKeyName).toHaveBeenCalledWith('changed'); + }); + + it('calls setNameInputBlurred when the user stops focusing the input', () => { + const wrapper = shallow(); + wrapper.find(EuiFieldText).simulate('blur'); + + expect(setNameInputBlurred).toHaveBeenCalledWith(true); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/api_keys/components/api_key_flyout.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/api_keys/components/api_key_flyout.tsx new file mode 100644 index 0000000000000..150778ad7fdbc --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/api_keys/components/api_key_flyout.tsx @@ -0,0 +1,103 @@ +/* + * 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 React from 'react'; + +import { useValues, useActions } from 'kea'; + +import { + EuiPortal, + EuiFormRow, + EuiFieldText, + EuiFlyout, + EuiFlyoutHeader, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiButton, + EuiForm, + EuiTitle, +} from '@elastic/eui'; + +import { CLOSE_BUTTON_LABEL, SAVE_BUTTON_LABEL } from '../../../../shared/constants'; +import { FlashMessages } from '../../../../shared/flash_messages'; + +import { ApiKeysLogic } from '../api_keys_logic'; +import { + API_KEY_FLYOUT_TITLE, + API_KEY_FORM_LABEL, + API_KEY_FORM_HELP_TEXT, + API_KEY_NAME_PLACEHOLDER, +} from '../constants'; + +export const ApiKeyFlyout: React.FC = () => { + const { setNameInputBlurred, setApiKeyName, onApiFormSubmit, hideApiKeyForm } = + useActions(ApiKeysLogic); + const { + activeApiToken: { name }, + activeApiTokenRawName: rawName, + } = useValues(ApiKeysLogic); + + return ( + + + + +

{API_KEY_FLYOUT_TITLE}

+
+
+ + + { + e.preventDefault(); + onApiFormSubmit(); + }} + component="form" + > + + setApiKeyName(e.target.value)} + onBlur={() => setNameInputBlurred(true)} + autoComplete="off" + maxLength={64} + required + fullWidth + autoFocus + /> + + + + + + + + {CLOSE_BUTTON_LABEL} + + + + + {SAVE_BUTTON_LABEL} + + + + +
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/api_keys/components/api_keys_list.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/api_keys/components/api_keys_list.test.tsx new file mode 100644 index 0000000000000..3dd300d0eb5c5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/api_keys/components/api_keys_list.test.tsx @@ -0,0 +1,193 @@ +/* + * 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 { setMockValues, setMockActions } from '../../../../__mocks__/kea_logic'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiBasicTable, EuiCopy, EuiConfirmModal } from '@elastic/eui'; + +import { HiddenText } from '../../../../shared/hidden_text'; + +import { ApiKey } from './api_key'; +import { ApiKeysList } from './api_keys_list'; + +describe('ApiKeysList', () => { + const stageTokenNameForDeletion = jest.fn(); + const hideDeleteModal = jest.fn(); + const deleteApiKey = jest.fn(); + const onPaginate = jest.fn(); + const apiToken = { + id: '1', + name: 'test', + key: 'foo', + }; + const apiTokens = [apiToken]; + const meta = { + page: { + current: 1, + size: 10, + total_pages: 1, + total_results: 5, + }, + }; + + const values = { apiTokens, meta, dataLoading: false }; + + beforeEach(() => { + setMockValues(values); + setMockActions({ deleteApiKey, onPaginate, stageTokenNameForDeletion, hideDeleteModal }); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiBasicTable)).toHaveLength(1); + }); + + describe('loading state', () => { + it('renders as loading when dataLoading is true', () => { + setMockValues({ + ...values, + dataLoading: true, + }); + const wrapper = shallow(); + + expect(wrapper.find(EuiBasicTable).prop('loading')).toBe(true); + }); + }); + + describe('pagination', () => { + it('derives pagination from meta object', () => { + setMockValues({ + ...values, + meta: { + page: { + current: 6, + size: 55, + total_pages: 1, + total_results: 1004, + }, + }, + }); + const wrapper = shallow(); + const { pagination } = wrapper.find(EuiBasicTable).props(); + + expect(pagination).toEqual({ + pageIndex: 5, + pageSize: 55, + totalItemCount: 1004, + hidePerPageOptions: true, + }); + }); + }); + + it('handles confirmModal submission', () => { + setMockValues({ + ...values, + deleteModalVisible: true, + }); + const wrapper = shallow(); + const modal = wrapper.find(EuiConfirmModal); + modal.prop('onConfirm')!({} as any); + + expect(deleteApiKey).toHaveBeenCalled(); + }); + + describe('columns', () => { + let columns: any[]; + + beforeAll(() => { + setMockValues(values); + const wrapper = shallow(); + columns = wrapper.find(EuiBasicTable).props().columns; + }); + + describe('column 1 (name)', () => { + const token = { + ...apiToken, + name: 'some-name', + }; + + it('renders correctly', () => { + const column = columns[0]; + const wrapper = shallow(
{column.render(token)}
); + + expect(wrapper.text()).toEqual('some-name'); + }); + }); + + describe('column 2 (key)', () => { + const token = { + ...apiToken, + key: 'abc-123', + }; + + it('renders nothing if no key is present', () => { + const tokenWithNoKey = { + key: undefined, + }; + const column = columns[1]; + const wrapper = shallow(
{column.render(tokenWithNoKey)}
); + + expect(wrapper.text()).toBe(''); + }); + + it('renders an EuiCopy component with the key', () => { + const column = columns[1]; + const wrapper = shallow(
{column.render(token)}
); + + expect(wrapper.find(EuiCopy).props().textToCopy).toEqual('abc-123'); + }); + + it('renders a HiddenText component with the key', () => { + const column = columns[1]; + const wrapper = shallow(
{column.render(token)}
) + .find(EuiCopy) + .dive(); + + expect(wrapper.find(HiddenText).props().text).toEqual('abc-123'); + }); + + it('renders a Key component', () => { + const column = columns[1]; + const wrapper = shallow(
{column.render(token)}
) + .find(EuiCopy) + .dive() + .find(HiddenText) + .dive(); + + expect(wrapper.find(ApiKey).props()).toEqual({ + copy: expect.any(Function), + toggleIsHidden: expect.any(Function), + isHidden: expect.any(Boolean), + text: ( + + ••••••• + + ), + }); + }); + }); + + describe('column 3 (delete action)', () => { + const token = { + ...apiToken, + name: 'some-name', + }; + + it('calls stageTokenNameForDeletion when clicked', () => { + const action = columns[2].actions[0]; + action.onClick(token); + + expect(stageTokenNameForDeletion).toHaveBeenCalledWith('some-name'); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/api_keys/components/api_keys_list.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/api_keys/components/api_keys_list.tsx new file mode 100644 index 0000000000000..5a79e965454b2 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/api_keys/components/api_keys_list.tsx @@ -0,0 +1,112 @@ +/* + * 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 React from 'react'; + +import { useActions, useValues } from 'kea'; + +import { EuiBasicTable, EuiBasicTableColumn, EuiCopy, EuiConfirmModal } from '@elastic/eui'; + +import { DELETE_BUTTON_LABEL, CANCEL_BUTTON_LABEL } from '../../../../shared/constants'; +import { HiddenText } from '../../../../shared/hidden_text'; +import { convertMetaToPagination, handlePageChange } from '../../../../shared/table_pagination'; +import { ApiToken } from '../../../types'; + +import { ApiKeysLogic } from '../api_keys_logic'; +import { + DELETE_API_KEY_BUTTON_DESCRIPTION, + COPIED_TOOLTIP, + NAME_TITLE, + KEY_TITLE, + API_KEYS_CONFIRM_DELETE_TITLE, + API_KEYS_CONFIRM_DELETE_LABEL, +} from '../constants'; + +import { ApiKey } from './api_key'; + +export const ApiKeysList: React.FC = () => { + const { deleteApiKey, onPaginate, stageTokenNameForDeletion, hideDeleteModal } = + useActions(ApiKeysLogic); + const { apiTokens, meta, dataLoading, deleteModalVisible } = useValues(ApiKeysLogic); + + const deleteModal = ( + +

{API_KEYS_CONFIRM_DELETE_LABEL}

+
+ ); + + const columns: Array> = [ + { + name: NAME_TITLE, + render: (token: ApiToken) => token.name, + }, + { + name: KEY_TITLE, + className: 'eui-textBreakAll', + render: (token: ApiToken) => { + const { key } = token; + if (!key) return null; + + return ( + + {(copy) => ( + + {({ hiddenText, isHidden, toggle }) => ( + + )} + + )} + + ); + }, + mobileOptions: { + width: '100%', + }, + }, + { + actions: [ + { + name: DELETE_BUTTON_LABEL, + description: DELETE_API_KEY_BUTTON_DESCRIPTION, + type: 'icon', + icon: 'trash', + color: 'danger', + onClick: (token: ApiToken) => stageTokenNameForDeletion(token.name), + }, + ], + }, + ]; + + return ( + <> + {deleteModalVisible && deleteModal} + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/api_keys/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/api_keys/constants.ts new file mode 100644 index 0000000000000..6c45dc38339c4 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/api_keys/constants.ts @@ -0,0 +1,149 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const CREATE_KEY_BUTTON_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.apiKeys.createKey.buttonLabel', + { + defaultMessage: 'Create key', + } +); + +export const ENDPOINT_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.apiKeys.endpointTitle', + { + defaultMessage: 'Endpoint', + } +); + +export const NAME_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.apiKeys.nameTitle', + { + defaultMessage: 'Name', + } +); + +export const KEY_TITLE = i18n.translate('xpack.enterpriseSearch.workplaceSearch.apiKeys.keyTitle', { + defaultMessage: 'Key', +}); + +export const COPIED_TOOLTIP = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.apiKeys.copied.tooltip', + { + defaultMessage: 'Copied', + } +); + +export const COPY_API_ENDPOINT_BUTTON_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.apiKeys.copyApiEndpoint.buttonLabel', + { + defaultMessage: 'Copy API Endpoint to clipboard.', + } +); + +export const COPY_API_KEY_BUTTON_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.apiKeys.copyApiKey.buttonLabel', + { + defaultMessage: 'Copy API Key to clipboard.', + } +); + +export const DELETE_API_KEY_BUTTON_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.apiKeys.deleteApiKey.buttonDescription', + { + defaultMessage: 'Delete API key', + } +); + +export const CREATE_MESSAGE = (name: string) => + i18n.translate('xpack.enterpriseSearch.workplaceSearch.apiKeys.createdMessage', { + defaultMessage: "API key '{name}' was created", + values: { name }, + }); + +export const DELETE_MESSAGE = (name: string) => + i18n.translate('xpack.enterpriseSearch.workplaceSearch.apiKeys.deletedMessage', { + defaultMessage: "API key '{name}' was deleted", + values: { name }, + }); + +export const API_KEY_FLYOUT_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.apiKeys.flyoutTitle', + { + defaultMessage: 'Create a new key', + } +); + +export const API_KEY_FORM_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.apiKeys.formLabel', + { + defaultMessage: 'Key name', + } +); + +export const API_KEY_FORM_HELP_TEXT = (name: string) => + i18n.translate('xpack.enterpriseSearch.workplaceSearch.apiKeys.formHelpText', { + defaultMessage: 'Your key will be named: {name}', + values: { name }, + }); + +export const API_KEY_NAME_PLACEHOLDER = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.apiKeys.namePlaceholder', + { + defaultMessage: 'i.e., my-api-key', + } +); + +export const SHOW_API_KEY_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.apiKeys.showApiKeyLabel', + { + defaultMessage: 'Show API Key', + } +); + +export const HIDE_API_KEY_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.apiKeys.hideApiKeyLabel', + { + defaultMessage: 'Hide API Key', + } +); + +export const API_KEYS_EMPTY_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.apiKeys.emptyTitle', + { + defaultMessage: 'Create your first API key', + } +); + +export const API_KEYS_EMPTY_BODY = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.apiKeys.emptyBody', + { + defaultMessage: 'Allow applications to access Elastic Workplace Search on your behalf.', + } +); + +export const API_KEYS_EMPTY_BUTTON_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.apiKeys.emptyButtonLabel', + { + defaultMessage: 'Learn about API keys', + } +); + +export const API_KEYS_CONFIRM_DELETE_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.apiKeys.confirmDeleteTitle', + { + defaultMessage: 'Delete API key', + } +); + +export const API_KEYS_CONFIRM_DELETE_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.apiKeys.confirmDeleteLabel', + { + defaultMessage: 'Are you sure you want to delete this API key? This action cannot be undone.', + } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/api_keys/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/api_keys/index.ts new file mode 100644 index 0000000000000..4affd04611624 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/api_keys/index.ts @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export { ApiKeys } from './api_keys'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/constants.ts index 079cb5e1a5a3d..cbc18f6d7a19e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/constants.ts @@ -282,7 +282,7 @@ export const SAVE_CUSTOM_BODY1 = i18n.translate( export const SAVE_CUSTOM_BODY2 = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.body2', { - defaultMessage: 'Be sure to copy your API keys below.', + defaultMessage: 'Be sure to copy your Source Identifier below.', } ); @@ -293,20 +293,6 @@ export const SAVE_CUSTOM_RETURN_BUTTON = i18n.translate( } ); -export const SAVE_CUSTOM_API_KEYS_TITLE = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.apiKeys.title', - { - defaultMessage: 'API Keys', - } -); - -export const SAVE_CUSTOM_API_KEYS_BODY = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.apiKeys.body', - { - defaultMessage: "You'll need these keys to sync documents for this custom source.", - } -); - export const SAVE_CUSTOM_VISUAL_WALKTHROUGH_TITLE = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.visualWalkthrough.title', { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.test.tsx index a8a5810e7c0a2..4715c50e4233c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.test.tsx @@ -36,7 +36,7 @@ describe('SaveCustom', () => { const wrapper = shallow(); expect(wrapper.find(EuiPanel)).toHaveLength(1); - expect(wrapper.find(EuiTitle)).toHaveLength(5); + expect(wrapper.find(EuiTitle)).toHaveLength(4); expect(wrapper.find(EuiLinkTo)).toHaveLength(1); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.tsx index 8108f8211f93d..bbf1b66277c70 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.tsx @@ -26,7 +26,6 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { LicensingLogic } from '../../../../../shared/licensing'; import { EuiLinkTo } from '../../../../../shared/react_router_helpers'; -import { CredentialItem } from '../../../../components/shared/credential_item'; import { LicenseBadge } from '../../../../components/shared/license_badge'; import { SOURCES_PATH, @@ -37,14 +36,14 @@ import { getSourcesPath, } from '../../../../routes'; import { CustomSource } from '../../../../types'; -import { ACCESS_TOKEN_LABEL, ID_LABEL, LEARN_CUSTOM_FEATURES_BUTTON } from '../../constants'; +import { LEARN_CUSTOM_FEATURES_BUTTON } from '../../constants'; + +import { SourceIdentifier } from '../source_identifier'; import { SAVE_CUSTOM_BODY1, SAVE_CUSTOM_BODY2, SAVE_CUSTOM_RETURN_BUTTON, - SAVE_CUSTOM_API_KEYS_TITLE, - SAVE_CUSTOM_API_KEYS_BODY, SAVE_CUSTOM_VISUAL_WALKTHROUGH_TITLE, SAVE_CUSTOM_VISUAL_WALKTHROUGH_LINK, SAVE_CUSTOM_STYLING_RESULTS_TITLE, @@ -62,7 +61,7 @@ interface SaveCustomProps { export const SaveCustom: React.FC = ({ documentationUrl, - newCustomSource: { id, accessToken, name }, + newCustomSource: { id, name }, isOrganization, header, }) => { @@ -106,24 +105,8 @@ export const SaveCustom: React.FC = ({ - - - -

{SAVE_CUSTOM_API_KEYS_TITLE}

-
- -

{SAVE_CUSTOM_API_KEYS_BODY}

-
- - - - -
-
+ + diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx index fd29b5f590967..29abbf94db397 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx @@ -37,7 +37,6 @@ import { EuiListGroupItemTo, EuiLinkTo } from '../../../../shared/react_router_h import { AppLogic } from '../../../app_logic'; import aclImage from '../../../assets/supports_acl.svg'; import { ComponentLoader } from '../../../components/shared/component_loader'; -import { CredentialItem } from '../../../components/shared/credential_item'; import { LicenseBadge } from '../../../components/shared/license_badge'; import { StatusItem } from '../../../components/shared/status_item'; import { ViewContentHeader } from '../../../components/shared/view_content_header'; @@ -78,8 +77,6 @@ import { STATUS_TEXT, ADDITIONAL_CONFIG_HEADING, EXTERNAL_IDENTITIES_LINK, - ACCESS_TOKEN_LABEL, - ID_LABEL, LEARN_CUSTOM_FEATURES_BUTTON, DOC_PERMISSIONS_DESCRIPTION, CUSTOM_CALLOUT_TITLE, @@ -92,6 +89,7 @@ import { } from '../constants'; import { SourceLogic } from '../source_logic'; +import { SourceIdentifier } from './source_identifier'; import { SourceLayout } from './source_layout'; export const Overview: React.FC = () => { @@ -106,7 +104,6 @@ export const Overview: React.FC = () => { groups, details, custom, - accessToken, licenseSupportsPermissions, serviceTypeSupportsPermissions, indexPermissions, @@ -432,9 +429,7 @@ export const Overview: React.FC = () => { - - - + ); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_identifier.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_identifier.test.tsx new file mode 100644 index 0000000000000..2a9af72f596ed --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_identifier.test.tsx @@ -0,0 +1,32 @@ +/* + * 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 React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiCopy, EuiButtonIcon, EuiFieldText } from '@elastic/eui'; + +import { SourceIdentifier } from './source_identifier'; + +describe('SourceIdentifier', () => { + const id = 'foo123'; + + it('renders the Source Identifier', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiFieldText).prop('value')).toEqual(id); + }); + + it('renders the copy button', () => { + const copyMock = jest.fn(); + const wrapper = shallow(); + + const copyEl = shallow(
{wrapper.find(EuiCopy).props().children(copyMock)}
); + expect(copyEl.find(EuiButtonIcon).props().onClick).toEqual(copyMock); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_identifier.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_identifier.tsx new file mode 100644 index 0000000000000..2c7784a554a25 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_identifier.tsx @@ -0,0 +1,73 @@ +/* + * 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 React from 'react'; + +import { + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiCopy, + EuiButtonIcon, + EuiFieldText, + EuiSpacer, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { EuiLinkTo } from '../../../../shared/react_router_helpers'; + +import { API_KEY_LABEL, COPY_TOOLTIP, COPIED_TOOLTIP } from '../../../constants'; +import { API_KEYS_PATH } from '../../../routes'; + +import { ID_LABEL } from '../constants'; + +interface Props { + id: string; +} + +export const SourceIdentifier: React.FC = ({ id }) => ( + <> + + + + {ID_LABEL} + + + + + {(copy) => ( + + )} + + + + + + + + +

+ + {API_KEY_LABEL} + + ), + }} + /> +

+
+ +); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts index 087716e565ad0..61e4aa3fc3884 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts @@ -147,13 +147,6 @@ export const EXTERNAL_IDENTITIES_LINK = i18n.translate( } ); -export const ACCESS_TOKEN_LABEL = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.sources.accessToken.label', - { - defaultMessage: 'Access Token', - } -); - export const ID_LABEL = i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.id.label', { defaultMessage: 'Source Identifier', }); diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/api_keys.test.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/api_keys.test.ts new file mode 100644 index 0000000000000..4855716cfc2fd --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/api_keys.test.ts @@ -0,0 +1,92 @@ +/* + * 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 { MockRouter, mockRequestHandler, mockDependencies } from '../../__mocks__'; + +import { registerApiKeysRoute } from './api_keys'; + +describe('api keys routes', () => { + describe('GET /internal/workplace_search/api_keys', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'get', + path: '/internal/workplace_search/api_keys', + }); + + registerApiKeysRoute({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/ws/org/api_tokens', + }); + }); + }); + + describe('POST /internal/workplace_search/api_keys', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'post', + path: '/internal/workplace_search/api_keys', + }); + + registerApiKeysRoute({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/ws/org/api_tokens', + }); + }); + + describe('validates', () => { + it('correctly', () => { + const request = { + body: { + name: 'my-api-key', + }, + }; + mockRouter.shouldValidate(request); + }); + }); + }); + + describe('DELETE /internal/workplace_search/api_keys/{tokenName}', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'delete', + path: '/internal/workplace_search/api_keys/{tokenName}', + }); + + registerApiKeysRoute({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/ws/org/api_tokens/:tokenName', + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/api_keys.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/api_keys.ts new file mode 100644 index 0000000000000..ff63c7b146750 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/api_keys.ts @@ -0,0 +1,57 @@ +/* + * 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 { schema } from '@kbn/config-schema'; + +import { RouteDependencies } from '../../plugin'; + +export function registerApiKeysRoute({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.get( + { + path: '/internal/workplace_search/api_keys', + validate: false, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/ws/org/api_tokens', + }) + ); + + router.post( + { + path: '/internal/workplace_search/api_keys', + validate: { + body: schema.object({ + name: schema.string(), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/ws/org/api_tokens', + }) + ); + + router.delete( + { + path: '/internal/workplace_search/api_keys/{tokenName}', + validate: { + params: schema.object({ + tokenName: schema.string(), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/ws/org/api_tokens/:tokenName', + }) + ); +} + +export const registerApiKeysRoutes = (dependencies: RouteDependencies) => { + registerApiKeysRoute(dependencies); +}; diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/index.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/index.ts index aa3b60a5ba047..24eff218c3345 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/index.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/index.ts @@ -7,6 +7,7 @@ import { RouteDependencies } from '../../plugin'; +import { registerApiKeysRoutes } from './api_keys'; import { registerGroupsRoutes } from './groups'; import { registerOAuthRoutes } from './oauth'; import { registerOverviewRoute } from './overview'; @@ -16,6 +17,7 @@ import { registerSettingsRoutes } from './settings'; import { registerSourcesRoutes } from './sources'; export const registerWorkplaceSearchRoutes = (dependencies: RouteDependencies) => { + registerApiKeysRoutes(dependencies); registerOverviewRoute(dependencies); registerOAuthRoutes(dependencies); registerGroupsRoutes(dependencies); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 4694b8c63d7e0..92c550f000823 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -9464,8 +9464,6 @@ "xpack.enterpriseSearch.workplaceSearch.contentSource.saveConfig.button": "構成を保存", "xpack.enterpriseSearch.workplaceSearch.contentSource.saveConfig.oauthStep1": "組織の{sourceName}アカウントでOAuthアプリを作成する", "xpack.enterpriseSearch.workplaceSearch.contentSource.saveConfig.oauthStep2": "適切な構成情報を入力する", - "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.apiKeys.body": "このカスタムソースでドキュメントを同期するには、これらのキーが必要です。", - "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.apiKeys.title": "API キー", "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.body1": "エンドポイントは要求を承認できます。", "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.body2": "必ず次のAPIキーをコピーしてください。", "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.displaySettings.text": "{link}を使用して、検索結果内でドキュメントが表示される方法をカスタマイズします。デフォルトでは、Workplace Searchは英字順でフィールドを使用します。", @@ -9790,7 +9788,6 @@ "xpack.enterpriseSearch.workplaceSearch.sourceRow.remoteLabel": "リモート", "xpack.enterpriseSearch.workplaceSearch.sourceRow.remoteTooltip": "リモートソースは直接ソースの検索サービスに依存しています。コンテンツはWorkplace Searchでインデックスされません。結果の速度と完全性はサードパーティサービスの正常性とパフォーマンスの機能です。", "xpack.enterpriseSearch.workplaceSearch.sourceRow.searchableToggleLabel": "ソース検索可能トグル", - "xpack.enterpriseSearch.workplaceSearch.sources.accessToken.label": "アクセストークン", "xpack.enterpriseSearch.workplaceSearch.sources.additionalConfig.heading": "追加の構成が必要", "xpack.enterpriseSearch.workplaceSearch.sources.applicationLinkTitles.github": "GitHub開発者ポータル", "xpack.enterpriseSearch.workplaceSearch.sources.baseUrlTitles.github": "GitHub Enterprise URL", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index ba523a4236b7d..dee5d1eaf89d4 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -9551,8 +9551,6 @@ "xpack.enterpriseSearch.workplaceSearch.contentSource.saveConfig.button": "保存配置", "xpack.enterpriseSearch.workplaceSearch.contentSource.saveConfig.oauthStep1": "在组织的 {sourceName} 帐户中创建 OAuth 应用", "xpack.enterpriseSearch.workplaceSearch.contentSource.saveConfig.oauthStep2": "提供适当的配置信息", - "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.apiKeys.body": "您将需要这些密钥以便为此定制源同步文档。", - "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.apiKeys.title": "API 密钥", "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.body1": "您的终端已准备好接受请求。", "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.body2": "确保在下面复制您的 API 密钥。", "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.displaySettings.text": "请使用 {link} 定制您的文档在搜索结果内显示的方式。Workplace Search 默认按字母顺序使用字段。", @@ -9877,7 +9875,6 @@ "xpack.enterpriseSearch.workplaceSearch.sourceRow.remoteLabel": "远程", "xpack.enterpriseSearch.workplaceSearch.sourceRow.remoteTooltip": "远程源直接依赖于源的搜索服务,且没有内容使用 Workplace Search 进行索引。速度和结果完整性取决于第三方服务的运行状况和性能。", "xpack.enterpriseSearch.workplaceSearch.sourceRow.searchableToggleLabel": "源可搜索切换", - "xpack.enterpriseSearch.workplaceSearch.sources.accessToken.label": "访问令牌", "xpack.enterpriseSearch.workplaceSearch.sources.additionalConfig.heading": "需要其他配置", "xpack.enterpriseSearch.workplaceSearch.sources.applicationLinkTitles.github": "GitHub 开发者门户", "xpack.enterpriseSearch.workplaceSearch.sources.baseUrlTitles.github": "GitHub Enterprise URL",