From cf55d6889429df959b48d6f54ccb4bad5b690cbe Mon Sep 17 00:00:00 2001 From: Peter Holmberg Date: Thu, 14 Mar 2019 17:20:33 +0100 Subject: [PATCH 001/103] using refId from panel model --- public/app/core/utils/explore.ts | 13 +++++------- public/app/core/utils/query.ts | 12 +++++++++++ .../features/dashboard/state/PanelModel.ts | 15 +++----------- public/app/features/explore/state/actions.ts | 20 ++++++++++--------- public/app/features/explore/state/reducers.ts | 8 +++++--- 5 files changed, 36 insertions(+), 32 deletions(-) create mode 100644 public/app/core/utils/query.ts diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts index 31e5a392050..33adbfb5a73 100644 --- a/public/app/core/utils/explore.ts +++ b/public/app/core/utils/explore.ts @@ -23,6 +23,7 @@ import { ResultGetter, } from 'app/types/explore'; import { LogsDedupStrategy } from 'app/core/logs_model'; +import { getNextQueryLetter } from './query'; export const DEFAULT_RANGE = { from: 'now-6h', @@ -225,12 +226,8 @@ export function generateKey(index = 0): string { return `Q-${Date.now()}-${Math.random()}-${index}`; } -export function generateRefId(index = 0): string { - return `${index + 1}`; -} - -export function generateEmptyQuery(index = 0): { refId: string; key: string } { - return { refId: generateRefId(index), key: generateKey(index) }; +export function generateEmptyQuery(queries: DataQuery[], index = 0): { refId: string; key: string } { + return { refId: getNextQueryLetter(queries), key: generateKey(index) }; } /** @@ -238,9 +235,9 @@ export function generateEmptyQuery(index = 0): { refId: string; key: string } { */ export function ensureQueries(queries?: DataQuery[]): DataQuery[] { if (queries && typeof queries === 'object' && queries.length > 0) { - return queries.map((query, i) => ({ ...query, ...generateEmptyQuery(i) })); + return queries.map((query, i) => ({ ...query, ...generateEmptyQuery(queries, i) })); } - return [{ ...generateEmptyQuery() }]; + return [{ ...generateEmptyQuery(queries) }]; } /** diff --git a/public/app/core/utils/query.ts b/public/app/core/utils/query.ts new file mode 100644 index 00000000000..3a3fd64fbc8 --- /dev/null +++ b/public/app/core/utils/query.ts @@ -0,0 +1,12 @@ +import _ from 'lodash'; +import { DataQuery } from '@grafana/ui/'; + +export const getNextQueryLetter = (queries: DataQuery[]): string => { + const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + + return _.find(letters, refId => { + return _.every(queries, other => { + return other.refId !== refId; + }); + }); +}; diff --git a/public/app/features/dashboard/state/PanelModel.ts b/public/app/features/dashboard/state/PanelModel.ts index 128bd8d0785..db9a9c04e14 100644 --- a/public/app/features/dashboard/state/PanelModel.ts +++ b/public/app/features/dashboard/state/PanelModel.ts @@ -5,6 +5,7 @@ import _ from 'lodash'; import { Emitter } from 'app/core/utils/emitter'; import { DataQuery, TimeSeries, Threshold, ScopedVars, PanelTypeChangedHook } from '@grafana/ui'; import { TableData } from '@grafana/ui/src'; +import { getNextQueryLetter } from '../../../core/utils/query'; export interface GridPos { x: number; @@ -128,7 +129,7 @@ export class PanelModel { if (this.targets) { for (const query of this.targets) { if (!query.refId) { - query.refId = this.getNextQueryLetter(); + query.refId = getNextQueryLetter(this.targets); } } } @@ -266,20 +267,10 @@ export class PanelModel { addQuery(query?: Partial) { query = query || { refId: 'A' }; - query.refId = this.getNextQueryLetter(); + query.refId = getNextQueryLetter(this.targets); this.targets.push(query as DataQuery); } - getNextQueryLetter(): string { - const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; - - return _.find(letters, refId => { - return _.every(this.targets, other => { - return other.refId !== refId; - }); - }); - } - changeQuery(query: DataQuery, index: number) { // ensure refId is maintained query.refId = this.targets[index].refId; diff --git a/public/app/features/explore/state/actions.ts b/public/app/features/explore/state/actions.ts index e0b84320fa7..fda8fe5eef4 100644 --- a/public/app/features/explore/state/actions.ts +++ b/public/app/features/explore/state/actions.ts @@ -60,7 +60,6 @@ import { splitCloseAction, splitOpenAction, addQueryRowAction, - AddQueryRowPayload, toggleGraphAction, toggleLogsAction, toggleTableAction, @@ -87,9 +86,12 @@ const updateExploreUIState = (exploreId, uiStateFragment: Partial { - const query = generateEmptyQuery(index + 1); - return addQueryRowAction({ exploreId, index, query }); +export function addQueryRow(exploreId: ExploreId, index: number): ThunkResult { + return (dispatch, getState) => { + const query = generateEmptyQuery(getState().explore[exploreId].queries, index); + + dispatch(addQueryRowAction({ exploreId, index, query })); + }; } /** @@ -126,10 +128,10 @@ export function changeQuery( index: number, override: boolean ): ThunkResult { - return dispatch => { + return (dispatch, getState) => { // Null query means reset if (query === null) { - query = { ...generateEmptyQuery(index) }; + query = { ...generateEmptyQuery(getState().explore[exploreId].queries) }; } dispatch(changeQueryAction({ exploreId, query, index, override })); @@ -287,7 +289,7 @@ export function importQueries( const nextQueries = importedQueries.map((q, i) => ({ ...q, - ...generateEmptyQuery(i), + ...generateEmptyQuery(queries), })); dispatch(queriesImportedAction({ exploreId, queries: nextQueries })); @@ -629,9 +631,9 @@ export function scanStart(exploreId: ExploreId, scanner: RangeScanner): ThunkRes * Use this action for clicks on query examples. Triggers a query run. */ export function setQueries(exploreId: ExploreId, rawQueries: DataQuery[]): ThunkResult { - return dispatch => { + return (dispatch, getState) => { // Inject react keys into query objects - const queries = rawQueries.map(q => ({ ...q, ...generateEmptyQuery() })); + const queries = rawQueries.map(q => ({ ...q, ...generateEmptyQuery(getState().explore[exploreId].queries) })); dispatch(setQueriesAction({ exploreId, queries })); dispatch(runQueries(exploreId)); }; diff --git a/public/app/features/explore/state/reducers.ts b/public/app/features/explore/state/reducers.ts index a8815842c89..32bfe09a96b 100644 --- a/public/app/features/explore/state/reducers.ts +++ b/public/app/features/explore/state/reducers.ts @@ -127,7 +127,7 @@ export const itemReducer = reducerFactory({} as ExploreItemSta const { query, index } = action.payload; // Override path: queries are completely reset - const nextQuery: DataQuery = { ...query, ...generateEmptyQuery(index) }; + const nextQuery: DataQuery = { ...query, ...generateEmptyQuery(state.queries) }; const nextQueries = [...queries]; nextQueries[index] = nextQuery; @@ -267,7 +267,7 @@ export const itemReducer = reducerFactory({} as ExploreItemSta // Modify all queries nextQueries = queries.map((query, i) => ({ ...modifier({ ...query }, modification), - ...generateEmptyQuery(i), + ...generateEmptyQuery(state.queries), })); // Discard all ongoing transactions nextQueryTransactions = []; @@ -276,7 +276,9 @@ export const itemReducer = reducerFactory({} as ExploreItemSta nextQueries = queries.map((query, i) => { // Synchronize all queries with local query cache to ensure consistency // TODO still needed? - return i === index ? { ...modifier({ ...query }, modification), ...generateEmptyQuery(i) } : query; + return i === index + ? { ...modifier({ ...query }, modification), ...generateEmptyQuery(state.queries) } + : query; }); nextQueryTransactions = queryTransactions // Consume the hint corresponding to the action From 52dcb9bf002f4670e452d88f1d53255407dd1aab Mon Sep 17 00:00:00 2001 From: Peter Holmberg Date: Thu, 14 Mar 2019 17:39:56 +0100 Subject: [PATCH 002/103] renaming function --- public/app/core/utils/explore.ts | 4 ++-- public/app/core/utils/query.ts | 2 +- public/app/features/dashboard/state/PanelModel.ts | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts index 33adbfb5a73..06456fef0ba 100644 --- a/public/app/core/utils/explore.ts +++ b/public/app/core/utils/explore.ts @@ -23,7 +23,7 @@ import { ResultGetter, } from 'app/types/explore'; import { LogsDedupStrategy } from 'app/core/logs_model'; -import { getNextQueryLetter } from './query'; +import { getNextRefIdLetter } from './query'; export const DEFAULT_RANGE = { from: 'now-6h', @@ -227,7 +227,7 @@ export function generateKey(index = 0): string { } export function generateEmptyQuery(queries: DataQuery[], index = 0): { refId: string; key: string } { - return { refId: getNextQueryLetter(queries), key: generateKey(index) }; + return { refId: getNextRefIdLetter(queries), key: generateKey(index) }; } /** diff --git a/public/app/core/utils/query.ts b/public/app/core/utils/query.ts index 3a3fd64fbc8..304dcf1846f 100644 --- a/public/app/core/utils/query.ts +++ b/public/app/core/utils/query.ts @@ -1,7 +1,7 @@ import _ from 'lodash'; import { DataQuery } from '@grafana/ui/'; -export const getNextQueryLetter = (queries: DataQuery[]): string => { +export const getNextRefIdLetter = (queries: DataQuery[]): string => { const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; return _.find(letters, refId => { diff --git a/public/app/features/dashboard/state/PanelModel.ts b/public/app/features/dashboard/state/PanelModel.ts index db9a9c04e14..5aca2bad462 100644 --- a/public/app/features/dashboard/state/PanelModel.ts +++ b/public/app/features/dashboard/state/PanelModel.ts @@ -5,7 +5,7 @@ import _ from 'lodash'; import { Emitter } from 'app/core/utils/emitter'; import { DataQuery, TimeSeries, Threshold, ScopedVars, PanelTypeChangedHook } from '@grafana/ui'; import { TableData } from '@grafana/ui/src'; -import { getNextQueryLetter } from '../../../core/utils/query'; +import { getNextRefIdLetter } from '../../../core/utils/query'; export interface GridPos { x: number; @@ -129,7 +129,7 @@ export class PanelModel { if (this.targets) { for (const query of this.targets) { if (!query.refId) { - query.refId = getNextQueryLetter(this.targets); + query.refId = getNextRefIdLetter(this.targets); } } } @@ -267,7 +267,7 @@ export class PanelModel { addQuery(query?: Partial) { query = query || { refId: 'A' }; - query.refId = getNextQueryLetter(this.targets); + query.refId = getNextRefIdLetter(this.targets); this.targets.push(query as DataQuery); } From 09eddd1676d03b5a9038d497c455cce4df710dc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Thu, 14 Mar 2019 18:48:12 +0100 Subject: [PATCH 003/103] Refactoring the bar gauge and the orientation modes --- .../components/BarGauge/BarGauge.story.tsx | 15 +- .../src/components/BarGauge/BarGauge.test.tsx | 1 + .../src/components/BarGauge/BarGauge.tsx | 214 ++++++----- .../__snapshots__/BarGauge.test.tsx.snap | 347 +----------------- .../plugins/panel/bargauge/BarGaugePanel.tsx | 1 + .../panel/bargauge/BarGaugePanelEditor.tsx | 13 +- public/app/plugins/panel/bargauge/types.ts | 4 + 7 files changed, 149 insertions(+), 446 deletions(-) diff --git a/packages/grafana-ui/src/components/BarGauge/BarGauge.story.tsx b/packages/grafana-ui/src/components/BarGauge/BarGauge.story.tsx index c7a53af5ccf..6754d43a5cc 100644 --- a/packages/grafana-ui/src/components/BarGauge/BarGauge.story.tsx +++ b/packages/grafana-ui/src/components/BarGauge/BarGauge.story.tsx @@ -1,6 +1,7 @@ import { storiesOf } from '@storybook/react'; -import { number, text } from '@storybook/addon-knobs'; +import { number, text, boolean } from '@storybook/addon-knobs'; import { BarGauge } from './BarGauge'; +import { VizOrientation } from '../../types'; import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; import { renderComponentWithTheme } from '../../utils/storybook/withTheme'; @@ -15,6 +16,8 @@ const getKnobs = () => { threshold2Color: text('threshold2Color', 'red'), unit: text('unit', 'ms'), decimals: number('decimals', 1), + horizontal: boolean('horizontal', false), + lcd: boolean('lcd', false), }; }; @@ -22,7 +25,7 @@ const BarGaugeStories = storiesOf('UI/BarGauge/BarGauge', module); BarGaugeStories.addDecorator(withCenteredStory); -BarGaugeStories.add('Vertical, with basic thresholds', () => { +BarGaugeStories.add('Simple with basic thresholds', () => { const { value, minValue, @@ -33,11 +36,13 @@ BarGaugeStories.add('Vertical, with basic thresholds', () => { threshold2Value, unit, decimals, + horizontal, + lcd, } = getKnobs(); return renderComponentWithTheme(BarGauge, { - width: 200, - height: 400, + width: 700, + height: 700, value: value, minValue: minValue, maxValue: maxValue, @@ -45,6 +50,8 @@ BarGaugeStories.add('Vertical, with basic thresholds', () => { prefix: '', postfix: '', decimals: decimals, + orientation: horizontal ? VizOrientation.Horizontal : VizOrientation.Vertical, + displayMode: lcd ? 'lcd' : 'simple', thresholds: [ { index: 0, value: -Infinity, color: 'green' }, { index: 1, value: threshold1Value, color: threshold1Color }, diff --git a/packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx b/packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx index 8fa0b2846a5..07a640ee7f7 100644 --- a/packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx +++ b/packages/grafana-ui/src/components/BarGauge/BarGauge.test.tsx @@ -15,6 +15,7 @@ const setup = (propOverrides?: object) => { minValue: 0, prefix: '', suffix: '', + displayMode: 'simple', thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }], unit: 'none', height: 300, diff --git a/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx b/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx index 97cc4792785..0de46709c16 100644 --- a/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx +++ b/packages/grafana-ui/src/components/BarGauge/BarGauge.tsx @@ -1,5 +1,5 @@ // Library -import React, { PureComponent, CSSProperties } from 'react'; +import React, { PureComponent, CSSProperties, ReactNode } from 'react'; import tinycolor from 'tinycolor2'; // Utils @@ -23,22 +23,37 @@ export interface Props extends Themeable { prefix?: string; suffix?: string; decimals?: number; + displayMode: 'simple' | 'lcd'; } -/* - * This visualization is still in POC state, needed more tests & better structure - */ export class BarGauge extends PureComponent { static defaultProps: Partial = { maxValue: 100, minValue: 0, value: 100, unit: 'none', + displayMode: 'simple', orientation: VizOrientation.Horizontal, thresholds: [], valueMappings: [], }; + render() { + const { maxValue, minValue, unit, decimals, displayMode } = this.props; + + const numericValue = this.getNumericValue(); + const valuePercent = Math.min(numericValue / (maxValue - minValue), 1); + + const formatFunc = getValueFormat(unit); + const valueFormatted = formatFunc(numericValue, decimals); + + if (displayMode === 'lcd') { + return this.renderLcdMode(valueFormatted, valuePercent); + } else { + return this.renderSimpleMode(valueFormatted, valuePercent); + } + } + getNumericValue(): number { if (Number.isFinite(this.props.value as number)) { return this.props.value as number; @@ -70,6 +85,70 @@ export class BarGauge extends PureComponent { }; } + getValueStyles(value: string, color: string, width: number): CSSProperties { + const guess = width / (value.length * 1.1); + const fontSize = Math.min(Math.max(guess, 14), 40); + + return { + color: color, + fontSize: fontSize + 'px', + }; + } + + /* + * Return width or height depending on viz orientation + * */ + get size() { + const { height, width, orientation } = this.props; + return orientation === VizOrientation.Horizontal ? width : height; + } + + renderSimpleMode(valueFormatted: string, valuePercent: number): ReactNode { + const { height, width, orientation } = this.props; + + const maxSize = this.size * BAR_SIZE_RATIO; + const barSize = Math.max(valuePercent * maxSize, 0); + const colors = this.getValueColors(); + const valueStyles = this.getValueStyles(valueFormatted, colors.value, this.size - maxSize); + + const containerStyles: CSSProperties = { + width: `${width}px`, + height: `${height}px`, + display: 'flex', + }; + + const barStyles: CSSProperties = { + backgroundColor: colors.bar, + }; + + // Custom styles for vertical orientation + if (orientation === VizOrientation.Vertical) { + containerStyles.flexDirection = 'column'; + containerStyles.justifyContent = 'flex-end'; + barStyles.height = `${barSize}px`; + barStyles.width = `${width}px`; + barStyles.borderTop = `1px solid ${colors.border}`; + } else { + // Custom styles for horizontal orientation + containerStyles.flexDirection = 'row-reverse'; + containerStyles.justifyContent = 'flex-end'; + containerStyles.alignItems = 'center'; + barStyles.height = `${height}px`; + barStyles.width = `${barSize}px`; + barStyles.marginRight = '10px'; + barStyles.borderRight = `1px solid ${colors.border}`; + } + + return ( +
+
+ {valueFormatted} +
+
+
+ ); + } + getCellColor(positionValue: TimeSeriesValue): string { const { thresholds, theme, value } = this.props; const activeThreshold = getThresholdForValue(thresholds, positionValue); @@ -92,117 +171,51 @@ export class BarGauge extends PureComponent { return 'gray'; } - getValueStyles(value: string, color: string, width: number): CSSProperties { - const guess = width / (value.length * 1.1); - const fontSize = Math.min(Math.max(guess, 14), 40); - - return { - color: color, - fontSize: fontSize + 'px', - }; - } - - renderVerticalBar(valueFormatted: string, valuePercent: number) { - const { height, width } = this.props; - - const maxHeight = height * BAR_SIZE_RATIO; - const barHeight = Math.max(valuePercent * maxHeight, 0); - const colors = this.getValueColors(); - const valueStyles = this.getValueStyles(valueFormatted, colors.value, width); - - const containerStyles: CSSProperties = { - width: `${width}px`, - height: `${height}px`, - display: 'flex', - flexDirection: 'column', - justifyContent: 'flex-end', - }; - - const barStyles: CSSProperties = { - height: `${barHeight}px`, - width: `${width}px`, - backgroundColor: colors.bar, - borderTop: `1px solid ${colors.border}`, - }; - - return ( -
-
- {valueFormatted} -
-
-
- ); - } - - renderHorizontalBar(valueFormatted: string, valuePercent: number) { - const { height, width } = this.props; - - const maxWidth = width * BAR_SIZE_RATIO; - const barWidth = Math.max(valuePercent * maxWidth, 0); - const colors = this.getValueColors(); - const valueStyles = this.getValueStyles(valueFormatted, colors.value, width * (1 - BAR_SIZE_RATIO)); - - valueStyles.marginLeft = '8px'; - - const containerStyles: CSSProperties = { - width: `${width}px`, - height: `${height}px`, - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - }; - - const barStyles = { - height: `${height}px`, - width: `${barWidth}px`, - backgroundColor: colors.bar, - borderRight: `1px solid ${colors.border}`, - }; - - return ( -
-
-
- {valueFormatted} -
-
- ); - } - - renderHorizontalLCD(valueFormatted: string, valuePercent: number) { - const { height, width, maxValue, minValue } = this.props; + renderLcdMode(valueFormatted: string, valuePercent: number): ReactNode { + const { height, width, maxValue, minValue, orientation } = this.props; const valueRange = maxValue - minValue; - const maxWidth = width * BAR_SIZE_RATIO; + const maxSize = this.size * BAR_SIZE_RATIO; const cellSpacing = 4; const cellCount = 30; - const cellWidth = (maxWidth - cellSpacing * cellCount) / cellCount; + const cellSize = (maxSize - cellSpacing * cellCount) / cellCount; const colors = this.getValueColors(); - const valueStyles = this.getValueStyles(valueFormatted, colors.value, width * (1 - BAR_SIZE_RATIO)); - valueStyles.marginLeft = '8px'; + const valueStyles = this.getValueStyles(valueFormatted, colors.value, this.size - maxSize); const containerStyles: CSSProperties = { width: `${width}px`, height: `${height}px`, display: 'flex', - flexDirection: 'row', - alignItems: 'center', }; + if (orientation === VizOrientation.Horizontal) { + containerStyles.flexDirection = 'row'; + containerStyles.alignItems = 'center'; + } else { + containerStyles.flexDirection = 'column-reverse'; + containerStyles.alignItems = 'center'; + } + const cells: JSX.Element[] = []; for (let i = 0; i < cellCount; i++) { const currentValue = (valueRange / cellCount) * i; const cellColor = this.getCellColor(currentValue); const cellStyles: CSSProperties = { - width: `${cellWidth}px`, backgroundColor: cellColor, - marginRight: '4px', - height: `${height}px`, borderRadius: '2px', }; + if (orientation === VizOrientation.Horizontal) { + cellStyles.width = `${cellSize}px`; + cellStyles.height = `${height}px`; + cellStyles.marginRight = '4px'; + } else { + cellStyles.height = `${cellSize}px`; + cellStyles.width = `${width}px`; + cellStyles.marginTop = '4px'; + } + cells.push(
); } @@ -215,21 +228,6 @@ export class BarGauge extends PureComponent {
); } - - render() { - const { maxValue, minValue, orientation, unit, decimals } = this.props; - - const numericValue = this.getNumericValue(); - const valuePercent = Math.min(numericValue / (maxValue - minValue), 1); - - const formatFunc = getValueFormat(unit); - const valueFormatted = formatFunc(numericValue, decimals); - const vertical = orientation === 'vertical'; - - return vertical - ? this.renderVerticalBar(valueFormatted, valuePercent) - : this.renderHorizontalLCD(valueFormatted, valuePercent); - } } interface BarColors { diff --git a/packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap b/packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap index 65c647bd90c..077389be418 100644 --- a/packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap +++ b/packages/grafana-ui/src/components/BarGauge/__snapshots__/BarGauge.test.tsx.snap @@ -6,353 +6,34 @@ exports[`Render BarGauge with basic options should render 1`] = ` Object { "alignItems": "center", "display": "flex", - "flexDirection": "row", + "flexDirection": "row-reverse", "height": "300px", + "justifyContent": "flex-end", "width": "300px", } } > -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
25
+
`; diff --git a/public/app/plugins/panel/bargauge/BarGaugePanel.tsx b/public/app/plugins/panel/bargauge/BarGaugePanel.tsx index 9f60c3440eb..708b472ec2e 100644 --- a/public/app/plugins/panel/bargauge/BarGaugePanel.tsx +++ b/public/app/plugins/panel/bargauge/BarGaugePanel.tsx @@ -35,6 +35,7 @@ export class BarGaugePanel extends PureComponent { thresholds={options.thresholds} valueMappings={options.valueMappings} theme={config.theme} + displayMode={options.displayMode} /> ); } diff --git a/public/app/plugins/panel/bargauge/BarGaugePanelEditor.tsx b/public/app/plugins/panel/bargauge/BarGaugePanelEditor.tsx index 4232155228b..87e5defd277 100644 --- a/public/app/plugins/panel/bargauge/BarGaugePanelEditor.tsx +++ b/public/app/plugins/panel/bargauge/BarGaugePanelEditor.tsx @@ -7,7 +7,7 @@ import { ThresholdsEditor, ValueMappingsEditor, PanelOptionsGrid, PanelOptionsGr // Types import { FormLabel, PanelEditorProps, Threshold, Select, ValueMapping } from '@grafana/ui'; -import { BarGaugeOptions, orientationOptions } from './types'; +import { BarGaugeOptions, orientationOptions, displayModes } from './types'; import { SingleStatValueOptions } from '../gauge/types'; export class BarGaugePanelEditor extends PureComponent> { @@ -32,6 +32,7 @@ export class BarGaugePanelEditor extends PureComponent this.props.onOptionsChange({ ...this.props.options, minValue: target.value }); onMaxValueChange = ({ target }) => this.props.onOptionsChange({ ...this.props.options, maxValue: target.value }); onOrientationChange = ({ value }) => this.props.onOptionsChange({ ...this.props.options, orientation: value }); + onDisplayModeChange = ({ value }) => this.props.onOptionsChange({ ...this.props.options, displayMode: value }); render() { const { options } = this.props; @@ -53,6 +54,16 @@ export class BarGaugePanelEditor extends PureComponent item.value === options.orientation)} />
+
+ Display Mode + + {error && !hideErrorMessage && {error}} +
+ ); + } +} diff --git a/packages/grafana-ui/src/types/forms.ts b/packages/grafana-ui/src/types/forms.ts new file mode 100644 index 00000000000..602ee434ee5 --- /dev/null +++ b/packages/grafana-ui/src/types/forms.ts @@ -0,0 +1,26 @@ +export enum InputStatus { + Invalid = 'invalid', + Valid = 'valid', +} + +export enum InputTypes { + Text = 'text', + Number = 'number', + Password = 'password', + Email = 'email', +} + +export enum EventsWithValidation { + onBlur = 'onBlur', + onFocus = 'onFocus', + onChange = 'onChange', +} + +export interface ValidationRule { + rule: (valueToValidate: string) => boolean; + errorMessage: string; +} + +export interface ValidationEvents { + [eventName: string]: ValidationRule[]; +} diff --git a/packages/grafana-ui/src/types/index.ts b/packages/grafana-ui/src/types/index.ts index b09d88bab4d..390f8a4db29 100644 --- a/packages/grafana-ui/src/types/index.ts +++ b/packages/grafana-ui/src/types/index.ts @@ -5,3 +5,4 @@ export * from './plugin'; export * from './datasource'; export * from './theme'; export * from './threshold'; +export * from './forms'; diff --git a/packages/grafana-ui/src/utils/index.ts b/packages/grafana-ui/src/utils/index.ts index a08b9ce1a89..00a6c20d4d1 100644 --- a/packages/grafana-ui/src/utils/index.ts +++ b/packages/grafana-ui/src/utils/index.ts @@ -7,3 +7,4 @@ export * from './thresholds'; export * from './string'; export * from './deprecationWarning'; export { getMappedValue } from './valueMappings'; +export * from './validate'; diff --git a/packages/grafana-ui/src/utils/validate.ts b/packages/grafana-ui/src/utils/validate.ts new file mode 100644 index 00000000000..20979ae33ff --- /dev/null +++ b/packages/grafana-ui/src/utils/validate.ts @@ -0,0 +1,15 @@ +import { EventsWithValidation, ValidationEvents, ValidationRule } from '../types'; + +export const validate = (value: string, validationRules: ValidationRule[]) => { + const errors = validationRules.reduce((acc, currentRule) => { + if (!currentRule.rule(value)) { + return acc.concat(currentRule.errorMessage); + } + return acc; + }, []); + return errors.length > 0 ? errors : null; +}; + +export const hasValidationEvent = (event: EventsWithValidation, validationEvents?: ValidationEvents) => { + return validationEvents && validationEvents[event]; +}; From 515fb5903ee772a1f43823d53c13c2ba2dea3e74 Mon Sep 17 00:00:00 2001 From: Peter Holmberg Date: Mon, 18 Mar 2019 10:44:00 +0100 Subject: [PATCH 017/103] sorting imports --- public/app/features/dashboard/state/PanelModel.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/public/app/features/dashboard/state/PanelModel.ts b/public/app/features/dashboard/state/PanelModel.ts index 5aca2bad462..f49ed2c0785 100644 --- a/public/app/features/dashboard/state/PanelModel.ts +++ b/public/app/features/dashboard/state/PanelModel.ts @@ -1,11 +1,13 @@ // Libraries import _ from 'lodash'; -// Types +// Utils import { Emitter } from 'app/core/utils/emitter'; +import { getNextRefIdLetter } from 'app/core/utils/query'; + +// Types import { DataQuery, TimeSeries, Threshold, ScopedVars, PanelTypeChangedHook } from '@grafana/ui'; import { TableData } from '@grafana/ui/src'; -import { getNextRefIdLetter } from '../../../core/utils/query'; export interface GridPos { x: number; From 39728c885b8c9567ad5887895cb21bd88c877ab8 Mon Sep 17 00:00:00 2001 From: Peter Holmberg Date: Mon, 18 Mar 2019 11:17:58 +0100 Subject: [PATCH 018/103] rename to char --- public/app/core/utils/explore.ts | 4 ++-- public/app/core/utils/query.ts | 2 +- public/app/features/dashboard/state/PanelModel.ts | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts index 45e26e79ebf..2e79610c3c6 100644 --- a/public/app/core/utils/explore.ts +++ b/public/app/core/utils/explore.ts @@ -23,7 +23,7 @@ import { ResultGetter, } from 'app/types/explore'; import { LogsDedupStrategy } from 'app/core/logs_model'; -import { getNextRefIdLetter } from './query'; +import { getNextRefIdChar } from './query'; export const DEFAULT_RANGE = { from: 'now-6h', @@ -227,7 +227,7 @@ export function generateKey(index = 0): string { } export function generateEmptyQuery(queries: DataQuery[], index = 0): DataQuery { - return { refId: getNextRefIdLetter(queries), key: generateKey(index) }; + return { refId: getNextRefIdChar(queries), key: generateKey(index) }; } /** diff --git a/public/app/core/utils/query.ts b/public/app/core/utils/query.ts index 304dcf1846f..933a73138a8 100644 --- a/public/app/core/utils/query.ts +++ b/public/app/core/utils/query.ts @@ -1,7 +1,7 @@ import _ from 'lodash'; import { DataQuery } from '@grafana/ui/'; -export const getNextRefIdLetter = (queries: DataQuery[]): string => { +export const getNextRefIdChar = (queries: DataQuery[]): string => { const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; return _.find(letters, refId => { diff --git a/public/app/features/dashboard/state/PanelModel.ts b/public/app/features/dashboard/state/PanelModel.ts index f49ed2c0785..8ffce0f1e3b 100644 --- a/public/app/features/dashboard/state/PanelModel.ts +++ b/public/app/features/dashboard/state/PanelModel.ts @@ -3,7 +3,7 @@ import _ from 'lodash'; // Utils import { Emitter } from 'app/core/utils/emitter'; -import { getNextRefIdLetter } from 'app/core/utils/query'; +import { getNextRefIdChar } from 'app/core/utils/query'; // Types import { DataQuery, TimeSeries, Threshold, ScopedVars, PanelTypeChangedHook } from '@grafana/ui'; @@ -131,7 +131,7 @@ export class PanelModel { if (this.targets) { for (const query of this.targets) { if (!query.refId) { - query.refId = getNextRefIdLetter(this.targets); + query.refId = getNextRefIdChar(this.targets); } } } @@ -269,7 +269,7 @@ export class PanelModel { addQuery(query?: Partial) { query = query || { refId: 'A' }; - query.refId = getNextRefIdLetter(this.targets); + query.refId = getNextRefIdChar(this.targets); this.targets.push(query as DataQuery); } From cb9bda810fae9bb49ee25d2059dddbec53766ad7 Mon Sep 17 00:00:00 2001 From: Peter Holmberg Date: Mon, 18 Mar 2019 11:21:40 +0100 Subject: [PATCH 019/103] test --- public/app/core/utils/query.test.ts | 30 +++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 public/app/core/utils/query.test.ts diff --git a/public/app/core/utils/query.test.ts b/public/app/core/utils/query.test.ts new file mode 100644 index 00000000000..a69162751a4 --- /dev/null +++ b/public/app/core/utils/query.test.ts @@ -0,0 +1,30 @@ +import { DataQuery } from '@grafana/ui'; +import { getNextRefIdChar } from './query'; + +const dataQueries: DataQuery[] = [ + { + refId: 'A', + }, + { + refId: 'B', + }, + { + refId: 'C', + }, + { + refId: 'D', + }, + { + refId: 'E', + }, +]; + +describe('Get next refId char', () => { + it('should return next char', () => { + expect(getNextRefIdChar(dataQueries)).toEqual('F'); + }); + + it('should get first char', () => { + expect(getNextRefIdChar([])).toEqual('A'); + }); +}); From be7a5dab69cefe7e2e6dac258fc31cbeb1ee99d8 Mon Sep 17 00:00:00 2001 From: Peter Holmberg Date: Mon, 18 Mar 2019 11:23:40 +0100 Subject: [PATCH 020/103] reorder imports --- public/app/core/utils/explore.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts index 2e79610c3c6..fdc63b931f7 100644 --- a/public/app/core/utils/explore.ts +++ b/public/app/core/utils/explore.ts @@ -9,6 +9,7 @@ import store from 'app/core/store'; import { parse as parseDate } from 'app/core/utils/datemath'; import { colors } from '@grafana/ui'; import TableModel, { mergeTablesIntoModel } from 'app/core/table_model'; +import { getNextRefIdChar } from './query'; // Types import { RawTimeRange, IntervalValues, DataQuery, DataSourceApi } from '@grafana/ui'; @@ -23,7 +24,6 @@ import { ResultGetter, } from 'app/types/explore'; import { LogsDedupStrategy } from 'app/core/logs_model'; -import { getNextRefIdChar } from './query'; export const DEFAULT_RANGE = { from: 'now-6h', From 2b9cf1132f987ee3f1db9a606d5ec7fc09f471bb Mon Sep 17 00:00:00 2001 From: Oleg Gaidarenko Date: Mon, 18 Mar 2019 13:31:57 +0100 Subject: [PATCH 021/103] Use ora#fail instead of console.log Since with ora#fail you can stderr it instead of using the stdout, and it's a bit nicer since it will show that cross sign :) --- scripts/cli/utils/useSpinner.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/cli/utils/useSpinner.ts b/scripts/cli/utils/useSpinner.ts index 81ed9bb6fcf..298a6516689 100644 --- a/scripts/cli/utils/useSpinner.ts +++ b/scripts/cli/utils/useSpinner.ts @@ -10,8 +10,7 @@ export const useSpinner = (spinnerLabel: string, fn: FnToSpin, killProcess await fn(options); spinner.succeed(); } catch (e) { - spinner.fail(); - console.log(e); + spinner.fail(e); if (killProcess) { process.exit(1); } From f3b9ce317e793900f23dd27055a545f4aafbee17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Fri, 15 Mar 2019 11:51:13 +0100 Subject: [PATCH 022/103] docs: intial draft for frontend review doc --- style_guides/frontend-review-checklist.md | 67 +++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 style_guides/frontend-review-checklist.md diff --git a/style_guides/frontend-review-checklist.md b/style_guides/frontend-review-checklist.md new file mode 100644 index 00000000000..39c1dea8ee4 --- /dev/null +++ b/style_guides/frontend-review-checklist.md @@ -0,0 +1,67 @@ +# Frontend Review Checklist + +## High level checks + +- [ ] The pull request adds value and the impact of the change is in line with [Frontend Style Guide](https://github.com/grafana/grafana/blob/master/style_guides/frontend.md). +- [ ] The pull request works the way it says it should do. +- [ ] The pull request does not increase the Angular code base. + > We are in the process of migrating to React so any increment of Angular code is generally discouraged from. (there are a few exceptions) +- [ ] The pull request closes one issue if possible and does not fix unrelated issues within the same pull request. +- [ ] The pull request contains necessary tests. + +## Low level checks + +- [ ] The pull request contains a title that explains the PR. +- [ ] The pull request contains necessary link(s) to issue(s). +- [ ] The pull request contains commits with commit messages that are small and understandable. +- [ ] The pull request does not contain magic strings or numbers that could be replaced with an `Enum` or `const` instead. +- [ ] The pull request does not increase the number of `implicit any` errors. +- [ ] The pull request does not contain uses of `any` or `{}` that are unexplainable. +- [ ] The pull request does not contain large React component that could easily be split into several smaller components. +- [ ] The pull request does not contain back end calls directly from components, use actions and Redux instead. + +### Bug specific checks + +- [ ] The pull request contains only one commit if possible. +- [ ] The pull request contains `closes: #Issue` or `fixes: #Issue` in pull request description. + +### Redux specific checks (skip if pull request does not contain Redux changes) + +- [ ] The pull request does not contain code that mutate state in reducers or thunks. +- [ ] The pull request uses helpers `actionCreatorFactory` and `reducerFactory` instead of traditional `switch statement` reducers in Redux. +- [ ] The pull request uses `reducerTester` to test reducers. +- [ ] The pull request does not contain code that access reducers state slice directly, instead the code uses state selectors to access state. + +## Common bad practices + +### 1. Missing Props/State type + +- React Component definitions + + ```jsx + // good + export class YourClass extends PureComponent<{},{}> { ... } + + // bad + export class YourClass extends PureComponent { ... } + ``` + +- React Component constructor + + ```typescript + // good + constructor(props:Props) {...} + + // bad + constructor(props) {...} + ``` + +- React Component defaultProps + + ```typescript + // good + static defaultProps: Partial = { ... } + + // bad + static defaultProps = { ... } + ``` From f251345b6804d3dad328fa60008e81d94323db16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Mon, 18 Mar 2019 07:55:44 +0100 Subject: [PATCH 023/103] docs: moved examples to frontend.md --- style_guides/frontend-review-checklist.md | 34 ------------ style_guides/frontend.md | 65 ++++++++++++++++------- 2 files changed, 46 insertions(+), 53 deletions(-) diff --git a/style_guides/frontend-review-checklist.md b/style_guides/frontend-review-checklist.md index 39c1dea8ee4..139c963b42d 100644 --- a/style_guides/frontend-review-checklist.md +++ b/style_guides/frontend-review-checklist.md @@ -31,37 +31,3 @@ - [ ] The pull request uses helpers `actionCreatorFactory` and `reducerFactory` instead of traditional `switch statement` reducers in Redux. - [ ] The pull request uses `reducerTester` to test reducers. - [ ] The pull request does not contain code that access reducers state slice directly, instead the code uses state selectors to access state. - -## Common bad practices - -### 1. Missing Props/State type - -- React Component definitions - - ```jsx - // good - export class YourClass extends PureComponent<{},{}> { ... } - - // bad - export class YourClass extends PureComponent { ... } - ``` - -- React Component constructor - - ```typescript - // good - constructor(props:Props) {...} - - // bad - constructor(props) {...} - ``` - -- React Component defaultProps - - ```typescript - // good - static defaultProps: Partial = { ... } - - // bad - static defaultProps = { ... } - ``` diff --git a/style_guides/frontend.md b/style_guides/frontend.md index caef4f711ef..18069183e66 100644 --- a/style_guides/frontend.md +++ b/style_guides/frontend.md @@ -1,36 +1,36 @@ # Frontend Style Guide -Generally we follow the Airbnb [React Style Guide](https://github.com/airbnb/javascript/tree/master/react). +Generally we follow the Airbnb [React Style Guide](https://github.com/airbnb/javascript/tree/master/react). ## Table of Contents - 1. [Basic Rules](#basic-rules) - 1. [File & Component Organization](#Organization) - 1. [Naming](#naming) - 1. [Declaration](#declaration) - 1. [Props](#props) - 1. [Refs](#refs) - 1. [Methods](#methods) - 1. [Ordering](#ordering) +1. [Basic Rules](#basic-rules) +1. [File & Component Organization](#Organization) +1. [Naming](#naming) +1. [Declaration](#declaration) +1. [Props](#props) +1. [Refs](#refs) +1. [Methods](#methods) +1. [Ordering](#ordering) ## Basic rules -* Try to keep files small and focused and break large components up into sub components. +- Try to keep files small and focused and break large components up into sub components. ## Organization -* Components and types that needs to be used by external plugins needs to go into @grafana/ui -* Components should get their own folder under features/xxx/components - * Sub components can live in that component folders, so small component do not need their own folder - * Place test next to their component file (same dir) - * Component sass should live in the same folder as component code -* State logic & domain models should live in features/xxx/state -* Containers (pages) can live in feature root features/xxx - * up for debate? +- Components and types that needs to be used by external plugins needs to go into @grafana/ui +- Components should get their own folder under features/xxx/components + - Sub components can live in that component folders, so small component do not need their own folder + - Place test next to their component file (same dir) + - Component sass should live in the same folder as component code +- State logic & domain models should live in features/xxx/state +- Containers (pages) can live in feature root features/xxx + - up for debate? ## Props -* Name callback props & handlers with a "on" prefix. +- Name callback props & handlers with a "on" prefix. ```tsx // good @@ -56,5 +56,32 @@ render() { } ``` +- React Component definitions +```jsx +// good +export class YourClass extends PureComponent<{},{}> { ... } +// bad +export class YourClass extends PureComponent { ... } +``` + +- React Component constructor + +```typescript +// good +constructor(props:Props) {...} + +// bad +constructor(props) {...} +``` + +- React Component defaultProps + +```typescript +// good +static defaultProps: Partial = { ... } + +// bad +static defaultProps = { ... } +``` From ed1b00190479a5053ed3e7c14d18fb5d2f5479b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Mon, 18 Mar 2019 12:03:34 +0100 Subject: [PATCH 024/103] docs: renamed file and added redux framework file --- ...st.md => pull-request-review-checklist.md} | 17 +- style_guides/redux.md | 158 ++++++++++++++++++ 2 files changed, 168 insertions(+), 7 deletions(-) rename style_guides/{frontend-review-checklist.md => pull-request-review-checklist.md} (82%) create mode 100644 style_guides/redux.md diff --git a/style_guides/frontend-review-checklist.md b/style_guides/pull-request-review-checklist.md similarity index 82% rename from style_guides/frontend-review-checklist.md rename to style_guides/pull-request-review-checklist.md index 139c963b42d..2fd017386ea 100644 --- a/style_guides/frontend-review-checklist.md +++ b/style_guides/pull-request-review-checklist.md @@ -1,4 +1,4 @@ -# Frontend Review Checklist +# Pull Request Review Checklist ## High level checks @@ -15,19 +15,22 @@ - [ ] The pull request contains necessary link(s) to issue(s). - [ ] The pull request contains commits with commit messages that are small and understandable. - [ ] The pull request does not contain magic strings or numbers that could be replaced with an `Enum` or `const` instead. -- [ ] The pull request does not increase the number of `implicit any` errors. -- [ ] The pull request does not contain uses of `any` or `{}` that are unexplainable. -- [ ] The pull request does not contain large React component that could easily be split into several smaller components. -- [ ] The pull request does not contain back end calls directly from components, use actions and Redux instead. ### Bug specific checks - [ ] The pull request contains only one commit if possible. - [ ] The pull request contains `closes: #Issue` or `fixes: #Issue` in pull request description. +## Frontend specific checks + +- [ ] The pull request does not increase the number of `implicit any` errors. +- [ ] The pull request does not contain uses of `any` or `{}` without comments describing why. +- [ ] The pull request does not contain large React component that could easily be split into several smaller components. +- [ ] The pull request does not contain back end calls directly from components, use actions and Redux instead. + ### Redux specific checks (skip if pull request does not contain Redux changes) - [ ] The pull request does not contain code that mutate state in reducers or thunks. -- [ ] The pull request uses helpers `actionCreatorFactory` and `reducerFactory` instead of traditional `switch statement` reducers in Redux. -- [ ] The pull request uses `reducerTester` to test reducers. +- [ ] The pull request uses helpers `actionCreatorFactory` and `reducerFactory` instead of traditional `switch statement` reducers in Redux. ([Redux framework](https://github.com/grafana/grafana/blob/master/style_guides/redux.md)) +- [ ] The pull request uses `reducerTester` to test reducers.([Redux framework](https://github.com/grafana/grafana/blob/master/style_guides/redux.md)) - [ ] The pull request does not contain code that access reducers state slice directly, instead the code uses state selectors to access state. diff --git a/style_guides/redux.md b/style_guides/redux.md new file mode 100644 index 00000000000..ff64fe400f3 --- /dev/null +++ b/style_guides/redux.md @@ -0,0 +1,158 @@ +# Redux framework + +To reduce the amount of boilerplate code used to create a strongly typed redux solution with actions, action creators, reducers and tests we've introduced a small framework around Redux. + +`+` Much less boilerplate code +`-` Non Redux standard api + +## New core functionality + +### actionCreatorFactory + +Used to create an action creator with the following signature + +```typescript +{ type: string , (payload: T): {type: string; payload: T;} } +``` + +where the `type` string will be ensured to be unique and `T` is the type supplied to the factory. + +#### Example + +```typescript +export const someAction = actionCreatorFactory('SOME_ACTION').create(); + +// later when dispatched +someAction('this rocks!'); +``` + +```typescript +// best practices, always use an interface as type +interface SomeAction { + data: string; +} +export const someAction = actionCreatorFactory('SOME_ACTION').create(); + +// later when dispatched +someAction({ data: 'best practices' }); +``` + +```typescript +// declaring an action creator with a type string that has already been defined will throw +export const someAction = actionCreatorFactory('SOME_ACTION').create(); +export const theAction = actionCreatorFactory('SOME_ACTION').create(); // will throw +``` + +### noPayloadActionCreatorFactory + +Used when you don't need to supply a payload for your action. Will create an action creator with the following signature + +```typescript +{ type: string , (): {type: string; payload: undefined;} } +``` + +where the `type` string will be ensured to be unique. + +#### Example + +```typescript +export const noPayloadAction = noPayloadActionCreatorFactory('NO_PAYLOAD').create(); + +// later when dispatched +noPayloadAction(); +``` + +```typescript +// declaring an action creator with a type string that has already been defined will throw +export const noPayloadAction = noPayloadActionCreatorFactory('NO_PAYLOAD').create(); +export const noAction = noPayloadActionCreatorFactory('NO_PAYLOAD').create(); // will throw +``` + +### reducerFactory + +Fluent API used to create a reducer. (same as implementing the standard switch statement in Redux) + +#### Example + +```typescript +interface ExampleReducerState { + data: string[]; +} + +const intialState: ExampleReducerState = { data: [] }; + +export const someAction = actionCreatorFactory('SOME_ACTION').create(); +export const otherAction = actionCreatorFactory('Other_ACTION').create(); + +export const exampleReducer = reducerFactory(intialState) + // addMapper is the function that ties an action creator to a state change + .addMapper({ + // action creator to filter out which mapper to use + filter: someAction, + // mapper function where the state change occurs + mapper: (state, action) => ({ ...state, data: state.data.concat(action.payload) }), + }) + // a developer can just chain addMapper functions until reducer is done + .addMapper({ + filter: otherAction, + mapper: (state, action) => ({ ...state, data: action.payload }), + }) + .create(); // this will return the reducer +``` + +#### Typing limitations + +There is a challenge left with the mapper function that I can not solve with TypeScript. The signature of a mapper is + +```typescript +(state: State, action: ActionOf) => State; +``` + +If you would to return an object that is not of the state type like the following mapper + +```typescript +mapper: (state, action) => ({ nonExistingProperty: ''}), +``` + +Then you would receive the following compile error + +```shell +[ts] Property 'data' is missing in type '{ nonExistingProperty: string; }' but required in type 'ExampleReducerState'. [2741] +``` + +But if you return an object that is spreading state and add a non existing property type like the following mapper + +```typescript +mapper: (state, action) => ({ ...state, nonExistingProperty: ''}), +``` + +Then you would not receive any compile error. + +If you want to make sure that never happens you can just supply the State type to the mapper callback like the following mapper: + +```typescript +mapper: (state, action): ExampleReducerState => ({ ...state, nonExistingProperty: 'kalle' }), +``` + +Then you would receive the following compile error + +```shell +[ts] +Type '{ nonExistingProperty: string; data: string[]; }' is not assignable to type 'ExampleReducerState'. + Object literal may only specify known properties, and 'nonExistingProperty' does not exist in type 'ExampleReducerState'. [2322] +``` + +## New test functionality + +### reducerTester + +Fluent API that simplifies the testing of reducers + +#### Example + +```typescript +reducerTester() + .givenReducer(someReducer, initialState) + .whenActionIsDispatched(someAction('reducer tests')) + .thenStateShouldEqual({ ...initialState, data: 'reducer tests' }); +``` From 384e11fd6832d06c26af3873a12ef6337bcc7d3b Mon Sep 17 00:00:00 2001 From: Peter Holmberg Date: Mon, 18 Mar 2019 15:41:46 +0100 Subject: [PATCH 025/103] Copied from new timepicker and unified component branch --- .../src/components/Input}/Input.test.tsx | 12 +-- .../grafana-ui/src/components/Input/Input.tsx | 40 +++----- .../Input}/__snapshots__/Input.test.tsx.snap | 0 packages/grafana-ui/src/components/index.ts | 1 + packages/grafana-ui/src/types/forms.ts | 26 ----- packages/grafana-ui/src/types/index.ts | 2 +- .../grafana-ui/src/types/input.ts | 0 packages/grafana-ui/src/utils/validate.ts | 25 +++-- public/app/core/components/Form/Input.tsx | 94 ------------------- public/app/core/components/Form/index.ts | 1 - public/app/core/utils/validate.ts | 16 ---- .../dashboard/panel_editor/QueryOptions.tsx | 9 +- public/app/types/index.ts | 1 - 13 files changed, 40 insertions(+), 187 deletions(-) rename {public/app/core/components/Form => packages/grafana-ui/src/components/Input}/Input.test.tsx (83%) rename {public/app/core/components/Form => packages/grafana-ui/src/components/Input}/__snapshots__/Input.test.tsx.snap (100%) delete mode 100644 packages/grafana-ui/src/types/forms.ts rename public/app/types/form.ts => packages/grafana-ui/src/types/input.ts (100%) delete mode 100644 public/app/core/components/Form/Input.tsx delete mode 100644 public/app/core/components/Form/index.ts delete mode 100644 public/app/core/utils/validate.ts diff --git a/public/app/core/components/Form/Input.test.tsx b/packages/grafana-ui/src/components/Input/Input.test.tsx similarity index 83% rename from public/app/core/components/Form/Input.test.tsx rename to packages/grafana-ui/src/components/Input/Input.test.tsx index 9e903208e80..1d39b594b1c 100644 --- a/public/app/core/components/Form/Input.test.tsx +++ b/packages/grafana-ui/src/components/Input/Input.test.tsx @@ -1,18 +1,16 @@ -import React from 'react'; +import React from 'react'; import renderer from 'react-test-renderer'; import { shallow } from 'enzyme'; -import { Input, EventsWithValidation } from './Input'; -import { ValidationEvents } from 'app/types'; +import { Input } from './Input'; +import { EventsWithValidation } from '../../utils'; +import { ValidationEvents } from '../../types'; const TEST_ERROR_MESSAGE = 'Value must be empty or less than 3 chars'; const testBlurValidation: ValidationEvents = { [EventsWithValidation.onBlur]: [ { rule: (value: string) => { - if (!value || value.length < 3) { - return true; - } - return false; + return !value || value.length < 3; }, errorMessage: TEST_ERROR_MESSAGE, }, diff --git a/packages/grafana-ui/src/components/Input/Input.tsx b/packages/grafana-ui/src/components/Input/Input.tsx index 57d6a753b17..f5f59e265c0 100644 --- a/packages/grafana-ui/src/components/Input/Input.tsx +++ b/packages/grafana-ui/src/components/Input/Input.tsx @@ -1,25 +1,13 @@ -import React, { PureComponent } from 'react'; +import React, { PureComponent, ChangeEvent } from 'react'; import classNames from 'classnames'; -import { ValidationEvents, ValidationRule } from '../../types/forms'; +import { validate, EventsWithValidation, hasValidationEvent } from '../../utils'; +import { ValidationEvents, ValidationRule } from '../../types'; export enum InputStatus { Invalid = 'invalid', Valid = 'valid', } -export enum InputTypes { - Text = 'text', - Number = 'number', - Password = 'password', - Email = 'email', -} - -export enum EventsWithValidation { - onBlur = 'onBlur', - onFocus = 'onFocus', - onChange = 'onChange', -} - interface Props extends React.HTMLProps { validationEvents?: ValidationEvents; hideErrorMessage?: boolean; @@ -27,7 +15,7 @@ interface Props extends React.HTMLProps { // Override event props and append status as argument onBlur?: (event: React.FocusEvent, status?: InputStatus) => void; onFocus?: (event: React.FocusEvent, status?: InputStatus) => void; - onChange?: (event: React.FormEvent, status?: InputStatus) => void; + onChange?: (event: React.ChangeEvent, status?: InputStatus) => void; } export class Input extends PureComponent { @@ -48,24 +36,24 @@ export class Input extends PureComponent { } validatorAsync = (validationRules: ValidationRule[]) => { - return evt => { + return (evt: ChangeEvent) => { const errors = validate(evt.target.value, validationRules); this.setState(prevState => { - return { - ...prevState, - error: errors ? errors[0] : null, - }; + return { ...prevState, error: errors ? errors[0] : null }; }); }; }; - populateEventPropsWithStatus = (restProps, validationEvents: ValidationEvents) => { + populateEventPropsWithStatus = (restProps: any, validationEvents: ValidationEvents | undefined) => { const inputElementProps = { ...restProps }; - Object.keys(EventsWithValidation).forEach((eventName: EventsWithValidation) => { - if (hasValidationEvent(eventName, validationEvents) || restProps[eventName]) { - inputElementProps[eventName] = async evt => { + if (!validationEvents) { + return inputElementProps; + } + Object.keys(EventsWithValidation).forEach(eventName => { + if (hasValidationEvent(eventName as EventsWithValidation, validationEvents) || restProps[eventName]) { + inputElementProps[eventName] = async (evt: ChangeEvent) => { evt.persist(); // Needed for async. https://reactjs.org/docs/events.html#event-pooling - if (hasValidationEvent(eventName, validationEvents)) { + if (hasValidationEvent(eventName as EventsWithValidation, validationEvents)) { await this.validatorAsync(validationEvents[eventName]).apply(this, [evt]); } if (restProps[eventName]) { diff --git a/public/app/core/components/Form/__snapshots__/Input.test.tsx.snap b/packages/grafana-ui/src/components/Input/__snapshots__/Input.test.tsx.snap similarity index 100% rename from public/app/core/components/Form/__snapshots__/Input.test.tsx.snap rename to packages/grafana-ui/src/components/Input/__snapshots__/Input.test.tsx.snap diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index b8c8d66cead..e20a52f6485 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -25,6 +25,7 @@ export { ValueMappingsEditor } from './ValueMappingsEditor/ValueMappingsEditor'; export { Switch } from './Switch/Switch'; export { EmptySearchResult } from './EmptySearchResult/EmptySearchResult'; export { UnitPicker } from './UnitPicker/UnitPicker'; +export { Input, InputStatus } from './Input/Input'; // Visualizations export { Gauge } from './Gauge/Gauge'; diff --git a/packages/grafana-ui/src/types/forms.ts b/packages/grafana-ui/src/types/forms.ts deleted file mode 100644 index 602ee434ee5..00000000000 --- a/packages/grafana-ui/src/types/forms.ts +++ /dev/null @@ -1,26 +0,0 @@ -export enum InputStatus { - Invalid = 'invalid', - Valid = 'valid', -} - -export enum InputTypes { - Text = 'text', - Number = 'number', - Password = 'password', - Email = 'email', -} - -export enum EventsWithValidation { - onBlur = 'onBlur', - onFocus = 'onFocus', - onChange = 'onChange', -} - -export interface ValidationRule { - rule: (valueToValidate: string) => boolean; - errorMessage: string; -} - -export interface ValidationEvents { - [eventName: string]: ValidationRule[]; -} diff --git a/packages/grafana-ui/src/types/index.ts b/packages/grafana-ui/src/types/index.ts index 390f8a4db29..c0aede431d0 100644 --- a/packages/grafana-ui/src/types/index.ts +++ b/packages/grafana-ui/src/types/index.ts @@ -5,4 +5,4 @@ export * from './plugin'; export * from './datasource'; export * from './theme'; export * from './threshold'; -export * from './forms'; +export * from './input'; diff --git a/public/app/types/form.ts b/packages/grafana-ui/src/types/input.ts similarity index 100% rename from public/app/types/form.ts rename to packages/grafana-ui/src/types/input.ts diff --git a/packages/grafana-ui/src/utils/validate.ts b/packages/grafana-ui/src/utils/validate.ts index 20979ae33ff..286ec700577 100644 --- a/packages/grafana-ui/src/utils/validate.ts +++ b/packages/grafana-ui/src/utils/validate.ts @@ -1,15 +1,24 @@ -import { EventsWithValidation, ValidationEvents, ValidationRule } from '../types'; +import { ValidationRule, ValidationEvents } from '../types/input'; + +export enum EventsWithValidation { + onBlur = 'onBlur', + onFocus = 'onFocus', + onChange = 'onChange', +} export const validate = (value: string, validationRules: ValidationRule[]) => { - const errors = validationRules.reduce((acc, currentRule) => { - if (!currentRule.rule(value)) { - return acc.concat(currentRule.errorMessage); - } - return acc; - }, []); + const errors = validationRules.reduce( + (acc, currRule) => { + if (!currRule.rule(value)) { + return acc.concat(currRule.errorMessage); + } + return acc; + }, + [] as string[] + ); return errors.length > 0 ? errors : null; }; -export const hasValidationEvent = (event: EventsWithValidation, validationEvents?: ValidationEvents) => { +export const hasValidationEvent = (event: EventsWithValidation, validationEvents: ValidationEvents | undefined) => { return validationEvents && validationEvents[event]; }; diff --git a/public/app/core/components/Form/Input.tsx b/public/app/core/components/Form/Input.tsx deleted file mode 100644 index 7940f3b1104..00000000000 --- a/public/app/core/components/Form/Input.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import React, { PureComponent } from 'react'; -import classNames from 'classnames'; -import { ValidationEvents, ValidationRule } from 'app/types'; -import { validate, hasValidationEvent } from 'app/core/utils/validate'; - -export enum InputStatus { - Invalid = 'invalid', - Valid = 'valid', -} - -export enum InputTypes { - Text = 'text', - Number = 'number', - Password = 'password', - Email = 'email', -} - -export enum EventsWithValidation { - onBlur = 'onBlur', - onFocus = 'onFocus', - onChange = 'onChange', -} - -interface Props extends React.HTMLProps { - validationEvents?: ValidationEvents; - hideErrorMessage?: boolean; - - // Override event props and append status as argument - onBlur?: (event: React.FocusEvent, status?: InputStatus) => void; - onFocus?: (event: React.FocusEvent, status?: InputStatus) => void; - onChange?: (event: React.FormEvent, status?: InputStatus) => void; -} - -export class Input extends PureComponent { - static defaultProps = { - className: '', - }; - - state = { - error: null, - }; - - get status() { - return this.state.error ? InputStatus.Invalid : InputStatus.Valid; - } - - get isInvalid() { - return this.status === InputStatus.Invalid; - } - - validatorAsync = (validationRules: ValidationRule[]) => { - return evt => { - const errors = validate(evt.target.value, validationRules); - this.setState(prevState => { - return { - ...prevState, - error: errors ? errors[0] : null, - }; - }); - }; - }; - - populateEventPropsWithStatus = (restProps, validationEvents: ValidationEvents) => { - const inputElementProps = { ...restProps }; - Object.keys(EventsWithValidation).forEach((eventName: EventsWithValidation) => { - if (hasValidationEvent(eventName, validationEvents) || restProps[eventName]) { - inputElementProps[eventName] = async evt => { - evt.persist(); // Needed for async. https://reactjs.org/docs/events.html#event-pooling - if (hasValidationEvent(eventName, validationEvents)) { - await this.validatorAsync(validationEvents[eventName]).apply(this, [evt]); - } - if (restProps[eventName]) { - restProps[eventName].apply(null, [evt, this.status]); - } - }; - } - }); - return inputElementProps; - }; - - render() { - const { validationEvents, className, hideErrorMessage, ...restProps } = this.props; - const { error } = this.state; - const inputClassName = classNames('gf-form-input', { invalid: this.isInvalid }, className); - const inputElementProps = this.populateEventPropsWithStatus(restProps, validationEvents); - - return ( -
- - {error && !hideErrorMessage && {error}} -
- ); - } -} diff --git a/public/app/core/components/Form/index.ts b/public/app/core/components/Form/index.ts deleted file mode 100644 index 6322cf3241a..00000000000 --- a/public/app/core/components/Form/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Input } from './Input'; diff --git a/public/app/core/utils/validate.ts b/public/app/core/utils/validate.ts deleted file mode 100644 index c6663882808..00000000000 --- a/public/app/core/utils/validate.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ValidationRule, ValidationEvents } from 'app/types'; -import { EventsWithValidation } from 'app/core/components/Form/Input'; - -export const validate = (value: string, validationRules: ValidationRule[]) => { - const errors = validationRules.reduce((acc, currRule) => { - if (!currRule.rule(value)) { - return acc.concat(currRule.errorMessage); - } - return acc; - }, []); - return errors.length > 0 ? errors : null; -}; - -export const hasValidationEvent = (event: EventsWithValidation, validationEvents: ValidationEvents) => { - return validationEvents && validationEvents[event]; -}; diff --git a/public/app/features/dashboard/panel_editor/QueryOptions.tsx b/public/app/features/dashboard/panel_editor/QueryOptions.tsx index 0d031cb12ba..377582d7ce5 100644 --- a/public/app/features/dashboard/panel_editor/QueryOptions.tsx +++ b/public/app/features/dashboard/panel_editor/QueryOptions.tsx @@ -5,17 +5,12 @@ import React, { PureComponent, ChangeEvent, FocusEvent } from 'react'; import { isValidTimeSpan } from 'app/core/utils/rangeutil'; // Components -import { Switch } from '@grafana/ui'; -import { Input } from 'app/core/components/Form'; -import { EventsWithValidation } from 'app/core/components/Form/Input'; -import { InputStatus } from 'app/core/components/Form/Input'; +import { DataSourceSelectItem, EventsWithValidation, Input, InputStatus, Switch, ValidationEvents } from '@grafana/ui'; import { DataSourceOption } from './DataSourceOption'; import { FormLabel } from '@grafana/ui'; // Types -import { PanelModel } from '../state/PanelModel'; -import { DataSourceSelectItem } from '@grafana/ui/src/types'; -import { ValidationEvents } from 'app/types'; +import { PanelModel } from '../state'; const timeRangeValidationEvents: ValidationEvents = { [EventsWithValidation.onBlur]: [ diff --git a/public/app/types/index.ts b/public/app/types/index.ts index eefba746c61..3bf76aeb3c3 100644 --- a/public/app/types/index.ts +++ b/public/app/types/index.ts @@ -12,6 +12,5 @@ export * from './plugins'; export * from './organization'; export * from './appNotifications'; export * from './search'; -export * from './form'; export * from './explore'; export * from './store'; From 6673915f2ebe62334e418bb7c74e70f1ea394498 Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Tue, 19 Mar 2019 08:16:19 +0100 Subject: [PATCH 026/103] Update style_guides/backend.md Co-Authored-By: bergquist --- style_guides/backend.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/style_guides/backend.md b/style_guides/backend.md index 8150530071b..1c6c86efc0b 100644 --- a/style_guides/backend.md +++ b/style_guides/backend.md @@ -17,7 +17,7 @@ The preferred solution, in this case, is to inject the `bus` into services or ta ### settings package In the `setting` packages there are many global variables which Grafana sets at startup. This is also something we want to move -away from and move as much configuration as possible to the `setting.Cfg` struct and pass the around just like the bus +away from and move as much configuration as possible to the `setting.Cfg` struct and pass it around, just like the bus. ## Linting and formatting We enforce strict `gofmt` formating and use some linters on our codebase. You can find the current list of linters at https://github.com/grafana/grafana/blob/master/scripts/gometalinter.sh#L23 @@ -27,4 +27,4 @@ We don't enforce `golint` but we encourage it and we will test so the number of ## Testing We use GoConvey for BDD/scenario based testing. Which we think is useful for testing certain chain or interactions. Ex https://github.com/grafana/grafana/blob/master/pkg/services/auth/auth_token_test.go -For smaller tests its preferred to use standard library testing. \ No newline at end of file +For smaller tests its preferred to use standard library testing. From 39e75d75b40a7a3cc570ac8fa36e762b9c8639d7 Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Tue, 19 Mar 2019 10:48:41 +0100 Subject: [PATCH 027/103] build: crcmod speedups rsync to gcp for deploy. --- scripts/build/ci-deploy/Dockerfile | 2 +- scripts/build/ci-deploy/build-deploy.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/build/ci-deploy/Dockerfile b/scripts/build/ci-deploy/Dockerfile index dd4987b96c3..e608d9156e7 100644 --- a/scripts/build/ci-deploy/Dockerfile +++ b/scripts/build/ci-deploy/Dockerfile @@ -10,7 +10,7 @@ FROM circleci/python:2.7-stretch USER root -RUN pip install awscli && \ +RUN pip install -U awscli crcmod && \ curl https://dl.google.com/dl/cloudsdk/channels/rapid/downloads/google-cloud-sdk-222.0.0-linux-x86_64.tar.gz | \ tar xvzf - -C /opt && \ apt update && \ diff --git a/scripts/build/ci-deploy/build-deploy.sh b/scripts/build/ci-deploy/build-deploy.sh index 8dedeead009..ed9c9e5459e 100755 --- a/scripts/build/ci-deploy/build-deploy.sh +++ b/scripts/build/ci-deploy/build-deploy.sh @@ -1,6 +1,6 @@ #!/bin/bash -_version="1.2.0" +_version="1.2.1" _tag="grafana/grafana-ci-deploy:${_version}" docker build -t $_tag . From 4152e5c16ca2059692cd18071128c1e6beac0b18 Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Tue, 19 Mar 2019 10:57:17 +0100 Subject: [PATCH 028/103] build: updated deploy container with crcmod. --- .circleci/config.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index da0e0665285..49fb3776534 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -322,7 +322,7 @@ jobs: deploy-enterprise-master: docker: - - image: grafana/grafana-ci-deploy:1.2.0 + - image: grafana/grafana-ci-deploy:1.2.1 steps: - attach_workspace: at: . @@ -345,7 +345,7 @@ jobs: deploy-enterprise-release: docker: - - image: grafana/grafana-ci-deploy:1.2.0 + - image: grafana/grafana-ci-deploy:1.2.1 steps: - checkout - attach_workspace: @@ -378,7 +378,7 @@ jobs: deploy-master: docker: - - image: grafana/grafana-ci-deploy:1.2.0 + - image: grafana/grafana-ci-deploy:1.2.1 steps: - attach_workspace: at: . @@ -409,7 +409,7 @@ jobs: deploy-release: docker: - - image: grafana/grafana-ci-deploy:1.2.0 + - image: grafana/grafana-ci-deploy:1.2.1 steps: - checkout - attach_workspace: From 4dceb60d204cb9de6849037838f42d8e84f3fc36 Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Tue, 19 Mar 2019 11:34:01 +0100 Subject: [PATCH 029/103] build: migrates the build container into the main repo. --- scripts/build/ci-build/Dockerfile | 114 +++++++++++++++++++++++++ scripts/build/ci-build/Makefile | 54 ++++++++++++ scripts/build/ci-build/README.md | 20 +++++ scripts/build/ci-build/bootstrap.sh | 5 ++ scripts/build/ci-build/build-deploy.sh | 7 ++ 5 files changed, 200 insertions(+) create mode 100644 scripts/build/ci-build/Dockerfile create mode 100644 scripts/build/ci-build/Makefile create mode 100644 scripts/build/ci-build/README.md create mode 100755 scripts/build/ci-build/bootstrap.sh create mode 100755 scripts/build/ci-build/build-deploy.sh diff --git a/scripts/build/ci-build/Dockerfile b/scripts/build/ci-build/Dockerfile new file mode 100644 index 00000000000..7c6ed58c0e4 --- /dev/null +++ b/scripts/build/ci-build/Dockerfile @@ -0,0 +1,114 @@ +FROM ubuntu:14.04 as toolchain + +ENV OSX_SDK_URL=https://s3.dockerproject.org/darwin/v2/ \ + OSX_SDK=MacOSX10.10.sdk \ + OSX_MIN=10.10 \ + CTNG=1.23.0 + +# FIRST PART +# build osx64 toolchain (stripped of man documentation) +# the toolchain produced is not self contained, it needs clang at runtime +# +# SECOND PART +# build gcc (no g++) centos6-x64 toolchain +# doc: https://crosstool-ng.github.io/docs/ +# apt-get should be all dep to build toolchain +# sed and 1st echo are for convenience to get the toolchain in /tmp/x86_64-centos6-linux-gnu +# other echo are to enable build by root (crosstool-NG refuse to do that by default) +# the last 2 rm are just to save some time and space writing docker layers +# +# THIRD PART +# build fpm and creates a set of deb from gem +# ruby2.0 depends on ruby1.9.3 which is install as default ruby +# rm/ln are here to change that +# created deb depends on rubygem-json but json gem is not build +# so do by hand + + +# might wanna make sure osx cross and the other tarball as well as the packages ends up somewhere other than tmp +# might also wanna put them as their own layer to not have to unpack them every time? + +RUN apt-get update && \ + apt-get install -y \ + clang-3.8 patch libxml2-dev \ + ca-certificates \ + curl \ + git \ + make \ + xz-utils && \ + git clone https://github.com/tpoechtrager/osxcross.git /tmp/osxcross && \ + curl -L ${OSX_SDK_URL}/${OSX_SDK}.tar.xz -o /tmp/osxcross/tarballs/${OSX_SDK}.tar.xz && \ + ln -s /usr/bin/clang-3.8 /usr/bin/clang && \ + ln -s /usr/bin/clang++-3.8 /usr/bin/clang++ && \ + ln -s /usr/bin/llvm-dsymutil-3.8 /usr/bin/dsymutil && \ + UNATTENDED=yes OSX_VERSION_MIN=${OSX_MIN} /tmp/osxcross/build.sh && \ + rm -rf /tmp/osxcross/target/SDK/${OSX_SDK}/usr/share && \ + cd /tmp && \ + tar cfJ osxcross.tar.xz osxcross/target && \ + rm -rf /tmp/osxcross && \ + apt-get install -y \ + bison curl flex gawk gcc g++ gperf help2man libncurses5-dev make patch python-dev texinfo xz-utils && \ + curl -L http://crosstool-ng.org/download/crosstool-ng/crosstool-ng-${CTNG}.tar.xz \ + | tar -xJ -C /tmp/ && \ + cd /tmp/crosstool-ng-${CTNG} && \ + ./configure --enable-local && \ + make && \ + ./ct-ng x86_64-centos6-linux-gnu && \ + sed -i '/CT_PREFIX_DIR=/d' .config && \ + echo 'CT_PREFIX_DIR="/tmp/${CT_HOST:+HOST-${CT_HOST}/}${CT_TARGET}"' >> .config && \ + echo 'CT_EXPERIMENTAL=y' >> .config && \ + echo 'CT_ALLOW_BUILD_AS_ROOT=y' >> .config && \ + echo 'CT_ALLOW_BUILD_AS_ROOT_SURE=y' >> .config && \ + ./ct-ng build && \ + cd /tmp && \ + rm /tmp/x86_64-centos6-linux-gnu/build.log.bz2 && \ + tar cfJ x86_64-centos6-linux-gnu.tar.xz x86_64-centos6-linux-gnu/ && \ + rm -rf /tmp/x86_64-centos6-linux-gnu/ && \ + rm -rf /tmp/crosstool-ng-${CTNG} + +# base image to crossbuild grafana +FROM ubuntu:14.04 + +ENV GOVERSION=1.11.5 \ + PATH=/usr/local/go/bin:$PATH \ + GOPATH=/go \ + NODEVERSION=10.14.2 + +COPY --from=toolchain /tmp/x86_64-centos6-linux-gnu.tar.xz /tmp/ +COPY --from=toolchain /tmp/osxcross.tar.xz /tmp/ + +RUN apt-get update && \ + apt-get install -y \ + clang-3.8 gcc-aarch64-linux-gnu gcc-arm-linux-gnueabihf gcc-mingw-w64-x86-64 \ + apt-transport-https \ + ca-certificates \ + curl \ + libfontconfig1 \ + gcc \ + g++ \ + git \ + make \ + rpm \ + xz-utils \ + expect \ + gnupg2 \ + unzip && \ + ln -s /usr/bin/clang-3.8 /usr/bin/clang && \ + ln -s /usr/bin/clang++-3.8 /usr/bin/clang++ && \ + ln -s /usr/bin/llvm-dsymutil-3.8 /usr/bin/dsymutil && \ + curl -L https://nodejs.org/dist/v${NODEVERSION}/node-v${NODEVERSION}-linux-x64.tar.xz \ + | tar -xJ --strip-components=1 -C /usr/local && \ + curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \ + echo "deb [arch=amd64] https://dl.yarnpkg.com/debian/ stable main" \ + | tee /etc/apt/sources.list.d/yarn.list && \ + apt-get update && apt-get install --no-install-recommends yarn && \ + curl -L https://storage.googleapis.com/golang/go${GOVERSION}.linux-amd64.tar.gz \ + | tar -xz -C /usr/local + +RUN apt-get install -y \ + gcc libc-dev make && \ + gpg2 --keyserver hkp://keys.gnupg.net --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB && \ + curl -sSL https://get.rvm.io | bash -s stable && \ + /bin/bash -l -c "rvm requirements && rvm install 2.2 && gem install -N fpm" + +ADD ./bootstrap.sh /tmp/bootstrap.sh \ No newline at end of file diff --git a/scripts/build/ci-build/Makefile b/scripts/build/ci-build/Makefile new file mode 100644 index 00000000000..64fa376d7cf --- /dev/null +++ b/scripts/build/ci-build/Makefile @@ -0,0 +1,54 @@ +VERSION="dev" +TAG="grafana/build-container" +USER_ID=$(shell id -u) +GROUP_ID=$(shell id -g) + +all: build deploy + +build: + docker build -t "${TAG}:${VERSION}" . + +deploy: + docker push "${TAG}:${VERSION}" + +run: + docker run -ti \ + -e "CIRCLE_BRANCH=local" \ + -e "CIRCLE_BUILD_NUM=472" \ + ${TAG}:${VERSION} \ + bash + +run-with-local-source-live: + docker run -d \ + -e "CIRCLE_BRANCH=local" \ + -e "CIRCLE_BUILD_NUM=472" \ + -w "/go/src/github.com/grafana/grafana" \ + --name grafana-build \ + -v "${GOPATH}/src/github.com/grafana/grafana:/go/src/github.com/grafana/grafana" \ + ${TAG}:${VERSION} \ + bash -c "/tmp/bootstrap.sh; mkdir /.cache; chown "${USER_ID}:${GROUP_ID}" /.cache; tail -f /dev/null" + docker exec -ti --user "${USER_ID}:${GROUP_ID}" grafana-build bash + +run-with-local-source-copy: + docker run -d \ + -e "CIRCLE_BRANCH=local" \ + -e "CIRCLE_BUILD_NUM=472" \ + -w "/go/src/github.com/grafana/grafana" \ + --name grafana-build \ + ${TAG}:${VERSION} \ + bash -c "/tmp/bootstrap.sh; tail -f /dev/null" + docker cp "${GOPATH}/src/github.com/grafana/grafana" grafana-build:/go/src/github.com/grafana/ + docker exec -ti grafana-build bash + +update-source: + docker cp "${GOPATH}/src/github.com/grafana/grafana" grafana-build:/go/src/github.com/grafana/ + +attach: + docker exec -ti grafana-build bash + +attach-live: + docker exec -ti --user "${USER_ID}:${GROUP_ID}" grafana-build bash + +stop: + docker kill grafana-build + docker rm grafana-build diff --git a/scripts/build/ci-build/README.md b/scripts/build/ci-build/README.md new file mode 100644 index 00000000000..e66ec1b3cf7 --- /dev/null +++ b/scripts/build/ci-build/README.md @@ -0,0 +1,20 @@ +# grafana-build-container +Grafana build container + +## Description + +This is a container for cross-platform builds of Grafana. You can run it locally using the Makefile. + +## Makefile targets + +* `make run-with-local-source-copy` + - Starts the container locally and copies your local sources into the container +* `make run-with-local-source-live` + - Starts the container (as your user) locally and maps your Grafana project dir into the container +* `make update-source` + - Updates the sources in the container from your local sources +* `make stop` + - Kills the container +* `make attach` + - Opens bash within the running container + diff --git a/scripts/build/ci-build/bootstrap.sh b/scripts/build/ci-build/bootstrap.sh new file mode 100755 index 00000000000..2eda345b5ab --- /dev/null +++ b/scripts/build/ci-build/bootstrap.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +cd /tmp +tar xfJ x86_64-centos6-linux-gnu.tar.xz +tar xfJ osxcross.tar.xz diff --git a/scripts/build/ci-build/build-deploy.sh b/scripts/build/ci-build/build-deploy.sh new file mode 100755 index 00000000000..c2a33e4a9e4 --- /dev/null +++ b/scripts/build/ci-build/build-deploy.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +_version="1.2.3" +_tag="grafana/build-container:${_version}" + +docker build -t $_tag . +docker push $_tag From d075af2b674176ecace3613a1e5d6fdc0d0e7671 Mon Sep 17 00:00:00 2001 From: Peter Holmberg Date: Tue, 19 Mar 2019 13:14:32 +0100 Subject: [PATCH 030/103] adding story and fixing tests --- .../ThresholdsEditor.story.tsx | 16 + .../ThresholdsEditor.test.tsx | 31 +- .../ThresholdsEditor/ThresholdsEditor.tsx | 4 +- .../ThresholdsEditor.test.tsx.snap | 446 +++++++++++++++++- 4 files changed, 477 insertions(+), 20 deletions(-) create mode 100644 packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.story.tsx diff --git a/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.story.tsx b/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.story.tsx new file mode 100644 index 00000000000..8d6112130e7 --- /dev/null +++ b/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.story.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; + +import { ThresholdsEditor } from './ThresholdsEditor'; + +const ThresholdsEditorStories = storiesOf('UI/ThresholdsEditor', module); +const thresholds = [{ index: 0, value: -Infinity, color: 'green' }, { index: 1, value: 50, color: 'red' }]; + +ThresholdsEditorStories.add('default', () => { + return ; +}); + +ThresholdsEditorStories.add('with thresholds', () => { + return ; +}); diff --git a/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx b/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx index 38cd8e5c763..db494053d6e 100644 --- a/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx +++ b/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx @@ -1,6 +1,7 @@ import React, { ChangeEvent } from 'react'; import { mount } from 'enzyme'; import { ThresholdsEditor, Props } from './ThresholdsEditor'; +import { colors } from '../../utils'; const setup = (propOverrides?: Partial) => { const props: Props = { @@ -31,7 +32,7 @@ describe('Initialization', () => { it('should add a base threshold if missing', () => { const { instance } = setup(); - expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: '#7EB26D' }]); + expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: colors[0] }]); }); }); @@ -41,7 +42,7 @@ describe('Add threshold', () => { instance.onAddThreshold(0); - expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: '#7EB26D' }]); + expect(instance.state.thresholds).toEqual([{ index: 0, value: -Infinity, color: colors[0] }]); }); it('should add threshold', () => { @@ -50,41 +51,41 @@ describe('Add threshold', () => { instance.onAddThreshold(1); expect(instance.state.thresholds).toEqual([ - { index: 0, value: -Infinity, color: '#7EB26D' }, - { index: 1, value: 50, color: '#EAB839' }, + { index: 0, value: -Infinity, color: colors[0] }, + { index: 1, value: 50, color: colors[2] }, ]); }); it('should add another threshold above a first', () => { const { instance } = setup({ - thresholds: [{ index: 0, value: -Infinity, color: '#7EB26D' }, { index: 1, value: 50, color: '#EAB839' }], + thresholds: [{ index: 0, value: -Infinity, color: colors[0] }, { index: 1, value: 50, color: colors[2] }], }); instance.onAddThreshold(2); expect(instance.state.thresholds).toEqual([ - { index: 0, value: -Infinity, color: '#7EB26D' }, - { index: 1, value: 50, color: '#EAB839' }, - { index: 2, value: 75, color: '#6ED0E0' }, + { index: 0, value: -Infinity, color: colors[0] }, + { index: 1, value: 50, color: colors[2] }, + { index: 2, value: 75, color: colors[3] }, ]); }); it('should add another threshold between first and second index', () => { const { instance } = setup({ thresholds: [ - { index: 0, value: -Infinity, color: '#7EB26D' }, - { index: 1, value: 50, color: '#EAB839' }, - { index: 2, value: 75, color: '#6ED0E0' }, + { index: 0, value: -Infinity, color: colors[0] }, + { index: 1, value: 50, color: colors[2] }, + { index: 2, value: 75, color: colors[3] }, ], }); instance.onAddThreshold(2); expect(instance.state.thresholds).toEqual([ - { index: 0, value: -Infinity, color: '#7EB26D' }, - { index: 1, value: 50, color: '#EAB839' }, - { index: 2, value: 62.5, color: '#EF843C' }, - { index: 3, value: 75, color: '#6ED0E0' }, + { index: 0, value: -Infinity, color: colors[0] }, + { index: 1, value: 50, color: colors[2] }, + { index: 2, value: 62.5, color: colors[4] }, + { index: 3, value: 75, color: colors[3] }, ]); }); }); diff --git a/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx b/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx index 3361e1bee46..adacf393a09 100644 --- a/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx +++ b/packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx @@ -3,8 +3,8 @@ import { Threshold } from '../../types'; import { ColorPicker } from '..'; import { PanelOptionsGroup } from '..'; import { colors } from '../../utils'; -import { ThemeContext } from '../../themes/ThemeContext'; -import { getColorFromHexRgbOrName } from '../../utils/namedColorsPalette'; +import { ThemeContext } from '../../themes'; +import { getColorFromHexRgbOrName } from '../../utils'; export interface Props { thresholds: Threshold[]; diff --git a/packages/grafana-ui/src/components/ThresholdsEditor/__snapshots__/ThresholdsEditor.test.tsx.snap b/packages/grafana-ui/src/components/ThresholdsEditor/__snapshots__/ThresholdsEditor.test.tsx.snap index b0dc025090b..bd0ab03bf51 100644 --- a/packages/grafana-ui/src/components/ThresholdsEditor/__snapshots__/ThresholdsEditor.test.tsx.snap +++ b/packages/grafana-ui/src/components/ThresholdsEditor/__snapshots__/ThresholdsEditor.test.tsx.snap @@ -1,7 +1,447 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Render should render with base threshold 1`] = ` - - - + + +
+
+ + Thresholds + +
+
+
+
+
+ +
+
+
+
+ +
+
+ + + + } + hideAfter={300} + > + +
+
+
+
+
+ + + + +
+
+
+ +
+
+
+
+
+
+
+ + `; From 9f6b793563c9f6fee7a1fc74eda86309b25a2773 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Tue, 19 Mar 2019 13:41:47 +0100 Subject: [PATCH 031/103] Update CHANGELOG.md --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f09ead1fe3..83d58bb81a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,16 @@ * **Datasource**: Empty user/password was not updated when updating datasources [#15608](https://github.com/grafana/grafana/pull/15608), thx [@Maddin-619](https://github.com/Maddin-619) * **Heatmap**: legend shows wrong colors for small values [#14019](https://github.com/grafana/grafana/issues/14019) +# 6.0.2 (unreleased) + +### Bug Fixes +* **Alerting**: Fixed issue with AlertList panel links resulting in panel not found errors. [#15975](https://github.com/grafana/grafana/pull/15975), [@torkelo](https://github.com/torkelo) +* **Dashboard**: Improved error handling when rendering dashboard panels. [#15970](https://github.com/grafana/grafana/pull/15970), [@torkelo](https://github.com/torkelo) +* **LDAP**: Fix allow anonymous server bind for ldap search. [#15872](https://github.com/grafana/grafana/pull/15872), [@marefr](https://github.com/marefr) +* **Discord**: Fix discord notifier so it doesn't crash when there are no image generated. [#15833](https://github.com/grafana/grafana/pull/15833), [@marefr](https://github.com/marefr) +* **Panel Edit**: Prevent search in VizPicker from stealing focus. [#15802](https://github.com/grafana/grafana/pull/15802), [@peterholmberg](https://github.com/peterholmberg) +* **Datasource admin**: Fixed url of back button in datasource edit page, when root_url configured. [#15759](https://github.com/grafana/grafana/pull/15759), [@dprokop](https://github.com/dprokop) + # 6.0.1 (2019-03-06) ### Bug Fixes From abbb7b81c760a07c0571823c1db7d55550ed9330 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Tue, 19 Mar 2019 13:42:25 +0100 Subject: [PATCH 032/103] fix(ci): frontend tests was accidentially commented out --- scripts/circle-test-frontend.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/circle-test-frontend.sh b/scripts/circle-test-frontend.sh index 9d945a03b7f..423dee84954 100755 --- a/scripts/circle-test-frontend.sh +++ b/scripts/circle-test-frontend.sh @@ -14,7 +14,7 @@ function exit_if_fail { start=$(date +%s) exit_if_fail npm run prettier:check -# exit_if_fail npm run test +exit_if_fail npm run test end=$(date +%s) seconds=$((end - start)) From e294252e926232b93290d07c4b851622169942c1 Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Tue, 5 Mar 2019 15:07:09 +0100 Subject: [PATCH 033/103] dashboards: user automatically becomes admin for created dashboards --- pkg/services/dashboards/dashboard_service.go | 43 ++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/pkg/services/dashboards/dashboard_service.go b/pkg/services/dashboards/dashboard_service.go index f8df6763994..424980c1a86 100644 --- a/pkg/services/dashboards/dashboard_service.go +++ b/pkg/services/dashboards/dashboard_service.go @@ -238,6 +238,49 @@ func (dr *dashboardServiceImpl) SaveDashboard(dto *SaveDashboardDTO) (*models.Da return nil, err } + // TODO: check if dashboard exists already. could have id set but not exist + if dto.Dashboard.Id == 0 && dto.Dashboard.Uid == "" { + rtEditor := models.ROLE_EDITOR + rtViewer := models.ROLE_VIEWER + + items := []*models.DashboardAcl{ + { + OrgId: dr.orgId, + DashboardId: cmd.Result.Id, + UserId: cmd.Result.CreatedBy, + Permission: models.PERMISSION_ADMIN, + Created: time.Now(), + Updated: time.Now(), + }, + { + OrgId: dr.orgId, + DashboardId: cmd.Result.Id, + Role: &rtEditor, + Permission: models.PERMISSION_EDIT, + Created: time.Now(), + Updated: time.Now(), + }, + { + OrgId: dr.orgId, + DashboardId: cmd.Result.Id, + Role: &rtViewer, + Permission: models.PERMISSION_VIEW, + Created: time.Now(), + Updated: time.Now(), + }, + } + + aclCmd := &models.UpdateDashboardAclCommand{ + DashboardId: cmd.Result.Id, + Items: items, + } + + if err = bus.Dispatch(aclCmd); err != nil { + return cmd.Result, err + } + + } + return cmd.Result, nil } From e174f7c20bd26fba79442a480528c54bf6060716 Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Tue, 5 Mar 2019 15:25:02 +0100 Subject: [PATCH 034/103] folders: admin for created folders --- pkg/services/dashboards/folder_service.go | 40 +++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/pkg/services/dashboards/folder_service.go b/pkg/services/dashboards/folder_service.go index b521b0e5213..917852f4781 100644 --- a/pkg/services/dashboards/folder_service.go +++ b/pkg/services/dashboards/folder_service.go @@ -5,6 +5,7 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/search" + "time" ) // FolderService service for operating on folders @@ -114,6 +115,45 @@ func (dr *dashboardServiceImpl) CreateFolder(cmd *models.CreateFolderCommand) er return toFolderError(err) } + rtEditor := models.ROLE_EDITOR + rtViewer := models.ROLE_VIEWER + + items := []*models.DashboardAcl{ + { + OrgId: dr.orgId, + DashboardId: saveDashboardCmd.Result.Id, + UserId: saveDashboardCmd.Result.CreatedBy, + Permission: models.PERMISSION_ADMIN, + Created: time.Now(), + Updated: time.Now(), + }, + { + OrgId: dr.orgId, + DashboardId: saveDashboardCmd.Result.Id, + Role: &rtEditor, + Permission: models.PERMISSION_EDIT, + Created: time.Now(), + Updated: time.Now(), + }, + { + OrgId: dr.orgId, + DashboardId: saveDashboardCmd.Result.Id, + Role: &rtViewer, + Permission: models.PERMISSION_VIEW, + Created: time.Now(), + Updated: time.Now(), + }, + } + + aclCmd := &models.UpdateDashboardAclCommand{ + DashboardId: saveDashboardCmd.Result.Id, + Items: items, + } + + if err = bus.Dispatch(aclCmd); err != nil { + return err + } + query := models.GetDashboardQuery{OrgId: dr.orgId, Id: saveDashboardCmd.Result.Id} dashFolder, err = getFolder(query) if err != nil { From c8c004095cc5707761560bfc4163a056ddb5d96a Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Tue, 5 Mar 2019 16:44:41 +0100 Subject: [PATCH 035/103] permissions: broken out func for making creator admin. --- pkg/api/api.go | 2 +- pkg/api/dashboard.go | 11 +++- pkg/api/folder.go | 2 +- pkg/services/dashboards/acl_service.go | 62 ++++++++++++++++++++ pkg/services/dashboards/dashboard_service.go | 43 -------------- 5 files changed, 74 insertions(+), 46 deletions(-) create mode 100644 pkg/services/dashboards/acl_service.go diff --git a/pkg/api/api.go b/pkg/api/api.go index f3dc35b6b06..b5214f93d35 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -265,7 +265,7 @@ func (hs *HTTPServer) registerRoutes() { apiRoute.Group("/folders", func(folderRoute routing.RouteRegister) { folderRoute.Get("/", Wrap(GetFolders)) folderRoute.Get("/id/:id", Wrap(GetFolderByID)) - folderRoute.Post("/", bind(m.CreateFolderCommand{}), Wrap(CreateFolder)) + folderRoute.Post("/", bind(m.CreateFolderCommand{}), Wrap(hs.CreateFolder)) folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) { folderUidRoute.Get("/", Wrap(GetFolderByUID)) diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 07c4f75778d..016146a5c61 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -213,7 +213,8 @@ func (hs *HTTPServer) PostDashboard(c *m.ReqContext, cmd m.SaveDashboardCommand) dash := cmd.GetDashboardModel() - if dash.Id == 0 && dash.Uid == "" { + newDashboard := dash.Id == 0 && dash.Uid == "" + if newDashboard { limitReached, err := hs.QuotaService.QuotaReached(c, "dashboard") if err != nil { return Error(500, "failed to get quota", err) @@ -276,6 +277,14 @@ func (hs *HTTPServer) PostDashboard(c *m.ReqContext, cmd m.SaveDashboardCommand) return Error(500, "Failed to save dashboard", err) } + if hs.Cfg.EditorsCanOwn && newDashboard { + aclService := dashboards.NewAclService() + err := aclService.MakeUserAdmin(cmd.OrgId, cmd.UserId, dashboard.Id) + if err != nil { + hs.log.Error("Could not make user admin", "error", err) + } + } + c.TimeRequest(metrics.M_Api_Dashboard_Save) return JSON(200, util.DynMap{ "status": "success", diff --git a/pkg/api/folder.go b/pkg/api/folder.go index 0e08343b556..4e106dc6452 100644 --- a/pkg/api/folder.go +++ b/pkg/api/folder.go @@ -54,7 +54,7 @@ func GetFolderByID(c *m.ReqContext) Response { return JSON(200, toFolderDto(g, folder)) } -func CreateFolder(c *m.ReqContext, cmd m.CreateFolderCommand) Response { +func (hs *HTTPServer) CreateFolder(c *m.ReqContext, cmd m.CreateFolderCommand) Response { s := dashboards.NewFolderService(c.OrgId, c.SignedInUser) err := s.CreateFolder(&cmd) if err != nil { diff --git a/pkg/services/dashboards/acl_service.go b/pkg/services/dashboards/acl_service.go new file mode 100644 index 00000000000..79b55470093 --- /dev/null +++ b/pkg/services/dashboards/acl_service.go @@ -0,0 +1,62 @@ +package dashboards + +import ( + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/models" + "time" +) + +// NewService factory for creating a new dashboard service +var NewAclService = func() *AclService { + return &AclService{ + log: log.New("dashboard-acl-service"), + } +} + +type AclService struct { + log log.Logger +} + +func (as *AclService) MakeUserAdmin(orgId int64, userId int64, dashboardId int64) error { + rtEditor := models.ROLE_EDITOR + rtViewer := models.ROLE_VIEWER + + items := []*models.DashboardAcl{ + { + OrgId: orgId, + DashboardId: dashboardId, + UserId: userId, + Permission: models.PERMISSION_ADMIN, + Created: time.Now(), + Updated: time.Now(), + }, + { + OrgId: orgId, + DashboardId: dashboardId, + Role: &rtEditor, + Permission: models.PERMISSION_EDIT, + Created: time.Now(), + Updated: time.Now(), + }, + { + OrgId: orgId, + DashboardId: dashboardId, + Role: &rtViewer, + Permission: models.PERMISSION_VIEW, + Created: time.Now(), + Updated: time.Now(), + }, + } + + aclCmd := &models.UpdateDashboardAclCommand{ + DashboardId: dashboardId, + Items: items, + } + + if err := bus.Dispatch(aclCmd); err != nil { + return err + } + + return nil +} diff --git a/pkg/services/dashboards/dashboard_service.go b/pkg/services/dashboards/dashboard_service.go index 424980c1a86..f8df6763994 100644 --- a/pkg/services/dashboards/dashboard_service.go +++ b/pkg/services/dashboards/dashboard_service.go @@ -238,49 +238,6 @@ func (dr *dashboardServiceImpl) SaveDashboard(dto *SaveDashboardDTO) (*models.Da return nil, err } - // TODO: check if dashboard exists already. could have id set but not exist - if dto.Dashboard.Id == 0 && dto.Dashboard.Uid == "" { - rtEditor := models.ROLE_EDITOR - rtViewer := models.ROLE_VIEWER - - items := []*models.DashboardAcl{ - { - OrgId: dr.orgId, - DashboardId: cmd.Result.Id, - UserId: cmd.Result.CreatedBy, - Permission: models.PERMISSION_ADMIN, - Created: time.Now(), - Updated: time.Now(), - }, - { - OrgId: dr.orgId, - DashboardId: cmd.Result.Id, - Role: &rtEditor, - Permission: models.PERMISSION_EDIT, - Created: time.Now(), - Updated: time.Now(), - }, - { - OrgId: dr.orgId, - DashboardId: cmd.Result.Id, - Role: &rtViewer, - Permission: models.PERMISSION_VIEW, - Created: time.Now(), - Updated: time.Now(), - }, - } - - aclCmd := &models.UpdateDashboardAclCommand{ - DashboardId: cmd.Result.Id, - Items: items, - } - - if err = bus.Dispatch(aclCmd); err != nil { - return cmd.Result, err - } - - } - return cmd.Result, nil } From da3dcd19184dacf4533b99d8532c6ea217378d25 Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Tue, 5 Mar 2019 16:53:16 +0100 Subject: [PATCH 036/103] folder: uses service to make user admin of created folder. --- pkg/api/folder.go | 5 +++ pkg/services/dashboards/folder_service.go | 40 ----------------------- 2 files changed, 5 insertions(+), 40 deletions(-) diff --git a/pkg/api/folder.go b/pkg/api/folder.go index 4e106dc6452..4e66439219d 100644 --- a/pkg/api/folder.go +++ b/pkg/api/folder.go @@ -61,6 +61,11 @@ func (hs *HTTPServer) CreateFolder(c *m.ReqContext, cmd m.CreateFolderCommand) R return toFolderError(err) } + if hs.Cfg.EditorsCanOwn { + aclService := dashboards.NewAclService() + aclService.MakeUserAdmin(c.OrgId, c.SignedInUser.UserId, cmd.Result.Id) + } + g := guardian.New(cmd.Result.Id, c.OrgId, c.SignedInUser) return JSON(200, toFolderDto(g, cmd.Result)) } diff --git a/pkg/services/dashboards/folder_service.go b/pkg/services/dashboards/folder_service.go index 917852f4781..b521b0e5213 100644 --- a/pkg/services/dashboards/folder_service.go +++ b/pkg/services/dashboards/folder_service.go @@ -5,7 +5,6 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/search" - "time" ) // FolderService service for operating on folders @@ -115,45 +114,6 @@ func (dr *dashboardServiceImpl) CreateFolder(cmd *models.CreateFolderCommand) er return toFolderError(err) } - rtEditor := models.ROLE_EDITOR - rtViewer := models.ROLE_VIEWER - - items := []*models.DashboardAcl{ - { - OrgId: dr.orgId, - DashboardId: saveDashboardCmd.Result.Id, - UserId: saveDashboardCmd.Result.CreatedBy, - Permission: models.PERMISSION_ADMIN, - Created: time.Now(), - Updated: time.Now(), - }, - { - OrgId: dr.orgId, - DashboardId: saveDashboardCmd.Result.Id, - Role: &rtEditor, - Permission: models.PERMISSION_EDIT, - Created: time.Now(), - Updated: time.Now(), - }, - { - OrgId: dr.orgId, - DashboardId: saveDashboardCmd.Result.Id, - Role: &rtViewer, - Permission: models.PERMISSION_VIEW, - Created: time.Now(), - Updated: time.Now(), - }, - } - - aclCmd := &models.UpdateDashboardAclCommand{ - DashboardId: saveDashboardCmd.Result.Id, - Items: items, - } - - if err = bus.Dispatch(aclCmd); err != nil { - return err - } - query := models.GetDashboardQuery{OrgId: dr.orgId, Id: saveDashboardCmd.Result.Id} dashFolder, err = getFolder(query) if err != nil { From 124fb743e8d9edfdaf58559e1925dc4a6dd13489 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Wed, 6 Mar 2019 08:09:34 +0100 Subject: [PATCH 037/103] teams: make test cases pass again --- pkg/api/dashboard_test.go | 4 ++++ pkg/api/folder_test.go | 11 ++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index 923bf57ce8a..d58e2246eec 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -972,8 +972,12 @@ func postDashboardScenario(desc string, url string, routePattern string, mock *d Convey(desc+" "+url, func() { defer bus.ClearBusHandlers() + cfg := setting.NewCfg() + cfg.EditorsCanOwn = false + hs := HTTPServer{ Bus: bus.GetBus(), + Cfg: cfg, } sc := setupScenarioContext(url) diff --git a/pkg/api/folder_test.go b/pkg/api/folder_test.go index 880de338c8f..914acf5797e 100644 --- a/pkg/api/folder_test.go +++ b/pkg/api/folder_test.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/setting" . "github.com/smartystreets/goconvey/convey" ) @@ -141,12 +142,20 @@ func createFolderScenario(desc string, url string, routePattern string, mock *fa Convey(desc+" "+url, func() { defer bus.ClearBusHandlers() + cfg := setting.NewCfg() + cfg.EditorsCanOwn = false + + hs := HTTPServer{ + Bus: bus.GetBus(), + Cfg: cfg, + } + sc := setupScenarioContext(url) sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { sc.context = c sc.context.SignedInUser = &m.SignedInUser{OrgId: TestOrgID, UserId: TestUserID} - return CreateFolder(c, cmd) + return hs.CreateFolder(c, cmd) }) origNewFolderService := dashboards.NewFolderService From efbd93f824ff9c83bed73dd30150dd15c95f3546 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Wed, 6 Mar 2019 08:40:42 +0100 Subject: [PATCH 038/103] teams: show teams and plugins for editors that can own --- pkg/api/folder_test.go | 2 +- pkg/api/index.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/pkg/api/folder_test.go b/pkg/api/folder_test.go index 914acf5797e..d5e4ee418cd 100644 --- a/pkg/api/folder_test.go +++ b/pkg/api/folder_test.go @@ -143,7 +143,7 @@ func createFolderScenario(desc string, url string, routePattern string, mock *fa defer bus.ClearBusHandlers() cfg := setting.NewCfg() - cfg.EditorsCanOwn = false + cfg.EditorsCanOwn = true hs := HTTPServer{ Bus: bus.GetBus(), diff --git a/pkg/api/index.go b/pkg/api/index.go index 904a885b171..88c4b7e929d 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -327,6 +327,34 @@ func (hs *HTTPServer) setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, er }) } + if c.OrgRole == m.ROLE_EDITOR && hs.Cfg.EditorsCanOwn { + cfgNode := &dtos.NavLink{ + Id: "cfg", + Text: "Configuration", + SubTitle: "Organization: " + c.OrgName, + Icon: "gicon gicon-cog", + Url: setting.AppSubUrl + "/org/teams", + Children: []*dtos.NavLink{ + { + Text: "Teams", + Id: "teams", + Description: "Manage org groups", + Icon: "gicon gicon-team", + Url: setting.AppSubUrl + "/org/teams", + }, + { + Text: "Plugins", + Id: "plugins", + Description: "View and configure plugins", + Icon: "gicon gicon-plugins", + Url: setting.AppSubUrl + "/plugins", + }, + }, + } + + data.NavTree = append(data.NavTree, cfgNode) + } + data.NavTree = append(data.NavTree, &dtos.NavLink{ Text: "Help", SubTitle: fmt.Sprintf(`%s v%s (%s)`, setting.ApplicationName, setting.BuildVersion, setting.BuildCommit), From 22e098b83019bb048212a704a406e84316f499c0 Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Wed, 6 Mar 2019 10:27:38 +0100 Subject: [PATCH 039/103] teams: editors can work with teams. --- pkg/api/api.go | 7 ++++--- pkg/middleware/auth.go | 17 +++++++++++++++++ public/app/routes/routes.ts | 2 +- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/pkg/api/api.go b/pkg/api/api.go index b5214f93d35..50700108394 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -14,6 +14,7 @@ func (hs *HTTPServer) registerRoutes() { reqGrafanaAdmin := middleware.ReqGrafanaAdmin reqEditorRole := middleware.ReqEditorRole reqOrgAdmin := middleware.ReqOrgAdmin + reqAdminOrEditorCanAdmin := middleware.EditorCanAdmin(hs.Cfg.EditorsCanOwn) redirectFromLegacyDashboardURL := middleware.RedirectFromLegacyDashboardURL() redirectFromLegacyDashboardSoloURL := middleware.RedirectFromLegacyDashboardSoloURL() quota := middleware.Quota(hs.QuotaService) @@ -41,8 +42,8 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/org/users", reqOrgAdmin, hs.Index) r.Get("/org/users/new", reqOrgAdmin, hs.Index) r.Get("/org/users/invite", reqOrgAdmin, hs.Index) - r.Get("/org/teams", reqOrgAdmin, hs.Index) - r.Get("/org/teams/*", reqOrgAdmin, hs.Index) + r.Get("/org/teams", reqAdminOrEditorCanAdmin, hs.Index) + r.Get("/org/teams/*", reqAdminOrEditorCanAdmin, hs.Index) r.Get("/org/apikeys/", reqOrgAdmin, hs.Index) r.Get("/dashboard/import/", reqSignedIn, hs.Index) r.Get("/configuration", reqGrafanaAdmin, hs.Index) @@ -161,7 +162,7 @@ func (hs *HTTPServer) registerRoutes() { teamsRoute.Delete("/:teamId/members/:userId", Wrap(RemoveTeamMember)) teamsRoute.Get("/:teamId/preferences", Wrap(GetTeamPreferences)) teamsRoute.Put("/:teamId/preferences", bind(dtos.UpdatePrefsCmd{}), Wrap(UpdateTeamPreferences)) - }, reqOrgAdmin) + }, reqAdminOrEditorCanAdmin) // team without requirement of user to be org admin apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) { diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index e06409211eb..6bf37e7fd50 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -86,3 +86,20 @@ func Auth(options *AuthOptions) macaron.Handler { } } } + +func EditorCanAdmin(enabled bool) macaron.Handler { + return func(c *m.ReqContext) { + ok := false + if c.OrgRole == m.ROLE_ADMIN { + ok = true + } + + if c.OrgRole == m.ROLE_EDITOR && enabled { + ok = true + } + + if !ok { + accessForbidden(c) + } + } +} diff --git a/public/app/routes/routes.ts b/public/app/routes/routes.ts index 442fb5acb0c..06af66d7d5d 100644 --- a/public/app/routes/routes.ts +++ b/public/app/routes/routes.ts @@ -207,7 +207,7 @@ export function setupAngularRoutes($routeProvider, $locationProvider) { .when('/org/teams/edit/:id/:page?', { template: '', resolve: { - roles: () => ['Admin'], + roles: () => (config.editorsCanOwn ? ['Editor', 'Admin'] : ['Admin']), component: () => TeamPages, }, }) From af4994ba1623482d42b2b79af80b6d9ed01b46b5 Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Wed, 6 Mar 2019 11:47:18 +0100 Subject: [PATCH 040/103] teams: editor added as admin for created teams. --- pkg/api/api.go | 2 +- pkg/api/team.go | 13 ++++++++++++- pkg/models/team_member.go | 20 +++++++++++--------- pkg/services/sqlstore/migrations/team_mig.go | 6 ++++++ pkg/services/sqlstore/team.go | 13 +++++++------ 5 files changed, 37 insertions(+), 17 deletions(-) diff --git a/pkg/api/api.go b/pkg/api/api.go index 50700108394..3cbcc8029a3 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -154,7 +154,7 @@ func (hs *HTTPServer) registerRoutes() { // team (admin permission required) apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) { - teamsRoute.Post("/", bind(m.CreateTeamCommand{}), Wrap(CreateTeam)) + teamsRoute.Post("/", bind(m.CreateTeamCommand{}), Wrap(hs.CreateTeam)) teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), Wrap(UpdateTeam)) teamsRoute.Delete("/:teamId", Wrap(DeleteTeamByID)) teamsRoute.Get("/:teamId/members", Wrap(GetTeamMembers)) diff --git a/pkg/api/team.go b/pkg/api/team.go index 32265e5d018..5c58a0df71c 100644 --- a/pkg/api/team.go +++ b/pkg/api/team.go @@ -8,7 +8,7 @@ import ( ) // POST /api/teams -func CreateTeam(c *m.ReqContext, cmd m.CreateTeamCommand) Response { +func (hs *HTTPServer) CreateTeam(c *m.ReqContext, cmd m.CreateTeamCommand) Response { cmd.OrgId = c.OrgId if err := bus.Dispatch(&cmd); err != nil { if err == m.ErrTeamNameTaken { @@ -17,6 +17,17 @@ func CreateTeam(c *m.ReqContext, cmd m.CreateTeamCommand) Response { return Error(500, "Failed to create Team", err) } + if c.OrgRole == m.ROLE_EDITOR && hs.Cfg.EditorsCanOwn { + addMemberCmd := m.AddTeamMemberCommand{ + UserId: c.SignedInUser.UserId, + OrgId: cmd.OrgId, + TeamId: cmd.Result.Id, + Permission: int64(m.PERMISSION_ADMIN), + } + err := bus.Dispatch(&addMemberCmd) + c.Logger.Error("Could not add creator to team.", "error", err) + } + return JSON(200, &util.DynMap{ "teamId": cmd.Result.Id, "message": "Team created", diff --git a/pkg/models/team_member.go b/pkg/models/team_member.go index dd64787f465..01659cb0347 100644 --- a/pkg/models/team_member.go +++ b/pkg/models/team_member.go @@ -12,11 +12,12 @@ var ( // TeamMember model type TeamMember struct { - Id int64 - OrgId int64 - TeamId int64 - UserId int64 - External bool + Id int64 + OrgId int64 + TeamId int64 + UserId int64 + External bool + Permission int64 Created time.Time Updated time.Time @@ -26,10 +27,11 @@ type TeamMember struct { // COMMANDS type AddTeamMemberCommand struct { - UserId int64 `json:"userId" binding:"Required"` - OrgId int64 `json:"-"` - TeamId int64 `json:"-"` - External bool `json:"-"` + UserId int64 `json:"userId" binding:"Required"` + OrgId int64 `json:"-"` + TeamId int64 `json:"-"` + External bool `json:"-"` + Permission int64 `json:"-"` } type RemoveTeamMemberCommand struct { diff --git a/pkg/services/sqlstore/migrations/team_mig.go b/pkg/services/sqlstore/migrations/team_mig.go index 34c46ad13cf..1ec27ee926d 100644 --- a/pkg/services/sqlstore/migrations/team_mig.go +++ b/pkg/services/sqlstore/migrations/team_mig.go @@ -54,4 +54,10 @@ func addTeamMigrations(mg *Migrator) { mg.AddMigration("Add column external to team_member table", NewAddColumnMigration(teamMemberV1, &Column{ Name: "external", Type: DB_Bool, Nullable: true, })) + + mg.AddMigration("Add column permission to team_member table", NewAddColumnMigration(teamMemberV1, &Column{ + Name: "permission", + Type: DB_BigInt, + Nullable: true, + })) } diff --git a/pkg/services/sqlstore/team.go b/pkg/services/sqlstore/team.go index 83593e6f2d7..c11a2d077ed 100644 --- a/pkg/services/sqlstore/team.go +++ b/pkg/services/sqlstore/team.go @@ -240,12 +240,13 @@ func AddTeamMember(cmd *m.AddTeamMemberCommand) error { } entity := m.TeamMember{ - OrgId: cmd.OrgId, - TeamId: cmd.TeamId, - UserId: cmd.UserId, - External: cmd.External, - Created: time.Now(), - Updated: time.Now(), + OrgId: cmd.OrgId, + TeamId: cmd.TeamId, + UserId: cmd.UserId, + External: cmd.External, + Created: time.Now(), + Updated: time.Now(), + Permission: cmd.Permission, } _, err := sess.Insert(&entity) From 7888457aaee0aa233bd2696b75436d6fc686ff06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Wed, 6 Mar 2019 12:27:18 +0100 Subject: [PATCH 041/103] teams: basic ui for permission in team members view --- pkg/models/team_member.go | 17 +- pkg/services/sqlstore/team.go | 2 +- public/app/features/teams/TeamMembers.tsx | 18 +- .../app/features/teams/__mocks__/teamMocks.ts | 2 + .../__snapshots__/TeamMembers.test.tsx.snap | 429 ++++++++++++++++++ public/app/types/acl.ts | 20 + public/app/types/teams.ts | 1 + 7 files changed, 478 insertions(+), 11 deletions(-) diff --git a/pkg/models/team_member.go b/pkg/models/team_member.go index 01659cb0347..813455d3d2b 100644 --- a/pkg/models/team_member.go +++ b/pkg/models/team_member.go @@ -55,12 +55,13 @@ type GetTeamMembersQuery struct { // Projections and DTOs type TeamMemberDTO struct { - OrgId int64 `json:"orgId"` - TeamId int64 `json:"teamId"` - UserId int64 `json:"userId"` - External bool `json:"-"` - Email string `json:"email"` - Login string `json:"login"` - AvatarUrl string `json:"avatarUrl"` - Labels []string `json:"labels"` + OrgId int64 `json:"orgId"` + TeamId int64 `json:"teamId"` + UserId int64 `json:"userId"` + External bool `json:"-"` + Email string `json:"email"` + Login string `json:"login"` + AvatarUrl string `json:"avatarUrl"` + Labels []string `json:"labels"` + Permission int64 `json:"permission"` } diff --git a/pkg/services/sqlstore/team.go b/pkg/services/sqlstore/team.go index c11a2d077ed..546e0231706 100644 --- a/pkg/services/sqlstore/team.go +++ b/pkg/services/sqlstore/team.go @@ -294,7 +294,7 @@ func GetTeamMembers(query *m.GetTeamMembersQuery) error { if query.External { sess.Where("team_member.external=?", dialect.BooleanStr(true)) } - sess.Cols("team_member.org_id", "team_member.team_id", "team_member.user_id", "user.email", "user.login", "team_member.external") + sess.Cols("team_member.org_id", "team_member.team_id", "team_member.user_id", "user.email", "user.login", "team_member.external", "team_member.permission") sess.Asc("user.login", "user.email") err := sess.Find(&query.Result) diff --git a/public/app/features/teams/TeamMembers.tsx b/public/app/features/teams/TeamMembers.tsx index e5c3aaafef0..341d9311b53 100644 --- a/public/app/features/teams/TeamMembers.tsx +++ b/public/app/features/teams/TeamMembers.tsx @@ -2,9 +2,9 @@ import React, { PureComponent } from 'react'; import { connect } from 'react-redux'; import SlideDown from 'app/core/components/Animations/SlideDown'; import { UserPicker } from 'app/core/components/Select/UserPicker'; -import { DeleteButton } from '@grafana/ui'; +import { DeleteButton, Select } from '@grafana/ui'; import { TagBadge } from 'app/core/components/TagFilter/TagBadge'; -import { TeamMember, User } from 'app/types'; +import { TeamMember, User, teamsPermissionLevels } from 'app/types'; import { loadTeamMembers, addTeamMember, removeTeamMember, setSearchMemberQuery } from './state/actions'; import { getSearchMemberQuery, getTeamMembers } from './state/selectors'; import { FilterInput } from 'app/core/components/FilterInput/FilterInput'; @@ -70,6 +70,7 @@ export class TeamMembers extends PureComponent { } renderMember(member: TeamMember, syncEnabled: boolean) { + const currentPermissionLevel = teamsPermissionLevels.find(dp => dp.value === member.permission); return ( @@ -77,6 +78,18 @@ export class TeamMembers extends PureComponent { {member.login} {member.email} + +
+ +
+ + @@ -205,6 +253,48 @@ exports[`Render should render team members 1`] = ` test@test.com + +
+ +
+ + @@ -255,6 +387,48 @@ exports[`Render should render team members 1`] = ` test@test.com + +
+ +
+ + @@ -363,6 +579,9 @@ exports[`Render should render team members when sync enabled 1`] = ` Email + + Permission + test@test.com + +
+ +
+ + test@test.com + +
+ +
+ + test@test.com + +
+ {}} - className="gf-form-select-box__control--menu-right" - value={currentPermissionLevel} - isDisabled={true} - /> -
- {' '} + + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ {}} + onChange={item => this.onPermissionChange(item, member)} className="gf-form-select-box__control--menu-right" - value={currentPermissionLevel} - isDisabled={true} + value={teamsPermissionLevels.find(dp => dp.value === member.permission)} />
@@ -176,6 +188,7 @@ const mapDispatchToProps = { addTeamMember, removeTeamMember, setSearchMemberQuery, + updateTeamMember, }; export default connect( diff --git a/public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap b/public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap index fc168457334..01d7b40ec61 100644 --- a/public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap +++ b/public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap @@ -206,7 +206,7 @@ exports[`Render should render team members 1`] = ` backspaceRemovesValue={true} className="gf-form-select-box__control--menu-right" isClearable={false} - isDisabled={true} + isDisabled={false} isLoading={false} isMulti={false} isSearchable={false} @@ -276,7 +276,7 @@ exports[`Render should render team members 1`] = ` backspaceRemovesValue={true} className="gf-form-select-box__control--menu-right" isClearable={false} - isDisabled={true} + isDisabled={false} isLoading={false} isMulti={false} isSearchable={false} @@ -346,7 +346,7 @@ exports[`Render should render team members 1`] = ` backspaceRemovesValue={true} className="gf-form-select-box__control--menu-right" isClearable={false} - isDisabled={true} + isDisabled={false} isLoading={false} isMulti={false} isSearchable={false} @@ -416,7 +416,7 @@ exports[`Render should render team members 1`] = ` backspaceRemovesValue={true} className="gf-form-select-box__control--menu-right" isClearable={false} - isDisabled={true} + isDisabled={false} isLoading={false} isMulti={false} isSearchable={false} @@ -486,7 +486,7 @@ exports[`Render should render team members 1`] = ` backspaceRemovesValue={true} className="gf-form-select-box__control--menu-right" isClearable={false} - isDisabled={true} + isDisabled={false} isLoading={false} isMulti={false} isSearchable={false} @@ -649,7 +649,7 @@ exports[`Render should render team members when sync enabled 1`] = ` backspaceRemovesValue={true} className="gf-form-select-box__control--menu-right" isClearable={false} - isDisabled={true} + isDisabled={false} isLoading={false} isMulti={false} isSearchable={false} @@ -735,7 +735,7 @@ exports[`Render should render team members when sync enabled 1`] = ` backspaceRemovesValue={true} className="gf-form-select-box__control--menu-right" isClearable={false} - isDisabled={true} + isDisabled={false} isLoading={false} isMulti={false} isSearchable={false} @@ -821,7 +821,7 @@ exports[`Render should render team members when sync enabled 1`] = ` backspaceRemovesValue={true} className="gf-form-select-box__control--menu-right" isClearable={false} - isDisabled={true} + isDisabled={false} isLoading={false} isMulti={false} isSearchable={false} @@ -907,7 +907,7 @@ exports[`Render should render team members when sync enabled 1`] = ` backspaceRemovesValue={true} className="gf-form-select-box__control--menu-right" isClearable={false} - isDisabled={true} + isDisabled={false} isLoading={false} isMulti={false} isSearchable={false} @@ -993,7 +993,7 @@ exports[`Render should render team members when sync enabled 1`] = ` backspaceRemovesValue={true} className="gf-form-select-box__control--menu-right" isClearable={false} - isDisabled={true} + isDisabled={false} isLoading={false} isMulti={false} isSearchable={false} diff --git a/public/app/features/teams/state/actions.ts b/public/app/features/teams/state/actions.ts index d948dc1c5a3..e2582839233 100644 --- a/public/app/features/teams/state/actions.ts +++ b/public/app/features/teams/state/actions.ts @@ -160,3 +160,12 @@ export function deleteTeam(id: number): ThunkResult { dispatch(loadTeams()); }; } + +export function updateTeamMember(member: TeamMember): ThunkResult { + return async dispatch => { + await getBackendSrv().put(`/api/teams/${member.teamId}/members/${member.userId}`, { + permission: member.permission, + }); + dispatch(loadTeamMembers()); + }; +} From 074ebf0f482e9f1a5e445c19fe947d6e8e2bd989 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Fri, 8 Mar 2019 11:56:48 +0100 Subject: [PATCH 046/103] teams: only write error message if error --- pkg/api/team.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pkg/api/team.go b/pkg/api/team.go index 5c58a0df71c..da72bda472b 100644 --- a/pkg/api/team.go +++ b/pkg/api/team.go @@ -24,8 +24,10 @@ func (hs *HTTPServer) CreateTeam(c *m.ReqContext, cmd m.CreateTeamCommand) Respo TeamId: cmd.Result.Id, Permission: int64(m.PERMISSION_ADMIN), } - err := bus.Dispatch(&addMemberCmd) - c.Logger.Error("Could not add creator to team.", "error", err) + + if err := bus.Dispatch(&addMemberCmd); err != nil { + c.Logger.Error("Could not add creator to team.", "error", err) + } } return JSON(200, &util.DynMap{ From 3c74ac304490aa3ef46f045fd7d61d718253c5d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Fri, 8 Mar 2019 12:25:10 +0100 Subject: [PATCH 047/103] teams: update only the selected user --- pkg/services/sqlstore/team.go | 2 +- public/app/types/acl.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/services/sqlstore/team.go b/pkg/services/sqlstore/team.go index 4822af7009c..0a5383d993f 100644 --- a/pkg/services/sqlstore/team.go +++ b/pkg/services/sqlstore/team.go @@ -272,7 +272,7 @@ func UpdateTeamMember(cmd *m.UpdateTeamMemberCommand) error { } member.Permission = cmd.Permission - _, err = sess.Update(member) + _, err = sess.Where("org_id=? and team_id=? and user_id=?", cmd.OrgId, cmd.TeamId, cmd.UserId).Update(member) return err }) diff --git a/public/app/types/acl.ts b/public/app/types/acl.ts index 8134ddb1749..12016732222 100644 --- a/public/app/types/acl.ts +++ b/public/app/types/acl.ts @@ -100,7 +100,7 @@ export const dashboardPermissionLevels: DashboardPermissionInfo[] = [ ]; export enum TeamPermissionLevel { - Member = 0, + Member = 1, Admin = 4, } From 1315a67022c5e117e36cfb14cd59cd0586c4c079 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Fri, 8 Mar 2019 13:10:03 +0100 Subject: [PATCH 048/103] teams: make sure we use TeamPermissionLevel enum --- .../app/features/teams/__mocks__/teamMocks.ts | 6 +-- .../__snapshots__/TeamMembers.test.tsx.snap | 40 +++++++++---------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/public/app/features/teams/__mocks__/teamMocks.ts b/public/app/features/teams/__mocks__/teamMocks.ts index 6d4b5ea3aad..3f0830eda16 100644 --- a/public/app/features/teams/__mocks__/teamMocks.ts +++ b/public/app/features/teams/__mocks__/teamMocks.ts @@ -1,4 +1,4 @@ -import { Team, TeamGroup, TeamMember } from 'app/types'; +import { Team, TeamGroup, TeamMember, TeamPermissionLevel } from 'app/types'; export const getMultipleMockTeams = (numberOfTeams: number): Team[] => { const teams: Team[] = []; @@ -36,7 +36,7 @@ export const getMockTeamMembers = (amount: number): TeamMember[] => { email: 'test@test.com', login: `testUser-${i}`, labels: ['label 1', 'label 2'], - permission: 0, + permission: TeamPermissionLevel.Member, }); } @@ -51,7 +51,7 @@ export const getMockTeamMember = (): TeamMember => { email: 'test@test.com', login: 'testUser', labels: [], - permission: 0, + permission: TeamPermissionLevel.Member, }; }; diff --git a/public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap b/public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap index 01d7b40ec61..c356727ebeb 100644 --- a/public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap +++ b/public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap @@ -218,7 +218,7 @@ exports[`Render should render team members 1`] = ` Object { "description": "Is team member", "label": "Member", - "value": 0, + "value": 1, }, Object { "description": "Can add/remove permissions and delete team.", @@ -231,7 +231,7 @@ exports[`Render should render team members 1`] = ` Object { "description": "Is team member", "label": "Member", - "value": 0, + "value": 1, } } width={null} @@ -288,7 +288,7 @@ exports[`Render should render team members 1`] = ` Object { "description": "Is team member", "label": "Member", - "value": 0, + "value": 1, }, Object { "description": "Can add/remove permissions and delete team.", @@ -301,7 +301,7 @@ exports[`Render should render team members 1`] = ` Object { "description": "Is team member", "label": "Member", - "value": 0, + "value": 1, } } width={null} @@ -358,7 +358,7 @@ exports[`Render should render team members 1`] = ` Object { "description": "Is team member", "label": "Member", - "value": 0, + "value": 1, }, Object { "description": "Can add/remove permissions and delete team.", @@ -371,7 +371,7 @@ exports[`Render should render team members 1`] = ` Object { "description": "Is team member", "label": "Member", - "value": 0, + "value": 1, } } width={null} @@ -428,7 +428,7 @@ exports[`Render should render team members 1`] = ` Object { "description": "Is team member", "label": "Member", - "value": 0, + "value": 1, }, Object { "description": "Can add/remove permissions and delete team.", @@ -441,7 +441,7 @@ exports[`Render should render team members 1`] = ` Object { "description": "Is team member", "label": "Member", - "value": 0, + "value": 1, } } width={null} @@ -498,7 +498,7 @@ exports[`Render should render team members 1`] = ` Object { "description": "Is team member", "label": "Member", - "value": 0, + "value": 1, }, Object { "description": "Can add/remove permissions and delete team.", @@ -511,7 +511,7 @@ exports[`Render should render team members 1`] = ` Object { "description": "Is team member", "label": "Member", - "value": 0, + "value": 1, } } width={null} @@ -661,7 +661,7 @@ exports[`Render should render team members when sync enabled 1`] = ` Object { "description": "Is team member", "label": "Member", - "value": 0, + "value": 1, }, Object { "description": "Can add/remove permissions and delete team.", @@ -674,7 +674,7 @@ exports[`Render should render team members when sync enabled 1`] = ` Object { "description": "Is team member", "label": "Member", - "value": 0, + "value": 1, } } width={null} @@ -747,7 +747,7 @@ exports[`Render should render team members when sync enabled 1`] = ` Object { "description": "Is team member", "label": "Member", - "value": 0, + "value": 1, }, Object { "description": "Can add/remove permissions and delete team.", @@ -760,7 +760,7 @@ exports[`Render should render team members when sync enabled 1`] = ` Object { "description": "Is team member", "label": "Member", - "value": 0, + "value": 1, } } width={null} @@ -833,7 +833,7 @@ exports[`Render should render team members when sync enabled 1`] = ` Object { "description": "Is team member", "label": "Member", - "value": 0, + "value": 1, }, Object { "description": "Can add/remove permissions and delete team.", @@ -846,7 +846,7 @@ exports[`Render should render team members when sync enabled 1`] = ` Object { "description": "Is team member", "label": "Member", - "value": 0, + "value": 1, } } width={null} @@ -919,7 +919,7 @@ exports[`Render should render team members when sync enabled 1`] = ` Object { "description": "Is team member", "label": "Member", - "value": 0, + "value": 1, }, Object { "description": "Can add/remove permissions and delete team.", @@ -932,7 +932,7 @@ exports[`Render should render team members when sync enabled 1`] = ` Object { "description": "Is team member", "label": "Member", - "value": 0, + "value": 1, } } width={null} @@ -1005,7 +1005,7 @@ exports[`Render should render team members when sync enabled 1`] = ` Object { "description": "Is team member", "label": "Member", - "value": 0, + "value": 1, }, Object { "description": "Can add/remove permissions and delete team.", @@ -1018,7 +1018,7 @@ exports[`Render should render team members when sync enabled 1`] = ` Object { "description": "Is team member", "label": "Member", - "value": 0, + "value": 1, } } width={null} From 3c46b786d2a58f23ebe9ae6fd8b846ffbcc21cdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Fri, 8 Mar 2019 13:53:42 +0100 Subject: [PATCH 049/103] teams: change back to permissionlevel for Member to 0 --- pkg/services/sqlstore/team.go | 2 +- .../__snapshots__/TeamMembers.test.tsx.snap | 40 +++++++++---------- public/app/types/acl.ts | 2 +- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/pkg/services/sqlstore/team.go b/pkg/services/sqlstore/team.go index 0a5383d993f..f7f7d7fc2cb 100644 --- a/pkg/services/sqlstore/team.go +++ b/pkg/services/sqlstore/team.go @@ -272,7 +272,7 @@ func UpdateTeamMember(cmd *m.UpdateTeamMemberCommand) error { } member.Permission = cmd.Permission - _, err = sess.Where("org_id=? and team_id=? and user_id=?", cmd.OrgId, cmd.TeamId, cmd.UserId).Update(member) + _, err = sess.Cols("permission").Where("org_id=? and team_id=? and user_id=?", cmd.OrgId, cmd.TeamId, cmd.UserId).Update(member) return err }) diff --git a/public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap b/public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap index c356727ebeb..01d7b40ec61 100644 --- a/public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap +++ b/public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap @@ -218,7 +218,7 @@ exports[`Render should render team members 1`] = ` Object { "description": "Is team member", "label": "Member", - "value": 1, + "value": 0, }, Object { "description": "Can add/remove permissions and delete team.", @@ -231,7 +231,7 @@ exports[`Render should render team members 1`] = ` Object { "description": "Is team member", "label": "Member", - "value": 1, + "value": 0, } } width={null} @@ -288,7 +288,7 @@ exports[`Render should render team members 1`] = ` Object { "description": "Is team member", "label": "Member", - "value": 1, + "value": 0, }, Object { "description": "Can add/remove permissions and delete team.", @@ -301,7 +301,7 @@ exports[`Render should render team members 1`] = ` Object { "description": "Is team member", "label": "Member", - "value": 1, + "value": 0, } } width={null} @@ -358,7 +358,7 @@ exports[`Render should render team members 1`] = ` Object { "description": "Is team member", "label": "Member", - "value": 1, + "value": 0, }, Object { "description": "Can add/remove permissions and delete team.", @@ -371,7 +371,7 @@ exports[`Render should render team members 1`] = ` Object { "description": "Is team member", "label": "Member", - "value": 1, + "value": 0, } } width={null} @@ -428,7 +428,7 @@ exports[`Render should render team members 1`] = ` Object { "description": "Is team member", "label": "Member", - "value": 1, + "value": 0, }, Object { "description": "Can add/remove permissions and delete team.", @@ -441,7 +441,7 @@ exports[`Render should render team members 1`] = ` Object { "description": "Is team member", "label": "Member", - "value": 1, + "value": 0, } } width={null} @@ -498,7 +498,7 @@ exports[`Render should render team members 1`] = ` Object { "description": "Is team member", "label": "Member", - "value": 1, + "value": 0, }, Object { "description": "Can add/remove permissions and delete team.", @@ -511,7 +511,7 @@ exports[`Render should render team members 1`] = ` Object { "description": "Is team member", "label": "Member", - "value": 1, + "value": 0, } } width={null} @@ -661,7 +661,7 @@ exports[`Render should render team members when sync enabled 1`] = ` Object { "description": "Is team member", "label": "Member", - "value": 1, + "value": 0, }, Object { "description": "Can add/remove permissions and delete team.", @@ -674,7 +674,7 @@ exports[`Render should render team members when sync enabled 1`] = ` Object { "description": "Is team member", "label": "Member", - "value": 1, + "value": 0, } } width={null} @@ -747,7 +747,7 @@ exports[`Render should render team members when sync enabled 1`] = ` Object { "description": "Is team member", "label": "Member", - "value": 1, + "value": 0, }, Object { "description": "Can add/remove permissions and delete team.", @@ -760,7 +760,7 @@ exports[`Render should render team members when sync enabled 1`] = ` Object { "description": "Is team member", "label": "Member", - "value": 1, + "value": 0, } } width={null} @@ -833,7 +833,7 @@ exports[`Render should render team members when sync enabled 1`] = ` Object { "description": "Is team member", "label": "Member", - "value": 1, + "value": 0, }, Object { "description": "Can add/remove permissions and delete team.", @@ -846,7 +846,7 @@ exports[`Render should render team members when sync enabled 1`] = ` Object { "description": "Is team member", "label": "Member", - "value": 1, + "value": 0, } } width={null} @@ -919,7 +919,7 @@ exports[`Render should render team members when sync enabled 1`] = ` Object { "description": "Is team member", "label": "Member", - "value": 1, + "value": 0, }, Object { "description": "Can add/remove permissions and delete team.", @@ -932,7 +932,7 @@ exports[`Render should render team members when sync enabled 1`] = ` Object { "description": "Is team member", "label": "Member", - "value": 1, + "value": 0, } } width={null} @@ -1005,7 +1005,7 @@ exports[`Render should render team members when sync enabled 1`] = ` Object { "description": "Is team member", "label": "Member", - "value": 1, + "value": 0, }, Object { "description": "Can add/remove permissions and delete team.", @@ -1018,7 +1018,7 @@ exports[`Render should render team members when sync enabled 1`] = ` Object { "description": "Is team member", "label": "Member", - "value": 1, + "value": 0, } } width={null} diff --git a/public/app/types/acl.ts b/public/app/types/acl.ts index 12016732222..8134ddb1749 100644 --- a/public/app/types/acl.ts +++ b/public/app/types/acl.ts @@ -100,7 +100,7 @@ export const dashboardPermissionLevels: DashboardPermissionInfo[] = [ ]; export enum TeamPermissionLevel { - Member = 1, + Member = 0, Admin = 4, } From 5adde259d307ac36277b419475712503cf25d63e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Fri, 8 Mar 2019 14:37:21 +0100 Subject: [PATCH 050/103] teams: team update test --- pkg/api/team.go | 3 ++- pkg/services/teams/team.go | 10 ++++++++ pkg/services/teams/teams_test.go | 42 ++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 pkg/services/teams/team.go create mode 100644 pkg/services/teams/teams_test.go diff --git a/pkg/api/team.go b/pkg/api/team.go index da72bda472b..3d357fa9763 100644 --- a/pkg/api/team.go +++ b/pkg/api/team.go @@ -4,6 +4,7 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/teams" "github.com/grafana/grafana/pkg/util" ) @@ -40,7 +41,7 @@ func (hs *HTTPServer) CreateTeam(c *m.ReqContext, cmd m.CreateTeamCommand) Respo func UpdateTeam(c *m.ReqContext, cmd m.UpdateTeamCommand) Response { cmd.OrgId = c.OrgId cmd.Id = c.ParamsInt64(":teamId") - if err := bus.Dispatch(&cmd); err != nil { + if err := teams.UpdateTeam(c.SignedInUser, &cmd); err != nil { if err == m.ErrTeamNameTaken { return Error(400, "Team name taken", err) } diff --git a/pkg/services/teams/team.go b/pkg/services/teams/team.go new file mode 100644 index 00000000000..4bd4b78d587 --- /dev/null +++ b/pkg/services/teams/team.go @@ -0,0 +1,10 @@ +package teams + +import ( + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" +) + +func UpdateTeam(user m.SignedInUser, cmd *m.UpdateTeamCommand) error { + return bus.Dispatch(cmd) +} diff --git a/pkg/services/teams/teams_test.go b/pkg/services/teams/teams_test.go new file mode 100644 index 00000000000..aaa19440bb4 --- /dev/null +++ b/pkg/services/teams/teams_test.go @@ -0,0 +1,42 @@ +package teams + +import ( + . "github.com/smartystreets/goconvey/convey" + m "github.com/grafana/grafana/pkg/models" +) + + +func TestUpdateTeam(t *testing.T) { + Convey("Updating a team as an editor", t, func() { + Convey("Given an editor and a team he isn't a member of", func() { + + UpdateTeam(editor, m.UpdateTeamCommand{ + Id: 0, + Name: "", + Email: "", + OrgId: 0, + }) + }) + + // the editor should not be able to update the team if they aren't members of it + + fakeDash := m.NewDashboard("Child dash") + fakeDash.Id = 1 + fakeDash.FolderId = 1 + fakeDash.HasAcl = false + + bus.AddHandler("test", func(query *m.GetDashboardsBySlugQuery) error { + dashboards := []*m.Dashboard{fakeDash} + query.Result = dashboards + return nil + }) + + var getDashboardQueries []*m.GetDashboardQuery + + bus.AddHandler("test", func(query *m.GetDashboardQuery) error { + query.Result = fakeDash + getDashboardQueries = append(getDashboardQueries, query) + return nil + }) + + bus.AddHandler("test", func(query *m.IsDashboardProvisionedQuery) error { From 90e9fda90c9904e273c30ed6563eb4e15de915e6 Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Fri, 8 Mar 2019 15:46:15 +0100 Subject: [PATCH 051/103] teams: start of team update guardian for editors --- pkg/models/team.go | 8 +- pkg/services/teams/team.go | 35 ++++++ pkg/services/teams/teams_test.go | 176 +++++++++++++++++++++++++------ 3 files changed, 183 insertions(+), 36 deletions(-) diff --git a/pkg/models/team.go b/pkg/models/team.go index 61285db3a5f..bd0d803d9d3 100644 --- a/pkg/models/team.go +++ b/pkg/models/team.go @@ -7,9 +7,11 @@ import ( // Typed errors var ( - ErrTeamNotFound = errors.New("Team not found") - ErrTeamNameTaken = errors.New("Team name is taken") - ErrTeamMemberNotFound = errors.New("Team member not found") + ErrTeamNotFound = errors.New("Team not found") + ErrTeamNameTaken = errors.New("Team name is taken") + ErrTeamMemberNotFound = errors.New("Team member not found") + ErrNotAllowedToUpdateTeam = errors.New("User not allowed to update team") + ErrNotAllowedToUpdateTeamInDifferentOrg = errors.New("User not allowed to update team in another org") ) // Team model diff --git a/pkg/services/teams/team.go b/pkg/services/teams/team.go index 4bd4b78d587..7ff18820b62 100644 --- a/pkg/services/teams/team.go +++ b/pkg/services/teams/team.go @@ -5,6 +5,41 @@ import ( m "github.com/grafana/grafana/pkg/models" ) +func canUpdateTeam(orgId int64, teamId int64, user m.SignedInUser) error { + if user.OrgRole == m.ROLE_ADMIN { + return nil + } + + if user.OrgId != orgId { + return m.ErrNotAllowedToUpdateTeamInDifferentOrg + } + + cmd := m.GetTeamMembersQuery{ + OrgId: orgId, + TeamId: teamId, + UserId: user.UserId, + // TODO: do we need to do something special about external users + // External: false, + } + + if err := bus.Dispatch(&cmd); err != nil { + // TODO: look into how we want to do logging + return err + } + + for _, member := range cmd.Result { + if member.UserId == user.UserId && member.Permission == int64(m.PERMISSION_ADMIN) { + return nil + } + } + + return m.ErrNotAllowedToUpdateTeam +} + func UpdateTeam(user m.SignedInUser, cmd *m.UpdateTeamCommand) error { + if err := canUpdateTeam(cmd.OrgId, cmd.Id, user); err != nil { + return err + } + return bus.Dispatch(cmd) } diff --git a/pkg/services/teams/teams_test.go b/pkg/services/teams/teams_test.go index aaa19440bb4..9dd42e1add5 100644 --- a/pkg/services/teams/teams_test.go +++ b/pkg/services/teams/teams_test.go @@ -1,42 +1,152 @@ package teams import ( - . "github.com/smartystreets/goconvey/convey" + "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" + "github.com/pkg/errors" + . "github.com/smartystreets/goconvey/convey" + "testing" ) - func TestUpdateTeam(t *testing.T) { - Convey("Updating a team as an editor", t, func() { + Convey("Updating a team", t, func() { + bus.ClearBusHandlers() Convey("Given an editor and a team he isn't a member of", func() { - - UpdateTeam(editor, m.UpdateTeamCommand{ - Id: 0, - Name: "", - Email: "", - OrgId: 0, + editor := m.SignedInUser{ + UserId: 1, + OrgId: 1, + OrgRole: m.ROLE_EDITOR, + } + + Convey("Should not be able to update the team", func() { + cmd := m.UpdateTeamCommand{ + Id: 1, + OrgId: editor.OrgId, + } + + bus.AddHandler("test", func(cmd *m.UpdateTeamCommand) error { + return errors.New("Editor not allowed to update team.") + }) + bus.AddHandler("test", func(cmd *m.GetTeamMembersQuery) error { + cmd.Result = []*m.TeamMemberDTO{} + return nil + }) + + err := UpdateTeam(editor, &cmd) + + So(err, ShouldEqual, m.ErrNotAllowedToUpdateTeam) + }) + }) + + Convey("Given an editor and a team he is a member of", func() { + editor := m.SignedInUser{ + UserId: 1, + OrgId: 1, + OrgRole: m.ROLE_EDITOR, + } + + testTeam := m.Team{ + Id: 1, + OrgId: 1, + } + + Convey("Should be able to update the team", func() { + cmd := m.UpdateTeamCommand{ + Id: testTeam.Id, + OrgId: testTeam.OrgId, + } + + teamUpdated := false + + bus.AddHandler("test", func(cmd *m.UpdateTeamCommand) error { + teamUpdated = true + return nil + }) + + bus.AddHandler("test", func(cmd *m.GetTeamMembersQuery) error { + cmd.Result = []*m.TeamMemberDTO{{ + OrgId: testTeam.OrgId, + TeamId: testTeam.Id, + UserId: editor.UserId, + Permission: int64(m.PERMISSION_ADMIN), + }} + return nil + }) + + err := UpdateTeam(editor, &cmd) + + So(teamUpdated, ShouldBeTrue) + So(err, ShouldBeNil) + }) + }) + + Convey("Given an editor and a team in another org", func() { + editor := m.SignedInUser{ + UserId: 1, + OrgId: 1, + OrgRole: m.ROLE_EDITOR, + } + + testTeam := m.Team{ + Id: 1, + OrgId: 2, + } + + Convey("Shouldn't be able to update the team", func() { + cmd := m.UpdateTeamCommand{ + Id: testTeam.Id, + OrgId: testTeam.OrgId, + } + + bus.AddHandler("test", func(cmd *m.UpdateTeamCommand) error { + return errors.New("Can't update a team in a different org.") + }) + bus.AddHandler("test", func(cmd *m.GetTeamMembersQuery) error { + cmd.Result = []*m.TeamMemberDTO{{ + OrgId: testTeam.OrgId, + TeamId: testTeam.Id, + UserId: editor.UserId, + Permission: int64(m.PERMISSION_ADMIN), + }} + return nil + }) + + err := UpdateTeam(editor, &cmd) + + So(err, ShouldEqual, m.ErrNotAllowedToUpdateTeamInDifferentOrg) + }) + }) + + Convey("Given an org admin and a team", func() { + editor := m.SignedInUser{ + UserId: 1, + OrgId: 1, + OrgRole: m.ROLE_ADMIN, + } + + testTeam := m.Team{ + Id: 1, + OrgId: 1, + } + + Convey("Should be able to update the team", func() { + cmd := m.UpdateTeamCommand{ + Id: testTeam.Id, + OrgId: testTeam.OrgId, + } + + teamUpdated := false + + bus.AddHandler("test", func(cmd *m.UpdateTeamCommand) error { + teamUpdated = true + return nil + }) + + err := UpdateTeam(editor, &cmd) + + So(teamUpdated, ShouldBeTrue) + So(err, ShouldBeNil) + }) + }) }) - }) - - // the editor should not be able to update the team if they aren't members of it - - fakeDash := m.NewDashboard("Child dash") - fakeDash.Id = 1 - fakeDash.FolderId = 1 - fakeDash.HasAcl = false - - bus.AddHandler("test", func(query *m.GetDashboardsBySlugQuery) error { - dashboards := []*m.Dashboard{fakeDash} - query.Result = dashboards - return nil - }) - - var getDashboardQueries []*m.GetDashboardQuery - - bus.AddHandler("test", func(query *m.GetDashboardQuery) error { - query.Result = fakeDash - getDashboardQueries = append(getDashboardQueries, query) - return nil - }) - - bus.AddHandler("test", func(query *m.IsDashboardProvisionedQuery) error { +} From 319879cfa8a069f9905031d9fc9dcc8ae5d5f483 Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Fri, 8 Mar 2019 15:58:32 +0100 Subject: [PATCH 052/103] teams: bugfix, user pointer. --- pkg/services/teams/team.go | 4 ++-- pkg/services/teams/teams_test.go | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pkg/services/teams/team.go b/pkg/services/teams/team.go index 7ff18820b62..6adf03b8b21 100644 --- a/pkg/services/teams/team.go +++ b/pkg/services/teams/team.go @@ -5,7 +5,7 @@ import ( m "github.com/grafana/grafana/pkg/models" ) -func canUpdateTeam(orgId int64, teamId int64, user m.SignedInUser) error { +func canUpdateTeam(orgId int64, teamId int64, user *m.SignedInUser) error { if user.OrgRole == m.ROLE_ADMIN { return nil } @@ -36,7 +36,7 @@ func canUpdateTeam(orgId int64, teamId int64, user m.SignedInUser) error { return m.ErrNotAllowedToUpdateTeam } -func UpdateTeam(user m.SignedInUser, cmd *m.UpdateTeamCommand) error { +func UpdateTeam(user *m.SignedInUser, cmd *m.UpdateTeamCommand) error { if err := canUpdateTeam(cmd.OrgId, cmd.Id, user); err != nil { return err } diff --git a/pkg/services/teams/teams_test.go b/pkg/services/teams/teams_test.go index 9dd42e1add5..12b773568c7 100644 --- a/pkg/services/teams/teams_test.go +++ b/pkg/services/teams/teams_test.go @@ -32,7 +32,7 @@ func TestUpdateTeam(t *testing.T) { return nil }) - err := UpdateTeam(editor, &cmd) + err := UpdateTeam(&editor, &cmd) So(err, ShouldEqual, m.ErrNotAllowedToUpdateTeam) }) @@ -73,7 +73,7 @@ func TestUpdateTeam(t *testing.T) { return nil }) - err := UpdateTeam(editor, &cmd) + err := UpdateTeam(&editor, &cmd) So(teamUpdated, ShouldBeTrue) So(err, ShouldBeNil) @@ -111,7 +111,7 @@ func TestUpdateTeam(t *testing.T) { return nil }) - err := UpdateTeam(editor, &cmd) + err := UpdateTeam(&editor, &cmd) So(err, ShouldEqual, m.ErrNotAllowedToUpdateTeamInDifferentOrg) }) @@ -142,7 +142,7 @@ func TestUpdateTeam(t *testing.T) { return nil }) - err := UpdateTeam(editor, &cmd) + err := UpdateTeam(&editor, &cmd) So(teamUpdated, ShouldBeTrue) So(err, ShouldBeNil) From 3be1d71f1ff1e1ed87e1c9a3896253fa4cdd6df3 Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Mon, 11 Mar 2019 11:12:52 +0100 Subject: [PATCH 053/103] teams: test refactorings. --- pkg/services/teams/team.go | 1 - pkg/services/teams/teams_test.go | 133 ++++++++++++------------------- 2 files changed, 52 insertions(+), 82 deletions(-) diff --git a/pkg/services/teams/team.go b/pkg/services/teams/team.go index 6adf03b8b21..ae9327699be 100644 --- a/pkg/services/teams/team.go +++ b/pkg/services/teams/team.go @@ -23,7 +23,6 @@ func canUpdateTeam(orgId int64, teamId int64, user *m.SignedInUser) error { } if err := bus.Dispatch(&cmd); err != nil { - // TODO: look into how we want to do logging return err } diff --git a/pkg/services/teams/teams_test.go b/pkg/services/teams/teams_test.go index 12b773568c7..1282eefc611 100644 --- a/pkg/services/teams/teams_test.go +++ b/pkg/services/teams/teams_test.go @@ -11,57 +11,43 @@ import ( func TestUpdateTeam(t *testing.T) { Convey("Updating a team", t, func() { bus.ClearBusHandlers() + + admin := m.SignedInUser{ + UserId: 1, + OrgId: 1, + OrgRole: m.ROLE_ADMIN, + } + editor := m.SignedInUser{ + UserId: 2, + OrgId: 1, + OrgRole: m.ROLE_EDITOR, + } + testTeam := m.Team{ + Id: 1, + OrgId: 1, + } + + updateTeamCmd := m.UpdateTeamCommand{ + Id: testTeam.Id, + OrgId: testTeam.OrgId, + } + Convey("Given an editor and a team he isn't a member of", func() { - editor := m.SignedInUser{ - UserId: 1, - OrgId: 1, - OrgRole: m.ROLE_EDITOR, - } - Convey("Should not be able to update the team", func() { - cmd := m.UpdateTeamCommand{ - Id: 1, - OrgId: editor.OrgId, - } - - bus.AddHandler("test", func(cmd *m.UpdateTeamCommand) error { - return errors.New("Editor not allowed to update team.") - }) + shouldNotUpdateTeam() bus.AddHandler("test", func(cmd *m.GetTeamMembersQuery) error { cmd.Result = []*m.TeamMemberDTO{} return nil }) - err := UpdateTeam(&editor, &cmd) - + err := UpdateTeam(&editor, &updateTeamCmd) So(err, ShouldEqual, m.ErrNotAllowedToUpdateTeam) }) }) Convey("Given an editor and a team he is a member of", func() { - editor := m.SignedInUser{ - UserId: 1, - OrgId: 1, - OrgRole: m.ROLE_EDITOR, - } - - testTeam := m.Team{ - Id: 1, - OrgId: 1, - } - Convey("Should be able to update the team", func() { - cmd := m.UpdateTeamCommand{ - Id: testTeam.Id, - OrgId: testTeam.OrgId, - } - - teamUpdated := false - - bus.AddHandler("test", func(cmd *m.UpdateTeamCommand) error { - teamUpdated = true - return nil - }) + teamUpdatedCallback := updateTeamCalled() bus.AddHandler("test", func(cmd *m.GetTeamMembersQuery) error { cmd.Result = []*m.TeamMemberDTO{{ @@ -73,38 +59,29 @@ func TestUpdateTeam(t *testing.T) { return nil }) - err := UpdateTeam(&editor, &cmd) - - So(teamUpdated, ShouldBeTrue) + err := UpdateTeam(&editor, &updateTeamCmd) + So(teamUpdatedCallback(), ShouldBeTrue) So(err, ShouldBeNil) }) }) Convey("Given an editor and a team in another org", func() { - editor := m.SignedInUser{ - UserId: 1, - OrgId: 1, - OrgRole: m.ROLE_EDITOR, - } - - testTeam := m.Team{ + testTeamOtherOrg := m.Team{ Id: 1, OrgId: 2, } Convey("Shouldn't be able to update the team", func() { cmd := m.UpdateTeamCommand{ - Id: testTeam.Id, - OrgId: testTeam.OrgId, + Id: testTeamOtherOrg.Id, + OrgId: testTeamOtherOrg.OrgId, } - bus.AddHandler("test", func(cmd *m.UpdateTeamCommand) error { - return errors.New("Can't update a team in a different org.") - }) + shouldNotUpdateTeam() bus.AddHandler("test", func(cmd *m.GetTeamMembersQuery) error { cmd.Result = []*m.TeamMemberDTO{{ - OrgId: testTeam.OrgId, - TeamId: testTeam.Id, + OrgId: testTeamOtherOrg.OrgId, + TeamId: testTeamOtherOrg.Id, UserId: editor.UserId, Permission: int64(m.PERMISSION_ADMIN), }} @@ -112,41 +89,35 @@ func TestUpdateTeam(t *testing.T) { }) err := UpdateTeam(&editor, &cmd) - So(err, ShouldEqual, m.ErrNotAllowedToUpdateTeamInDifferentOrg) }) }) Convey("Given an org admin and a team", func() { - editor := m.SignedInUser{ - UserId: 1, - OrgId: 1, - OrgRole: m.ROLE_ADMIN, - } - - testTeam := m.Team{ - Id: 1, - OrgId: 1, - } - Convey("Should be able to update the team", func() { - cmd := m.UpdateTeamCommand{ - Id: testTeam.Id, - OrgId: testTeam.OrgId, - } + teamUpdatedCallback := updateTeamCalled() + err := UpdateTeam(&admin, &updateTeamCmd) - teamUpdated := false - - bus.AddHandler("test", func(cmd *m.UpdateTeamCommand) error { - teamUpdated = true - return nil - }) - - err := UpdateTeam(&editor, &cmd) - - So(teamUpdated, ShouldBeTrue) + So(teamUpdatedCallback(), ShouldBeTrue) So(err, ShouldBeNil) }) }) }) } + +func updateTeamCalled() func() bool { + wasCalled := false + bus.AddHandler("test", func(cmd *m.UpdateTeamCommand) error { + wasCalled = true + return nil + }) + + return func() bool { return wasCalled } +} + +func shouldNotUpdateTeam() { + bus.AddHandler("test", func(cmd *m.UpdateTeamCommand) error { + return errors.New("UpdateTeamCommand not expected.") + }) + +} From 0d61f895773fd91f338769700ed70f3968fe528c Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Mon, 11 Mar 2019 11:26:01 +0100 Subject: [PATCH 054/103] teams: cleanup. --- pkg/api/team.go | 7 ++++++- pkg/services/teams/team.go | 10 +--------- pkg/services/teams/teams_test.go | 16 +++++++++++----- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/pkg/api/team.go b/pkg/api/team.go index 3d357fa9763..6e62b186f83 100644 --- a/pkg/api/team.go +++ b/pkg/api/team.go @@ -41,7 +41,12 @@ func (hs *HTTPServer) CreateTeam(c *m.ReqContext, cmd m.CreateTeamCommand) Respo func UpdateTeam(c *m.ReqContext, cmd m.UpdateTeamCommand) Response { cmd.OrgId = c.OrgId cmd.Id = c.ParamsInt64(":teamId") - if err := teams.UpdateTeam(c.SignedInUser, &cmd); err != nil { + + if err := teams.CanUpdateTeam(cmd.OrgId, cmd.Id, c.SignedInUser); err != nil { + return Error(403, "User not allowed to update team", err) + } + + if err := bus.Dispatch(&cmd); err != nil { if err == m.ErrTeamNameTaken { return Error(400, "Team name taken", err) } diff --git a/pkg/services/teams/team.go b/pkg/services/teams/team.go index ae9327699be..9419d649204 100644 --- a/pkg/services/teams/team.go +++ b/pkg/services/teams/team.go @@ -5,7 +5,7 @@ import ( m "github.com/grafana/grafana/pkg/models" ) -func canUpdateTeam(orgId int64, teamId int64, user *m.SignedInUser) error { +func CanUpdateTeam(orgId int64, teamId int64, user *m.SignedInUser) error { if user.OrgRole == m.ROLE_ADMIN { return nil } @@ -34,11 +34,3 @@ func canUpdateTeam(orgId int64, teamId int64, user *m.SignedInUser) error { return m.ErrNotAllowedToUpdateTeam } - -func UpdateTeam(user *m.SignedInUser, cmd *m.UpdateTeamCommand) error { - if err := canUpdateTeam(cmd.OrgId, cmd.Id, user); err != nil { - return err - } - - return bus.Dispatch(cmd) -} diff --git a/pkg/services/teams/teams_test.go b/pkg/services/teams/teams_test.go index 1282eefc611..7fac1be6880 100644 --- a/pkg/services/teams/teams_test.go +++ b/pkg/services/teams/teams_test.go @@ -40,12 +40,12 @@ func TestUpdateTeam(t *testing.T) { return nil }) - err := UpdateTeam(&editor, &updateTeamCmd) + err := CanUpdateTeam(&editor, &updateTeamCmd) So(err, ShouldEqual, m.ErrNotAllowedToUpdateTeam) }) }) - Convey("Given an editor and a team he is a member of", func() { + Convey("Given an editor and a team he is an admin in", func() { Convey("Should be able to update the team", func() { teamUpdatedCallback := updateTeamCalled() @@ -59,7 +59,7 @@ func TestUpdateTeam(t *testing.T) { return nil }) - err := UpdateTeam(&editor, &updateTeamCmd) + err := CanUpdateTeam(&editor, &updateTeamCmd) So(teamUpdatedCallback(), ShouldBeTrue) So(err, ShouldBeNil) }) @@ -88,7 +88,7 @@ func TestUpdateTeam(t *testing.T) { return nil }) - err := UpdateTeam(&editor, &cmd) + err := CanUpdateTeam(&editor, &cmd) So(err, ShouldEqual, m.ErrNotAllowedToUpdateTeamInDifferentOrg) }) }) @@ -96,12 +96,18 @@ func TestUpdateTeam(t *testing.T) { Convey("Given an org admin and a team", func() { Convey("Should be able to update the team", func() { teamUpdatedCallback := updateTeamCalled() - err := UpdateTeam(&admin, &updateTeamCmd) + err := CanUpdateTeam(&admin, &updateTeamCmd) So(teamUpdatedCallback(), ShouldBeTrue) So(err, ShouldBeNil) }) }) + Convey("Given that the editorsCanOwn feature toggle is disabled", func() { + + Convey("Given an editor and a team he is an admin", func() { + + }) + }) }) } From d668550aa2127254d83166c7da90053b9d85728a Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Mon, 11 Mar 2019 11:45:06 +0100 Subject: [PATCH 055/103] teams: added feature toggle and refactor tests --- pkg/api/team.go | 4 +-- pkg/services/teams/team.go | 6 +++- pkg/services/teams/teams_test.go | 50 +++++--------------------------- 3 files changed, 15 insertions(+), 45 deletions(-) diff --git a/pkg/api/team.go b/pkg/api/team.go index 6e62b186f83..e9239acffa3 100644 --- a/pkg/api/team.go +++ b/pkg/api/team.go @@ -38,11 +38,11 @@ func (hs *HTTPServer) CreateTeam(c *m.ReqContext, cmd m.CreateTeamCommand) Respo } // PUT /api/teams/:teamId -func UpdateTeam(c *m.ReqContext, cmd m.UpdateTeamCommand) Response { +func (hs *HTTPServer) UpdateTeam(c *m.ReqContext, cmd m.UpdateTeamCommand) Response { cmd.OrgId = c.OrgId cmd.Id = c.ParamsInt64(":teamId") - if err := teams.CanUpdateTeam(cmd.OrgId, cmd.Id, c.SignedInUser); err != nil { + if err := teams.CanUpdateTeam(cmd.OrgId, cmd.Id, c.SignedInUser, hs.Cfg.EditorsCanOwn); err != nil { return Error(403, "User not allowed to update team", err) } diff --git a/pkg/services/teams/team.go b/pkg/services/teams/team.go index 9419d649204..3818b22bca3 100644 --- a/pkg/services/teams/team.go +++ b/pkg/services/teams/team.go @@ -5,11 +5,15 @@ import ( m "github.com/grafana/grafana/pkg/models" ) -func CanUpdateTeam(orgId int64, teamId int64, user *m.SignedInUser) error { +func CanUpdateTeam(orgId int64, teamId int64, user *m.SignedInUser, editorCanOwn bool) error { if user.OrgRole == m.ROLE_ADMIN { return nil } + if !editorCanOwn { + return m.ErrNotAllowedToUpdateTeam + } + if user.OrgId != orgId { return m.ErrNotAllowedToUpdateTeamInDifferentOrg } diff --git a/pkg/services/teams/teams_test.go b/pkg/services/teams/teams_test.go index 7fac1be6880..50237af2945 100644 --- a/pkg/services/teams/teams_test.go +++ b/pkg/services/teams/teams_test.go @@ -3,7 +3,6 @@ package teams import ( "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" - "github.com/pkg/errors" . "github.com/smartystreets/goconvey/convey" "testing" ) @@ -27,28 +26,20 @@ func TestUpdateTeam(t *testing.T) { OrgId: 1, } - updateTeamCmd := m.UpdateTeamCommand{ - Id: testTeam.Id, - OrgId: testTeam.OrgId, - } - Convey("Given an editor and a team he isn't a member of", func() { Convey("Should not be able to update the team", func() { - shouldNotUpdateTeam() bus.AddHandler("test", func(cmd *m.GetTeamMembersQuery) error { cmd.Result = []*m.TeamMemberDTO{} return nil }) - err := CanUpdateTeam(&editor, &updateTeamCmd) + err := CanUpdateTeam(testTeam.OrgId, testTeam.Id, &editor, true) So(err, ShouldEqual, m.ErrNotAllowedToUpdateTeam) }) }) Convey("Given an editor and a team he is an admin in", func() { Convey("Should be able to update the team", func() { - teamUpdatedCallback := updateTeamCalled() - bus.AddHandler("test", func(cmd *m.GetTeamMembersQuery) error { cmd.Result = []*m.TeamMemberDTO{{ OrgId: testTeam.OrgId, @@ -59,8 +50,7 @@ func TestUpdateTeam(t *testing.T) { return nil }) - err := CanUpdateTeam(&editor, &updateTeamCmd) - So(teamUpdatedCallback(), ShouldBeTrue) + err := CanUpdateTeam(testTeam.OrgId, testTeam.Id, &editor, true) So(err, ShouldBeNil) }) }) @@ -72,12 +62,6 @@ func TestUpdateTeam(t *testing.T) { } Convey("Shouldn't be able to update the team", func() { - cmd := m.UpdateTeamCommand{ - Id: testTeamOtherOrg.Id, - OrgId: testTeamOtherOrg.OrgId, - } - - shouldNotUpdateTeam() bus.AddHandler("test", func(cmd *m.GetTeamMembersQuery) error { cmd.Result = []*m.TeamMemberDTO{{ OrgId: testTeamOtherOrg.OrgId, @@ -88,42 +72,24 @@ func TestUpdateTeam(t *testing.T) { return nil }) - err := CanUpdateTeam(&editor, &cmd) + err := CanUpdateTeam(testTeamOtherOrg.OrgId, testTeamOtherOrg.Id, &editor, true) So(err, ShouldEqual, m.ErrNotAllowedToUpdateTeamInDifferentOrg) }) }) Convey("Given an org admin and a team", func() { Convey("Should be able to update the team", func() { - teamUpdatedCallback := updateTeamCalled() - err := CanUpdateTeam(&admin, &updateTeamCmd) - - So(teamUpdatedCallback(), ShouldBeTrue) + err := CanUpdateTeam(testTeam.OrgId, testTeam.Id, &admin, true) So(err, ShouldBeNil) }) }) + Convey("Given that the editorsCanOwn feature toggle is disabled", func() { + Convey("Editors should not be able to update teams", func() { + err := CanUpdateTeam(testTeam.OrgId, testTeam.Id, &editor, false) - Convey("Given an editor and a team he is an admin", func() { - + So(err, ShouldEqual, m.ErrNotAllowedToUpdateTeam) }) }) }) } - -func updateTeamCalled() func() bool { - wasCalled := false - bus.AddHandler("test", func(cmd *m.UpdateTeamCommand) error { - wasCalled = true - return nil - }) - - return func() bool { return wasCalled } -} - -func shouldNotUpdateTeam() { - bus.AddHandler("test", func(cmd *m.UpdateTeamCommand) error { - return errors.New("UpdateTeamCommand not expected.") - }) - -} From 8e7a8282c1f52141c0c9d351a74f54a45c74baef Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Mon, 11 Mar 2019 11:51:06 +0100 Subject: [PATCH 056/103] teams: removed feature toggle as it is already in middleware --- pkg/api/api.go | 2 +- pkg/api/team.go | 2 +- pkg/services/teams/team.go | 6 +----- pkg/services/teams/teams_test.go | 16 ++++------------ 4 files changed, 7 insertions(+), 19 deletions(-) diff --git a/pkg/api/api.go b/pkg/api/api.go index e5d725342fe..c004d600b1b 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -155,7 +155,7 @@ func (hs *HTTPServer) registerRoutes() { // team (admin permission required) apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) { teamsRoute.Post("/", bind(m.CreateTeamCommand{}), Wrap(hs.CreateTeam)) - teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), Wrap(UpdateTeam)) + teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), Wrap(hs.UpdateTeam)) teamsRoute.Delete("/:teamId", Wrap(DeleteTeamByID)) teamsRoute.Get("/:teamId/members", Wrap(GetTeamMembers)) teamsRoute.Post("/:teamId/members", bind(m.AddTeamMemberCommand{}), Wrap(AddTeamMember)) diff --git a/pkg/api/team.go b/pkg/api/team.go index e9239acffa3..6d74b11e588 100644 --- a/pkg/api/team.go +++ b/pkg/api/team.go @@ -42,7 +42,7 @@ func (hs *HTTPServer) UpdateTeam(c *m.ReqContext, cmd m.UpdateTeamCommand) Respo cmd.OrgId = c.OrgId cmd.Id = c.ParamsInt64(":teamId") - if err := teams.CanUpdateTeam(cmd.OrgId, cmd.Id, c.SignedInUser, hs.Cfg.EditorsCanOwn); err != nil { + if err := teams.CanUpdateTeam(cmd.OrgId, cmd.Id, c.SignedInUser); err != nil { return Error(403, "User not allowed to update team", err) } diff --git a/pkg/services/teams/team.go b/pkg/services/teams/team.go index 3818b22bca3..9419d649204 100644 --- a/pkg/services/teams/team.go +++ b/pkg/services/teams/team.go @@ -5,15 +5,11 @@ import ( m "github.com/grafana/grafana/pkg/models" ) -func CanUpdateTeam(orgId int64, teamId int64, user *m.SignedInUser, editorCanOwn bool) error { +func CanUpdateTeam(orgId int64, teamId int64, user *m.SignedInUser) error { if user.OrgRole == m.ROLE_ADMIN { return nil } - if !editorCanOwn { - return m.ErrNotAllowedToUpdateTeam - } - if user.OrgId != orgId { return m.ErrNotAllowedToUpdateTeamInDifferentOrg } diff --git a/pkg/services/teams/teams_test.go b/pkg/services/teams/teams_test.go index 50237af2945..85bbddf014f 100644 --- a/pkg/services/teams/teams_test.go +++ b/pkg/services/teams/teams_test.go @@ -33,7 +33,7 @@ func TestUpdateTeam(t *testing.T) { return nil }) - err := CanUpdateTeam(testTeam.OrgId, testTeam.Id, &editor, true) + err := CanUpdateTeam(testTeam.OrgId, testTeam.Id, &editor) So(err, ShouldEqual, m.ErrNotAllowedToUpdateTeam) }) }) @@ -50,7 +50,7 @@ func TestUpdateTeam(t *testing.T) { return nil }) - err := CanUpdateTeam(testTeam.OrgId, testTeam.Id, &editor, true) + err := CanUpdateTeam(testTeam.OrgId, testTeam.Id, &editor) So(err, ShouldBeNil) }) }) @@ -72,24 +72,16 @@ func TestUpdateTeam(t *testing.T) { return nil }) - err := CanUpdateTeam(testTeamOtherOrg.OrgId, testTeamOtherOrg.Id, &editor, true) + err := CanUpdateTeam(testTeamOtherOrg.OrgId, testTeamOtherOrg.Id, &editor) So(err, ShouldEqual, m.ErrNotAllowedToUpdateTeamInDifferentOrg) }) }) Convey("Given an org admin and a team", func() { Convey("Should be able to update the team", func() { - err := CanUpdateTeam(testTeam.OrgId, testTeam.Id, &admin, true) + err := CanUpdateTeam(testTeam.OrgId, testTeam.Id, &admin) So(err, ShouldBeNil) }) }) - - Convey("Given that the editorsCanOwn feature toggle is disabled", func() { - Convey("Editors should not be able to update teams", func() { - err := CanUpdateTeam(testTeam.OrgId, testTeam.Id, &editor, false) - - So(err, ShouldEqual, m.ErrNotAllowedToUpdateTeam) - }) - }) }) } From 23231e6d510b60f5609ee76343f808a5dd5becf6 Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Mon, 11 Mar 2019 12:03:15 +0100 Subject: [PATCH 057/103] teams: added delete team guard --- pkg/api/api.go | 2 +- pkg/api/team.go | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/pkg/api/api.go b/pkg/api/api.go index c004d600b1b..e5d725342fe 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -155,7 +155,7 @@ func (hs *HTTPServer) registerRoutes() { // team (admin permission required) apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) { teamsRoute.Post("/", bind(m.CreateTeamCommand{}), Wrap(hs.CreateTeam)) - teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), Wrap(hs.UpdateTeam)) + teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), Wrap(UpdateTeam)) teamsRoute.Delete("/:teamId", Wrap(DeleteTeamByID)) teamsRoute.Get("/:teamId/members", Wrap(GetTeamMembers)) teamsRoute.Post("/:teamId/members", bind(m.AddTeamMemberCommand{}), Wrap(AddTeamMember)) diff --git a/pkg/api/team.go b/pkg/api/team.go index 6d74b11e588..61d966c2a8b 100644 --- a/pkg/api/team.go +++ b/pkg/api/team.go @@ -38,12 +38,12 @@ func (hs *HTTPServer) CreateTeam(c *m.ReqContext, cmd m.CreateTeamCommand) Respo } // PUT /api/teams/:teamId -func (hs *HTTPServer) UpdateTeam(c *m.ReqContext, cmd m.UpdateTeamCommand) Response { +func UpdateTeam(c *m.ReqContext, cmd m.UpdateTeamCommand) Response { cmd.OrgId = c.OrgId cmd.Id = c.ParamsInt64(":teamId") if err := teams.CanUpdateTeam(cmd.OrgId, cmd.Id, c.SignedInUser); err != nil { - return Error(403, "User not allowed to update team", err) + return Error(403, "Not allowed to update team", err) } if err := bus.Dispatch(&cmd); err != nil { @@ -58,11 +58,19 @@ func (hs *HTTPServer) UpdateTeam(c *m.ReqContext, cmd m.UpdateTeamCommand) Respo // DELETE /api/teams/:teamId func DeleteTeamByID(c *m.ReqContext) Response { - if err := bus.Dispatch(&m.DeleteTeamCommand{OrgId: c.OrgId, Id: c.ParamsInt64(":teamId")}); err != nil { + orgId := c.OrgId + teamId := c.ParamsInt64(":teamId") + user := c.SignedInUser + + if err := teams.CanUpdateTeam(orgId, teamId, user); err != nil { + return Error(403, "Not allowed to delete team", err) + } + + if err := bus.Dispatch(&m.DeleteTeamCommand{OrgId: orgId, Id: teamId}); err != nil { if err == m.ErrTeamNotFound { return Error(404, "Failed to delete Team. ID not found", nil) } - return Error(500, "Failed to update Team", err) + return Error(500, "Failed to delete Team", err) } return Success("Team deleted") } From 1f949e58e1f9e15d43cb88daa0f753ca927bd8cd Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Mon, 11 Mar 2019 13:14:06 +0100 Subject: [PATCH 058/103] teams: teams guard on all teams update methods. --- pkg/api/team.go | 9 ++++++++- pkg/api/team_members.go | 33 ++++++++++++++++++++++++++++----- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/pkg/api/team.go b/pkg/api/team.go index 61d966c2a8b..223ad404793 100644 --- a/pkg/api/team.go +++ b/pkg/api/team.go @@ -131,5 +131,12 @@ func GetTeamPreferences(c *m.ReqContext) Response { // PUT /api/teams/:teamId/preferences func UpdateTeamPreferences(c *m.ReqContext, dtoCmd dtos.UpdatePrefsCmd) Response { - return updatePreferencesFor(c.OrgId, 0, c.ParamsInt64(":teamId"), &dtoCmd) + teamId := c.ParamsInt64(":teamId") + orgId := c.OrgId + + if err := teams.CanUpdateTeam(orgId, teamId, c.SignedInUser); err != nil { + return Error(403, "Not allowed to update team preferences.", err) + } + + return updatePreferencesFor(orgId, 0, teamId, &dtoCmd) } diff --git a/pkg/api/team_members.go b/pkg/api/team_members.go index e0919262111..b2bb1781020 100644 --- a/pkg/api/team_members.go +++ b/pkg/api/team_members.go @@ -4,6 +4,7 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/teams" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) @@ -30,8 +31,15 @@ func GetTeamMembers(c *m.ReqContext) Response { // POST /api/teams/:teamId/members func AddTeamMember(c *m.ReqContext, cmd m.AddTeamMemberCommand) Response { - cmd.TeamId = c.ParamsInt64(":teamId") - cmd.OrgId = c.OrgId + teamId := c.ParamsInt64(":teamId") + orgId := c.OrgId + + if err := teams.CanUpdateTeam(orgId, teamId, c.SignedInUser); err != nil { + return Error(403, "Not allowed to add team member", err) + } + + cmd.TeamId = teamId + cmd.OrgId = orgId if err := bus.Dispatch(&cmd); err != nil { if err == m.ErrTeamNotFound { @@ -52,9 +60,16 @@ func AddTeamMember(c *m.ReqContext, cmd m.AddTeamMemberCommand) Response { // PUT /:teamId/members/:userId func UpdateTeamMember(c *m.ReqContext, cmd m.UpdateTeamMemberCommand) Response { - cmd.TeamId = c.ParamsInt64(":teamId") + teamId := c.ParamsInt64(":teamId") + orgId := c.OrgId + + if err := teams.CanUpdateTeam(orgId, teamId, c.SignedInUser); err != nil { + return Error(403, "Not allowed to update team member", err) + } + + cmd.TeamId = teamId cmd.UserId = c.ParamsInt64(":userId") - cmd.OrgId = c.OrgId + cmd.OrgId = orgId if err := bus.Dispatch(&cmd); err != nil { if err == m.ErrTeamMemberNotFound { @@ -67,7 +82,15 @@ func UpdateTeamMember(c *m.ReqContext, cmd m.UpdateTeamMemberCommand) Response { // DELETE /api/teams/:teamId/members/:userId func RemoveTeamMember(c *m.ReqContext) Response { - if err := bus.Dispatch(&m.RemoveTeamMemberCommand{OrgId: c.OrgId, TeamId: c.ParamsInt64(":teamId"), UserId: c.ParamsInt64(":userId")}); err != nil { + orgId := c.OrgId + teamId := c.ParamsInt64(":teamId") + userId := c.ParamsInt64(":userId") + + if err := teams.CanUpdateTeam(orgId, teamId, c.SignedInUser); err != nil { + return Error(403, "Not allowed to remove team member", err) + } + + if err := bus.Dispatch(&m.RemoveTeamMemberCommand{OrgId: orgId, TeamId: teamId, UserId: userId}); err != nil { if err == m.ErrTeamNotFound { return Error(404, "Team not found", nil) } From 89d4db8eb6d02cd585cf53cbfeda01dd5a6f1e9a Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Mon, 11 Mar 2019 14:40:57 +0100 Subject: [PATCH 059/103] teams: team listing shows only your teams (editors). --- pkg/api/team.go | 16 +++++++++++----- pkg/models/team.go | 11 ++++++----- pkg/services/sqlstore/team.go | 4 ++++ public/app/features/teams/state/actions.ts | 6 ++++-- 4 files changed, 25 insertions(+), 12 deletions(-) diff --git a/pkg/api/team.go b/pkg/api/team.go index 223ad404793..e4adb0bd430 100644 --- a/pkg/api/team.go +++ b/pkg/api/team.go @@ -86,12 +86,18 @@ func SearchTeams(c *m.ReqContext) Response { page = 1 } + var userIdFilter int64 + if c.QueryBool("showMine") { + userIdFilter = c.SignedInUser.UserId + } + query := m.SearchTeamsQuery{ - OrgId: c.OrgId, - Query: c.Query("query"), - Name: c.Query("name"), - Page: page, - Limit: perPage, + OrgId: c.OrgId, + Query: c.Query("query"), + Name: c.Query("name"), + UserIdFilter: userIdFilter, + Page: page, + Limit: perPage, } if err := bus.Dispatch(&query); err != nil { diff --git a/pkg/models/team.go b/pkg/models/team.go index bd0d803d9d3..bb9289ee5e5 100644 --- a/pkg/models/team.go +++ b/pkg/models/team.go @@ -61,11 +61,12 @@ type GetTeamsByUserQuery struct { } type SearchTeamsQuery struct { - Query string - Name string - Limit int - Page int - OrgId int64 + Query string + Name string + Limit int + Page int + OrgId int64 + UserIdFilter int64 Result SearchTeamQueryResult } diff --git a/pkg/services/sqlstore/team.go b/pkg/services/sqlstore/team.go index f7f7d7fc2cb..a9ee6979406 100644 --- a/pkg/services/sqlstore/team.go +++ b/pkg/services/sqlstore/team.go @@ -149,6 +149,10 @@ func SearchTeams(query *m.SearchTeamsQuery) error { params := make([]interface{}, 0) sql.WriteString(getTeamSelectSqlBase()) + if query.UserIdFilter > 0 { + sql.WriteString(`INNER JOIN team_member on team.id = team_member.team_id AND team_member.user_id = ?`) + params = append(params, query.UserIdFilter) + } sql.WriteString(` WHERE team.org_id = ?`) params = append(params, query.OrgId) diff --git a/public/app/features/teams/state/actions.ts b/public/app/features/teams/state/actions.ts index e2582839233..bfccddeefc5 100644 --- a/public/app/features/teams/state/actions.ts +++ b/public/app/features/teams/state/actions.ts @@ -1,8 +1,9 @@ import { ThunkAction } from 'redux-thunk'; import { getBackendSrv } from 'app/core/services/backend_srv'; -import { StoreState, Team, TeamGroup, TeamMember } from 'app/types'; +import { OrgRole, StoreState, Team, TeamGroup, TeamMember } from 'app/types'; import { updateNavIndex, UpdateNavIndexAction } from 'app/core/actions'; import { buildNavModel } from './navModel'; +import { contextSrv } from '../../../core/services/context_srv'; export enum ActionTypes { LoadTeams = 'LOAD_TEAMS', @@ -85,7 +86,8 @@ export const setSearchQuery = (searchQuery: string): SetSearchQueryAction => ({ export function loadTeams(): ThunkResult { return async dispatch => { - const response = await getBackendSrv().get('/api/teams/search', { perpage: 1000, page: 1 }); + const showMine = contextSrv.user.orgRole === OrgRole.Editor; + const response = await getBackendSrv().get('/api/teams/search', { perpage: 1000, page: 1, showMine }); dispatch(teamsLoaded(response.teams)); }; } From d593ffe3c1a8e0e0fefd343c769355a02cdecdeb Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Mon, 11 Mar 2019 15:05:28 +0100 Subject: [PATCH 060/103] dashboards: better error handling --- pkg/api/dashboard.go | 3 ++- pkg/api/folder.go | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 016146a5c61..deecdf2c1c8 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -281,7 +281,8 @@ func (hs *HTTPServer) PostDashboard(c *m.ReqContext, cmd m.SaveDashboardCommand) aclService := dashboards.NewAclService() err := aclService.MakeUserAdmin(cmd.OrgId, cmd.UserId, dashboard.Id) if err != nil { - hs.log.Error("Could not make user admin", "error", err) + hs.log.Error("Could not make user admin", "dashboard", cmd.Result.Title, "user", c.SignedInUser.UserId, "error", err) + return Error(500, "Failed to make user admin of dashboard", err) } } diff --git a/pkg/api/folder.go b/pkg/api/folder.go index 4e66439219d..a2d6a765b16 100644 --- a/pkg/api/folder.go +++ b/pkg/api/folder.go @@ -63,7 +63,10 @@ func (hs *HTTPServer) CreateFolder(c *m.ReqContext, cmd m.CreateFolderCommand) R if hs.Cfg.EditorsCanOwn { aclService := dashboards.NewAclService() - aclService.MakeUserAdmin(c.OrgId, c.SignedInUser.UserId, cmd.Result.Id) + if err := aclService.MakeUserAdmin(c.OrgId, c.SignedInUser.UserId, cmd.Result.Id); err != nil { + hs.log.Error("Could not make user admin", "folder", cmd.Result.Title, "user", c.SignedInUser.UserId, "error", err) + return Error(500, "Failed to make user admin of folder", err) + } } g := guardian.New(cmd.Result.Id, c.OrgId, c.SignedInUser) From 0b209de5d1addb9a31b86aa720778ebf551ac18a Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Mon, 11 Mar 2019 15:34:32 +0100 Subject: [PATCH 061/103] dashboard: only admin permission added to dashboard in folder. --- pkg/api/dashboard.go | 3 +- pkg/api/folder.go | 2 +- pkg/services/dashboards/acl_service.go | 39 +++++++++++++++----------- 3 files changed, 25 insertions(+), 19 deletions(-) diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index deecdf2c1c8..b7b2383d1f8 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -279,7 +279,8 @@ func (hs *HTTPServer) PostDashboard(c *m.ReqContext, cmd m.SaveDashboardCommand) if hs.Cfg.EditorsCanOwn && newDashboard { aclService := dashboards.NewAclService() - err := aclService.MakeUserAdmin(cmd.OrgId, cmd.UserId, dashboard.Id) + inFolder := cmd.FolderId > 0 + err := aclService.MakeUserAdmin(cmd.OrgId, cmd.UserId, dashboard.Id, !inFolder) if err != nil { hs.log.Error("Could not make user admin", "dashboard", cmd.Result.Title, "user", c.SignedInUser.UserId, "error", err) return Error(500, "Failed to make user admin of dashboard", err) diff --git a/pkg/api/folder.go b/pkg/api/folder.go index a2d6a765b16..fd10897fc9a 100644 --- a/pkg/api/folder.go +++ b/pkg/api/folder.go @@ -63,7 +63,7 @@ func (hs *HTTPServer) CreateFolder(c *m.ReqContext, cmd m.CreateFolderCommand) R if hs.Cfg.EditorsCanOwn { aclService := dashboards.NewAclService() - if err := aclService.MakeUserAdmin(c.OrgId, c.SignedInUser.UserId, cmd.Result.Id); err != nil { + if err := aclService.MakeUserAdmin(c.OrgId, c.SignedInUser.UserId, cmd.Result.Id, true); err != nil { hs.log.Error("Could not make user admin", "folder", cmd.Result.Title, "user", c.SignedInUser.UserId, "error", err) return Error(500, "Failed to make user admin of folder", err) } diff --git a/pkg/services/dashboards/acl_service.go b/pkg/services/dashboards/acl_service.go index 79b55470093..dae3ec1372b 100644 --- a/pkg/services/dashboards/acl_service.go +++ b/pkg/services/dashboards/acl_service.go @@ -18,7 +18,7 @@ type AclService struct { log log.Logger } -func (as *AclService) MakeUserAdmin(orgId int64, userId int64, dashboardId int64) error { +func (as *AclService) MakeUserAdmin(orgId int64, userId int64, dashboardId int64, setViewAndEditPermissions bool) error { rtEditor := models.ROLE_EDITOR rtViewer := models.ROLE_VIEWER @@ -31,22 +31,27 @@ func (as *AclService) MakeUserAdmin(orgId int64, userId int64, dashboardId int64 Created: time.Now(), Updated: time.Now(), }, - { - OrgId: orgId, - DashboardId: dashboardId, - Role: &rtEditor, - Permission: models.PERMISSION_EDIT, - Created: time.Now(), - Updated: time.Now(), - }, - { - OrgId: orgId, - DashboardId: dashboardId, - Role: &rtViewer, - Permission: models.PERMISSION_VIEW, - Created: time.Now(), - Updated: time.Now(), - }, + } + + if setViewAndEditPermissions { + items = append(items, + &models.DashboardAcl{ + OrgId: orgId, + DashboardId: dashboardId, + Role: &rtEditor, + Permission: models.PERMISSION_EDIT, + Created: time.Now(), + Updated: time.Now(), + }, + &models.DashboardAcl{ + OrgId: orgId, + DashboardId: dashboardId, + Role: &rtViewer, + Permission: models.PERMISSION_VIEW, + Created: time.Now(), + Updated: time.Now(), + }, + ) } aclCmd := &models.UpdateDashboardAclCommand{ From a6a3d698da2f09802ad25363917371fe6bda5237 Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Mon, 11 Mar 2019 15:48:05 +0100 Subject: [PATCH 062/103] teams: cleanup. --- pkg/services/sqlstore/team.go | 14 ++++---------- pkg/services/teams/team.go | 2 -- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/pkg/services/sqlstore/team.go b/pkg/services/sqlstore/team.go index a9ee6979406..7c5a5f88983 100644 --- a/pkg/services/sqlstore/team.go +++ b/pkg/services/sqlstore/team.go @@ -92,10 +92,8 @@ func UpdateTeam(cmd *m.UpdateTeamCommand) error { // DeleteTeam will delete a team, its member and any permissions connected to the team func DeleteTeam(cmd *m.DeleteTeamCommand) error { return inTransaction(func(sess *DBSession) error { - if teamExists, err := teamExists(cmd.OrgId, cmd.Id, sess); err != nil { + if _, err := teamExists(cmd.OrgId, cmd.Id, sess); err != nil { return err - } else if !teamExists { - return m.ErrTeamNotFound } deletes := []string{ @@ -118,7 +116,7 @@ func teamExists(orgId int64, teamId int64, sess *DBSession) (bool, error) { if res, err := sess.Query("SELECT 1 from team WHERE org_id=? and id=?", orgId, teamId); err != nil { return false, err } else if len(res) != 1 { - return false, nil + return false, m.ErrTeamNotFound } return true, nil @@ -238,10 +236,8 @@ func AddTeamMember(cmd *m.AddTeamMemberCommand) error { return m.ErrTeamMemberAlreadyAdded } - if teamExists, err := teamExists(cmd.OrgId, cmd.TeamId, sess); err != nil { + if _, err := teamExists(cmd.OrgId, cmd.TeamId, sess); err != nil { return err - } else if !teamExists { - return m.ErrTeamNotFound } entity := m.TeamMember{ @@ -285,10 +281,8 @@ func UpdateTeamMember(cmd *m.UpdateTeamMemberCommand) error { // RemoveTeamMember removes a member from a team func RemoveTeamMember(cmd *m.RemoveTeamMemberCommand) error { return inTransaction(func(sess *DBSession) error { - if teamExists, err := teamExists(cmd.OrgId, cmd.TeamId, sess); err != nil { + if _, err := teamExists(cmd.OrgId, cmd.TeamId, sess); err != nil { return err - } else if !teamExists { - return m.ErrTeamNotFound } var rawSql = "DELETE FROM team_member WHERE org_id=? and team_id=? and user_id=?" diff --git a/pkg/services/teams/team.go b/pkg/services/teams/team.go index 9419d649204..080fe961ab6 100644 --- a/pkg/services/teams/team.go +++ b/pkg/services/teams/team.go @@ -18,8 +18,6 @@ func CanUpdateTeam(orgId int64, teamId int64, user *m.SignedInUser) error { OrgId: orgId, TeamId: teamId, UserId: user.UserId, - // TODO: do we need to do something special about external users - // External: false, } if err := bus.Dispatch(&cmd); err != nil { From a90b3e331ecc2c9912802d693d5e8cdcf6ac1657 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Tue, 12 Mar 2019 07:32:47 +0100 Subject: [PATCH 063/103] config: updated feature toggle name --- conf/defaults.ini | 2 +- conf/sample.ini | 2 +- pkg/api/api.go | 2 +- pkg/api/dashboard.go | 2 +- pkg/api/dashboard_test.go | 2 +- pkg/api/folder.go | 2 +- pkg/api/folder_test.go | 2 +- pkg/api/frontendsettings.go | 2 +- pkg/api/index.go | 2 +- pkg/api/team.go | 2 +- pkg/setting/setting.go | 7 +++---- public/app/core/config.ts | 4 ++-- public/app/features/teams/TeamMembers.tsx | 4 ++-- public/app/routes/routes.ts | 2 +- 14 files changed, 18 insertions(+), 19 deletions(-) diff --git a/conf/defaults.ini b/conf/defaults.ini index 492525e6b5f..bb415721391 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -259,7 +259,7 @@ external_manage_info = viewers_can_edit = false # Editors can administrate dashboard, folders and teams they create -editors_can_own = false +editors_can_admin = false [auth] # Login cookie name diff --git a/conf/sample.ini b/conf/sample.ini index fd414c2af47..321c1120693 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -239,7 +239,7 @@ log_queries = ;viewers_can_edit = false # Editors can administrate dashboard, folders and teams they create -;editors_can_own = false +;editors_can_admin = false [auth] # Login cookie name diff --git a/pkg/api/api.go b/pkg/api/api.go index e5d725342fe..9ffb0278935 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -14,7 +14,7 @@ func (hs *HTTPServer) registerRoutes() { reqGrafanaAdmin := middleware.ReqGrafanaAdmin reqEditorRole := middleware.ReqEditorRole reqOrgAdmin := middleware.ReqOrgAdmin - reqAdminOrEditorCanAdmin := middleware.EditorCanAdmin(hs.Cfg.EditorsCanOwn) + reqAdminOrEditorCanAdmin := middleware.EditorCanAdmin(hs.Cfg.EditorsCanAdmin) redirectFromLegacyDashboardURL := middleware.RedirectFromLegacyDashboardURL() redirectFromLegacyDashboardSoloURL := middleware.RedirectFromLegacyDashboardSoloURL() quota := middleware.Quota(hs.QuotaService) diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index b7b2383d1f8..14a1e3baf32 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -277,7 +277,7 @@ func (hs *HTTPServer) PostDashboard(c *m.ReqContext, cmd m.SaveDashboardCommand) return Error(500, "Failed to save dashboard", err) } - if hs.Cfg.EditorsCanOwn && newDashboard { + if hs.Cfg.EditorsCanAdmin && newDashboard { aclService := dashboards.NewAclService() inFolder := cmd.FolderId > 0 err := aclService.MakeUserAdmin(cmd.OrgId, cmd.UserId, dashboard.Id, !inFolder) diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index d58e2246eec..5411643af96 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -973,7 +973,7 @@ func postDashboardScenario(desc string, url string, routePattern string, mock *d defer bus.ClearBusHandlers() cfg := setting.NewCfg() - cfg.EditorsCanOwn = false + cfg.EditorsCanAdmin = false hs := HTTPServer{ Bus: bus.GetBus(), diff --git a/pkg/api/folder.go b/pkg/api/folder.go index fd10897fc9a..66c640f96e8 100644 --- a/pkg/api/folder.go +++ b/pkg/api/folder.go @@ -61,7 +61,7 @@ func (hs *HTTPServer) CreateFolder(c *m.ReqContext, cmd m.CreateFolderCommand) R return toFolderError(err) } - if hs.Cfg.EditorsCanOwn { + if hs.Cfg.EditorsCanAdmin { aclService := dashboards.NewAclService() if err := aclService.MakeUserAdmin(c.OrgId, c.SignedInUser.UserId, cmd.Result.Id, true); err != nil { hs.log.Error("Could not make user admin", "folder", cmd.Result.Title, "user", c.SignedInUser.UserId, "error", err) diff --git a/pkg/api/folder_test.go b/pkg/api/folder_test.go index d5e4ee418cd..15c51e476b5 100644 --- a/pkg/api/folder_test.go +++ b/pkg/api/folder_test.go @@ -143,7 +143,7 @@ func createFolderScenario(desc string, url string, routePattern string, mock *fa defer bus.ClearBusHandlers() cfg := setting.NewCfg() - cfg.EditorsCanOwn = true + cfg.EditorsCanAdmin = true hs := HTTPServer{ Bus: bus.GetBus(), diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 67a511b8b4d..cd61c2f3ebc 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -167,7 +167,7 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *m.ReqContext) (map[string]interf "externalUserMngLinkUrl": setting.ExternalUserMngLinkUrl, "externalUserMngLinkName": setting.ExternalUserMngLinkName, "viewersCanEdit": setting.ViewersCanEdit, - "editorsCanOwn": hs.Cfg.EditorsCanOwn, + "editorsCanAdmin": hs.Cfg.EditorsCanAdmin, "disableSanitizeHtml": hs.Cfg.DisableSanitizeHtml, "buildInfo": map[string]interface{}{ "version": setting.BuildVersion, diff --git a/pkg/api/index.go b/pkg/api/index.go index 88c4b7e929d..3f60290ea55 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -327,7 +327,7 @@ func (hs *HTTPServer) setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, er }) } - if c.OrgRole == m.ROLE_EDITOR && hs.Cfg.EditorsCanOwn { + if c.OrgRole == m.ROLE_EDITOR && hs.Cfg.EditorsCanAdmin { cfgNode := &dtos.NavLink{ Id: "cfg", Text: "Configuration", diff --git a/pkg/api/team.go b/pkg/api/team.go index e4adb0bd430..619d24ea0b1 100644 --- a/pkg/api/team.go +++ b/pkg/api/team.go @@ -18,7 +18,7 @@ func (hs *HTTPServer) CreateTeam(c *m.ReqContext, cmd m.CreateTeamCommand) Respo return Error(500, "Failed to create Team", err) } - if c.OrgRole == m.ROLE_EDITOR && hs.Cfg.EditorsCanOwn { + if c.OrgRole == m.ROLE_EDITOR && hs.Cfg.EditorsCanAdmin { addMemberCmd := m.AddTeamMemberCommand{ UserId: c.SignedInUser.UserId, OrgId: cmd.OrgId, diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index bc57291b5f9..8c6d8c54f11 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -239,14 +239,13 @@ type Cfg struct { LoginMaxLifetimeDays int TokenRotationIntervalMinutes int - // User - EditorsCanOwn bool - // Dataproxy SendUserHeader bool // DistributedCache RemoteCacheOptions *RemoteCacheOptions + + EditorsCanAdmin bool } type CommandLineArgs struct { @@ -670,7 +669,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error { ExternalUserMngLinkName = users.Key("external_manage_link_name").String() ExternalUserMngInfo = users.Key("external_manage_info").String() ViewersCanEdit = users.Key("viewers_can_edit").MustBool(false) - cfg.EditorsCanOwn = users.Key("editors_can_own").MustBool(false) + cfg.EditorsCanAdmin = users.Key("editors_can_admin").MustBool(false) // auth auth := iniFile.Section("auth") diff --git a/public/app/core/config.ts b/public/app/core/config.ts index 9789888e60f..fe9005973b8 100644 --- a/public/app/core/config.ts +++ b/public/app/core/config.ts @@ -37,7 +37,7 @@ export class Settings { passwordHint: any; loginError: any; viewersCanEdit: boolean; - editorsCanOwn: boolean; + editorsCanAdmin: boolean; disableSanitizeHtml: boolean; theme: GrafanaTheme; @@ -59,7 +59,7 @@ export class Settings { isEnterprise: false, }, viewersCanEdit: false, - editorsCanOwn: false, + editorsCanAdmin: false, disableSanitizeHtml: false, }; diff --git a/public/app/features/teams/TeamMembers.tsx b/public/app/features/teams/TeamMembers.tsx index 29f1e2e4947..bcb2f3fab4b 100644 --- a/public/app/features/teams/TeamMembers.tsx +++ b/public/app/features/teams/TeamMembers.tsx @@ -93,7 +93,7 @@ export class TeamMembers extends PureComponent { {member.login} {member.email} - +
this.onPermissionChange(item, member)} + className="gf-form-select-box__control--menu-right" + value={value} + /> + )} + {!isUserTeamAdmin && {value.label}} +
+ +
+ ); + } + renderMember(member: TeamMember, syncEnabled: boolean) { return ( @@ -93,19 +125,7 @@ export class TeamMembers extends PureComponent { {member.login} {member.email} - - -
- + + Member +
@@ -271,41 +239,9 @@ exports[`Render should render team members 1`] = `
- + + Member +
@@ -411,41 +315,9 @@ exports[`Render should render team members 1`] = `
- + + Member +
@@ -644,41 +484,9 @@ exports[`Render should render team members when sync enabled 1`] = `
- + + Member +
@@ -816,41 +592,9 @@ exports[`Render should render team members when sync enabled 1`] = `
- + + Member +
@@ -983,6 +695,152 @@ exports[`Render should render team members when sync enabled 1`] = ` + +
+ + Member + +
+ +
+ + + + + + + + + + +
+
+`; + +exports[`Render when feature toggle editorsCanAdmin is turned on should render permissions select if user is Grafana Admin 1`] = ` +
+
+
+ +
+
+ +
+ +
+ +
+ Add team member +
+
+ +
+
+
+
+ + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + Name + + Email + + Permission + +
+ + + testUser-1 + + test@test.com +
- - +
+ + + testUser-2 + + test@test.com + +
+
+ +
+ + + testUser-3 + + test@test.com + +
+
+ +
+ + + testUser-4 + + test@test.com + +
+
+ +
+ + + testUser-5 + + test@test.com + +
+
+ +
+
+
+`; + +exports[`Render when feature toggle editorsCanAdmin is turned on should render permissions select if user is Org Admin 1`] = ` +
+
+
+ +
+
+ +
+ +
+ +
+ Add team member +
+
+ +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + Name + + Email + + Permission + +
+ + + testUser-1 + + test@test.com + +
+
+ +
+ + + testUser-2 + + test@test.com + +
+
+ +
+ + + testUser-3 + + test@test.com + +
+
+ +
+ + + testUser-4 + + test@test.com + +
+
+ +
+ + + testUser-5 + + test@test.com + +
+
+ +
+
+
+`; + +exports[`Render when feature toggle editorsCanAdmin is turned on should render permissions select if user is team admin 1`] = ` +
+
+
+ +
+
+ +
+ +
+ +
+ Add team member +
+
+ +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - {this.renderPermissionsSelect(member)} + {this.renderPermissions(member)} {syncEnabled && this.renderLabels(member.labels)} ); @@ -152,7 +158,11 @@ export class TeamMembers extends PureComponent {
-
diff --git a/public/app/features/teams/__mocks__/teamMocks.ts b/public/app/features/teams/__mocks__/teamMocks.ts index 3f0830eda16..f38f8f2b144 100644 --- a/public/app/features/teams/__mocks__/teamMocks.ts +++ b/public/app/features/teams/__mocks__/teamMocks.ts @@ -25,7 +25,7 @@ export const getMockTeam = (): Team => { }; }; -export const getMockTeamMembers = (amount: number): TeamMember[] => { +export const getMockTeamMembers = (amount: number, teamAdminId: number): TeamMember[] => { const teamMembers: TeamMember[] = []; for (let i = 1; i <= amount; i++) { @@ -36,7 +36,7 @@ export const getMockTeamMembers = (amount: number): TeamMember[] => { email: 'test@test.com', login: `testUser-${i}`, labels: ['label 1', 'label 2'], - permission: TeamPermissionLevel.Member, + permission: i === teamAdminId ? TeamPermissionLevel.Admin : TeamPermissionLevel.Member, }); } diff --git a/public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap b/public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap index 77b50436590..da89d26d191 100644 --- a/public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap +++ b/public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap @@ -201,9 +201,41 @@ exports[`Render should render team members 1`] = `
- - Member - +
@@ -249,6 +314,7 @@ exports[`Render should render team members 1`] = ` className="text-right" > @@ -277,9 +343,41 @@ exports[`Render should render team members 1`] = `
- - Member - +
@@ -325,6 +456,7 @@ exports[`Render should render team members 1`] = ` className="text-right" > @@ -353,9 +485,41 @@ exports[`Render should render team members 1`] = `
- - Member - +
@@ -510,6 +707,7 @@ exports[`Render should render team members when sync enabled 1`] = ` className="text-right" > @@ -538,9 +736,41 @@ exports[`Render should render team members when sync enabled 1`] = `
- - Member - +
@@ -618,6 +881,7 @@ exports[`Render should render team members when sync enabled 1`] = ` className="text-right" > @@ -646,9 +910,41 @@ exports[`Render should render team members when sync enabled 1`] = `
- - Member - +
@@ -726,6 +1055,7 @@ exports[`Render should render team members when sync enabled 1`] = ` className="text-right" > @@ -888,6 +1218,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on should render p className="text-right" > @@ -958,6 +1289,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on should render p className="text-right" > @@ -1028,6 +1360,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on should render p className="text-right" > @@ -1098,6 +1431,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on should render p className="text-right" > @@ -1168,6 +1502,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on should render p className="text-right" > @@ -1330,6 +1665,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on should render p className="text-right" > @@ -1400,6 +1736,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on should render p className="text-right" > @@ -1470,6 +1807,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on should render p className="text-right" > @@ -1540,6 +1878,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on should render p className="text-right" > @@ -1610,6 +1949,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on should render p className="text-right" > @@ -1641,7 +1981,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on should render p />
+
+ + Name + + Email + + Permission + +
+ + + testUser-1 + + test@test.com + +
+
+ +
+ + + testUser-2 + + test@test.com + +
+
+ +
+ + + testUser-3 + + test@test.com + +
+
+ +
+ + + testUser-4 + + test@test.com + +
+
+ +
+ + + testUser-5 + + test@test.com + +
+
From b783fa7039daff42e0406262c80fdf19c852dc53 Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Tue, 12 Mar 2019 13:59:53 +0100 Subject: [PATCH 066/103] team: renames teams.CanUpdate teamguardian.CanAdmin --- pkg/api/dashboard_test.go | 1 + pkg/api/folder_test.go | 5 +---- pkg/api/team.go | 17 ++++++++++++----- pkg/api/team_members.go | 8 ++++---- pkg/services/{teams => teamguardian}/team.go | 4 ++-- .../{teams => teamguardian}/teams_test.go | 10 +++++----- public/app/types/acl.ts | 2 +- 7 files changed, 26 insertions(+), 21 deletions(-) rename pkg/services/{teams => teamguardian}/team.go (85%) rename pkg/services/{teams => teamguardian}/teams_test.go (87%) diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index 5411643af96..c54647d9847 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -1028,6 +1028,7 @@ func restoreDashboardVersionScenario(desc string, url string, routePattern strin defer bus.ClearBusHandlers() hs := HTTPServer{ + Cfg: setting.NewCfg(), Bus: bus.GetBus(), } diff --git a/pkg/api/folder_test.go b/pkg/api/folder_test.go index 15c51e476b5..5e7184ae0c9 100644 --- a/pkg/api/folder_test.go +++ b/pkg/api/folder_test.go @@ -142,12 +142,9 @@ func createFolderScenario(desc string, url string, routePattern string, mock *fa Convey(desc+" "+url, func() { defer bus.ClearBusHandlers() - cfg := setting.NewCfg() - cfg.EditorsCanAdmin = true - hs := HTTPServer{ Bus: bus.GetBus(), - Cfg: cfg, + Cfg: setting.NewCfg(), } sc := setupScenarioContext(url) diff --git a/pkg/api/team.go b/pkg/api/team.go index 619d24ea0b1..6d5753fdc90 100644 --- a/pkg/api/team.go +++ b/pkg/api/team.go @@ -4,7 +4,7 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/teams" + "github.com/grafana/grafana/pkg/services/teamguardian" "github.com/grafana/grafana/pkg/util" ) @@ -42,7 +42,7 @@ func UpdateTeam(c *m.ReqContext, cmd m.UpdateTeamCommand) Response { cmd.OrgId = c.OrgId cmd.Id = c.ParamsInt64(":teamId") - if err := teams.CanUpdateTeam(cmd.OrgId, cmd.Id, c.SignedInUser); err != nil { + if err := teamguardian.CanAdmin(cmd.OrgId, cmd.Id, c.SignedInUser); err != nil { return Error(403, "Not allowed to update team", err) } @@ -62,7 +62,7 @@ func DeleteTeamByID(c *m.ReqContext) Response { teamId := c.ParamsInt64(":teamId") user := c.SignedInUser - if err := teams.CanUpdateTeam(orgId, teamId, user); err != nil { + if err := teamguardian.CanAdmin(orgId, teamId, user); err != nil { return Error(403, "Not allowed to delete team", err) } @@ -132,7 +132,14 @@ func GetTeamByID(c *m.ReqContext) Response { // GET /api/teams/:teamId/preferences func GetTeamPreferences(c *m.ReqContext) Response { - return getPreferencesFor(c.OrgId, 0, c.ParamsInt64(":teamId")) + teamId := c.ParamsInt64(":teamId") + orgId := c.OrgId + + if err := teamguardian.CanAdmin(orgId, teamId, c.SignedInUser); err != nil { + return Error(403, "Not allowed to view team preferences.", err) + } + + return getPreferencesFor(orgId, 0, teamId) } // PUT /api/teams/:teamId/preferences @@ -140,7 +147,7 @@ func UpdateTeamPreferences(c *m.ReqContext, dtoCmd dtos.UpdatePrefsCmd) Response teamId := c.ParamsInt64(":teamId") orgId := c.OrgId - if err := teams.CanUpdateTeam(orgId, teamId, c.SignedInUser); err != nil { + if err := teamguardian.CanAdmin(orgId, teamId, c.SignedInUser); err != nil { return Error(403, "Not allowed to update team preferences.", err) } diff --git a/pkg/api/team_members.go b/pkg/api/team_members.go index b2bb1781020..669326ded18 100644 --- a/pkg/api/team_members.go +++ b/pkg/api/team_members.go @@ -4,7 +4,7 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/teams" + "github.com/grafana/grafana/pkg/services/teamguardian" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) @@ -34,7 +34,7 @@ func AddTeamMember(c *m.ReqContext, cmd m.AddTeamMemberCommand) Response { teamId := c.ParamsInt64(":teamId") orgId := c.OrgId - if err := teams.CanUpdateTeam(orgId, teamId, c.SignedInUser); err != nil { + if err := teamguardian.CanAdmin(orgId, teamId, c.SignedInUser); err != nil { return Error(403, "Not allowed to add team member", err) } @@ -63,7 +63,7 @@ func UpdateTeamMember(c *m.ReqContext, cmd m.UpdateTeamMemberCommand) Response { teamId := c.ParamsInt64(":teamId") orgId := c.OrgId - if err := teams.CanUpdateTeam(orgId, teamId, c.SignedInUser); err != nil { + if err := teamguardian.CanAdmin(orgId, teamId, c.SignedInUser); err != nil { return Error(403, "Not allowed to update team member", err) } @@ -86,7 +86,7 @@ func RemoveTeamMember(c *m.ReqContext) Response { teamId := c.ParamsInt64(":teamId") userId := c.ParamsInt64(":userId") - if err := teams.CanUpdateTeam(orgId, teamId, c.SignedInUser); err != nil { + if err := teamguardian.CanAdmin(orgId, teamId, c.SignedInUser); err != nil { return Error(403, "Not allowed to remove team member", err) } diff --git a/pkg/services/teams/team.go b/pkg/services/teamguardian/team.go similarity index 85% rename from pkg/services/teams/team.go rename to pkg/services/teamguardian/team.go index 080fe961ab6..9946ae7c734 100644 --- a/pkg/services/teams/team.go +++ b/pkg/services/teamguardian/team.go @@ -1,11 +1,11 @@ -package teams +package teamguardian import ( "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" ) -func CanUpdateTeam(orgId int64, teamId int64, user *m.SignedInUser) error { +func CanAdmin(orgId int64, teamId int64, user *m.SignedInUser) error { if user.OrgRole == m.ROLE_ADMIN { return nil } diff --git a/pkg/services/teams/teams_test.go b/pkg/services/teamguardian/teams_test.go similarity index 87% rename from pkg/services/teams/teams_test.go rename to pkg/services/teamguardian/teams_test.go index 85bbddf014f..9b1ba7ee4cb 100644 --- a/pkg/services/teams/teams_test.go +++ b/pkg/services/teamguardian/teams_test.go @@ -1,4 +1,4 @@ -package teams +package teamguardian import ( "github.com/grafana/grafana/pkg/bus" @@ -33,7 +33,7 @@ func TestUpdateTeam(t *testing.T) { return nil }) - err := CanUpdateTeam(testTeam.OrgId, testTeam.Id, &editor) + err := CanAdmin(testTeam.OrgId, testTeam.Id, &editor) So(err, ShouldEqual, m.ErrNotAllowedToUpdateTeam) }) }) @@ -50,7 +50,7 @@ func TestUpdateTeam(t *testing.T) { return nil }) - err := CanUpdateTeam(testTeam.OrgId, testTeam.Id, &editor) + err := CanAdmin(testTeam.OrgId, testTeam.Id, &editor) So(err, ShouldBeNil) }) }) @@ -72,14 +72,14 @@ func TestUpdateTeam(t *testing.T) { return nil }) - err := CanUpdateTeam(testTeamOtherOrg.OrgId, testTeamOtherOrg.Id, &editor) + err := CanAdmin(testTeamOtherOrg.OrgId, testTeamOtherOrg.Id, &editor) So(err, ShouldEqual, m.ErrNotAllowedToUpdateTeamInDifferentOrg) }) }) Convey("Given an org admin and a team", func() { Convey("Should be able to update the team", func() { - err := CanUpdateTeam(testTeam.OrgId, testTeam.Id, &admin) + err := CanAdmin(testTeam.OrgId, testTeam.Id, &admin) So(err, ShouldBeNil) }) }) diff --git a/public/app/types/acl.ts b/public/app/types/acl.ts index 8134ddb1749..55e9bff620b 100644 --- a/public/app/types/acl.ts +++ b/public/app/types/acl.ts @@ -115,6 +115,6 @@ export const teamsPermissionLevels: TeamPermissionInfo[] = [ { value: TeamPermissionLevel.Admin, label: 'Admin', - description: 'Can add/remove permissions and delete team.', + description: 'Can add/remove permissions, members and delete team.', }, ]; From 8593668ab23acf72a6f88abaa6c01ce52c9284cf Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Tue, 12 Mar 2019 14:19:12 +0100 Subject: [PATCH 067/103] teams: tests use the new message for modifying team members. --- .../__snapshots__/TeamMembers.test.tsx.snap | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap b/public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap index d8540ed0615..77b50436590 100644 --- a/public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap +++ b/public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap @@ -866,7 +866,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on should render p "value": 0, }, Object { - "description": "Can add/remove permissions and delete team.", + "description": "Can add/remove permissions, members and delete team.", "label": "Admin", "value": 4, }, @@ -936,7 +936,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on should render p "value": 0, }, Object { - "description": "Can add/remove permissions and delete team.", + "description": "Can add/remove permissions, members and delete team.", "label": "Admin", "value": 4, }, @@ -1006,7 +1006,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on should render p "value": 0, }, Object { - "description": "Can add/remove permissions and delete team.", + "description": "Can add/remove permissions, members and delete team.", "label": "Admin", "value": 4, }, @@ -1076,7 +1076,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on should render p "value": 0, }, Object { - "description": "Can add/remove permissions and delete team.", + "description": "Can add/remove permissions, members and delete team.", "label": "Admin", "value": 4, }, @@ -1146,7 +1146,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on should render p "value": 0, }, Object { - "description": "Can add/remove permissions and delete team.", + "description": "Can add/remove permissions, members and delete team.", "label": "Admin", "value": 4, }, @@ -1154,7 +1154,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on should render p } value={ Object { - "description": "Can add/remove permissions and delete team.", + "description": "Can add/remove permissions, members and delete team.", "label": "Admin", "value": 4, } @@ -1308,7 +1308,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on should render p "value": 0, }, Object { - "description": "Can add/remove permissions and delete team.", + "description": "Can add/remove permissions, members and delete team.", "label": "Admin", "value": 4, }, @@ -1378,7 +1378,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on should render p "value": 0, }, Object { - "description": "Can add/remove permissions and delete team.", + "description": "Can add/remove permissions, members and delete team.", "label": "Admin", "value": 4, }, @@ -1448,7 +1448,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on should render p "value": 0, }, Object { - "description": "Can add/remove permissions and delete team.", + "description": "Can add/remove permissions, members and delete team.", "label": "Admin", "value": 4, }, @@ -1518,7 +1518,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on should render p "value": 0, }, Object { - "description": "Can add/remove permissions and delete team.", + "description": "Can add/remove permissions, members and delete team.", "label": "Admin", "value": 4, }, @@ -1588,7 +1588,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on should render p "value": 0, }, Object { - "description": "Can add/remove permissions and delete team.", + "description": "Can add/remove permissions, members and delete team.", "label": "Admin", "value": 4, }, @@ -1596,7 +1596,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on should render p } value={ Object { - "description": "Can add/remove permissions and delete team.", + "description": "Can add/remove permissions, members and delete team.", "label": "Admin", "value": 4, } @@ -1750,7 +1750,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on should render p "value": 0, }, Object { - "description": "Can add/remove permissions and delete team.", + "description": "Can add/remove permissions, members and delete team.", "label": "Admin", "value": 4, }, @@ -1758,7 +1758,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on should render p } value={ Object { - "description": "Can add/remove permissions and delete team.", + "description": "Can add/remove permissions, members and delete team.", "label": "Admin", "value": 4, } @@ -1820,7 +1820,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on should render p "value": 0, }, Object { - "description": "Can add/remove permissions and delete team.", + "description": "Can add/remove permissions, members and delete team.", "label": "Admin", "value": 4, }, @@ -1890,7 +1890,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on should render p "value": 0, }, Object { - "description": "Can add/remove permissions and delete team.", + "description": "Can add/remove permissions, members and delete team.", "label": "Admin", "value": 4, }, @@ -1960,7 +1960,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on should render p "value": 0, }, Object { - "description": "Can add/remove permissions and delete team.", + "description": "Can add/remove permissions, members and delete team.", "label": "Admin", "value": 4, }, @@ -2030,7 +2030,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on should render p "value": 0, }, Object { - "description": "Can add/remove permissions and delete team.", + "description": "Can add/remove permissions, members and delete team.", "label": "Admin", "value": 4, }, From 21d3d274523be3817caed94d2e85f6a77f1dc877 Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Tue, 12 Mar 2019 16:59:39 +0100 Subject: [PATCH 068/103] teams: editors can't remove the last admin from a team. --- pkg/api/api.go | 2 +- pkg/api/team_members.go | 9 ++++++-- pkg/models/team.go | 1 + pkg/models/team_member.go | 7 +++--- pkg/services/sqlstore/team.go | 35 ++++++++++++++++++++++++++++++ pkg/services/sqlstore/team_test.go | 17 +++++++++++++++ 6 files changed, 65 insertions(+), 6 deletions(-) diff --git a/pkg/api/api.go b/pkg/api/api.go index 9ffb0278935..9acd9485312 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -160,7 +160,7 @@ func (hs *HTTPServer) registerRoutes() { teamsRoute.Get("/:teamId/members", Wrap(GetTeamMembers)) teamsRoute.Post("/:teamId/members", bind(m.AddTeamMemberCommand{}), Wrap(AddTeamMember)) teamsRoute.Put("/:teamId/members/:userId", bind(m.UpdateTeamMemberCommand{}), Wrap(UpdateTeamMember)) - teamsRoute.Delete("/:teamId/members/:userId", Wrap(RemoveTeamMember)) + teamsRoute.Delete("/:teamId/members/:userId", Wrap(hs.RemoveTeamMember)) teamsRoute.Get("/:teamId/preferences", Wrap(GetTeamPreferences)) teamsRoute.Put("/:teamId/preferences", bind(dtos.UpdatePrefsCmd{}), Wrap(UpdateTeamPreferences)) }, reqAdminOrEditorCanAdmin) diff --git a/pkg/api/team_members.go b/pkg/api/team_members.go index 669326ded18..72aded688ec 100644 --- a/pkg/api/team_members.go +++ b/pkg/api/team_members.go @@ -81,7 +81,7 @@ func UpdateTeamMember(c *m.ReqContext, cmd m.UpdateTeamMemberCommand) Response { } // DELETE /api/teams/:teamId/members/:userId -func RemoveTeamMember(c *m.ReqContext) Response { +func (hs *HTTPServer) RemoveTeamMember(c *m.ReqContext) Response { orgId := c.OrgId teamId := c.ParamsInt64(":teamId") userId := c.ParamsInt64(":userId") @@ -90,7 +90,12 @@ func RemoveTeamMember(c *m.ReqContext) Response { return Error(403, "Not allowed to remove team member", err) } - if err := bus.Dispatch(&m.RemoveTeamMemberCommand{OrgId: orgId, TeamId: teamId, UserId: userId}); err != nil { + protectLastAdmin := false + if c.OrgRole == m.ROLE_EDITOR { + protectLastAdmin = true + } + + if err := bus.Dispatch(&m.RemoveTeamMemberCommand{OrgId: orgId, TeamId: teamId, UserId: userId, ProtectLastAdmin: protectLastAdmin}); err != nil { if err == m.ErrTeamNotFound { return Error(404, "Team not found", nil) } diff --git a/pkg/models/team.go b/pkg/models/team.go index bb9289ee5e5..5b659331601 100644 --- a/pkg/models/team.go +++ b/pkg/models/team.go @@ -10,6 +10,7 @@ var ( ErrTeamNotFound = errors.New("Team not found") ErrTeamNameTaken = errors.New("Team name is taken") ErrTeamMemberNotFound = errors.New("Team member not found") + ErrLastTeamAdmin = errors.New("Not allowed to remove last admin") ErrNotAllowedToUpdateTeam = errors.New("User not allowed to update team") ErrNotAllowedToUpdateTeamInDifferentOrg = errors.New("User not allowed to update team in another org") ) diff --git a/pkg/models/team_member.go b/pkg/models/team_member.go index 1140e39b095..0cc39b0f605 100644 --- a/pkg/models/team_member.go +++ b/pkg/models/team_member.go @@ -42,9 +42,10 @@ type UpdateTeamMemberCommand struct { } type RemoveTeamMemberCommand struct { - OrgId int64 `json:"-"` - UserId int64 - TeamId int64 + OrgId int64 `json:"-"` + UserId int64 + TeamId int64 + ProtectLastAdmin bool `json:"-"` } // ---------------------- diff --git a/pkg/services/sqlstore/team.go b/pkg/services/sqlstore/team.go index 7c5a5f88983..3848adcc7dc 100644 --- a/pkg/services/sqlstore/team.go +++ b/pkg/services/sqlstore/team.go @@ -285,6 +285,18 @@ func RemoveTeamMember(cmd *m.RemoveTeamMemberCommand) error { return err } + if cmd.ProtectLastAdmin { + lastAdmin, err := isLastAdmin(sess, cmd.OrgId, cmd.TeamId, cmd.UserId) + if err != nil { + return err + } + + if lastAdmin { + return m.ErrLastTeamAdmin + } + + } + var rawSql = "DELETE FROM team_member WHERE org_id=? and team_id=? and user_id=?" res, err := sess.Exec(rawSql, cmd.OrgId, cmd.TeamId, cmd.UserId) if err != nil { @@ -299,6 +311,29 @@ func RemoveTeamMember(cmd *m.RemoveTeamMemberCommand) error { }) } +func isLastAdmin(sess *DBSession, orgId int64, teamId int64, userId int64) (bool, error) { + rawSql := "SELECT user_id FROM team_member WHERE org_id=? and team_id=? and permission=?" + userIds := []*int64{} + err := sess.SQL(rawSql, orgId, teamId, m.PERMISSION_ADMIN).Find(&userIds) + if err != nil { + return false, err + } + + isAdmin := false + for _, adminId := range userIds { + if userId == *adminId { + isAdmin = true + break + } + } + + if isAdmin && len(userIds) == 1 { + return true, nil + } + + return false, err +} + // GetTeamMembers return a list of members for the specified team func GetTeamMembers(query *m.GetTeamMembersQuery) error { query.Result = make([]*m.TeamMemberDTO, 0) diff --git a/pkg/services/sqlstore/team_test.go b/pkg/services/sqlstore/team_test.go index 1c5f2024a79..ca5379bae65 100644 --- a/pkg/services/sqlstore/team_test.go +++ b/pkg/services/sqlstore/team_test.go @@ -152,6 +152,23 @@ func TestTeamCommandsAndQueries(t *testing.T) { So(len(q2.Result), ShouldEqual, 0) }) + Convey("When ProtectLastAdmin is set to true", func() { + err = AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0], Permission: int64(m.PERMISSION_ADMIN)}) + So(err, ShouldBeNil) + + Convey("A user should not be able to remove the last admin", func() { + err = RemoveTeamMember(&m.RemoveTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0], ProtectLastAdmin: true}) + So(err, ShouldEqual, m.ErrLastTeamAdmin) + }) + + Convey("A user should be able to remove an admin if there are other admins", func() { + err = AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[1], Permission: int64(m.PERMISSION_ADMIN)}) + err = RemoveTeamMember(&m.RemoveTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0], ProtectLastAdmin: true}) + So(err, ShouldEqual, nil) + }) + + }) + Convey("Should be able to remove a group with users and permissions", func() { groupId := group2.Result.Id err := AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: groupId, UserId: userIds[1]}) From c823ad5de7f1ce2da450b2a65902bcf236c2d113 Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Tue, 12 Mar 2019 17:24:18 +0100 Subject: [PATCH 069/103] team: uses PermissionType instead of int64 for permissions. --- pkg/api/team.go | 2 +- pkg/models/team_member.go | 20 ++++++++++---------- pkg/services/sqlstore/team.go | 1 + pkg/services/sqlstore/team_test.go | 8 ++++---- 4 files changed, 16 insertions(+), 15 deletions(-) diff --git a/pkg/api/team.go b/pkg/api/team.go index 6d5753fdc90..ab853888f76 100644 --- a/pkg/api/team.go +++ b/pkg/api/team.go @@ -23,7 +23,7 @@ func (hs *HTTPServer) CreateTeam(c *m.ReqContext, cmd m.CreateTeamCommand) Respo UserId: c.SignedInUser.UserId, OrgId: cmd.OrgId, TeamId: cmd.Result.Id, - Permission: int64(m.PERMISSION_ADMIN), + Permission: m.PERMISSION_ADMIN, } if err := bus.Dispatch(&addMemberCmd); err != nil { diff --git a/pkg/models/team_member.go b/pkg/models/team_member.go index 0cc39b0f605..9b7c2aeb0a4 100644 --- a/pkg/models/team_member.go +++ b/pkg/models/team_member.go @@ -17,7 +17,7 @@ type TeamMember struct { TeamId int64 UserId int64 External bool - Permission int64 + Permission PermissionType Created time.Time Updated time.Time @@ -27,18 +27,18 @@ type TeamMember struct { // COMMANDS type AddTeamMemberCommand struct { - UserId int64 `json:"userId" binding:"Required"` - OrgId int64 `json:"-"` - TeamId int64 `json:"-"` - External bool `json:"-"` - Permission int64 `json:"-"` + UserId int64 `json:"userId" binding:"Required"` + OrgId int64 `json:"-"` + TeamId int64 `json:"-"` + External bool `json:"-"` + Permission PermissionType `json:"-"` } type UpdateTeamMemberCommand struct { - UserId int64 `json:"-"` - OrgId int64 `json:"-"` - TeamId int64 `json:"-"` - Permission int64 `json:"permission"` + UserId int64 `json:"-"` + OrgId int64 `json:"-"` + TeamId int64 `json:"-"` + Permission PermissionType `json:"permission"` } type RemoveTeamMemberCommand struct { diff --git a/pkg/services/sqlstore/team.go b/pkg/services/sqlstore/team.go index 3848adcc7dc..bf993a930f2 100644 --- a/pkg/services/sqlstore/team.go +++ b/pkg/services/sqlstore/team.go @@ -271,6 +271,7 @@ func UpdateTeamMember(cmd *m.UpdateTeamMemberCommand) error { return m.ErrTeamMemberNotFound } + // TODO: check to make sure that permission is a legal value member.Permission = cmd.Permission _, err = sess.Cols("permission").Where("org_id=? and team_id=? and user_id=?", cmd.OrgId, cmd.TeamId, cmd.UserId).Update(member) diff --git a/pkg/services/sqlstore/team_test.go b/pkg/services/sqlstore/team_test.go index ca5379bae65..ac357c57a53 100644 --- a/pkg/services/sqlstore/team_test.go +++ b/pkg/services/sqlstore/team_test.go @@ -91,7 +91,7 @@ func TestTeamCommandsAndQueries(t *testing.T) { UserId: userId, OrgId: testOrgId, TeamId: team.Id, - Permission: int64(m.PERMISSION_ADMIN), + Permission: m.PERMISSION_ADMIN, }) So(err, ShouldBeNil) @@ -107,7 +107,7 @@ func TestTeamCommandsAndQueries(t *testing.T) { UserId: 1, OrgId: testOrgId, TeamId: group1.Result.Id, - Permission: int64(m.PERMISSION_ADMIN), + Permission: m.PERMISSION_ADMIN, }) So(err, ShouldEqual, m.ErrTeamMemberNotFound) @@ -153,7 +153,7 @@ func TestTeamCommandsAndQueries(t *testing.T) { }) Convey("When ProtectLastAdmin is set to true", func() { - err = AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0], Permission: int64(m.PERMISSION_ADMIN)}) + err = AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0], Permission: m.PERMISSION_ADMIN}) So(err, ShouldBeNil) Convey("A user should not be able to remove the last admin", func() { @@ -162,7 +162,7 @@ func TestTeamCommandsAndQueries(t *testing.T) { }) Convey("A user should be able to remove an admin if there are other admins", func() { - err = AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[1], Permission: int64(m.PERMISSION_ADMIN)}) + err = AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[1], Permission: m.PERMISSION_ADMIN}) err = RemoveTeamMember(&m.RemoveTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0], ProtectLastAdmin: true}) So(err, ShouldEqual, nil) }) From c826f39a8bef2bbfb76c300df655bf4b17536644 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Wed, 13 Mar 2019 07:18:57 +0100 Subject: [PATCH 070/103] teams: defaulting invalid permission level to member permission level --- pkg/services/sqlstore/team.go | 5 ++++- pkg/services/sqlstore/team_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/pkg/services/sqlstore/team.go b/pkg/services/sqlstore/team.go index bf993a930f2..c36e45ac503 100644 --- a/pkg/services/sqlstore/team.go +++ b/pkg/services/sqlstore/team.go @@ -271,7 +271,10 @@ func UpdateTeamMember(cmd *m.UpdateTeamMemberCommand) error { return m.ErrTeamMemberNotFound } - // TODO: check to make sure that permission is a legal value + if cmd.Permission != int64(m.PERMISSION_ADMIN) { + cmd.Permission = 0 + } + member.Permission = cmd.Permission _, err = sess.Cols("permission").Where("org_id=? and team_id=? and user_id=?", cmd.OrgId, cmd.TeamId, cmd.UserId).Update(member) diff --git a/pkg/services/sqlstore/team_test.go b/pkg/services/sqlstore/team_test.go index ac357c57a53..5580f5f9fab 100644 --- a/pkg/services/sqlstore/team_test.go +++ b/pkg/services/sqlstore/team_test.go @@ -102,6 +102,34 @@ func TestTeamCommandsAndQueries(t *testing.T) { So(qAfterUpdate.Result[0].Permission, ShouldEqual, m.PERMISSION_ADMIN) }) + Convey("Should default to member permission level when updating a user with invalid permission level", func() { + userID := userIds[0] + team := group1.Result + addMemberCmd := m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: team.Id, UserId: userID} + err = AddTeamMember(&addMemberCmd) + So(err, ShouldBeNil) + + qBeforeUpdate := &m.GetTeamMembersQuery{OrgId: testOrgId, TeamId: team.Id} + err = GetTeamMembers(qBeforeUpdate) + So(err, ShouldBeNil) + So(qBeforeUpdate.Result[0].Permission, ShouldEqual, 0) + + invalidPermissionLevel := 1337 + err = UpdateTeamMember(&m.UpdateTeamMemberCommand{ + UserId: userID, + OrgId: testOrgId, + TeamId: team.Id, + Permission: int64(invalidPermissionLevel), + }) + + So(err, ShouldBeNil) + + qAfterUpdate := &m.GetTeamMembersQuery{OrgId: testOrgId, TeamId: team.Id} + err = GetTeamMembers(qAfterUpdate) + So(err, ShouldBeNil) + So(qAfterUpdate.Result[0].Permission, ShouldEqual, 0) + }) + Convey("Shouldn't be able to update a user not in the team.", func() { err = UpdateTeamMember(&m.UpdateTeamMemberCommand{ UserId: 1, From 246e1280489432ae0998ec5a8dbb3066ea9e95b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Wed, 13 Mar 2019 07:27:32 +0100 Subject: [PATCH 071/103] teams: changed permission to permission type instead of int --- pkg/services/sqlstore/team.go | 2 +- pkg/services/sqlstore/team_test.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/services/sqlstore/team.go b/pkg/services/sqlstore/team.go index c36e45ac503..f7cb7b1ce45 100644 --- a/pkg/services/sqlstore/team.go +++ b/pkg/services/sqlstore/team.go @@ -271,7 +271,7 @@ func UpdateTeamMember(cmd *m.UpdateTeamMemberCommand) error { return m.ErrTeamMemberNotFound } - if cmd.Permission != int64(m.PERMISSION_ADMIN) { + if cmd.Permission != m.PERMISSION_ADMIN { cmd.Permission = 0 } diff --git a/pkg/services/sqlstore/team_test.go b/pkg/services/sqlstore/team_test.go index 5580f5f9fab..c63b28625b7 100644 --- a/pkg/services/sqlstore/team_test.go +++ b/pkg/services/sqlstore/team_test.go @@ -114,12 +114,12 @@ func TestTeamCommandsAndQueries(t *testing.T) { So(err, ShouldBeNil) So(qBeforeUpdate.Result[0].Permission, ShouldEqual, 0) - invalidPermissionLevel := 1337 + invalidPermissionLevel := m.PERMISSION_EDIT err = UpdateTeamMember(&m.UpdateTeamMemberCommand{ UserId: userID, OrgId: testOrgId, TeamId: team.Id, - Permission: int64(invalidPermissionLevel), + Permission: invalidPermissionLevel, }) So(err, ShouldBeNil) From c420af16b14e586b96d190e52f13805e0491e16a Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Wed, 13 Mar 2019 10:11:53 +0100 Subject: [PATCH 072/103] teams: editor/viewer team admin cant remove the last admin. --- pkg/api/team_members.go | 6 +++++- pkg/models/team_member.go | 9 +++++---- pkg/services/sqlstore/team.go | 12 ++++++++++++ pkg/services/sqlstore/team_test.go | 12 +++++++++++- 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/pkg/api/team_members.go b/pkg/api/team_members.go index 72aded688ec..4e2dd86a959 100644 --- a/pkg/api/team_members.go +++ b/pkg/api/team_members.go @@ -67,6 +67,10 @@ func UpdateTeamMember(c *m.ReqContext, cmd m.UpdateTeamMemberCommand) Response { return Error(403, "Not allowed to update team member", err) } + if c.OrgRole != m.ROLE_ADMIN { + cmd.ProtectLastAdmin = true + } + cmd.TeamId = teamId cmd.UserId = c.ParamsInt64(":userId") cmd.OrgId = orgId @@ -91,7 +95,7 @@ func (hs *HTTPServer) RemoveTeamMember(c *m.ReqContext) Response { } protectLastAdmin := false - if c.OrgRole == m.ROLE_EDITOR { + if c.OrgRole != m.ROLE_ADMIN { protectLastAdmin = true } diff --git a/pkg/models/team_member.go b/pkg/models/team_member.go index 9b7c2aeb0a4..6d0ae7793b3 100644 --- a/pkg/models/team_member.go +++ b/pkg/models/team_member.go @@ -35,10 +35,11 @@ type AddTeamMemberCommand struct { } type UpdateTeamMemberCommand struct { - UserId int64 `json:"-"` - OrgId int64 `json:"-"` - TeamId int64 `json:"-"` - Permission PermissionType `json:"permission"` + UserId int64 `json:"-"` + OrgId int64 `json:"-"` + TeamId int64 `json:"-"` + Permission PermissionType `json:"permission"` + ProtectLastAdmin bool `json:"-"` } type RemoveTeamMemberCommand struct { diff --git a/pkg/services/sqlstore/team.go b/pkg/services/sqlstore/team.go index f7cb7b1ce45..85801f42832 100644 --- a/pkg/services/sqlstore/team.go +++ b/pkg/services/sqlstore/team.go @@ -271,6 +271,18 @@ func UpdateTeamMember(cmd *m.UpdateTeamMemberCommand) error { return m.ErrTeamMemberNotFound } + if cmd.ProtectLastAdmin { + lastAdmin, err := isLastAdmin(sess, cmd.OrgId, cmd.TeamId, cmd.UserId) + if err != nil { + return err + } + + if lastAdmin { + return m.ErrLastTeamAdmin + } + + } + if cmd.Permission != m.PERMISSION_ADMIN { cmd.Permission = 0 } diff --git a/pkg/services/sqlstore/team_test.go b/pkg/services/sqlstore/team_test.go index c63b28625b7..7ac78733af7 100644 --- a/pkg/services/sqlstore/team_test.go +++ b/pkg/services/sqlstore/team_test.go @@ -190,11 +190,21 @@ func TestTeamCommandsAndQueries(t *testing.T) { }) Convey("A user should be able to remove an admin if there are other admins", func() { - err = AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[1], Permission: m.PERMISSION_ADMIN}) + AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[1], Permission: m.PERMISSION_ADMIN}) err = RemoveTeamMember(&m.RemoveTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0], ProtectLastAdmin: true}) So(err, ShouldEqual, nil) }) + Convey("A user should not be able to remove the admin permission for the last admin", func() { + err = UpdateTeamMember(&m.UpdateTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0], Permission: 0, ProtectLastAdmin: true}) + So(err, ShouldEqual, m.ErrLastTeamAdmin) + }) + + Convey("A user should be able to remove the admin permission if there are other admins", func() { + AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[1], Permission: m.PERMISSION_ADMIN}) + err = UpdateTeamMember(&m.UpdateTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0], Permission: 0, ProtectLastAdmin: true}) + So(err, ShouldEqual, nil) + }) }) Convey("Should be able to remove a group with users and permissions", func() { From 782b5b6a3ab5b1965f1cf1a17d03867cfc376cbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Wed, 13 Mar 2019 10:38:09 +0100 Subject: [PATCH 073/103] teams: viewers and editors can view teams --- pkg/api/api.go | 8 ++++---- pkg/api/index.go | 9 +-------- pkg/api/team.go | 5 +++++ pkg/middleware/auth.go | 11 +++-------- public/app/routes/routes.ts | 4 ++-- 5 files changed, 15 insertions(+), 22 deletions(-) diff --git a/pkg/api/api.go b/pkg/api/api.go index 9acd9485312..24183e63782 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -14,7 +14,7 @@ func (hs *HTTPServer) registerRoutes() { reqGrafanaAdmin := middleware.ReqGrafanaAdmin reqEditorRole := middleware.ReqEditorRole reqOrgAdmin := middleware.ReqOrgAdmin - reqAdminOrEditorCanAdmin := middleware.EditorCanAdmin(hs.Cfg.EditorsCanAdmin) + reqAdminOrCanAdmin := middleware.AdminOrCanAdmin(hs.Cfg.EditorsCanAdmin) redirectFromLegacyDashboardURL := middleware.RedirectFromLegacyDashboardURL() redirectFromLegacyDashboardSoloURL := middleware.RedirectFromLegacyDashboardSoloURL() quota := middleware.Quota(hs.QuotaService) @@ -42,8 +42,8 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/org/users", reqOrgAdmin, hs.Index) r.Get("/org/users/new", reqOrgAdmin, hs.Index) r.Get("/org/users/invite", reqOrgAdmin, hs.Index) - r.Get("/org/teams", reqAdminOrEditorCanAdmin, hs.Index) - r.Get("/org/teams/*", reqAdminOrEditorCanAdmin, hs.Index) + r.Get("/org/teams", reqAdminOrCanAdmin, hs.Index) + r.Get("/org/teams/*", reqAdminOrCanAdmin, hs.Index) r.Get("/org/apikeys/", reqOrgAdmin, hs.Index) r.Get("/dashboard/import/", reqSignedIn, hs.Index) r.Get("/configuration", reqGrafanaAdmin, hs.Index) @@ -163,7 +163,7 @@ func (hs *HTTPServer) registerRoutes() { teamsRoute.Delete("/:teamId/members/:userId", Wrap(hs.RemoveTeamMember)) teamsRoute.Get("/:teamId/preferences", Wrap(GetTeamPreferences)) teamsRoute.Put("/:teamId/preferences", bind(dtos.UpdatePrefsCmd{}), Wrap(UpdateTeamPreferences)) - }, reqAdminOrEditorCanAdmin) + }, reqAdminOrCanAdmin) // team without requirement of user to be org admin apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) { diff --git a/pkg/api/index.go b/pkg/api/index.go index 3f60290ea55..e7555e14621 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -327,7 +327,7 @@ func (hs *HTTPServer) setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, er }) } - if c.OrgRole == m.ROLE_EDITOR && hs.Cfg.EditorsCanAdmin { + if (c.OrgRole == m.ROLE_EDITOR || c.OrgRole == m.ROLE_VIEWER) && hs.Cfg.EditorsCanAdmin { cfgNode := &dtos.NavLink{ Id: "cfg", Text: "Configuration", @@ -342,13 +342,6 @@ func (hs *HTTPServer) setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, er Icon: "gicon gicon-team", Url: setting.AppSubUrl + "/org/teams", }, - { - Text: "Plugins", - Id: "plugins", - Description: "View and configure plugins", - Icon: "gicon gicon-plugins", - Url: setting.AppSubUrl + "/plugins", - }, }, } diff --git a/pkg/api/team.go b/pkg/api/team.go index ab853888f76..eb7b1df37be 100644 --- a/pkg/api/team.go +++ b/pkg/api/team.go @@ -11,6 +11,11 @@ import ( // POST /api/teams func (hs *HTTPServer) CreateTeam(c *m.ReqContext, cmd m.CreateTeamCommand) Response { cmd.OrgId = c.OrgId + + if c.OrgRole == m.ROLE_VIEWER { + return Error(403, "Not allowed to create team.", nil) + } + if err := bus.Dispatch(&cmd); err != nil { if err == m.ErrTeamNameTaken { return Error(409, "Team name taken", err) diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index 6bf37e7fd50..8c1e5e04ae7 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -87,18 +87,13 @@ func Auth(options *AuthOptions) macaron.Handler { } } -func EditorCanAdmin(enabled bool) macaron.Handler { +func AdminOrCanAdmin(enabled bool) macaron.Handler { return func(c *m.ReqContext) { - ok := false if c.OrgRole == m.ROLE_ADMIN { - ok = true + return } - if c.OrgRole == m.ROLE_EDITOR && enabled { - ok = true - } - - if !ok { + if !enabled { accessForbidden(c) } } diff --git a/public/app/routes/routes.ts b/public/app/routes/routes.ts index 6fe0483c100..19bb96be603 100644 --- a/public/app/routes/routes.ts +++ b/public/app/routes/routes.ts @@ -195,7 +195,7 @@ export function setupAngularRoutes($routeProvider, $locationProvider) { .when('/org/teams', { template: '', resolve: { - roles: () => ['Editor', 'Admin'], + roles: () => (config.editorsCanAdmin ? [] : ['Editor', 'Admin']), component: () => TeamList, }, }) @@ -207,7 +207,7 @@ export function setupAngularRoutes($routeProvider, $locationProvider) { .when('/org/teams/edit/:id/:page?', { template: '', resolve: { - roles: () => (config.editorsCanAdmin ? ['Editor', 'Admin'] : ['Admin']), + roles: () => (config.editorsCanAdmin ? [] : ['Admin']), component: () => TeamPages, }, }) From b60e71c28b0c62c23717a88ba48bde97a97fdeab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Wed, 13 Mar 2019 14:05:08 +0100 Subject: [PATCH 074/103] teams: moved logic for searchteams to backend --- pkg/api/api.go | 2 +- pkg/api/team.go | 4 ++-- pkg/api/team_test.go | 10 ++++++++-- public/app/features/teams/state/actions.ts | 6 ++---- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/pkg/api/api.go b/pkg/api/api.go index 24183e63782..9d1151a757e 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -168,7 +168,7 @@ func (hs *HTTPServer) registerRoutes() { // team without requirement of user to be org admin apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) { teamsRoute.Get("/:teamId", Wrap(GetTeamByID)) - teamsRoute.Get("/search", Wrap(SearchTeams)) + teamsRoute.Get("/search", Wrap(hs.SearchTeams)) }) // org information available to all users. diff --git a/pkg/api/team.go b/pkg/api/team.go index eb7b1df37be..fd34c0ab720 100644 --- a/pkg/api/team.go +++ b/pkg/api/team.go @@ -81,7 +81,7 @@ func DeleteTeamByID(c *m.ReqContext) Response { } // GET /api/teams/search -func SearchTeams(c *m.ReqContext) Response { +func (hs *HTTPServer) SearchTeams(c *m.ReqContext) Response { perPage := c.QueryInt("perpage") if perPage <= 0 { perPage = 1000 @@ -92,7 +92,7 @@ func SearchTeams(c *m.ReqContext) Response { } var userIdFilter int64 - if c.QueryBool("showMine") { + if hs.Cfg.EditorsCanAdmin && c.OrgRole != m.ROLE_ADMIN { userIdFilter = c.SignedInUser.UserId } diff --git a/pkg/api/team_test.go b/pkg/api/team_test.go index a1984288870..cab59cc5f98 100644 --- a/pkg/api/team_test.go +++ b/pkg/api/team_test.go @@ -3,6 +3,8 @@ package api import ( "testing" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" @@ -20,6 +22,10 @@ func TestTeamApiEndpoint(t *testing.T) { TotalCount: 2, } + hs := &HTTPServer{ + Cfg: setting.NewCfg(), + } + Convey("When searching with no parameters", func() { loggedInUserScenario("When calling GET on", "/api/teams/search", func(sc *scenarioContext) { var sentLimit int @@ -33,7 +39,7 @@ func TestTeamApiEndpoint(t *testing.T) { return nil }) - sc.handlerFunc = SearchTeams + sc.handlerFunc = hs.SearchTeams sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() So(sentLimit, ShouldEqual, 1000) @@ -60,7 +66,7 @@ func TestTeamApiEndpoint(t *testing.T) { return nil }) - sc.handlerFunc = SearchTeams + sc.handlerFunc = hs.SearchTeams sc.fakeReqWithParams("GET", sc.url, map[string]string{"perpage": "10", "page": "2"}).exec() So(sentLimit, ShouldEqual, 10) diff --git a/public/app/features/teams/state/actions.ts b/public/app/features/teams/state/actions.ts index bfccddeefc5..e2582839233 100644 --- a/public/app/features/teams/state/actions.ts +++ b/public/app/features/teams/state/actions.ts @@ -1,9 +1,8 @@ import { ThunkAction } from 'redux-thunk'; import { getBackendSrv } from 'app/core/services/backend_srv'; -import { OrgRole, StoreState, Team, TeamGroup, TeamMember } from 'app/types'; +import { StoreState, Team, TeamGroup, TeamMember } from 'app/types'; import { updateNavIndex, UpdateNavIndexAction } from 'app/core/actions'; import { buildNavModel } from './navModel'; -import { contextSrv } from '../../../core/services/context_srv'; export enum ActionTypes { LoadTeams = 'LOAD_TEAMS', @@ -86,8 +85,7 @@ export const setSearchQuery = (searchQuery: string): SetSearchQueryAction => ({ export function loadTeams(): ThunkResult { return async dispatch => { - const showMine = contextSrv.user.orgRole === OrgRole.Editor; - const response = await getBackendSrv().get('/api/teams/search', { perpage: 1000, page: 1, showMine }); + const response = await getBackendSrv().get('/api/teams/search', { perpage: 1000, page: 1 }); dispatch(teamsLoaded(response.teams)); }; } From b82b94a2470e6d6f5889076b20dd4155ec4d473e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Wed, 13 Mar 2019 15:34:38 +0100 Subject: [PATCH 075/103] teams: disable buttons for team members --- .../components/DeleteButton/DeleteButton.tsx | 24 +- .../app/features/teams/TeamMembers.test.tsx | 34 +- public/app/features/teams/TeamMembers.tsx | 28 +- .../app/features/teams/__mocks__/teamMocks.ts | 4 +- .../__snapshots__/TeamMembers.test.tsx.snap | 884 ++++++++++++++---- .../features/teams/state/selectors.test.ts | 2 +- 6 files changed, 734 insertions(+), 242 deletions(-) diff --git a/packages/grafana-ui/src/components/DeleteButton/DeleteButton.tsx b/packages/grafana-ui/src/components/DeleteButton/DeleteButton.tsx index df65d156ab3..d262c821968 100644 --- a/packages/grafana-ui/src/components/DeleteButton/DeleteButton.tsx +++ b/packages/grafana-ui/src/components/DeleteButton/DeleteButton.tsx @@ -2,6 +2,7 @@ import React, { PureComponent, SyntheticEvent } from 'react'; interface Props { onConfirm(): void; + disabled?: boolean; } interface State { @@ -33,25 +34,22 @@ export class DeleteButton extends PureComponent { }; render() { - const { onConfirm } = this.props; - let showConfirm; - let showDeleteButton; - - if (this.state.showConfirm) { - showConfirm = 'show'; - showDeleteButton = 'hide'; - } else { - showConfirm = 'hide'; - showDeleteButton = 'show'; - } + const { onConfirm, disabled } = this.props; + const showConfirmClass = this.state.showConfirm ? 'show' : 'hide'; + const showDeleteButtonClass = this.state.showConfirm ? 'hide' : 'show'; + const disabledClass = disabled ? 'disabled btn-inverse' : ''; + const onClick = disabled ? () => {} : this.onClickDelete; return ( - + - + Cancel diff --git a/public/app/features/teams/TeamMembers.test.tsx b/public/app/features/teams/TeamMembers.test.tsx index 64609f1fd79..f6f0b4a5e49 100644 --- a/public/app/features/teams/TeamMembers.test.tsx +++ b/public/app/features/teams/TeamMembers.test.tsx @@ -6,16 +6,17 @@ import { getMockTeamMember, getMockTeamMembers } from './__mocks__/teamMocks'; import { SelectOptionItem } from '@grafana/ui'; import { contextSrv } from 'app/core/services/context_srv'; +const signedInUserId = 1; +const originalContextSrv = contextSrv; + jest.mock('app/core/services/context_srv', () => ({ contextSrv: { isGrafanaAdmin: false, hasRole: role => false, - user: { id: 1 }, + user: { id: signedInUserId }, }, })); -const originalContextSrv = contextSrv; - interface SetupProps { propOverrides?: object; isGrafanaAdmin?: boolean; @@ -64,7 +65,7 @@ describe('Render', () => { it('should render team members', () => { const { wrapper } = setup({ propOverrides: { - members: getMockTeamMembers(5), + members: getMockTeamMembers(5, 5), }, }); @@ -74,7 +75,7 @@ describe('Render', () => { it('should render team members when sync enabled', () => { const { wrapper } = setup({ propOverrides: { - members: getMockTeamMembers(5), + members: getMockTeamMembers(5, 5), syncEnabled: true, }, }); @@ -84,8 +85,7 @@ describe('Render', () => { describe('when feature toggle editorsCanAdmin is turned on', () => { it('should render permissions select if user is Grafana Admin', () => { - const members = getMockTeamMembers(5); - members[4].permission = TeamPermissionLevel.Admin; + const members = getMockTeamMembers(5, 5); const { wrapper } = setup({ propOverrides: { members, editorsCanAdmin: true }, isGrafanaAdmin: true, @@ -96,8 +96,7 @@ describe('Render', () => { }); it('should render permissions select if user is Org Admin', () => { - const members = getMockTeamMembers(5); - members[4].permission = TeamPermissionLevel.Admin; + const members = getMockTeamMembers(5, 5); const { wrapper } = setup({ propOverrides: { members, editorsCanAdmin: true }, isGrafanaAdmin: false, @@ -108,8 +107,7 @@ describe('Render', () => { }); it('should render permissions select if user is team admin', () => { - const members = getMockTeamMembers(5); - members[0].permission = TeamPermissionLevel.Admin; + const members = getMockTeamMembers(5, signedInUserId); const { wrapper } = setup({ propOverrides: { members, editorsCanAdmin: true }, isGrafanaAdmin: false, @@ -118,6 +116,20 @@ describe('Render', () => { expect(wrapper).toMatchSnapshot(); }); + + it('should render span and disable buttons if user is team member', () => { + const members = getMockTeamMembers(5, 5); + const { wrapper } = setup({ + propOverrides: { + members, + editorsCanAdmin: true, + }, + isGrafanaAdmin: false, + isOrgAdmin: false, + }); + + expect(wrapper).toMatchSnapshot(); + }); }); }); diff --git a/public/app/features/teams/TeamMembers.tsx b/public/app/features/teams/TeamMembers.tsx index 89ad24f9c58..fc5706ec68f 100644 --- a/public/app/features/teams/TeamMembers.tsx +++ b/public/app/features/teams/TeamMembers.tsx @@ -39,7 +39,7 @@ export class TeamMembers extends PureComponent { constructor(props) { super(props); this.state = { isAdding: false, newTeamMember: null }; - this.renderPermissionsSelect = this.renderPermissionsSelect.bind(this); + this.renderPermissions = this.renderPermissions.bind(this); } componentDidMount() { @@ -88,13 +88,19 @@ export class TeamMembers extends PureComponent { this.props.updateTeamMember(updatedTeamMember); }; - renderPermissionsSelect(member: TeamMember) { + private isSignedInUserTeamAdmin = () => { const { members, editorsCanAdmin } = this.props; const userInMembers = members.find(m => m.userId === contextSrv.user.id); - const isUserTeamAdmin = - contextSrv.isGrafanaAdmin || contextSrv.hasRole(OrgRole.Admin) - ? true - : userInMembers && userInMembers.permission === TeamPermissionLevel.Admin; + const isAdmin = contextSrv.isGrafanaAdmin || contextSrv.hasRole(OrgRole.Admin); + const userIsTeamAdmin = userInMembers && userInMembers.permission === TeamPermissionLevel.Admin; + const isSignedInUserTeamAdmin = isAdmin || userIsTeamAdmin; + + return isSignedInUserTeamAdmin || !editorsCanAdmin; + }; + + renderPermissions(member: TeamMember) { + const { editorsCanAdmin } = this.props; + const isUserTeamAdmin = this.isSignedInUserTeamAdmin(); const value = teamsPermissionLevels.find(dp => dp.value === member.permission); return ( @@ -125,10 +131,10 @@ export class TeamMembers extends PureComponent { {member.login} {member.email} - this.onRemoveMember(member)} /> + this.onRemoveMember(member)} disabled={!this.isSignedInUserTeamAdmin()} />
+
+
+`; + +exports[`Render when feature toggle editorsCanAdmin is turned on should render span and disable buttons if user is team member 1`] = ` +
+
+
+ +
+
+ +
+ +
+ +
+ Add team member +
+
+ +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/app/features/teams/state/selectors.test.ts b/public/app/features/teams/state/selectors.test.ts index e88fbdfd4b1..1721437c929 100644 --- a/public/app/features/teams/state/selectors.test.ts +++ b/public/app/features/teams/state/selectors.test.ts @@ -40,7 +40,7 @@ describe('Team selectors', () => { }); describe('Get members', () => { - const mockTeamMembers = getMockTeamMembers(5); + const mockTeamMembers = getMockTeamMembers(5, 5); it('should return team members', () => { const mockState: TeamState = { From fc0461134f0e4ff014f0b2b33719abf4eb842987 Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Wed, 13 Mar 2019 15:47:47 +0100 Subject: [PATCH 076/103] dashboards: simplified code. --- pkg/api/dashboard_test.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index c54647d9847..ea69c049115 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -972,12 +972,9 @@ func postDashboardScenario(desc string, url string, routePattern string, mock *d Convey(desc+" "+url, func() { defer bus.ClearBusHandlers() - cfg := setting.NewCfg() - cfg.EditorsCanAdmin = false - hs := HTTPServer{ Bus: bus.GetBus(), - Cfg: cfg, + Cfg: setting.NewCfg(), } sc := setupScenarioContext(url) From ccfd6789ca1f9ae0ea6b9927cc45813d795c5ad9 Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Wed, 13 Mar 2019 16:32:59 +0100 Subject: [PATCH 077/103] teams: cleanup. --- pkg/models/team_member.go | 18 +++++++++--------- pkg/services/teamguardian/team.go | 2 +- pkg/services/teamguardian/teams_test.go | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/pkg/models/team_member.go b/pkg/models/team_member.go index 6d0ae7793b3..c9afd4cd75f 100644 --- a/pkg/models/team_member.go +++ b/pkg/models/team_member.go @@ -64,13 +64,13 @@ type GetTeamMembersQuery struct { // Projections and DTOs type TeamMemberDTO struct { - OrgId int64 `json:"orgId"` - TeamId int64 `json:"teamId"` - UserId int64 `json:"userId"` - External bool `json:"-"` - Email string `json:"email"` - Login string `json:"login"` - AvatarUrl string `json:"avatarUrl"` - Labels []string `json:"labels"` - Permission int64 `json:"permission"` + OrgId int64 `json:"orgId"` + TeamId int64 `json:"teamId"` + UserId int64 `json:"userId"` + External bool `json:"-"` + Email string `json:"email"` + Login string `json:"login"` + AvatarUrl string `json:"avatarUrl"` + Labels []string `json:"labels"` + Permission PermissionType `json:"permission"` } diff --git a/pkg/services/teamguardian/team.go b/pkg/services/teamguardian/team.go index 9946ae7c734..6fddc318f5e 100644 --- a/pkg/services/teamguardian/team.go +++ b/pkg/services/teamguardian/team.go @@ -25,7 +25,7 @@ func CanAdmin(orgId int64, teamId int64, user *m.SignedInUser) error { } for _, member := range cmd.Result { - if member.UserId == user.UserId && member.Permission == int64(m.PERMISSION_ADMIN) { + if member.UserId == user.UserId && member.Permission == m.PERMISSION_ADMIN { return nil } } diff --git a/pkg/services/teamguardian/teams_test.go b/pkg/services/teamguardian/teams_test.go index 9b1ba7ee4cb..2ec86769a29 100644 --- a/pkg/services/teamguardian/teams_test.go +++ b/pkg/services/teamguardian/teams_test.go @@ -45,7 +45,7 @@ func TestUpdateTeam(t *testing.T) { OrgId: testTeam.OrgId, TeamId: testTeam.Id, UserId: editor.UserId, - Permission: int64(m.PERMISSION_ADMIN), + Permission: m.PERMISSION_ADMIN, }} return nil }) @@ -67,7 +67,7 @@ func TestUpdateTeam(t *testing.T) { OrgId: testTeamOtherOrg.OrgId, TeamId: testTeamOtherOrg.Id, UserId: editor.UserId, - Permission: int64(m.PERMISSION_ADMIN), + Permission: m.PERMISSION_ADMIN, }} return nil }) From 3f57a81c4722cbd77328e370272e749fdcdcbc7a Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Wed, 13 Mar 2019 16:46:35 +0100 Subject: [PATCH 078/103] teams: cleanup. --- pkg/api/team_members.go | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/pkg/api/team_members.go b/pkg/api/team_members.go index 4e2dd86a959..1674cc120ce 100644 --- a/pkg/api/team_members.go +++ b/pkg/api/team_members.go @@ -31,16 +31,13 @@ func GetTeamMembers(c *m.ReqContext) Response { // POST /api/teams/:teamId/members func AddTeamMember(c *m.ReqContext, cmd m.AddTeamMemberCommand) Response { - teamId := c.ParamsInt64(":teamId") - orgId := c.OrgId + cmd.OrgId = c.OrgId + cmd.TeamId = c.ParamsInt64(":teamId") - if err := teamguardian.CanAdmin(orgId, teamId, c.SignedInUser); err != nil { + if err := teamguardian.CanAdmin(cmd.OrgId, cmd.TeamId, c.SignedInUser); err != nil { return Error(403, "Not allowed to add team member", err) } - cmd.TeamId = teamId - cmd.OrgId = orgId - if err := bus.Dispatch(&cmd); err != nil { if err == m.ErrTeamNotFound { return Error(404, "Team not found", nil) From 6a63725df04fa5a2c717f7af63b485a171cb66fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Wed, 13 Mar 2019 16:48:15 +0100 Subject: [PATCH 079/103] teams: comment explaining input validation Co-Authored-By: xlson --- pkg/services/sqlstore/team.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/services/sqlstore/team.go b/pkg/services/sqlstore/team.go index 85801f42832..d76d8401499 100644 --- a/pkg/services/sqlstore/team.go +++ b/pkg/services/sqlstore/team.go @@ -283,7 +283,7 @@ func UpdateTeamMember(cmd *m.UpdateTeamMemberCommand) error { } - if cmd.Permission != m.PERMISSION_ADMIN { + if cmd.Permission != m.PERMISSION_ADMIN { // make sure we don't get invalid permission levels in store cmd.Permission = 0 } From 178d637b4e5fa21fb89ed102de6e6a6c022fe065 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Thu, 14 Mar 2019 07:53:31 +0100 Subject: [PATCH 080/103] refactor: splitted TeamMembers to TeamMemberRow --- .../app/features/teams/TeamMemberRow.test.tsx | 82 + public/app/features/teams/TeamMemberRow.tsx | 106 + .../app/features/teams/TeamMembers.test.tsx | 206 +- public/app/features/teams/TeamMembers.tsx | 95 +- .../__snapshots__/TeamMemberRow.test.tsx.snap | 191 ++ .../__snapshots__/TeamMembers.test.tsx.snap | 2430 ++--------------- 6 files changed, 677 insertions(+), 2433 deletions(-) create mode 100644 public/app/features/teams/TeamMemberRow.test.tsx create mode 100644 public/app/features/teams/TeamMemberRow.tsx create mode 100644 public/app/features/teams/__snapshots__/TeamMemberRow.test.tsx.snap diff --git a/public/app/features/teams/TeamMemberRow.test.tsx b/public/app/features/teams/TeamMemberRow.test.tsx new file mode 100644 index 00000000000..87f771cc833 --- /dev/null +++ b/public/app/features/teams/TeamMemberRow.test.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { TeamMember, TeamPermissionLevel } from '../../types'; +import { getMockTeamMember } from './__mocks__/teamMocks'; +import { TeamMemberRow, Props } from './TeamMemberRow'; +import { SelectOptionItem } from '@grafana/ui'; + +const setup = (propOverrides?: object) => { + const props: Props = { + member: getMockTeamMember(), + syncEnabled: false, + editorsCanAdmin: false, + signedInUserIsTeamAdmin: false, + updateTeamMember: jest.fn(), + removeTeamMember: jest.fn(), + }; + + Object.assign(props, propOverrides); + + const wrapper = shallow(); + const instance = wrapper.instance() as TeamMemberRow; + + return { + wrapper, + instance, + }; +}; + +describe('Render', () => { + describe('when feature toggle editorsCanAdmin is turned on', () => { + it('should render permissions select if user is team admin', () => { + const { wrapper } = setup({ editorsCanAdmin: true, signedInUserIsTeamAdmin: true }); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should render span and disable buttons if user is team member', () => { + const { wrapper } = setup({ editorsCanAdmin: true, signedInUserIsTeamAdmin: false }); + + expect(wrapper).toMatchSnapshot(); + }); + }); + + describe('when feature toggle editorsCanAdmin is turned off', () => { + it('should not render permissions', () => { + const { wrapper } = setup({ editorsCanAdmin: false, signedInUserIsTeamAdmin: true }); + + expect(wrapper).toMatchSnapshot(); + }); + }); +}); + +describe('Functions', () => { + describe('on remove member', () => { + const member = getMockTeamMember(); + const { instance } = setup({ member }); + + instance.onRemoveMember(member); + + expect(instance.props.removeTeamMember).toHaveBeenCalledWith(1); + }); + + describe('on update permision for user in team', () => { + const member: TeamMember = { + userId: 3, + teamId: 2, + avatarUrl: '', + email: 'user@user.org', + labels: [], + login: 'member', + permission: TeamPermissionLevel.Member, + }; + const { instance } = setup({ member }); + const permission = TeamPermissionLevel.Admin; + const item: SelectOptionItem = { value: permission }; + const expectedTeamMemeber = { ...member, permission }; + + instance.onPermissionChange(item, member); + + expect(instance.props.updateTeamMember).toHaveBeenCalledWith(expectedTeamMemeber); + }); +}); diff --git a/public/app/features/teams/TeamMemberRow.tsx b/public/app/features/teams/TeamMemberRow.tsx new file mode 100644 index 00000000000..e0bd26f4fd7 --- /dev/null +++ b/public/app/features/teams/TeamMemberRow.tsx @@ -0,0 +1,106 @@ +import React, { PureComponent } from 'react'; +import { connect } from 'react-redux'; +import { DeleteButton, Select, SelectOptionItem } from '@grafana/ui'; + +import { TeamMember, teamsPermissionLevels } from 'app/types'; +import { WithFeatureToggle } from 'app/core/components/WithFeatureToggle'; +import { updateTeamMember, removeTeamMember } from './state/actions'; +import { TagBadge } from 'app/core/components/TagFilter/TagBadge'; + +export interface Props { + member: TeamMember; + syncEnabled: boolean; + editorsCanAdmin: boolean; + signedInUserIsTeamAdmin: boolean; + removeTeamMember?: typeof removeTeamMember; + updateTeamMember?: typeof updateTeamMember; +} + +export class TeamMemberRow extends PureComponent { + constructor(props: Props) { + super(props); + this.renderLabels = this.renderLabels.bind(this); + this.renderPermissions = this.renderPermissions.bind(this); + } + + onRemoveMember(member: TeamMember) { + this.props.removeTeamMember(member.userId); + } + + onPermissionChange = (item: SelectOptionItem, member: TeamMember) => { + const permission = item.value; + const updatedTeamMember = { ...member, permission }; + + this.props.updateTeamMember(updatedTeamMember); + }; + + renderPermissions(member: TeamMember) { + const { editorsCanAdmin, signedInUserIsTeamAdmin } = this.props; + const value = teamsPermissionLevels.find(dp => dp.value === member.permission); + + return ( + + + + ); + } + + renderLabels(labels: string[]) { + if (!labels) { + return + ); + } + + render() { + const { member, syncEnabled, signedInUserIsTeamAdmin } = this.props; + return ( + + + + + {this.renderPermissions(member)} + {syncEnabled && this.renderLabels(member.labels)} + + + ); + } +} + +function mapStateToProps(state) { + return {}; +} + +const mapDispatchToProps = { + removeTeamMember, + updateTeamMember, +}; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(TeamMemberRow); diff --git a/public/app/features/teams/TeamMembers.test.tsx b/public/app/features/teams/TeamMembers.test.tsx index f6f0b4a5e49..fc65b0532e1 100644 --- a/public/app/features/teams/TeamMembers.test.tsx +++ b/public/app/features/teams/TeamMembers.test.tsx @@ -1,45 +1,29 @@ import React from 'react'; import { shallow } from 'enzyme'; import { TeamMembers, Props, State } from './TeamMembers'; -import { TeamMember, TeamPermissionLevel } from '../../types'; -import { getMockTeamMember, getMockTeamMembers } from './__mocks__/teamMocks'; -import { SelectOptionItem } from '@grafana/ui'; -import { contextSrv } from 'app/core/services/context_srv'; +import { TeamMember, OrgRole } from '../../types'; +import { getMockTeamMembers } from './__mocks__/teamMocks'; +import { User } from 'app/core/services/context_srv'; const signedInUserId = 1; -const originalContextSrv = contextSrv; -jest.mock('app/core/services/context_srv', () => ({ - contextSrv: { - isGrafanaAdmin: false, - hasRole: role => false, - user: { id: signedInUserId }, - }, -})); - -interface SetupProps { - propOverrides?: object; - isGrafanaAdmin?: boolean; - isOrgAdmin?: boolean; -} - -const setup = (setupProps: SetupProps) => { +const setup = (propOverrides?: object) => { const props: Props = { members: [] as TeamMember[], searchMemberQuery: '', setSearchMemberQuery: jest.fn(), loadTeamMembers: jest.fn(), addTeamMember: jest.fn(), - removeTeamMember: jest.fn(), - updateTeamMember: jest.fn(), syncEnabled: false, editorsCanAdmin: false, + signedInUser: { + id: signedInUserId, + isGrafanaAdmin: false, + orgRole: OrgRole.Viewer, + } as User, }; - contextSrv.isGrafanaAdmin = setupProps.isGrafanaAdmin || false; - contextSrv.hasRole = role => setupProps.isOrgAdmin || false; - - Object.assign(props, setupProps.propOverrides); + Object.assign(props, propOverrides); const wrapper = shallow(); const instance = wrapper.instance() as TeamMembers; @@ -51,11 +35,6 @@ const setup = (setupProps: SetupProps) => { }; describe('Render', () => { - beforeEach(() => { - contextSrv.isGrafanaAdmin = originalContextSrv.isGrafanaAdmin; - contextSrv.hasRole = originalContextSrv.hasRole; - }); - it('should render component', () => { const { wrapper } = setup({}); @@ -63,74 +42,16 @@ describe('Render', () => { }); it('should render team members', () => { - const { wrapper } = setup({ - propOverrides: { - members: getMockTeamMembers(5, 5), - }, - }); + const { wrapper } = setup({ members: getMockTeamMembers(5, 5) }); expect(wrapper).toMatchSnapshot(); }); it('should render team members when sync enabled', () => { - const { wrapper } = setup({ - propOverrides: { - members: getMockTeamMembers(5, 5), - syncEnabled: true, - }, - }); + const { wrapper } = setup({ members: getMockTeamMembers(5, 5), syncEnabled: true }); expect(wrapper).toMatchSnapshot(); }); - - describe('when feature toggle editorsCanAdmin is turned on', () => { - it('should render permissions select if user is Grafana Admin', () => { - const members = getMockTeamMembers(5, 5); - const { wrapper } = setup({ - propOverrides: { members, editorsCanAdmin: true }, - isGrafanaAdmin: true, - isOrgAdmin: false, - }); - - expect(wrapper).toMatchSnapshot(); - }); - - it('should render permissions select if user is Org Admin', () => { - const members = getMockTeamMembers(5, 5); - const { wrapper } = setup({ - propOverrides: { members, editorsCanAdmin: true }, - isGrafanaAdmin: false, - isOrgAdmin: true, - }); - - expect(wrapper).toMatchSnapshot(); - }); - - it('should render permissions select if user is team admin', () => { - const members = getMockTeamMembers(5, signedInUserId); - const { wrapper } = setup({ - propOverrides: { members, editorsCanAdmin: true }, - isGrafanaAdmin: false, - isOrgAdmin: false, - }); - - expect(wrapper).toMatchSnapshot(); - }); - - it('should render span and disable buttons if user is team member', () => { - const members = getMockTeamMembers(5, 5); - const { wrapper } = setup({ - propOverrides: { - members, - editorsCanAdmin: true, - }, - isGrafanaAdmin: false, - isOrgAdmin: false, - }); - - expect(wrapper).toMatchSnapshot(); - }); - }); }); describe('Functions', () => { @@ -144,15 +65,6 @@ describe('Functions', () => { }); }); - describe('on remove member', () => { - const { instance } = setup({}); - const mockTeamMember = getMockTeamMember(); - - instance.onRemoveMember(mockTeamMember); - - expect(instance.props.removeTeamMember).toHaveBeenCalledWith(1); - }); - describe('on add user to team', () => { const { wrapper, instance } = setup({}); const state = wrapper.state() as State; @@ -169,23 +81,85 @@ describe('Functions', () => { expect(instance.props.addTeamMember).toHaveBeenCalledWith(1); }); - describe('on update permision for user in team', () => { - const { instance } = setup({}); - const permission = TeamPermissionLevel.Admin; - const item: SelectOptionItem = { value: permission }; - const member: TeamMember = { - userId: 3, - teamId: 2, - avatarUrl: '', - email: 'user@user.org', - labels: [], - login: 'member', - permission: TeamPermissionLevel.Member, - }; - const expectedTeamMemeber = { ...member, permission }; + describe('isSignedInUserTeamAdmin', () => { + describe('when feature toggle editorsCanAdmin is turned off', () => { + it('should return true', () => { + const { instance } = setup({ editorsCanAdmin: false }); - instance.onPermissionChange(item, member); + const result = instance.isSignedInUserTeamAdmin(); - expect(instance.props.updateTeamMember).toHaveBeenCalledWith(expectedTeamMemeber); + expect(result).toBe(true); + }); + }); + + describe('when feature toggle editorsCanAdmin is turned on', () => { + it('should return true if signed in user is grafanaAdmin', () => { + const members = getMockTeamMembers(5, 5); + const { instance } = setup({ + members, + editorsCanAdmin: true, + signedInUser: { + id: signedInUserId, + isGrafanaAdmin: true, + orgRole: OrgRole.Viewer, + }, + }); + + const result = instance.isSignedInUserTeamAdmin(); + + expect(result).toBe(true); + }); + + it('should return true if signed in user is org admin', () => { + const members = getMockTeamMembers(5, 5); + const { instance } = setup({ + members, + editorsCanAdmin: true, + signedInUser: { + id: signedInUserId, + isGrafanaAdmin: false, + orgRole: OrgRole.Admin, + }, + }); + + const result = instance.isSignedInUserTeamAdmin(); + + expect(result).toBe(true); + }); + + it('should return true if signed in user is team admin', () => { + const members = getMockTeamMembers(5, signedInUserId); + const { instance } = setup({ + members, + editorsCanAdmin: true, + signedInUser: { + id: signedInUserId, + isGrafanaAdmin: false, + orgRole: OrgRole.Viewer, + }, + }); + + const result = instance.isSignedInUserTeamAdmin(); + + expect(result).toBe(true); + }); + + it('should return false if signed in user is not grafanaAdmin, org admin or team admin', () => { + const members = getMockTeamMembers(5, 5); + const { instance } = setup({ + members, + editorsCanAdmin: true, + signedInUser: { + id: signedInUserId, + isGrafanaAdmin: false, + orgRole: OrgRole.Viewer, + }, + }); + + const result = instance.isSignedInUserTeamAdmin(); + + expect(result).toBe(false); + }); + }); }); }); diff --git a/public/app/features/teams/TeamMembers.tsx b/public/app/features/teams/TeamMembers.tsx index fc5706ec68f..db2b9024a1f 100644 --- a/public/app/features/teams/TeamMembers.tsx +++ b/public/app/features/teams/TeamMembers.tsx @@ -2,32 +2,25 @@ import React, { PureComponent } from 'react'; import { connect } from 'react-redux'; import SlideDown from 'app/core/components/Animations/SlideDown'; import { UserPicker } from 'app/core/components/Select/UserPicker'; -import { DeleteButton, Select, SelectOptionItem } from '@grafana/ui'; import { TagBadge } from 'app/core/components/TagFilter/TagBadge'; -import { TeamMember, User, teamsPermissionLevels, TeamPermissionLevel, OrgRole } from 'app/types'; -import { - loadTeamMembers, - addTeamMember, - removeTeamMember, - setSearchMemberQuery, - updateTeamMember, -} from './state/actions'; +import { TeamMember, User, TeamPermissionLevel, OrgRole } from 'app/types'; +import { loadTeamMembers, addTeamMember, setSearchMemberQuery } from './state/actions'; import { getSearchMemberQuery, getTeamMembers } from './state/selectors'; import { FilterInput } from 'app/core/components/FilterInput/FilterInput'; import { WithFeatureToggle } from 'app/core/components/WithFeatureToggle'; import { config } from 'app/core/config'; -import { contextSrv } from 'app/core/services/context_srv'; +import { contextSrv, User as SignedInUser } from 'app/core/services/context_srv'; +import TeamMemberRow from './TeamMemberRow'; export interface Props { members: TeamMember[]; searchMemberQuery: string; loadTeamMembers: typeof loadTeamMembers; addTeamMember: typeof addTeamMember; - removeTeamMember: typeof removeTeamMember; setSearchMemberQuery: typeof setSearchMemberQuery; - updateTeamMember: typeof updateTeamMember; syncEnabled: boolean; editorsCanAdmin?: boolean; + signedInUser?: SignedInUser; } export interface State { @@ -39,7 +32,6 @@ export class TeamMembers extends PureComponent { constructor(props) { super(props); this.state = { isAdding: false, newTeamMember: null }; - this.renderPermissions = this.renderPermissions.bind(this); } componentDidMount() { @@ -50,10 +42,6 @@ export class TeamMembers extends PureComponent { this.props.setSearchMemberQuery(value); }; - onRemoveMember(member: TeamMember) { - this.props.removeTeamMember(member.userId); - } - onToggleAdding = () => { this.setState({ isAdding: !this.state.isAdding }); }; @@ -81,65 +69,16 @@ export class TeamMembers extends PureComponent { ); } - onPermissionChange = (item: SelectOptionItem, member: TeamMember) => { - const permission = item.value; - const updatedTeamMember = { ...member, permission }; - - this.props.updateTeamMember(updatedTeamMember); - }; - - private isSignedInUserTeamAdmin = () => { - const { members, editorsCanAdmin } = this.props; - const userInMembers = members.find(m => m.userId === contextSrv.user.id); - const isAdmin = contextSrv.isGrafanaAdmin || contextSrv.hasRole(OrgRole.Admin); + isSignedInUserTeamAdmin = (): boolean => { + const { members, editorsCanAdmin, signedInUser } = this.props; + const userInMembers = members.find(m => m.userId === signedInUser.id); + const isAdmin = signedInUser.isGrafanaAdmin || signedInUser.orgRole === OrgRole.Admin; const userIsTeamAdmin = userInMembers && userInMembers.permission === TeamPermissionLevel.Admin; const isSignedInUserTeamAdmin = isAdmin || userIsTeamAdmin; return isSignedInUserTeamAdmin || !editorsCanAdmin; }; - renderPermissions(member: TeamMember) { - const { editorsCanAdmin } = this.props; - const isUserTeamAdmin = this.isSignedInUserTeamAdmin(); - const value = teamsPermissionLevels.find(dp => dp.value === member.permission); - - return ( - - - - ); - } - - renderMember(member: TeamMember, syncEnabled: boolean) { - return ( - - - - - {this.renderPermissions(member)} - {syncEnabled && this.renderLabels(member.labels)} - - - ); - } - render() { const { isAdding } = this.state; const { searchMemberQuery, members, syncEnabled, editorsCanAdmin } = this.props; @@ -198,7 +137,18 @@ export class TeamMembers extends PureComponent { - {members && members.map(member => this.renderMember(member, syncEnabled))} + + {members && + members.map(member => ( + + ))} +
+ + Name + + Email + + Permission + +
+ + + testUser-1 + + test@test.com + +
+ + Member + +
+
+ +
+ + + testUser-2 + + test@test.com + +
+ + Member + +
+
+ +
+ + + testUser-3 + + test@test.com + +
+ + Member + +
+
+ +
+ + + testUser-4 + + test@test.com + +
+ + Member + +
+
+ +
+ + + testUser-5 + + test@test.com + +
+ + Admin + +
+
+ +
+ {signedInUserIsTeamAdmin && ( +
; + } + + return ( + + {labels.map(label => ( + {}} /> + ))} +
+ + {member.login}{member.email} + this.onRemoveMember(member)} disabled={!signedInUserIsTeamAdmin} /> +
-
- {isUserTeamAdmin && ( -
- - {member.login}{member.email} - this.onRemoveMember(member)} disabled={!this.isSignedInUserTeamAdmin()} /> -
@@ -211,15 +161,14 @@ function mapStateToProps(state) { members: getTeamMembers(state.team), searchMemberQuery: getSearchMemberQuery(state.team), editorsCanAdmin: config.editorsCanAdmin, // this makes the feature toggle mockable/controllable from tests, + signedInUser: contextSrv.user, // this makes the feature toggle mockable/controllable from tests, }; } const mapDispatchToProps = { loadTeamMembers, addTeamMember, - removeTeamMember, setSearchMemberQuery, - updateTeamMember, }; export default connect( diff --git a/public/app/features/teams/__snapshots__/TeamMemberRow.test.tsx.snap b/public/app/features/teams/__snapshots__/TeamMemberRow.test.tsx.snap new file mode 100644 index 00000000000..3e7630d0618 --- /dev/null +++ b/public/app/features/teams/__snapshots__/TeamMemberRow.test.tsx.snap @@ -0,0 +1,191 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render when feature toggle editorsCanAdmin is turned off should not render permissions 1`] = ` + + + + + + testUser + + + test@test.com + + + +
+ +
+ +
+ + + + +`; + +exports[`Render when feature toggle editorsCanAdmin is turned on should render span and disable buttons if user is team member 1`] = ` + + + + + + testUser + + + test@test.com + + + +
+ + Member + +
+ +
+ + + + +`; diff --git a/public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap b/public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap index da89d26d191..4d35a4a772b 100644 --- a/public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap +++ b/public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap @@ -177,361 +177,106 @@ exports[`Render should render team members 1`] = ` - - - - - - testUser-1 - - - test@test.com - - - -
- -
- -
- - - - - + - - - - - testUser-3 - - - test@test.com - - - -
- -
- -
- - - - - + - - - - - testUser-5 - - - test@test.com - - - -
- -
- -
- - - - - - - - - - - - - - testUser-2 - - - test@test.com - - - -
- -
- -
- - - - - - - - - - - - - - testUser-4 - - - test@test.com - - - -
- -
- -
- - - - - - - - - - -
-
-`; - -exports[`Render when feature toggle editorsCanAdmin is turned on should render permissions select if user is Grafana Admin 1`] = ` -
-
-
- -
-
- -
- -
- -
- Add team member -
-
- -
-
-
-
- - - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - Name - - Email - - Permission - -
- - - testUser-1 - - test@test.com - -
-
- -
- - - testUser-2 - - test@test.com - -
-
- -
- - - testUser-3 - - test@test.com - -
-
- -
- - - testUser-4 - - test@test.com - -
-
- -
- - - testUser-5 - - test@test.com - -
-
- -
-
-
-`; - -exports[`Render when feature toggle editorsCanAdmin is turned on should render permissions select if user is Org Admin 1`] = ` -
-
-
- -
-
- -
- -
- -
- Add team member -
-
- -
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - Name - - Email - - Permission - -
- - - testUser-1 - - test@test.com - -
-
- -
- - - testUser-2 - - test@test.com - -
-
- -
- - - testUser-3 - - test@test.com - -
-
- -
- - - testUser-4 - - test@test.com - -
-
- -
- - - testUser-5 - - test@test.com - -
-
- -
-
-
-`; - -exports[`Render when feature toggle editorsCanAdmin is turned on should render permissions select if user is team admin 1`] = ` -
-
-
- -
-
- -
- -
- -
- Add team member -
-
- -
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - - - - - - - - - -
- - Name - - Email - - Permission - -
- - - testUser-1 - - test@test.com - -
- - Admin - -
-
- -
- - - testUser-2 - - test@test.com - -
- - Member - -
-
- -
- - - testUser-3 - - test@test.com - -
- - Member - -
-
- -
- - - testUser-4 - - test@test.com - -
- - Member - -
-
- -
- - - testUser-5 - - test@test.com - -
- - Member - -
-
- -
-
-
-`; - -exports[`Render when feature toggle editorsCanAdmin is turned on should render span and disable buttons if user is team member 1`] = ` -
-
-
- -
-
- -
- -
- -
- Add team member -
-
- -
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - - - - - - - + member={ + Object { + "avatarUrl": "some/url/", + "email": "test@test.com", + "labels": Array [ + "label 1", + "label 2", + ], + "login": "testUser-5", + "permission": 4, + "teamId": 1, + "userId": 5, + } + } + signedInUserIsTeamAdmin={true} + syncEnabled={true} + />
- - Name - - Email - - Permission - -
- - - testUser-1 - - test@test.com - -
- - Member - -
-
- -
- - - testUser-2 - - test@test.com - -
- - Member - -
-
- -
- - - testUser-3 - - test@test.com - -
- - Member - -
-
- -
- - - testUser-4 - - test@test.com - -
- - Member - -
-
- -
- - - testUser-5 - - test@test.com - -
- - Admin - -
-
- -
From e3fc61b326e8bdfd4f01cdbf25b6927454477e15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Thu, 14 Mar 2019 08:02:24 +0100 Subject: [PATCH 081/103] refactor: moved test from TeamMembers to TeamMemberRow --- .../app/features/teams/TeamMemberRow.test.tsx | 8 + .../app/features/teams/TeamMembers.test.tsx | 6 - .../__snapshots__/TeamMemberRow.test.tsx.snap | 51 +++++ .../__snapshots__/TeamMembers.test.tsx.snap | 203 +----------------- 4 files changed, 64 insertions(+), 204 deletions(-) diff --git a/public/app/features/teams/TeamMemberRow.test.tsx b/public/app/features/teams/TeamMemberRow.test.tsx index 87f771cc833..0607825bff3 100644 --- a/public/app/features/teams/TeamMemberRow.test.tsx +++ b/public/app/features/teams/TeamMemberRow.test.tsx @@ -27,6 +27,14 @@ const setup = (propOverrides?: object) => { }; describe('Render', () => { + it('should render team members when sync enabled', () => { + const member = getMockTeamMember(); + member.labels = ['LDAP']; + const { wrapper } = setup({ member, syncEnabled: true }); + + expect(wrapper).toMatchSnapshot(); + }); + describe('when feature toggle editorsCanAdmin is turned on', () => { it('should render permissions select if user is team admin', () => { const { wrapper } = setup({ editorsCanAdmin: true, signedInUserIsTeamAdmin: true }); diff --git a/public/app/features/teams/TeamMembers.test.tsx b/public/app/features/teams/TeamMembers.test.tsx index fc65b0532e1..02bfc4149f0 100644 --- a/public/app/features/teams/TeamMembers.test.tsx +++ b/public/app/features/teams/TeamMembers.test.tsx @@ -46,12 +46,6 @@ describe('Render', () => { expect(wrapper).toMatchSnapshot(); }); - - it('should render team members when sync enabled', () => { - const { wrapper } = setup({ members: getMockTeamMembers(5, 5), syncEnabled: true }); - - expect(wrapper).toMatchSnapshot(); - }); }); describe('Functions', () => { diff --git a/public/app/features/teams/__snapshots__/TeamMemberRow.test.tsx.snap b/public/app/features/teams/__snapshots__/TeamMemberRow.test.tsx.snap index 3e7630d0618..3dff08ddc1e 100644 --- a/public/app/features/teams/__snapshots__/TeamMemberRow.test.tsx.snap +++ b/public/app/features/teams/__snapshots__/TeamMemberRow.test.tsx.snap @@ -1,5 +1,56 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Render should render team members when sync enabled 1`] = ` + + + + + + testUser + + + test@test.com + + + +
+ + Member + +
+ +
+ + + + + + + +`; + exports[`Render when feature toggle editorsCanAdmin is turned off should not render permissions 1`] = ` - - - - -
`; - -exports[`Render should render team members when sync enabled 1`] = ` -
-
-
- -
-
- -
- -
- -
- Add team member -
-
- -
-
-
-
- - - - - - - - - - - - - - - - - -
- - Name - - Email - - Permission - - -
-
-
-`; From 8c34f595f0683d2277e5f907039689348de9e2b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Thu, 14 Mar 2019 08:21:53 +0100 Subject: [PATCH 082/103] teams: disable new team button if user is viewer --- public/app/features/teams/TeamList.test.tsx | 44 ++- public/app/features/teams/TeamList.tsx | 13 +- .../__snapshots__/TeamList.test.tsx.snap | 250 ++++++++++++++++++ 3 files changed, 303 insertions(+), 4 deletions(-) diff --git a/public/app/features/teams/TeamList.test.tsx b/public/app/features/teams/TeamList.test.tsx index 369771bb340..da5afb58796 100644 --- a/public/app/features/teams/TeamList.test.tsx +++ b/public/app/features/teams/TeamList.test.tsx @@ -1,8 +1,9 @@ import React from 'react'; import { shallow } from 'enzyme'; import { Props, TeamList } from './TeamList'; -import { NavModel, Team } from '../../types'; +import { NavModel, Team, OrgRole } from '../../types'; import { getMockTeam, getMultipleMockTeams } from './__mocks__/teamMocks'; +import { User } from 'app/core/services/context_srv'; const setup = (propOverrides?: object) => { const props: Props = { @@ -21,6 +22,11 @@ const setup = (propOverrides?: object) => { searchQuery: '', teamsCount: 0, hasFetched: false, + editorsCanAdmin: false, + signedInUser: { + id: 1, + orgRole: OrgRole.Viewer, + } as User, }; Object.assign(props, propOverrides); @@ -49,6 +55,42 @@ describe('Render', () => { expect(wrapper).toMatchSnapshot(); }); + + describe('when feature toggle editorsCanAdmin is turned on', () => { + describe('and signedin user is not viewer', () => { + it('should enable the new team button', () => { + const { wrapper } = setup({ + teams: getMultipleMockTeams(1), + teamsCount: 1, + hasFetched: true, + editorsCanAdmin: true, + signedInUser: { + id: 1, + orgRole: OrgRole.Editor, + } as User, + }); + + expect(wrapper).toMatchSnapshot(); + }); + }); + + describe('and signedin user is a viewer', () => { + it('should disable the new team button', () => { + const { wrapper } = setup({ + teams: getMultipleMockTeams(1), + teamsCount: 1, + hasFetched: true, + editorsCanAdmin: true, + signedInUser: { + id: 1, + orgRole: OrgRole.Viewer, + } as User, + }); + + expect(wrapper).toMatchSnapshot(); + }); + }); + }); }); describe('Life cycle', () => { diff --git a/public/app/features/teams/TeamList.tsx b/public/app/features/teams/TeamList.tsx index 60921a3378b..f603994b578 100644 --- a/public/app/features/teams/TeamList.tsx +++ b/public/app/features/teams/TeamList.tsx @@ -4,11 +4,13 @@ import { hot } from 'react-hot-loader'; import Page from 'app/core/components/Page/Page'; import { DeleteButton } from '@grafana/ui'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; -import { NavModel, Team } from 'app/types'; +import { NavModel, Team, OrgRole } from 'app/types'; import { loadTeams, deleteTeam, setSearchQuery } from './state/actions'; import { getSearchQuery, getTeams, getTeamsCount } from './state/selectors'; import { getNavModel } from 'app/core/selectors/navModel'; import { FilterInput } from 'app/core/components/FilterInput/FilterInput'; +import { config } from 'app/core/config'; +import { contextSrv, User } from 'app/core/services/context_srv'; export interface Props { navModel: NavModel; @@ -19,6 +21,8 @@ export interface Props { loadTeams: typeof loadTeams; deleteTeam: typeof deleteTeam; setSearchQuery: typeof setSearchQuery; + editorsCanAdmin?: boolean; + signedInUser?: User; } export class TeamList extends PureComponent { @@ -84,7 +88,8 @@ export class TeamList extends PureComponent { } renderTeamList() { - const { teams, searchQuery } = this.props; + const { teams, searchQuery, editorsCanAdmin, signedInUser } = this.props; + const disabledClass = editorsCanAdmin && signedInUser.orgRole === OrgRole.Viewer ? ' disabled' : ''; return ( <> @@ -101,7 +106,7 @@ export class TeamList extends PureComponent { @@ -152,6 +157,8 @@ function mapStateToProps(state) { searchQuery: getSearchQuery(state.teams), teamsCount: getTeamsCount(state.teams), hasFetched: state.teams.hasFetched, + editorsCanAdmin: config.editorsCanAdmin, // this makes the feature toggle mockable/controllable from tests, + signedInUser: contextSrv.user, // this makes the feature toggle mockable/controllable from tests, }; } diff --git a/public/app/features/teams/__snapshots__/TeamList.test.tsx.snap b/public/app/features/teams/__snapshots__/TeamList.test.tsx.snap index 76d13e6a3d6..d4dd2170bae 100644 --- a/public/app/features/teams/__snapshots__/TeamList.test.tsx.snap +++ b/public/app/features/teams/__snapshots__/TeamList.test.tsx.snap @@ -343,3 +343,253 @@ exports[`Render should render teams table 1`] = ` `; + +exports[`Render when feature toggle editorsCanAdmin is turned on and signedin user is a viewer should disable the new team button 1`] = ` + + +
+
+ +
+ +
+ + + + + + + + + + + + + + + + + +
+ + Name + + Email + + Members + +
+ + + + + + test-1 + + + + test-1@test.com + + + + 1 + + + +
+
+ + +`; + +exports[`Render when feature toggle editorsCanAdmin is turned on and signedin user is not viewer should enable the new team button 1`] = ` + + +
+
+ +
+ +
+ + + + + + + + + + + + + + + + + +
+ + Name + + Email + + Members + +
+ + + + + + test-1 + + + + test-1@test.com + + + + 1 + + + +
+
+ + +`; From d1481cac50fff0114c833c43be8905f5b505ee4f Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Thu, 14 Mar 2019 09:08:48 +0100 Subject: [PATCH 083/103] teams: refactored db code. --- pkg/services/sqlstore/migrations/team_mig.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pkg/services/sqlstore/migrations/team_mig.go b/pkg/services/sqlstore/migrations/team_mig.go index 1ec27ee926d..981a4865caa 100644 --- a/pkg/services/sqlstore/migrations/team_mig.go +++ b/pkg/services/sqlstore/migrations/team_mig.go @@ -56,8 +56,6 @@ func addTeamMigrations(mg *Migrator) { })) mg.AddMigration("Add column permission to team_member table", NewAddColumnMigration(teamMemberV1, &Column{ - Name: "permission", - Type: DB_BigInt, - Nullable: true, + Name: "permission", Type: DB_SmallInt, Nullable: true, })) } From 13ed10495a96927bb31e26fe72d7aec579c4ce66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Thu, 14 Mar 2019 09:49:35 +0100 Subject: [PATCH 084/103] teams: hide tabs settings and groupsync for non team admins --- .../app/features/teams/TeamMembers.test.tsx | 82 ---------------- public/app/features/teams/TeamMembers.tsx | 22 ++--- public/app/features/teams/TeamPages.test.tsx | 52 +++++++++- public/app/features/teams/TeamPages.tsx | 32 +++++-- .../__snapshots__/TeamPages.test.tsx.snap | 22 +++++ .../features/teams/state/selectors.test.ts | 96 ++++++++++++++++++- public/app/features/teams/state/selectors.ts | 18 +++- 7 files changed, 215 insertions(+), 109 deletions(-) diff --git a/public/app/features/teams/TeamMembers.test.tsx b/public/app/features/teams/TeamMembers.test.tsx index 02bfc4149f0..64e679f92ef 100644 --- a/public/app/features/teams/TeamMembers.test.tsx +++ b/public/app/features/teams/TeamMembers.test.tsx @@ -74,86 +74,4 @@ describe('Functions', () => { expect(instance.props.addTeamMember).toHaveBeenCalledWith(1); }); - - describe('isSignedInUserTeamAdmin', () => { - describe('when feature toggle editorsCanAdmin is turned off', () => { - it('should return true', () => { - const { instance } = setup({ editorsCanAdmin: false }); - - const result = instance.isSignedInUserTeamAdmin(); - - expect(result).toBe(true); - }); - }); - - describe('when feature toggle editorsCanAdmin is turned on', () => { - it('should return true if signed in user is grafanaAdmin', () => { - const members = getMockTeamMembers(5, 5); - const { instance } = setup({ - members, - editorsCanAdmin: true, - signedInUser: { - id: signedInUserId, - isGrafanaAdmin: true, - orgRole: OrgRole.Viewer, - }, - }); - - const result = instance.isSignedInUserTeamAdmin(); - - expect(result).toBe(true); - }); - - it('should return true if signed in user is org admin', () => { - const members = getMockTeamMembers(5, 5); - const { instance } = setup({ - members, - editorsCanAdmin: true, - signedInUser: { - id: signedInUserId, - isGrafanaAdmin: false, - orgRole: OrgRole.Admin, - }, - }); - - const result = instance.isSignedInUserTeamAdmin(); - - expect(result).toBe(true); - }); - - it('should return true if signed in user is team admin', () => { - const members = getMockTeamMembers(5, signedInUserId); - const { instance } = setup({ - members, - editorsCanAdmin: true, - signedInUser: { - id: signedInUserId, - isGrafanaAdmin: false, - orgRole: OrgRole.Viewer, - }, - }); - - const result = instance.isSignedInUserTeamAdmin(); - - expect(result).toBe(true); - }); - - it('should return false if signed in user is not grafanaAdmin, org admin or team admin', () => { - const members = getMockTeamMembers(5, 5); - const { instance } = setup({ - members, - editorsCanAdmin: true, - signedInUser: { - id: signedInUserId, - isGrafanaAdmin: false, - orgRole: OrgRole.Viewer, - }, - }); - - const result = instance.isSignedInUserTeamAdmin(); - - expect(result).toBe(false); - }); - }); - }); }); diff --git a/public/app/features/teams/TeamMembers.tsx b/public/app/features/teams/TeamMembers.tsx index db2b9024a1f..76fbdc87f1c 100644 --- a/public/app/features/teams/TeamMembers.tsx +++ b/public/app/features/teams/TeamMembers.tsx @@ -3,9 +3,9 @@ import { connect } from 'react-redux'; import SlideDown from 'app/core/components/Animations/SlideDown'; import { UserPicker } from 'app/core/components/Select/UserPicker'; import { TagBadge } from 'app/core/components/TagFilter/TagBadge'; -import { TeamMember, User, TeamPermissionLevel, OrgRole } from 'app/types'; +import { TeamMember, User } from 'app/types'; import { loadTeamMembers, addTeamMember, setSearchMemberQuery } from './state/actions'; -import { getSearchMemberQuery, getTeamMembers } from './state/selectors'; +import { getSearchMemberQuery, getTeamMembers, isSignedInUserTeamAdmin } from './state/selectors'; import { FilterInput } from 'app/core/components/FilterInput/FilterInput'; import { WithFeatureToggle } from 'app/core/components/WithFeatureToggle'; import { config } from 'app/core/config'; @@ -69,19 +69,11 @@ export class TeamMembers extends PureComponent { ); } - isSignedInUserTeamAdmin = (): boolean => { - const { members, editorsCanAdmin, signedInUser } = this.props; - const userInMembers = members.find(m => m.userId === signedInUser.id); - const isAdmin = signedInUser.isGrafanaAdmin || signedInUser.orgRole === OrgRole.Admin; - const userIsTeamAdmin = userInMembers && userInMembers.permission === TeamPermissionLevel.Admin; - const isSignedInUserTeamAdmin = isAdmin || userIsTeamAdmin; - - return isSignedInUserTeamAdmin || !editorsCanAdmin; - }; - render() { const { isAdding } = this.state; - const { searchMemberQuery, members, syncEnabled, editorsCanAdmin } = this.props; + const { searchMemberQuery, members, syncEnabled, editorsCanAdmin, signedInUser } = this.props; + const isTeamAdmin = isSignedInUserTeamAdmin({ members, editorsCanAdmin, signedInUser }); + return (
@@ -100,7 +92,7 @@ export class TeamMembers extends PureComponent { @@ -145,7 +137,7 @@ export class TeamMembers extends PureComponent { member={member} syncEnabled={syncEnabled} editorsCanAdmin={editorsCanAdmin} - signedInUserIsTeamAdmin={this.isSignedInUserTeamAdmin()} + signedInUserIsTeamAdmin={isTeamAdmin} /> ))} diff --git a/public/app/features/teams/TeamPages.test.tsx b/public/app/features/teams/TeamPages.test.tsx index 5d751f46989..74e9dfa24f2 100644 --- a/public/app/features/teams/TeamPages.test.tsx +++ b/public/app/features/teams/TeamPages.test.tsx @@ -1,8 +1,9 @@ import React from 'react'; import { shallow } from 'enzyme'; import { TeamPages, Props } from './TeamPages'; -import { NavModel, Team } from '../../types'; +import { NavModel, Team, TeamMember, OrgRole } from '../../types'; import { getMockTeam } from './__mocks__/teamMocks'; +import { User } from 'app/core/services/context_srv'; jest.mock('app/core/config', () => ({ buildInfo: { isEnterprise: true }, @@ -15,6 +16,13 @@ const setup = (propOverrides?: object) => { loadTeam: jest.fn(), pageName: 'members', team: {} as Team, + members: [] as TeamMember[], + editorsCanAdmin: false, + signedInUser: { + id: 1, + isGrafanaAdmin: false, + orgRole: OrgRole.Viewer, + } as User, }; Object.assign(props, propOverrides); @@ -65,4 +73,46 @@ describe('Render', () => { expect(wrapper).toMatchSnapshot(); }); + + describe('when feature toggle editorsCanAdmin is turned on', () => { + it('should render settings page if user is team admin', () => { + const { wrapper } = setup({ + team: getMockTeam(), + pageName: 'settings', + preferences: { + homeDashboardId: 1, + theme: 'Default', + timezone: 'Default', + }, + editorsCanAdmin: true, + signedInUser: { + id: 1, + isGrafanaAdmin: false, + orgRole: OrgRole.Admin, + } as User, + }); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should not render settings page if user is team member', () => { + const { wrapper } = setup({ + team: getMockTeam(), + pageName: 'settings', + preferences: { + homeDashboardId: 1, + theme: 'Default', + timezone: 'Default', + }, + editorsCanAdmin: true, + signedInUser: { + id: 1, + isGrafanaAdmin: false, + orgRole: OrgRole.Viewer, + } as User, + }); + + expect(wrapper).toMatchSnapshot(); + }); + }); }); diff --git a/public/app/features/teams/TeamPages.tsx b/public/app/features/teams/TeamPages.tsx index 15adc8b3856..a64fe61612d 100644 --- a/public/app/features/teams/TeamPages.tsx +++ b/public/app/features/teams/TeamPages.tsx @@ -7,12 +7,13 @@ import Page from 'app/core/components/Page/Page'; import TeamMembers from './TeamMembers'; import TeamSettings from './TeamSettings'; import TeamGroupSync from './TeamGroupSync'; -import { NavModel, Team } from 'app/types'; +import { NavModel, Team, TeamMember } from 'app/types'; import { loadTeam } from './state/actions'; -import { getTeam } from './state/selectors'; +import { getTeam, getTeamMembers, isSignedInUserTeamAdmin } from './state/selectors'; import { getTeamLoadingNav } from './state/navModel'; import { getNavModel } from 'app/core/selectors/navModel'; import { getRouteParamsId, getRouteParamsPage } from '../../core/selectors/location'; +import { contextSrv, User } from 'app/core/services/context_srv'; export interface Props { team: Team; @@ -20,6 +21,9 @@ export interface Props { teamId: number; pageName: string; navModel: NavModel; + members?: TeamMember[]; + editorsCanAdmin?: boolean; + signedInUser?: User; } interface State { @@ -61,7 +65,15 @@ export class TeamPages extends PureComponent { return _.includes(pages, currentPage) ? currentPage : pages[0]; } - renderPage() { + hideTabsFromNonTeamAdmin = (navModel: NavModel, isSignedInUserTeamAdmin: boolean) => { + if (!isSignedInUserTeamAdmin && navModel.main && navModel.main.children) { + navModel.main.children = navModel.main.children.filter(navItem => navItem.text === 'Members'); + } + + return navModel; + }; + + renderPage(isSignedInUserTeamAdmin: boolean) { const { isSyncEnabled } = this.state; const currentPage = this.getCurrentPage(); @@ -70,21 +82,22 @@ export class TeamPages extends PureComponent { return ; case PageTypes.Settings: - return ; + return isSignedInUserTeamAdmin && ; case PageTypes.GroupSync: - return isSyncEnabled && ; + return isSignedInUserTeamAdmin && isSyncEnabled && ; } return null; } render() { - const { team, navModel } = this.props; + const { team, navModel, members, editorsCanAdmin, signedInUser } = this.props; + const isTeamAdmin = isSignedInUserTeamAdmin({ members, editorsCanAdmin, signedInUser }); return ( - + - {team && Object.keys(team).length !== 0 && this.renderPage()} + {team && Object.keys(team).length !== 0 && this.renderPage(isTeamAdmin)} ); @@ -101,6 +114,9 @@ function mapStateToProps(state) { teamId: teamId, pageName: pageName, team: getTeam(state.team, teamId), + members: getTeamMembers(state.team), + editorsCanAdmin: config.editorsCanAdmin, // this makes the feature toggle mockable/controllable from tests, + signedInUser: contextSrv.user, // this makes the feature toggle mockable/controllable from tests, }; } diff --git a/public/app/features/teams/__snapshots__/TeamPages.test.tsx.snap b/public/app/features/teams/__snapshots__/TeamPages.test.tsx.snap index 70f37cea4c5..6fdf7d063ba 100644 --- a/public/app/features/teams/__snapshots__/TeamPages.test.tsx.snap +++ b/public/app/features/teams/__snapshots__/TeamPages.test.tsx.snap @@ -47,3 +47,25 @@ exports[`Render should render settings and preferences page 1`] = ` `; + +exports[`Render when feature toggle editorsCanAdmin is turned on should not render settings page if user is team member 1`] = ` + + + +`; + +exports[`Render when feature toggle editorsCanAdmin is turned on should render settings page if user is team admin 1`] = ` + + + + + +`; diff --git a/public/app/features/teams/state/selectors.test.ts b/public/app/features/teams/state/selectors.test.ts index 1721437c929..5d9981e0403 100644 --- a/public/app/features/teams/state/selectors.test.ts +++ b/public/app/features/teams/state/selectors.test.ts @@ -1,6 +1,7 @@ -import { getTeam, getTeamMembers, getTeams } from './selectors'; +import { getTeam, getTeamMembers, getTeams, isSignedInUserTeamAdmin, Config } from './selectors'; import { getMockTeam, getMockTeamMembers, getMultipleMockTeams } from '../__mocks__/teamMocks'; -import { Team, TeamGroup, TeamsState, TeamState } from '../../../types'; +import { Team, TeamGroup, TeamsState, TeamState, OrgRole } from '../../../types'; +import { User } from 'app/core/services/context_srv'; describe('Teams selectors', () => { describe('Get teams', () => { @@ -55,3 +56,94 @@ describe('Team selectors', () => { }); }); }); + +const signedInUserId = 1; + +const setup = (configOverrides?: Partial) => { + const defaultConfig: Config = { + editorsCanAdmin: false, + members: getMockTeamMembers(5, 5), + signedInUser: { + id: signedInUserId, + isGrafanaAdmin: false, + orgRole: OrgRole.Viewer, + } as User, + }; + + return { ...defaultConfig, ...configOverrides }; +}; + +describe('isSignedInUserTeamAdmin', () => { + describe('when feature toggle editorsCanAdmin is turned off', () => { + it('should return true', () => { + const config = setup(); + + const result = isSignedInUserTeamAdmin(config); + + expect(result).toBe(true); + }); + }); + + describe('when feature toggle editorsCanAdmin is turned on', () => { + it('should return true if signed in user is grafanaAdmin', () => { + const config = setup({ + editorsCanAdmin: true, + signedInUser: { + id: signedInUserId, + isGrafanaAdmin: true, + orgRole: OrgRole.Viewer, + } as User, + }); + + const result = isSignedInUserTeamAdmin(config); + + expect(result).toBe(true); + }); + + it('should return true if signed in user is org admin', () => { + const config = setup({ + editorsCanAdmin: true, + signedInUser: { + id: signedInUserId, + isGrafanaAdmin: false, + orgRole: OrgRole.Admin, + } as User, + }); + + const result = isSignedInUserTeamAdmin(config); + + expect(result).toBe(true); + }); + + it('should return true if signed in user is team admin', () => { + const config = setup({ + members: getMockTeamMembers(5, signedInUserId), + editorsCanAdmin: true, + signedInUser: { + id: signedInUserId, + isGrafanaAdmin: false, + orgRole: OrgRole.Viewer, + } as User, + }); + + const result = isSignedInUserTeamAdmin(config); + + expect(result).toBe(true); + }); + + it('should return false if signed in user is not grafanaAdmin, org admin or team admin', () => { + const config = setup({ + editorsCanAdmin: true, + signedInUser: { + id: signedInUserId, + isGrafanaAdmin: false, + orgRole: OrgRole.Viewer, + } as User, + }); + + const result = isSignedInUserTeamAdmin(config); + + expect(result).toBe(false); + }); + }); +}); diff --git a/public/app/features/teams/state/selectors.ts b/public/app/features/teams/state/selectors.ts index 9201993bf0d..d8b8220bb44 100644 --- a/public/app/features/teams/state/selectors.ts +++ b/public/app/features/teams/state/selectors.ts @@ -1,4 +1,5 @@ -import { Team, TeamsState, TeamState } from 'app/types'; +import { Team, TeamsState, TeamState, TeamMember, OrgRole, TeamPermissionLevel } from 'app/types'; +import { User } from 'app/core/services/context_srv'; export const getSearchQuery = (state: TeamsState) => state.searchQuery; export const getSearchMemberQuery = (state: TeamState) => state.searchMemberQuery; @@ -28,3 +29,18 @@ export const getTeamMembers = (state: TeamState) => { return regex.test(member.login) || regex.test(member.email); }); }; + +export interface Config { + members: TeamMember[]; + editorsCanAdmin: boolean; + signedInUser: User; +} + +export const isSignedInUserTeamAdmin = (config: Config): boolean => { + const userInMembers = config.members.find(m => m.userId === config.signedInUser.id); + const isAdmin = config.signedInUser.isGrafanaAdmin || config.signedInUser.orgRole === OrgRole.Admin; + const userIsTeamAdmin = userInMembers && userInMembers.permission === TeamPermissionLevel.Admin; + const isSignedInUserTeamAdmin = isAdmin || userIsTeamAdmin; + + return isSignedInUserTeamAdmin || !config.editorsCanAdmin; +}; From b796027bc6854d663f0789c34324d99146f3d390 Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Thu, 14 Mar 2019 09:14:35 +0100 Subject: [PATCH 085/103] teams: refactor. --- pkg/services/sqlstore/team.go | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/pkg/services/sqlstore/team.go b/pkg/services/sqlstore/team.go index d76d8401499..e52983b5918 100644 --- a/pkg/services/sqlstore/team.go +++ b/pkg/services/sqlstore/team.go @@ -259,28 +259,21 @@ func AddTeamMember(cmd *m.AddTeamMemberCommand) error { func UpdateTeamMember(cmd *m.UpdateTeamMemberCommand) error { return inTransaction(func(sess *DBSession) error { rawSql := `SELECT * FROM team_member WHERE org_id=? and team_id=? and user_id=?` - var member m.TeamMember exists, err := sess.SQL(rawSql, cmd.OrgId, cmd.TeamId, cmd.UserId).Get(&member) if err != nil { return err } - if !exists { return m.ErrTeamMemberNotFound } if cmd.ProtectLastAdmin { - lastAdmin, err := isLastAdmin(sess, cmd.OrgId, cmd.TeamId, cmd.UserId) + _, err := isLastAdmin(sess, cmd.OrgId, cmd.TeamId, cmd.UserId) if err != nil { return err } - - if lastAdmin { - return m.ErrLastTeamAdmin - } - } if cmd.Permission != m.PERMISSION_ADMIN { // make sure we don't get invalid permission levels in store @@ -302,15 +295,10 @@ func RemoveTeamMember(cmd *m.RemoveTeamMemberCommand) error { } if cmd.ProtectLastAdmin { - lastAdmin, err := isLastAdmin(sess, cmd.OrgId, cmd.TeamId, cmd.UserId) + _, err := isLastAdmin(sess, cmd.OrgId, cmd.TeamId, cmd.UserId) if err != nil { return err } - - if lastAdmin { - return m.ErrLastTeamAdmin - } - } var rawSql = "DELETE FROM team_member WHERE org_id=? and team_id=? and user_id=?" @@ -344,7 +332,7 @@ func isLastAdmin(sess *DBSession, orgId int64, teamId int64, userId int64) (bool } if isAdmin && len(userIds) == 1 { - return true, nil + return true, m.ErrLastTeamAdmin } return false, err From 9f8e43916dbdc49c69b6a2bdcfe1232d827ce522 Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Thu, 14 Mar 2019 09:18:06 +0100 Subject: [PATCH 086/103] permissions: refactor. --- pkg/api/dashboard.go | 3 +-- pkg/api/folder.go | 3 +-- pkg/services/dashboards/acl_service.go | 14 +------------- 3 files changed, 3 insertions(+), 17 deletions(-) diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 14a1e3baf32..e7fcf5d4355 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -278,9 +278,8 @@ func (hs *HTTPServer) PostDashboard(c *m.ReqContext, cmd m.SaveDashboardCommand) } if hs.Cfg.EditorsCanAdmin && newDashboard { - aclService := dashboards.NewAclService() inFolder := cmd.FolderId > 0 - err := aclService.MakeUserAdmin(cmd.OrgId, cmd.UserId, dashboard.Id, !inFolder) + err := dashboards.MakeUserAdmin(cmd.OrgId, cmd.UserId, dashboard.Id, !inFolder) if err != nil { hs.log.Error("Could not make user admin", "dashboard", cmd.Result.Title, "user", c.SignedInUser.UserId, "error", err) return Error(500, "Failed to make user admin of dashboard", err) diff --git a/pkg/api/folder.go b/pkg/api/folder.go index 66c640f96e8..4b64fc1139f 100644 --- a/pkg/api/folder.go +++ b/pkg/api/folder.go @@ -62,8 +62,7 @@ func (hs *HTTPServer) CreateFolder(c *m.ReqContext, cmd m.CreateFolderCommand) R } if hs.Cfg.EditorsCanAdmin { - aclService := dashboards.NewAclService() - if err := aclService.MakeUserAdmin(c.OrgId, c.SignedInUser.UserId, cmd.Result.Id, true); err != nil { + if err := dashboards.MakeUserAdmin(c.OrgId, c.SignedInUser.UserId, cmd.Result.Id, true); err != nil { hs.log.Error("Could not make user admin", "folder", cmd.Result.Title, "user", c.SignedInUser.UserId, "error", err) return Error(500, "Failed to make user admin of folder", err) } diff --git a/pkg/services/dashboards/acl_service.go b/pkg/services/dashboards/acl_service.go index dae3ec1372b..6158b190d68 100644 --- a/pkg/services/dashboards/acl_service.go +++ b/pkg/services/dashboards/acl_service.go @@ -2,23 +2,11 @@ package dashboards import ( "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/log" "github.com/grafana/grafana/pkg/models" "time" ) -// NewService factory for creating a new dashboard service -var NewAclService = func() *AclService { - return &AclService{ - log: log.New("dashboard-acl-service"), - } -} - -type AclService struct { - log log.Logger -} - -func (as *AclService) MakeUserAdmin(orgId int64, userId int64, dashboardId int64, setViewAndEditPermissions bool) error { +func MakeUserAdmin(orgId int64, userId int64, dashboardId int64, setViewAndEditPermissions bool) error { rtEditor := models.ROLE_EDITOR rtViewer := models.ROLE_VIEWER From 9f33f0034342790bce1d289a3d4e9a3965d26587 Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Thu, 14 Mar 2019 09:37:56 +0100 Subject: [PATCH 087/103] teams: refactor. --- pkg/services/sqlstore/team.go | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/pkg/services/sqlstore/team.go b/pkg/services/sqlstore/team.go index e52983b5918..b561f2e00f6 100644 --- a/pkg/services/sqlstore/team.go +++ b/pkg/services/sqlstore/team.go @@ -255,19 +255,28 @@ func AddTeamMember(cmd *m.AddTeamMemberCommand) error { }) } +func getTeamMember(sess *DBSession, orgId int64, teamId int64, userId int64) (m.TeamMember, error) { + rawSql := `SELECT * FROM team_member WHERE org_id=? and team_id=? and user_id=?` + var member m.TeamMember + exists, err := sess.SQL(rawSql, orgId, teamId, userId).Get(&member) + + if err != nil { + return member, err + } + if !exists { + return member, m.ErrTeamMemberNotFound + } + + return member, nil +} + // UpdateTeamMember updates a team member func UpdateTeamMember(cmd *m.UpdateTeamMemberCommand) error { return inTransaction(func(sess *DBSession) error { - rawSql := `SELECT * FROM team_member WHERE org_id=? and team_id=? and user_id=?` - var member m.TeamMember - exists, err := sess.SQL(rawSql, cmd.OrgId, cmd.TeamId, cmd.UserId).Get(&member) - + member, err := getTeamMember(sess, cmd.OrgId, cmd.TeamId, cmd.UserId) if err != nil { return err } - if !exists { - return m.ErrTeamMemberNotFound - } if cmd.ProtectLastAdmin { _, err := isLastAdmin(sess, cmd.OrgId, cmd.TeamId, cmd.UserId) From 6589a4e55f9ed120ec8d36f461cc7ea07a561074 Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Thu, 14 Mar 2019 10:02:46 +0100 Subject: [PATCH 088/103] teams: better names for api permissions. --- pkg/api/api.go | 8 ++++---- pkg/middleware/auth.go | 7 ++++++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/pkg/api/api.go b/pkg/api/api.go index 9d1151a757e..32213e3a58a 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -14,7 +14,7 @@ func (hs *HTTPServer) registerRoutes() { reqGrafanaAdmin := middleware.ReqGrafanaAdmin reqEditorRole := middleware.ReqEditorRole reqOrgAdmin := middleware.ReqOrgAdmin - reqAdminOrCanAdmin := middleware.AdminOrCanAdmin(hs.Cfg.EditorsCanAdmin) + reqCanAccessTeams := middleware.AdminOrFeatureEnabled(hs.Cfg.EditorsCanAdmin) redirectFromLegacyDashboardURL := middleware.RedirectFromLegacyDashboardURL() redirectFromLegacyDashboardSoloURL := middleware.RedirectFromLegacyDashboardSoloURL() quota := middleware.Quota(hs.QuotaService) @@ -42,8 +42,8 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/org/users", reqOrgAdmin, hs.Index) r.Get("/org/users/new", reqOrgAdmin, hs.Index) r.Get("/org/users/invite", reqOrgAdmin, hs.Index) - r.Get("/org/teams", reqAdminOrCanAdmin, hs.Index) - r.Get("/org/teams/*", reqAdminOrCanAdmin, hs.Index) + r.Get("/org/teams", reqCanAccessTeams, hs.Index) + r.Get("/org/teams/*", reqCanAccessTeams, hs.Index) r.Get("/org/apikeys/", reqOrgAdmin, hs.Index) r.Get("/dashboard/import/", reqSignedIn, hs.Index) r.Get("/configuration", reqGrafanaAdmin, hs.Index) @@ -163,7 +163,7 @@ func (hs *HTTPServer) registerRoutes() { teamsRoute.Delete("/:teamId/members/:userId", Wrap(hs.RemoveTeamMember)) teamsRoute.Get("/:teamId/preferences", Wrap(GetTeamPreferences)) teamsRoute.Put("/:teamId/preferences", bind(dtos.UpdatePrefsCmd{}), Wrap(UpdateTeamPreferences)) - }, reqAdminOrCanAdmin) + }, reqCanAccessTeams) // team without requirement of user to be org admin apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) { diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index 8c1e5e04ae7..c00241ea34c 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -87,7 +87,12 @@ func Auth(options *AuthOptions) macaron.Handler { } } -func AdminOrCanAdmin(enabled bool) macaron.Handler { +// AdminOrFeatureEnabled creates a middleware that allows access +// if the signed in user is either an Org Admin or if the +// feature flag is enabled. +// Intended for when feature flags open up access to APIs that +// are otherwise only available to admins. +func AdminOrFeatureEnabled(enabled bool) macaron.Handler { return func(c *m.ReqContext) { if c.OrgRole == m.ROLE_ADMIN { return From adf0390b2c8f1beaaf0d904b884f59ff160310ee Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Thu, 14 Mar 2019 12:10:10 +0100 Subject: [PATCH 089/103] teams: local access to bus, moving away from dep on global. --- pkg/api/api.go | 12 ++++++------ pkg/api/team.go | 20 ++++++++++---------- pkg/api/team_members.go | 16 ++++++++-------- pkg/services/teamguardian/team.go | 2 +- pkg/services/teamguardian/teams_test.go | 8 ++++---- 5 files changed, 29 insertions(+), 29 deletions(-) diff --git a/pkg/api/api.go b/pkg/api/api.go index 32213e3a58a..86bc83f558b 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -155,14 +155,14 @@ func (hs *HTTPServer) registerRoutes() { // team (admin permission required) apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) { teamsRoute.Post("/", bind(m.CreateTeamCommand{}), Wrap(hs.CreateTeam)) - teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), Wrap(UpdateTeam)) - teamsRoute.Delete("/:teamId", Wrap(DeleteTeamByID)) + teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), Wrap(hs.UpdateTeam)) + teamsRoute.Delete("/:teamId", Wrap(hs.DeleteTeamByID)) teamsRoute.Get("/:teamId/members", Wrap(GetTeamMembers)) - teamsRoute.Post("/:teamId/members", bind(m.AddTeamMemberCommand{}), Wrap(AddTeamMember)) - teamsRoute.Put("/:teamId/members/:userId", bind(m.UpdateTeamMemberCommand{}), Wrap(UpdateTeamMember)) + teamsRoute.Post("/:teamId/members", bind(m.AddTeamMemberCommand{}), Wrap(hs.AddTeamMember)) + teamsRoute.Put("/:teamId/members/:userId", bind(m.UpdateTeamMemberCommand{}), Wrap(hs.UpdateTeamMember)) teamsRoute.Delete("/:teamId/members/:userId", Wrap(hs.RemoveTeamMember)) - teamsRoute.Get("/:teamId/preferences", Wrap(GetTeamPreferences)) - teamsRoute.Put("/:teamId/preferences", bind(dtos.UpdatePrefsCmd{}), Wrap(UpdateTeamPreferences)) + teamsRoute.Get("/:teamId/preferences", Wrap(hs.GetTeamPreferences)) + teamsRoute.Put("/:teamId/preferences", bind(dtos.UpdatePrefsCmd{}), Wrap(hs.UpdateTeamPreferences)) }, reqCanAccessTeams) // team without requirement of user to be org admin diff --git a/pkg/api/team.go b/pkg/api/team.go index fd34c0ab720..cb0ff2f25a0 100644 --- a/pkg/api/team.go +++ b/pkg/api/team.go @@ -43,15 +43,15 @@ func (hs *HTTPServer) CreateTeam(c *m.ReqContext, cmd m.CreateTeamCommand) Respo } // PUT /api/teams/:teamId -func UpdateTeam(c *m.ReqContext, cmd m.UpdateTeamCommand) Response { +func (hs *HTTPServer) UpdateTeam(c *m.ReqContext, cmd m.UpdateTeamCommand) Response { cmd.OrgId = c.OrgId cmd.Id = c.ParamsInt64(":teamId") - if err := teamguardian.CanAdmin(cmd.OrgId, cmd.Id, c.SignedInUser); err != nil { + if err := teamguardian.CanAdmin(hs.Bus, cmd.OrgId, cmd.Id, c.SignedInUser); err != nil { return Error(403, "Not allowed to update team", err) } - if err := bus.Dispatch(&cmd); err != nil { + if err := hs.Bus.Dispatch(&cmd); err != nil { if err == m.ErrTeamNameTaken { return Error(400, "Team name taken", err) } @@ -62,16 +62,16 @@ func UpdateTeam(c *m.ReqContext, cmd m.UpdateTeamCommand) Response { } // DELETE /api/teams/:teamId -func DeleteTeamByID(c *m.ReqContext) Response { +func (hs *HTTPServer) DeleteTeamByID(c *m.ReqContext) Response { orgId := c.OrgId teamId := c.ParamsInt64(":teamId") user := c.SignedInUser - if err := teamguardian.CanAdmin(orgId, teamId, user); err != nil { + if err := teamguardian.CanAdmin(hs.Bus, orgId, teamId, user); err != nil { return Error(403, "Not allowed to delete team", err) } - if err := bus.Dispatch(&m.DeleteTeamCommand{OrgId: orgId, Id: teamId}); err != nil { + if err := hs.Bus.Dispatch(&m.DeleteTeamCommand{OrgId: orgId, Id: teamId}); err != nil { if err == m.ErrTeamNotFound { return Error(404, "Failed to delete Team. ID not found", nil) } @@ -136,11 +136,11 @@ func GetTeamByID(c *m.ReqContext) Response { } // GET /api/teams/:teamId/preferences -func GetTeamPreferences(c *m.ReqContext) Response { +func (hs *HTTPServer) GetTeamPreferences(c *m.ReqContext) Response { teamId := c.ParamsInt64(":teamId") orgId := c.OrgId - if err := teamguardian.CanAdmin(orgId, teamId, c.SignedInUser); err != nil { + if err := teamguardian.CanAdmin(hs.Bus, orgId, teamId, c.SignedInUser); err != nil { return Error(403, "Not allowed to view team preferences.", err) } @@ -148,11 +148,11 @@ func GetTeamPreferences(c *m.ReqContext) Response { } // PUT /api/teams/:teamId/preferences -func UpdateTeamPreferences(c *m.ReqContext, dtoCmd dtos.UpdatePrefsCmd) Response { +func (hs *HTTPServer) UpdateTeamPreferences(c *m.ReqContext, dtoCmd dtos.UpdatePrefsCmd) Response { teamId := c.ParamsInt64(":teamId") orgId := c.OrgId - if err := teamguardian.CanAdmin(orgId, teamId, c.SignedInUser); err != nil { + if err := teamguardian.CanAdmin(hs.Bus, orgId, teamId, c.SignedInUser); err != nil { return Error(403, "Not allowed to update team preferences.", err) } diff --git a/pkg/api/team_members.go b/pkg/api/team_members.go index 1674cc120ce..54a4d8220e5 100644 --- a/pkg/api/team_members.go +++ b/pkg/api/team_members.go @@ -30,15 +30,15 @@ func GetTeamMembers(c *m.ReqContext) Response { } // POST /api/teams/:teamId/members -func AddTeamMember(c *m.ReqContext, cmd m.AddTeamMemberCommand) Response { +func (hs *HTTPServer) AddTeamMember(c *m.ReqContext, cmd m.AddTeamMemberCommand) Response { cmd.OrgId = c.OrgId cmd.TeamId = c.ParamsInt64(":teamId") - if err := teamguardian.CanAdmin(cmd.OrgId, cmd.TeamId, c.SignedInUser); err != nil { + if err := teamguardian.CanAdmin(hs.Bus, cmd.OrgId, cmd.TeamId, c.SignedInUser); err != nil { return Error(403, "Not allowed to add team member", err) } - if err := bus.Dispatch(&cmd); err != nil { + if err := hs.Bus.Dispatch(&cmd); err != nil { if err == m.ErrTeamNotFound { return Error(404, "Team not found", nil) } @@ -56,11 +56,11 @@ func AddTeamMember(c *m.ReqContext, cmd m.AddTeamMemberCommand) Response { } // PUT /:teamId/members/:userId -func UpdateTeamMember(c *m.ReqContext, cmd m.UpdateTeamMemberCommand) Response { +func (hs *HTTPServer) UpdateTeamMember(c *m.ReqContext, cmd m.UpdateTeamMemberCommand) Response { teamId := c.ParamsInt64(":teamId") orgId := c.OrgId - if err := teamguardian.CanAdmin(orgId, teamId, c.SignedInUser); err != nil { + if err := teamguardian.CanAdmin(hs.Bus, orgId, teamId, c.SignedInUser); err != nil { return Error(403, "Not allowed to update team member", err) } @@ -72,7 +72,7 @@ func UpdateTeamMember(c *m.ReqContext, cmd m.UpdateTeamMemberCommand) Response { cmd.UserId = c.ParamsInt64(":userId") cmd.OrgId = orgId - if err := bus.Dispatch(&cmd); err != nil { + if err := hs.Bus.Dispatch(&cmd); err != nil { if err == m.ErrTeamMemberNotFound { return Error(404, "Team member not found.", nil) } @@ -87,7 +87,7 @@ func (hs *HTTPServer) RemoveTeamMember(c *m.ReqContext) Response { teamId := c.ParamsInt64(":teamId") userId := c.ParamsInt64(":userId") - if err := teamguardian.CanAdmin(orgId, teamId, c.SignedInUser); err != nil { + if err := teamguardian.CanAdmin(hs.Bus, orgId, teamId, c.SignedInUser); err != nil { return Error(403, "Not allowed to remove team member", err) } @@ -96,7 +96,7 @@ func (hs *HTTPServer) RemoveTeamMember(c *m.ReqContext) Response { protectLastAdmin = true } - if err := bus.Dispatch(&m.RemoveTeamMemberCommand{OrgId: orgId, TeamId: teamId, UserId: userId, ProtectLastAdmin: protectLastAdmin}); err != nil { + if err := hs.Bus.Dispatch(&m.RemoveTeamMemberCommand{OrgId: orgId, TeamId: teamId, UserId: userId, ProtectLastAdmin: protectLastAdmin}); err != nil { if err == m.ErrTeamNotFound { return Error(404, "Team not found", nil) } diff --git a/pkg/services/teamguardian/team.go b/pkg/services/teamguardian/team.go index 6fddc318f5e..70053d12da1 100644 --- a/pkg/services/teamguardian/team.go +++ b/pkg/services/teamguardian/team.go @@ -5,7 +5,7 @@ import ( m "github.com/grafana/grafana/pkg/models" ) -func CanAdmin(orgId int64, teamId int64, user *m.SignedInUser) error { +func CanAdmin(bus bus.Bus, orgId int64, teamId int64, user *m.SignedInUser) error { if user.OrgRole == m.ROLE_ADMIN { return nil } diff --git a/pkg/services/teamguardian/teams_test.go b/pkg/services/teamguardian/teams_test.go index 2ec86769a29..8af69569620 100644 --- a/pkg/services/teamguardian/teams_test.go +++ b/pkg/services/teamguardian/teams_test.go @@ -33,7 +33,7 @@ func TestUpdateTeam(t *testing.T) { return nil }) - err := CanAdmin(testTeam.OrgId, testTeam.Id, &editor) + err := CanAdmin(bus.GetBus(), testTeam.OrgId, testTeam.Id, &editor) So(err, ShouldEqual, m.ErrNotAllowedToUpdateTeam) }) }) @@ -50,7 +50,7 @@ func TestUpdateTeam(t *testing.T) { return nil }) - err := CanAdmin(testTeam.OrgId, testTeam.Id, &editor) + err := CanAdmin(bus.GetBus(), testTeam.OrgId, testTeam.Id, &editor) So(err, ShouldBeNil) }) }) @@ -72,14 +72,14 @@ func TestUpdateTeam(t *testing.T) { return nil }) - err := CanAdmin(testTeamOtherOrg.OrgId, testTeamOtherOrg.Id, &editor) + err := CanAdmin(bus.GetBus(), testTeamOtherOrg.OrgId, testTeamOtherOrg.Id, &editor) So(err, ShouldEqual, m.ErrNotAllowedToUpdateTeamInDifferentOrg) }) }) Convey("Given an org admin and a team", func() { Convey("Should be able to update the team", func() { - err := CanAdmin(testTeam.OrgId, testTeam.Id, &admin) + err := CanAdmin(bus.GetBus(), testTeam.OrgId, testTeam.Id, &admin) So(err, ShouldBeNil) }) }) From a615b78f8a17b193e802628f58a6ff92f6fc63b1 Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Thu, 14 Mar 2019 12:18:07 +0100 Subject: [PATCH 090/103] permissions: removes global access to bus from MakeUserAdmin. --- pkg/api/dashboard.go | 2 +- pkg/api/folder.go | 2 +- pkg/api/team.go | 4 ++-- pkg/services/dashboards/acl_service.go | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index e7fcf5d4355..c47e8f31ccc 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -279,7 +279,7 @@ func (hs *HTTPServer) PostDashboard(c *m.ReqContext, cmd m.SaveDashboardCommand) if hs.Cfg.EditorsCanAdmin && newDashboard { inFolder := cmd.FolderId > 0 - err := dashboards.MakeUserAdmin(cmd.OrgId, cmd.UserId, dashboard.Id, !inFolder) + err := dashboards.MakeUserAdmin(hs.Bus, cmd.OrgId, cmd.UserId, dashboard.Id, !inFolder) if err != nil { hs.log.Error("Could not make user admin", "dashboard", cmd.Result.Title, "user", c.SignedInUser.UserId, "error", err) return Error(500, "Failed to make user admin of dashboard", err) diff --git a/pkg/api/folder.go b/pkg/api/folder.go index 4b64fc1139f..0a9a2671071 100644 --- a/pkg/api/folder.go +++ b/pkg/api/folder.go @@ -62,7 +62,7 @@ func (hs *HTTPServer) CreateFolder(c *m.ReqContext, cmd m.CreateFolderCommand) R } if hs.Cfg.EditorsCanAdmin { - if err := dashboards.MakeUserAdmin(c.OrgId, c.SignedInUser.UserId, cmd.Result.Id, true); err != nil { + if err := dashboards.MakeUserAdmin(hs.Bus, c.OrgId, c.SignedInUser.UserId, cmd.Result.Id, true); err != nil { hs.log.Error("Could not make user admin", "folder", cmd.Result.Title, "user", c.SignedInUser.UserId, "error", err) return Error(500, "Failed to make user admin of folder", err) } diff --git a/pkg/api/team.go b/pkg/api/team.go index cb0ff2f25a0..ecfd8028c1b 100644 --- a/pkg/api/team.go +++ b/pkg/api/team.go @@ -16,7 +16,7 @@ func (hs *HTTPServer) CreateTeam(c *m.ReqContext, cmd m.CreateTeamCommand) Respo return Error(403, "Not allowed to create team.", nil) } - if err := bus.Dispatch(&cmd); err != nil { + if err := hs.Bus.Dispatch(&cmd); err != nil { if err == m.ErrTeamNameTaken { return Error(409, "Team name taken", err) } @@ -31,7 +31,7 @@ func (hs *HTTPServer) CreateTeam(c *m.ReqContext, cmd m.CreateTeamCommand) Respo Permission: m.PERMISSION_ADMIN, } - if err := bus.Dispatch(&addMemberCmd); err != nil { + if err := hs.Bus.Dispatch(&addMemberCmd); err != nil { c.Logger.Error("Could not add creator to team.", "error", err) } } diff --git a/pkg/services/dashboards/acl_service.go b/pkg/services/dashboards/acl_service.go index 6158b190d68..864fbb80a6b 100644 --- a/pkg/services/dashboards/acl_service.go +++ b/pkg/services/dashboards/acl_service.go @@ -6,7 +6,7 @@ import ( "time" ) -func MakeUserAdmin(orgId int64, userId int64, dashboardId int64, setViewAndEditPermissions bool) error { +func MakeUserAdmin(bus bus.Bus, orgId int64, userId int64, dashboardId int64, setViewAndEditPermissions bool) error { rtEditor := models.ROLE_EDITOR rtViewer := models.ROLE_VIEWER From 53c74fa2f56a4b1b029be8a71a3ec882d88b86e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Thu, 14 Mar 2019 14:24:13 +0100 Subject: [PATCH 091/103] teams: refactor so that you can only delete teams if you are team admin --- pkg/models/team.go | 13 ++++++----- pkg/services/sqlstore/team.go | 19 +++++++++++++--- public/app/features/teams/TeamList.tsx | 7 ++++-- .../app/features/teams/__mocks__/teamMocks.ts | 2 ++ .../__snapshots__/TeamList.test.tsx.snap | 7 ++++++ public/app/features/teams/state/navModel.ts | 3 ++- public/app/features/teams/state/selectors.ts | 22 +++++++++++++++---- public/app/types/teams.ts | 3 +++ 8 files changed, 60 insertions(+), 16 deletions(-) diff --git a/pkg/models/team.go b/pkg/models/team.go index 5b659331601..bc8cbba8100 100644 --- a/pkg/models/team.go +++ b/pkg/models/team.go @@ -73,12 +73,13 @@ type SearchTeamsQuery struct { } type TeamDTO struct { - Id int64 `json:"id"` - OrgId int64 `json:"orgId"` - Name string `json:"name"` - Email string `json:"email"` - AvatarUrl string `json:"avatarUrl"` - MemberCount int64 `json:"memberCount"` + Id int64 `json:"id"` + OrgId int64 `json:"orgId"` + Name string `json:"name"` + Email string `json:"email"` + AvatarUrl string `json:"avatarUrl"` + MemberCount int64 `json:"memberCount"` + Permission PermissionType `json:"permission"` } type SearchTeamQueryResult struct { diff --git a/pkg/services/sqlstore/team.go b/pkg/services/sqlstore/team.go index b561f2e00f6..03fd2df78fc 100644 --- a/pkg/services/sqlstore/team.go +++ b/pkg/services/sqlstore/team.go @@ -23,13 +23,25 @@ func init() { bus.AddHandler("sql", GetTeamMembers) } +func getTeamSearchSqlBase() string { + return `SELECT + team.id as id, + team.org_id, + team.name as name, + team.email as email, + (SELECT COUNT(*) from team_member where team_member.team_id = team.id) as member_count, + team_member.permission + FROM team as team + INNER JOIN team_member on team.id = team_member.team_id AND team_member.user_id = ? ` +} + func getTeamSelectSqlBase() string { return `SELECT team.id as id, team.org_id, team.name as name, team.email as email, - (SELECT COUNT(*) from team_member where team_member.team_id = team.id) as member_count + (SELECT COUNT(*) from team_member where team_member.team_id = team.id) as member_count FROM team as team ` } @@ -146,10 +158,11 @@ func SearchTeams(query *m.SearchTeamsQuery) error { var sql bytes.Buffer params := make([]interface{}, 0) - sql.WriteString(getTeamSelectSqlBase()) if query.UserIdFilter > 0 { - sql.WriteString(`INNER JOIN team_member on team.id = team_member.team_id AND team_member.user_id = ?`) + sql.WriteString(getTeamSearchSqlBase()) params = append(params, query.UserIdFilter) + } else { + sql.WriteString(getTeamSelectSqlBase()) } sql.WriteString(` WHERE team.org_id = ?`) diff --git a/public/app/features/teams/TeamList.tsx b/public/app/features/teams/TeamList.tsx index f603994b578..e9d51785d72 100644 --- a/public/app/features/teams/TeamList.tsx +++ b/public/app/features/teams/TeamList.tsx @@ -6,7 +6,7 @@ import { DeleteButton } from '@grafana/ui'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; import { NavModel, Team, OrgRole } from 'app/types'; import { loadTeams, deleteTeam, setSearchQuery } from './state/actions'; -import { getSearchQuery, getTeams, getTeamsCount } from './state/selectors'; +import { getSearchQuery, getTeams, getTeamsCount, isPermissionTeamAdmin } from './state/selectors'; import { getNavModel } from 'app/core/selectors/navModel'; import { FilterInput } from 'app/core/components/FilterInput/FilterInput'; import { config } from 'app/core/config'; @@ -43,7 +43,10 @@ export class TeamList extends PureComponent { }; renderTeam(team: Team) { + const { editorsCanAdmin, signedInUser } = this.props; + const permission = team.permission; const teamUrl = `org/teams/edit/${team.id}`; + const canDelete = isPermissionTeamAdmin({ permission, editorsCanAdmin, signedInUser }); return ( @@ -62,7 +65,7 @@ export class TeamList extends PureComponent { {team.memberCount} - this.deleteTeam(team)} /> + this.deleteTeam(team)} disabled={!canDelete} /> ); diff --git a/public/app/features/teams/__mocks__/teamMocks.ts b/public/app/features/teams/__mocks__/teamMocks.ts index f38f8f2b144..abaa5ef555f 100644 --- a/public/app/features/teams/__mocks__/teamMocks.ts +++ b/public/app/features/teams/__mocks__/teamMocks.ts @@ -9,6 +9,7 @@ export const getMultipleMockTeams = (numberOfTeams: number): Team[] => { avatarUrl: 'some/url/', email: `test-${i}@test.com`, memberCount: i, + permission: TeamPermissionLevel.Member, }); } @@ -22,6 +23,7 @@ export const getMockTeam = (): Team => { avatarUrl: 'some/url/', email: 'test@test.com', memberCount: 1, + permission: TeamPermissionLevel.Member, }; }; diff --git a/public/app/features/teams/__snapshots__/TeamList.test.tsx.snap b/public/app/features/teams/__snapshots__/TeamList.test.tsx.snap index d4dd2170bae..430466559c5 100644 --- a/public/app/features/teams/__snapshots__/TeamList.test.tsx.snap +++ b/public/app/features/teams/__snapshots__/TeamList.test.tsx.snap @@ -133,6 +133,7 @@ exports[`Render should render teams table 1`] = ` className="text-right" > @@ -183,6 +184,7 @@ exports[`Render should render teams table 1`] = ` className="text-right" > @@ -233,6 +235,7 @@ exports[`Render should render teams table 1`] = ` className="text-right" > @@ -283,6 +286,7 @@ exports[`Render should render teams table 1`] = ` className="text-right" > @@ -333,6 +337,7 @@ exports[`Render should render teams table 1`] = ` className="text-right" > @@ -458,6 +463,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on and signedin us className="text-right" > @@ -583,6 +589,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on and signedin us className="text-right" > diff --git a/public/app/features/teams/state/navModel.ts b/public/app/features/teams/state/navModel.ts index 2fd5a68e680..aeb6b85f91e 100644 --- a/public/app/features/teams/state/navModel.ts +++ b/public/app/features/teams/state/navModel.ts @@ -1,4 +1,4 @@ -import { Team, NavModelItem, NavModel } from 'app/types'; +import { Team, NavModelItem, NavModel, TeamPermissionLevel } from 'app/types'; import config from 'app/core/config'; export function buildNavModel(team: Team): NavModelItem { @@ -47,6 +47,7 @@ export function getTeamLoadingNav(pageName: string): NavModel { name: 'Loading', email: 'loading', memberCount: 0, + permission: TeamPermissionLevel.Member, }); let node: NavModelItem; diff --git a/public/app/features/teams/state/selectors.ts b/public/app/features/teams/state/selectors.ts index d8b8220bb44..e770abfc093 100644 --- a/public/app/features/teams/state/selectors.ts +++ b/public/app/features/teams/state/selectors.ts @@ -37,10 +37,24 @@ export interface Config { } export const isSignedInUserTeamAdmin = (config: Config): boolean => { - const userInMembers = config.members.find(m => m.userId === config.signedInUser.id); - const isAdmin = config.signedInUser.isGrafanaAdmin || config.signedInUser.orgRole === OrgRole.Admin; - const userIsTeamAdmin = userInMembers && userInMembers.permission === TeamPermissionLevel.Admin; + const { members, signedInUser, editorsCanAdmin } = config; + const userInMembers = members.find(m => m.userId === signedInUser.id); + const permission = userInMembers ? userInMembers.permission : TeamPermissionLevel.Member; + + return isPermissionTeamAdmin({ permission, signedInUser, editorsCanAdmin }); +}; + +export interface PermissionConfig { + permission: TeamPermissionLevel; + editorsCanAdmin: boolean; + signedInUser: User; +} + +export const isPermissionTeamAdmin = (config: PermissionConfig): boolean => { + const { permission, signedInUser, editorsCanAdmin } = config; + const isAdmin = signedInUser.isGrafanaAdmin || signedInUser.orgRole === OrgRole.Admin; + const userIsTeamAdmin = permission === TeamPermissionLevel.Admin; const isSignedInUserTeamAdmin = isAdmin || userIsTeamAdmin; - return isSignedInUserTeamAdmin || !config.editorsCanAdmin; + return isSignedInUserTeamAdmin || !editorsCanAdmin; }; diff --git a/public/app/types/teams.ts b/public/app/types/teams.ts index ef804e437d4..707ff97b738 100644 --- a/public/app/types/teams.ts +++ b/public/app/types/teams.ts @@ -1,9 +1,12 @@ +import { TeamPermissionLevel } from './acl'; + export interface Team { id: number; name: string; avatarUrl: string; email: string; memberCount: number; + permission: TeamPermissionLevel; } export interface TeamMember { From b71c9803a9e89c0c9f5b2e55935108ba5949b3ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Mon, 18 Mar 2019 13:24:47 +0100 Subject: [PATCH 092/103] fix: new team link goes nowhere for viewers --- public/app/features/teams/TeamList.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/public/app/features/teams/TeamList.tsx b/public/app/features/teams/TeamList.tsx index e9d51785d72..5d3ef005c9e 100644 --- a/public/app/features/teams/TeamList.tsx +++ b/public/app/features/teams/TeamList.tsx @@ -92,7 +92,9 @@ export class TeamList extends PureComponent { renderTeamList() { const { teams, searchQuery, editorsCanAdmin, signedInUser } = this.props; - const disabledClass = editorsCanAdmin && signedInUser.orgRole === OrgRole.Viewer ? ' disabled' : ''; + const isCanAdminAndViewer = editorsCanAdmin && signedInUser.orgRole === OrgRole.Viewer; + const disabledClass = isCanAdminAndViewer ? ' disabled' : ''; + const newTeamHref = isCanAdminAndViewer ? '#' : 'org/teams/new'; return ( <> @@ -109,7 +111,7 @@ export class TeamList extends PureComponent { From e23e6a8bdc1c5c4addbdf4bf76334fcb17b901a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Mon, 18 Mar 2019 14:09:15 +0100 Subject: [PATCH 093/103] fix: fixed snapshots and permission select not beeing able to click --- public/app/features/teams/TeamMemberRow.tsx | 2 +- .../teams/__snapshots__/TeamList.test.tsx.snap | 2 +- .../__snapshots__/TeamMemberRow.test.tsx.snap | 16 ++++++++++++---- public/sass/pages/_admin.scss | 6 ++++++ 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/public/app/features/teams/TeamMemberRow.tsx b/public/app/features/teams/TeamMemberRow.tsx index e0bd26f4fd7..0111d1efd8e 100644 --- a/public/app/features/teams/TeamMemberRow.tsx +++ b/public/app/features/teams/TeamMemberRow.tsx @@ -40,7 +40,7 @@ export class TeamMemberRow extends PureComponent { return ( - +
{signedInUserIsTeamAdmin && (