- Plugin is connected! Continue to Grafana OnCall by clicking the{' '}
- icon over there 👈
+ Plugin is connected! Continue to Grafana OnCall by clicking OnCall under Alerts & IRM in the navigation over
+ there 👈
- Plugin is connected! Continue to Grafana OnCall by clicking the
-
-
- icon over there 👈
+ Plugin is connected! Continue to Grafana OnCall by clicking OnCall under Alerts & IRM in the navigation over there 👈
- Plugin is connected! Continue to Grafana OnCall by clicking the - -
- icon over there 👈 + Plugin is connected! Continue to Grafana OnCall by clicking OnCall under Alerts & IRM in the navigation over there 👈
- + + +- Plugin is connected! Continue to Grafana OnCall by clicking the - -
- icon over there 👈 + Plugin is connected! Continue to Grafana OnCall by clicking OnCall under Alerts & IRM in the navigation over there 👈
- + + ++ ++ `; @@ -334,16 +382,39 @@ exports[`PluginConfigPage Plugin reset: successful - false 1`] = ` There was an error resetting your plugin, try again.+ ++ `; diff --git a/grafana-plugin/src/state/plugin/__snapshots__/plugin.test.ts.snap b/grafana-plugin/src/state/plugin/__snapshots__/plugin.test.ts.snap index 1bb513e3..329fd8fc 100644 --- a/grafana-plugin/src/state/plugin/__snapshots__/plugin.test.ts.snap +++ b/grafana-plugin/src/state/plugin/__snapshots__/plugin.test.ts.snap @@ -1,58 +1,58 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`PluginState.generateInvalidOnCallApiURLErrorMsg it returns the proper error message - configured through env var: false 1`] = ` -"Could not communicate with your OnCall API at http://hello.com. -Validate that the URL is correct, your OnCall API is running, and that it is accessible from your Grafana instance." +"Could not communicate with OnCall API at http://hello.com. +Validate that the URL is correct, OnCall API is running, and that it is accessible from your Grafana instance." `; exports[`PluginState.generateInvalidOnCallApiURLErrorMsg it returns the proper error message - configured through env var: true 1`] = ` -"Could not communicate with your OnCall API at http://hello.com (NOTE: your OnCall API URL is currently being taken from process.env of your UI). -Validate that the URL is correct, your OnCall API is running, and that it is accessible from your Grafana instance." +"Could not communicate with OnCall API at http://hello.com (NOTE: OnCall API URL is currently being taken from process.env of your UI). +Validate that the URL is correct, OnCall API is running, and that it is accessible from your Grafana instance." `; exports[`PluginState.generateOnCallApiUrlConfiguredThroughEnvVarMsg it returns the proper error message - configured through env var: false 1`] = `""`; -exports[`PluginState.generateOnCallApiUrlConfiguredThroughEnvVarMsg it returns the proper error message - configured through env var: true 1`] = `" (NOTE: your OnCall API URL is currently being taken from process.env of your UI)"`; +exports[`PluginState.generateOnCallApiUrlConfiguredThroughEnvVarMsg it returns the proper error message - configured through env var: true 1`] = `" (NOTE: OnCall API URL is currently being taken from process.env of your UI)"`; exports[`PluginState.generateUnknownErrorMsg it returns the proper error message - configured through env var: false 1`] = ` -"An unknown error occured when trying to install the plugin. Are you sure that your OnCall API URL, http://hello.com, is correct? +"An unknown error occurred when trying to install the plugin. Verify OnCall API URL, http://hello.com, is correct? Refresh your page and try again, or try removing your plugin configuration and reconfiguring." `; exports[`PluginState.generateUnknownErrorMsg it returns the proper error message - configured through env var: false 2`] = ` -"An unknown error occured when trying to sync the plugin. Are you sure that your OnCall API URL, http://hello.com, is correct? +"An unknown error occurred when trying to sync the plugin. Verify OnCall API URL, http://hello.com, is correct? Refresh your page and try again, or try removing your plugin configuration and reconfiguring." `; exports[`PluginState.generateUnknownErrorMsg it returns the proper error message - configured through env var: true 1`] = ` -"An unknown error occured when trying to install the plugin. Are you sure that your OnCall API URL, http://hello.com, is correct (NOTE: your OnCall API URL is currently being taken from process.env of your UI)? +"An unknown error occurred when trying to install the plugin. Verify OnCall API URL, http://hello.com, is correct (NOTE: OnCall API URL is currently being taken from process.env of your UI)? Refresh your page and try again, or try removing your plugin configuration and reconfiguring." `; exports[`PluginState.generateUnknownErrorMsg it returns the proper error message - configured through env var: true 2`] = ` -"An unknown error occured when trying to sync the plugin. Are you sure that your OnCall API URL, http://hello.com, is correct (NOTE: your OnCall API URL is currently being taken from process.env of your UI)? +"An unknown error occurred when trying to sync the plugin. Verify OnCall API URL, http://hello.com, is correct (NOTE: OnCall API URL is currently being taken from process.env of your UI)? Refresh your page and try again, or try removing your plugin configuration and reconfiguring." `; exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a 400 network error properly - has custom error message: false 1`] = ` -"An unknown error occured when trying to install the plugin. Are you sure that your OnCall API URL, http://hello.com, is correct (NOTE: your OnCall API URL is currently being taken from process.env of your UI)? +"An unknown error occurred when trying to install the plugin. Verify OnCall API URL, http://hello.com, is correct (NOTE: OnCall API URL is currently being taken from process.env of your UI)? Refresh your page and try again, or try removing your plugin configuration and reconfiguring." `; exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a 400 network error properly - has custom error message: true 1`] = `"ohhhh nooo an error"`; exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a non-400 network error properly - status code: 409 1`] = ` -"An unknown error occured when trying to install the plugin. Are you sure that your OnCall API URL, http://hello.com, is correct (NOTE: your OnCall API URL is currently being taken from process.env of your UI)? +"An unknown error occurred when trying to install the plugin. Verify OnCall API URL, http://hello.com, is correct (NOTE: OnCall API URL is currently being taken from process.env of your UI)? Refresh your page and try again, or try removing your plugin configuration and reconfiguring." `; exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a non-400 network error properly - status code: 502 1`] = ` -"Could not communicate with your OnCall API at http://hello.com (NOTE: your OnCall API URL is currently being taken from process.env of your UI). -Validate that the URL is correct, your OnCall API is running, and that it is accessible from your Grafana instance." +"Could not communicate with OnCall API at http://hello.com (NOTE: OnCall API URL is currently being taken from process.env of your UI). +Validate that the URL is correct, OnCall API is running, and that it is accessible from your Grafana instance." `; exports[`PluginState.getHumanReadableErrorFromOnCallError it handles an unknown error properly 1`] = ` -"An unknown error occured when trying to install the plugin. Are you sure that your OnCall API URL, http://hello.com, is correct (NOTE: your OnCall API URL is currently being taken from process.env of your UI)? +"An unknown error occurred when trying to install the plugin. Verify OnCall API URL, http://hello.com, is correct (NOTE: OnCall API URL is currently being taken from process.env of your UI)? Refresh your page and try again, or try removing your plugin configuration and reconfiguring." `; diff --git a/grafana-plugin/src/state/plugin/index.ts b/grafana-plugin/src/state/plugin/index.ts index 26d0f740..7a63925c 100644 --- a/grafana-plugin/src/state/plugin/index.ts +++ b/grafana-plugin/src/state/plugin/index.ts @@ -48,21 +48,19 @@ class PluginState { static grafanaBackend = getBackendSrv(); static generateOnCallApiUrlConfiguredThroughEnvVarMsg = (isConfiguredThroughEnvVar: boolean): string => - isConfiguredThroughEnvVar - ? ' (NOTE: your OnCall API URL is currently being taken from process.env of your UI)' - : ''; + isConfiguredThroughEnvVar ? ' (NOTE: OnCall API URL is currently being taken from process.env of your UI)' : ''; static generateInvalidOnCallApiURLErrorMsg = (onCallApiUrl: string, isConfiguredThroughEnvVar: boolean): string => - `Could not communicate with your OnCall API at ${onCallApiUrl}${this.generateOnCallApiUrlConfiguredThroughEnvVarMsg( + `Could not communicate with OnCall API at ${onCallApiUrl}${this.generateOnCallApiUrlConfiguredThroughEnvVarMsg( isConfiguredThroughEnvVar - )}.\nValidate that the URL is correct, your OnCall API is running, and that it is accessible from your Grafana instance.`; + )}.\nValidate that the URL is correct, OnCall API is running, and that it is accessible from your Grafana instance.`; static generateUnknownErrorMsg = ( onCallApiUrl: string, verb: InstallationVerb, isConfiguredThroughEnvVar: boolean ): string => - `An unknown error occured when trying to ${verb} the plugin. Are you sure that your OnCall API URL, ${onCallApiUrl}, is correct${this.generateOnCallApiUrlConfiguredThroughEnvVarMsg( + `An unknown error occurred when trying to ${verb} the plugin. Verify OnCall API URL, ${onCallApiUrl}, is correct${this.generateOnCallApiUrlConfiguredThroughEnvVarMsg( isConfiguredThroughEnvVar )}?\nRefresh your page and try again, or try removing your plugin configuration and reconfiguring.`; @@ -78,7 +76,7 @@ class PluginState { installationVerb, onCallApiUrlIsConfiguredThroughEnvVar ); - const consoleMsg = `occured while trying to ${installationVerb} the plugin w/ the OnCall backend`; + const consoleMsg = `occurred while trying to ${installationVerb} the plugin w/ the OnCall backend`; if (isNetworkError(e)) { const { status: statusCode } = e.response; @@ -104,7 +102,7 @@ class PluginState { errorMsg = unknownErrorMsg; } } else { - // a non-network related error occured.. this scenario shouldn't occur... + // a non-network related error occurred.. this scenario shouldn't occur... console.warn(`An unknown error ${consoleMsg}`, e); errorMsg = unknownErrorMsg; } @@ -121,11 +119,11 @@ class PluginState { if (isNetworkError(e)) { // The user likely put in a bogus URL for the OnCall API URL - console.warn('An HTTP related error occured while trying to provision the plugin w/ Grafana', e.response); + console.warn('An HTTP related error occurred while trying to provision the plugin w/ Grafana', e.response); errorMsg = this.generateInvalidOnCallApiURLErrorMsg(onCallApiUrl, onCallApiUrlIsConfiguredThroughEnvVar); } else { - // a non-network related error occured.. this scenario shouldn't occur... - console.warn('An unknown error occured while trying to provision the plugin w/ Grafana', e); + // a non-network related error occurred.. this scenario shouldn't occur... + console.warn('An unknown error occurred while trying to provision the plugin w/ Grafana', e); errorMsg = this.generateUnknownErrorMsg(onCallApiUrl, installationVerb, onCallApiUrlIsConfiguredThroughEnvVar); } return errorMsg; @@ -137,16 +135,20 @@ class PluginState { static updateGrafanaPluginSettings = async (data: UpdateGrafanaPluginSettingsProps, enabled = true) => this.grafanaBackend.post(this.GRAFANA_PLUGIN_SETTINGS_URL, { ...data, enabled, pinned: true }); - static createGrafanaToken = async () => { - const baseUrl = '/api/auth/keys'; - const keys = await this.grafanaBackend.get(baseUrl); - const existingKey = keys.find((key: { id: number; name: string; role: string }) => key.name === 'OnCall'); + static readonly KEYS_BASE_URL = '/api/auth/keys'; + static getGrafanaToken = async () => { + const keys = await this.grafanaBackend.get(this.KEYS_BASE_URL); + return keys.find((key: { id: number; name: string; role: string }) => key.name === 'OnCall'); + }; + + static createGrafanaToken = async () => { + const existingKey = await this.getGrafanaToken(); if (existingKey) { - await this.grafanaBackend.delete(`${baseUrl}/${existingKey.id}`); + await this.grafanaBackend.delete(`${this.KEYS_BASE_URL}/${existingKey.id}`); } - return await this.grafanaBackend.post(baseUrl, { + return await this.grafanaBackend.post(this.KEYS_BASE_URL, { name: 'OnCall', role: 'Admin', secondsToLive: null, @@ -205,9 +207,27 @@ class PluginState { onCallApiUrlIsConfiguredThroughEnvVar = false ): Promise=> { try { + /** + * Allows the plugin config page to repair settings like the app initialization screen if a user deletes + * an API key on accident but leaves the plugin settings intact. + */ + const existingKey = await this.getGrafanaToken(); + if (!existingKey) { + try { + await this.installPlugin(); + } catch (e) { + return this.getHumanReadableErrorFromOnCallError( + e, + onCallApiUrl, + 'install', + onCallApiUrlIsConfiguredThroughEnvVar + ); + } + } + const startSyncResponse = await makeRequest(`${this.ONCALL_BASE_URL}/sync`, { method: 'POST' }); if (typeof startSyncResponse === 'string') { - // an error occured trying to initiate the sync + // an error occurred trying to initiate the sync return startSyncResponse; } @@ -300,11 +320,22 @@ class PluginState { return null; }; - static checkIfBackendIsInMaintenanceMode = async (): Promise => { - const response = await makeRequest ('/maintenance-mode-status', { - method: 'GET', - }); - return response.currently_undergoing_maintenance_message; + static checkIfBackendIsInMaintenanceMode = async ( + onCallApiUrl: string, + onCallApiUrlIsConfiguredThroughEnvVar = false + ): Promise => { + try { + return await makeRequest ('/maintenance-mode-status', { + method: 'GET', + }); + } catch (e) { + return this.getHumanReadableErrorFromOnCallError( + e, + onCallApiUrl, + 'install', + onCallApiUrlIsConfiguredThroughEnvVar + ); + } }; static checkIfPluginIsConnected = async ( diff --git a/grafana-plugin/src/state/plugin/plugin.test.ts b/grafana-plugin/src/state/plugin/plugin.test.ts index 7b67fad0..24292a9d 100644 --- a/grafana-plugin/src/state/plugin/plugin.test.ts +++ b/grafana-plugin/src/state/plugin/plugin.test.ts @@ -383,6 +383,7 @@ describe('PluginState.syncDataWithOnCall', () => { const errorMsg = 'asdfasdf'; makeRequest.mockResolvedValueOnce(errorMsg); + PluginState.getGrafanaToken = jest.fn().mockReturnValueOnce({ id: 1 }); PluginState.pollOnCallDataSyncStatus = jest.fn(); // test @@ -403,6 +404,7 @@ describe('PluginState.syncDataWithOnCall', () => { const mockedPollOnCallDataSyncStatusResponse = 'dfjkdfjdf'; makeRequest.mockResolvedValueOnce(mockedResponse); + PluginState.getGrafanaToken = jest.fn().mockReturnValueOnce({ id: 1 }); PluginState.pollOnCallDataSyncStatus = jest.fn().mockResolvedValueOnce(mockedPollOnCallDataSyncStatusResponse); // test @@ -427,6 +429,7 @@ describe('PluginState.syncDataWithOnCall', () => { const mockedHumanReadableError = 'asdfjkdfjkdfjk'; makeRequest.mockRejectedValueOnce(mockedError); + PluginState.getGrafanaToken = jest.fn().mockReturnValueOnce({ id: 1 }); PluginState.pollOnCallDataSyncStatus = jest.fn(); PluginState.getHumanReadableErrorFromOnCallError = jest.fn().mockReturnValueOnce(mockedHumanReadableError); @@ -663,13 +666,14 @@ describe('PluginState.checkIfBackendIsInMaintenanceMode', () => { // mocks const maintenanceModeMsg = 'asdfljkadsjlfkajsdf'; const mockedResp = { currently_undergoing_maintenance_message: maintenanceModeMsg }; + const onCallApiUrl = 'http://hello.com'; makeRequest.mockResolvedValueOnce(mockedResp); // test - const response = await PluginState.checkIfBackendIsInMaintenanceMode(); + const response = await PluginState.checkIfBackendIsInMaintenanceMode(onCallApiUrl); // assertions - expect(response).toEqual(maintenanceModeMsg); + expect(response).toEqual(mockedResp); expect(makeRequest).toHaveBeenCalledTimes(1); expect(makeRequest).toHaveBeenCalledWith('/maintenance-mode-status', { method: 'GET' }); }); diff --git a/grafana-plugin/src/state/rootBaseStore/index.ts b/grafana-plugin/src/state/rootBaseStore/index.ts index b8019992..eaf8804a 100644 --- a/grafana-plugin/src/state/rootBaseStore/index.ts +++ b/grafana-plugin/src/state/rootBaseStore/index.ts @@ -1,3 +1,5 @@ +import { OrgRole } from '@grafana/data'; +import { contextSrv } from 'grafana/app/core/core'; import { action, observable } from 'mobx'; import moment from 'moment-timezone'; import qs from 'query-string'; @@ -32,8 +34,7 @@ import { UserGroupStore } from 'models/user_group/user_group'; import { makeRequest } from 'network'; import { AppFeature } from 'state/features'; import PluginState from 'state/plugin'; -import { isUserActionAllowed, UserActions } from 'utils/authorization'; -import { GRAFANA_LICENSE_OSS } from 'utils/consts'; +import { APP_VERSION, CLOUD_VERSION_REGEX, GRAFANA_LICENSE_CLOUD, GRAFANA_LICENSE_OSS } from 'utils/consts'; // ------ Dashboard ------ // @@ -162,13 +163,15 @@ export class RootBaseStore { return this.setupPluginError('🚫 Plugin has not been initialized'); } - const isInMaintenanceMode = await PluginState.checkIfBackendIsInMaintenanceMode(); - if (isInMaintenanceMode !== null) { + const maintenanceMode = await PluginState.checkIfBackendIsInMaintenanceMode(this.onCallApiUrl); + if (typeof maintenanceMode === 'string') { + return this.setupPluginError(maintenanceMode); + } else if (maintenanceMode.currently_undergoing_maintenance_message) { this.currentlyUndergoingMaintenance = true; - return this.setupPluginError(`🚧 ${isInMaintenanceMode} 🚧`); + return this.setupPluginError(`🚧 ${maintenanceMode.currently_undergoing_maintenance_message} 🚧`); } - // at this point we know the plugin is provionsed + // at this point we know the plugin is provisioned const pluginConnectionStatus = await PluginState.checkIfPluginIsConnected(this.onCallApiUrl); if (typeof pluginConnectionStatus === 'string') { return this.setupPluginError(pluginConnectionStatus); @@ -178,28 +181,38 @@ export class RootBaseStore { if (is_user_anonymous) { return this.setupPluginError( - '😞 Unfortunately Grafana OnCall is available for authorized users only, please sign in to proceed.' + '😞 Grafana OnCall is available for authorized users only, please sign in to proceed.' ); } else if (!is_installed || !token_ok) { if (!allow_signup) { return this.setupPluginError('🚫 OnCall has temporarily disabled signup of new users. Please try again later.'); } - - if (!isUserActionAllowed(UserActions.PluginsInstall)) { - return this.setupPluginError( - '🚫 An Admin in your organization must sign on and setup OnCall before it can be used' - ); - } - - try { - /** - * this will install AND sync the necessary data - * the sync is done automatically by the /plugin/install OnCall API endpoint - * therefore there is no need to trigger an additional/separate sync, nor poll a status - */ - await PluginState.installPlugin(); - } catch (e) { - return this.setupPluginError(PluginState.getHumanReadableErrorFromOnCallError(e, this.onCallApiUrl, 'install')); + const missingPermissions = this.checkMissingSetupPermissions(); + if (missingPermissions.length === 0) { + try { + /** + * this will install AND sync the necessary data + * the sync is done automatically by the /plugin/install OnCall API endpoint + * therefore there is no need to trigger an additional/separate sync, nor poll a status + */ + await PluginState.installPlugin(); + } catch (e) { + return this.setupPluginError( + PluginState.getHumanReadableErrorFromOnCallError(e, this.onCallApiUrl, 'install') + ); + } + } else { + if (contextSrv.accessControlEnabled()) { + return this.setupPluginError( + '🚫 User is missing permission(s) ' + + missingPermissions.join(', ') + + ' to setup OnCall before it can be used' + ); + } else { + return this.setupPluginError( + '🚫 User with Admin permissions in your organization must sign on and setup OnCall before it can be used' + ); + } } } else { const syncDataResponse = await PluginState.syncDataWithOnCall(this.onCallApiUrl); @@ -223,13 +236,37 @@ export class RootBaseStore { this.appLoading = false; } + checkMissingSetupPermissions() { + const fallback = contextSrv.user.orgRole === OrgRole.Admin && !contextSrv.accessControlEnabled(); + const setupRequiredPermissions = [ + 'plugins:write', + 'org.users:read', + 'teams:read', + 'apikeys:create', + 'apikeys:delete', + ]; + return setupRequiredPermissions.filter(function (permission) { + return !contextSrv.hasAccess(permission, fallback); + }); + } + hasFeature(feature: string | AppFeature) { // todo use AppFeature only return this.features?.[feature]; } + get license() { + if (this.backendLicense) { + return this.backendLicense; + } + if (CLOUD_VERSION_REGEX.test(APP_VERSION)) { + return GRAFANA_LICENSE_CLOUD; + } + return GRAFANA_LICENSE_OSS; + } + isOpenSource(): boolean { - return this.backendLicense === GRAFANA_LICENSE_OSS; + return this.license === GRAFANA_LICENSE_OSS; } @observable diff --git a/grafana-plugin/src/state/rootBaseStore/rootBaseStore.test.ts b/grafana-plugin/src/state/rootBaseStore/rootBaseStore.test.ts index ee8a4351..e3229fc2 100644 --- a/grafana-plugin/src/state/rootBaseStore/rootBaseStore.test.ts +++ b/grafana-plugin/src/state/rootBaseStore/rootBaseStore.test.ts @@ -1,17 +1,24 @@ +import { OrgRole } from '@grafana/data'; +import { contextSrv } from 'grafana/app/core/core'; import { OnCallAppPluginMeta } from 'types'; import PluginState from 'state/plugin'; -import { UserActions, isUserActionAllowed as isUserActionAllowedOriginal } from 'utils/authorization'; +import { isUserActionAllowed as isUserActionAllowedOriginal } from 'utils/authorization'; import { RootBaseStore } from './'; jest.mock('state/plugin'); jest.mock('utils/authorization'); +jest.mock('grafana/app/core/core', () => ({ + contextSrv: { + user: { + orgRole: null, + }, + }, +})); const isUserActionAllowed = isUserActionAllowedOriginal as jest.Mock >; -const PluginInstallAction = UserActions.PluginsInstall; - const generatePluginData = ( onCallApiUrl: OnCallAppPluginMeta['jsonData']['onCallApiUrl'] = null ): OnCallAppPluginMeta => @@ -42,7 +49,9 @@ describe('rootBaseStore', () => { const onCallApiUrl = 'http://asdfasdf.com'; const rootBaseStore = new RootBaseStore(); - PluginState.checkIfBackendIsInMaintenanceMode = jest.fn().mockResolvedValueOnce(null); + PluginState.checkIfBackendIsInMaintenanceMode = jest + .fn() + .mockResolvedValueOnce({ currently_undergoing_maintenance_message: null }); PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce(errorMsg); // test @@ -62,14 +71,16 @@ describe('rootBaseStore', () => { const rootBaseStore = new RootBaseStore(); const maintenanceMessage = 'mncvnmvcmnvkjdjkd'; - PluginState.checkIfBackendIsInMaintenanceMode = jest.fn().mockResolvedValueOnce(maintenanceMessage); + PluginState.checkIfBackendIsInMaintenanceMode = jest + .fn() + .mockResolvedValueOnce({ currently_undergoing_maintenance_message: maintenanceMessage }); // test await rootBaseStore.setupPlugin(generatePluginData(onCallApiUrl)); // assertions expect(PluginState.checkIfBackendIsInMaintenanceMode).toHaveBeenCalledTimes(1); - expect(PluginState.checkIfBackendIsInMaintenanceMode).toHaveBeenCalledWith(); + expect(PluginState.checkIfBackendIsInMaintenanceMode).toHaveBeenCalledWith(onCallApiUrl); expect(rootBaseStore.appLoading).toBe(false); expect(rootBaseStore.initializationError).toEqual(`🚧 ${maintenanceMessage} 🚧`); @@ -81,7 +92,9 @@ describe('rootBaseStore', () => { const onCallApiUrl = 'http://asdfasdf.com'; const rootBaseStore = new RootBaseStore(); - PluginState.checkIfBackendIsInMaintenanceMode = jest.fn().mockResolvedValueOnce(null); + PluginState.checkIfBackendIsInMaintenanceMode = jest + .fn() + .mockResolvedValueOnce({ currently_undergoing_maintenance_message: null }); PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce({ is_user_anonymous: true, is_installed: true, @@ -100,7 +113,7 @@ describe('rootBaseStore', () => { expect(rootBaseStore.appLoading).toBe(false); expect(rootBaseStore.initializationError).toEqual( - '😞 Unfortunately Grafana OnCall is available for authorized users only, please sign in to proceed.' + '😞 Grafana OnCall is available for authorized users only, please sign in to proceed.' ); }); @@ -109,7 +122,9 @@ describe('rootBaseStore', () => { const onCallApiUrl = 'http://asdfasdf.com'; const rootBaseStore = new RootBaseStore(); - PluginState.checkIfBackendIsInMaintenanceMode = jest.fn().mockResolvedValueOnce(null); + PluginState.checkIfBackendIsInMaintenanceMode = jest + .fn() + .mockResolvedValueOnce({ currently_undergoing_maintenance_message: null }); PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce({ is_user_anonymous: false, is_installed: false, @@ -140,7 +155,13 @@ describe('rootBaseStore', () => { const onCallApiUrl = 'http://asdfasdf.com'; const rootBaseStore = new RootBaseStore(); - PluginState.checkIfBackendIsInMaintenanceMode = jest.fn().mockResolvedValueOnce(null); + contextSrv.user.orgRole = OrgRole.Viewer; + contextSrv.accessControlEnabled = jest.fn().mockReturnValue(false); + contextSrv.hasAccess = jest.fn().mockReturnValue(false); + + PluginState.checkIfBackendIsInMaintenanceMode = jest + .fn() + .mockResolvedValueOnce({ currently_undergoing_maintenance_message: null }); PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce({ is_user_anonymous: false, is_installed: false, @@ -159,14 +180,11 @@ describe('rootBaseStore', () => { expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledTimes(1); expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledWith(onCallApiUrl); - expect(isUserActionAllowed).toHaveBeenCalledTimes(1); - expect(isUserActionAllowed).toHaveBeenCalledWith(PluginInstallAction); - expect(PluginState.installPlugin).toHaveBeenCalledTimes(0); expect(rootBaseStore.appLoading).toBe(false); expect(rootBaseStore.initializationError).toEqual( - '🚫 An Admin in your organization must sign on and setup OnCall before it can be used' + '🚫 User with Admin permissions in your organization must sign on and setup OnCall before it can be used' ); }); @@ -179,7 +197,13 @@ describe('rootBaseStore', () => { const rootBaseStore = new RootBaseStore(); const mockedLoadCurrentUser = jest.fn(); - PluginState.checkIfBackendIsInMaintenanceMode = jest.fn().mockResolvedValueOnce(null); + contextSrv.user.orgRole = OrgRole.Admin; + contextSrv.accessControlEnabled = jest.fn().mockResolvedValueOnce(false); + contextSrv.hasAccess = jest.fn().mockReturnValue(true); + + PluginState.checkIfBackendIsInMaintenanceMode = jest + .fn() + .mockResolvedValueOnce({ currently_undergoing_maintenance_message: null }); PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce({ ...scenario, is_user_anonymous: false, @@ -198,9 +222,6 @@ describe('rootBaseStore', () => { expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledTimes(1); expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledWith(onCallApiUrl); - expect(isUserActionAllowed).toHaveBeenCalledTimes(1); - expect(isUserActionAllowed).toHaveBeenCalledWith(PluginInstallAction); - expect(PluginState.installPlugin).toHaveBeenCalledTimes(1); expect(PluginState.installPlugin).toHaveBeenCalledWith(); @@ -211,6 +232,71 @@ describe('rootBaseStore', () => { expect(rootBaseStore.initializationError).toBeNull(); }); + test.each([ + { role: OrgRole.Admin, missing_permissions: [], expected_result: true }, + { role: OrgRole.Viewer, missing_permissions: [], expected_result: true }, + { + role: OrgRole.Admin, + missing_permissions: ['plugins:write', 'org.users:read', 'teams:read', 'apikeys:create', 'apikeys:delete'], + expected_result: false, + }, + { + role: OrgRole.Viewer, + missing_permissions: ['plugins:write', 'org.users:read', 'teams:read', 'apikeys:create', 'apikeys:delete'], + expected_result: false, + }, + ])('signup is allowed, accessControlEnabled, various roles and permissions', async (scenario) => { + // mocks/setup + const onCallApiUrl = 'http://asdfasdf.com'; + const rootBaseStore = new RootBaseStore(); + const mockedLoadCurrentUser = jest.fn(); + + contextSrv.user.orgRole = scenario.role; + contextSrv.accessControlEnabled = jest.fn().mockReturnValue(true); + rootBaseStore.checkMissingSetupPermissions = jest.fn().mockImplementation(() => scenario.missing_permissions); + + PluginState.checkIfBackendIsInMaintenanceMode = jest + .fn() + .mockResolvedValueOnce({ currently_undergoing_maintenance_message: null }); + PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce({ + ...scenario, + is_user_anonymous: false, + allow_signup: true, + version: 'asdfasdf', + license: 'asdfasdf', + }); + isUserActionAllowed.mockReturnValueOnce(true); + PluginState.installPlugin = jest.fn().mockResolvedValueOnce(null); + rootBaseStore.userStore.loadCurrentUser = mockedLoadCurrentUser; + + // test + await rootBaseStore.setupPlugin(generatePluginData(onCallApiUrl)); + + // assertions + expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledTimes(1); + expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledWith(onCallApiUrl); + + expect(rootBaseStore.appLoading).toBe(false); + + if (scenario.expected_result) { + expect(PluginState.installPlugin).toHaveBeenCalledTimes(1); + expect(PluginState.installPlugin).toHaveBeenCalledWith(); + + expect(mockedLoadCurrentUser).toHaveBeenCalledTimes(1); + expect(mockedLoadCurrentUser).toHaveBeenCalledWith(); + + expect(rootBaseStore.initializationError).toBeNull(); + } else { + expect(PluginState.installPlugin).toHaveBeenCalledTimes(0); + + expect(rootBaseStore.initializationError).toEqual( + '🚫 User is missing permission(s) ' + + scenario.missing_permissions.join(', ') + + ' to setup OnCall before it can be used' + ); + } + }); + test('plugin is not installed, signup is allowed, the user is an admin, and plugin installation throws an error', async () => { // mocks/setup const onCallApiUrl = 'http://asdfasdf.com'; @@ -218,7 +304,13 @@ describe('rootBaseStore', () => { const installPluginError = new Error('asdasdfasdfasf'); const humanReadableErrorMsg = 'asdfasldkfjaksdjflk'; - PluginState.checkIfBackendIsInMaintenanceMode = jest.fn().mockResolvedValueOnce(null); + contextSrv.user.orgRole = OrgRole.Admin; + contextSrv.accessControlEnabled = jest.fn().mockReturnValue(false); + contextSrv.hasAccess = jest.fn().mockReturnValue(true); + + PluginState.checkIfBackendIsInMaintenanceMode = jest + .fn() + .mockResolvedValueOnce({ currently_undergoing_maintenance_message: null }); PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce({ is_user_anonymous: false, is_installed: false, @@ -238,9 +330,6 @@ describe('rootBaseStore', () => { expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledTimes(1); expect(PluginState.checkIfPluginIsConnected).toHaveBeenCalledWith(onCallApiUrl); - expect(isUserActionAllowed).toHaveBeenCalledTimes(1); - expect(isUserActionAllowed).toHaveBeenCalledWith(PluginInstallAction); - expect(PluginState.installPlugin).toHaveBeenCalledTimes(1); expect(PluginState.installPlugin).toHaveBeenCalledWith(); @@ -263,7 +352,9 @@ describe('rootBaseStore', () => { const version = 'asdfalkjslkjdf'; const license = 'lkjdkjfdkjfdjkfd'; - PluginState.checkIfBackendIsInMaintenanceMode = jest.fn().mockResolvedValueOnce(null); + PluginState.checkIfBackendIsInMaintenanceMode = jest + .fn() + .mockResolvedValueOnce({ currently_undergoing_maintenance_message: null }); PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce({ is_user_anonymous: false, is_installed: true, @@ -299,7 +390,9 @@ describe('rootBaseStore', () => { const mockedLoadCurrentUser = jest.fn(); const syncDataWithOnCallError = 'asdasdfasdfasf'; - PluginState.checkIfBackendIsInMaintenanceMode = jest.fn().mockResolvedValueOnce(null); + PluginState.checkIfBackendIsInMaintenanceMode = jest + .fn() + .mockResolvedValueOnce({ currently_undergoing_maintenance_message: null }); PluginState.checkIfPluginIsConnected = jest.fn().mockResolvedValueOnce({ is_user_anonymous: false, is_installed: true, diff --git a/grafana-plugin/src/utils/authorization/index.ts b/grafana-plugin/src/utils/authorization/index.ts index 10574c47..0efc0056 100644 --- a/grafana-plugin/src/utils/authorization/index.ts +++ b/grafana-plugin/src/utils/authorization/index.ts @@ -25,7 +25,6 @@ export enum Resource { OTHER_SETTINGS = 'other-settings', TEAMS = 'teams', - PLUGINS = 'plugins', } export enum Action { @@ -35,7 +34,6 @@ export enum Action { TEST = 'test', EXPORT = 'export', UPDATE_SETTINGS = 'update-settings', - INSTALL = 'install', } type Actions = @@ -66,8 +64,7 @@ type Actions = | 'UserSettingsAdmin' | 'OtherSettingsRead' | 'OtherSettingsWrite' - | 'TeamsWrite' - | 'PluginsInstall'; + | 'TeamsWrite'; const roleMapping: Record = { [OrgRole.Admin]: 0, @@ -164,5 +161,4 @@ export const UserActions: { [action in Actions]: UserAction } = { // These are not oncall specific TeamsWrite: constructAction(Resource.TEAMS, Action.WRITE, OrgRole.Admin, false), - PluginsInstall: constructAction(Resource.PLUGINS, Action.INSTALL, OrgRole.Admin, false), }; diff --git a/grafana-plugin/src/utils/consts.ts b/grafana-plugin/src/utils/consts.ts index 228e72ad..2ee3b7c0 100644 --- a/grafana-plugin/src/utils/consts.ts +++ b/grafana-plugin/src/utils/consts.ts @@ -4,9 +4,17 @@ import plugin from '../../package.json'; // eslint-disable-line export const APP_TITLE = 'Grafana OnCall'; export const APP_SUBTITLE = `Developer-friendly incident response (${plugin?.version})`; +export const APP_VERSION = `${plugin?.version}`; + +export const CLOUD_VERSION_REGEX = new RegExp('r[\\d]+-v[\\d]+.[\\d]+.[\\d]+'); + // License export const GRAFANA_LICENSE_OSS = 'OpenSource'; +export const GRAFANA_LICENSE_CLOUD = 'Cloud'; + +export const FALLBACK_LICENSE = CLOUD_VERSION_REGEX.test(APP_VERSION) ? GRAFANA_LICENSE_CLOUD : GRAFANA_LICENSE_OSS; + // height of new Grafana sticky header with breadcrumbs export const GRAFANA_HEADER_HEIGTH = 80;