Commit 8517148a authored by Andrew Fontaine's avatar Andrew Fontaine

Check if User can Update Protected Environment

If an environment is protected, and a user cannot modify it, they should
not be able to update/delete feature flags on that environment, nor can
they rename, edit, or delete the feature flag.
parent 516145a1
...@@ -53,6 +53,10 @@ export default { ...@@ -53,6 +53,10 @@ export default {
required: false, required: false,
default: __('Create'), default: __('Create'),
}, },
disabled: {
type: Boolean,
default: false,
},
}, },
data() { data() {
return { return {
...@@ -175,10 +179,12 @@ export default { ...@@ -175,10 +179,12 @@ export default {
:aria-label="placeholder" :aria-label="placeholder"
:value="filter" :value="filter"
:placeholder="placeholder" :placeholder="placeholder"
:disabled="disabled"
@input="fetchEnvironments" @input="fetchEnvironments"
/> />
<gl-button <gl-button
v-if="!disabled"
class="js-clear-search-input btn-transparent clear-search-input position-right-0" class="js-clear-search-input btn-transparent clear-search-input position-right-0"
@click="clearInput" @click="clearInput"
> >
......
...@@ -32,6 +32,9 @@ export default { ...@@ -32,6 +32,9 @@ export default {
}; };
}, },
computed: { computed: {
permissions() {
return gon && gon.features && gon.features.featureFlagPermissions;
},
modalTitle() { modalTitle() {
return sprintf( return sprintf(
s__('FeatureFlags|Delete %{name}?'), s__('FeatureFlags|Delete %{name}?'),
...@@ -65,6 +68,9 @@ export default { ...@@ -65,6 +68,9 @@ export default {
scopeName(name) { scopeName(name) {
return name === '*' ? s__('FeatureFlags|* (All environments)') : name; return name === '*' ? s__('FeatureFlags|* (All environments)') : name;
}, },
canDeleteFlag(flag) {
return !this.permissions || (flag.scopes || []).every(scope => scope.can_update);
},
setDeleteModalData(featureFlag) { setDeleteModalData(featureFlag) {
this.deleteFeatureFlagUrl = featureFlag.destroy_path; this.deleteFeatureFlagUrl = featureFlag.destroy_path;
this.deleteFeatureFlagName = featureFlag.name; this.deleteFeatureFlagName = featureFlag.name;
...@@ -149,6 +155,7 @@ export default { ...@@ -149,6 +155,7 @@ export default {
v-gl-modal-directive="modalId" v-gl-modal-directive="modalId"
class="js-feature-flag-delete-button" class="js-feature-flag-delete-button"
variant="danger" variant="danger"
:disabled="!canDeleteFlag(featureFlag)"
@click="setDeleteModalData(featureFlag)" @click="setDeleteModalData(featureFlag)"
> >
<icon name="remove" :size="16" /> <icon name="remove" :size="16" />
......
<script> <script>
import _ from 'underscore'; import _ from 'underscore';
import { GlButton } from '@gitlab/ui'; import { GlButton, GlBadge } from '@gitlab/ui';
import { s__, sprintf } from '~/locale'; import { s__, sprintf } from '~/locale';
import ToggleButton from '~/vue_shared/components/toggle_button.vue'; import ToggleButton from '~/vue_shared/components/toggle_button.vue';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
...@@ -10,6 +10,7 @@ import { internalKeyID } from '../store/modules/helpers'; ...@@ -10,6 +10,7 @@ import { internalKeyID } from '../store/modules/helpers';
export default { export default {
components: { components: {
GlButton, GlButton,
GlBadge,
ToggleButton, ToggleButton,
Icon, Icon,
EnvironmentsDropdown, EnvironmentsDropdown,
...@@ -71,6 +72,13 @@ export default { ...@@ -71,6 +72,13 @@ export default {
// eslint-disable-next-line no-underscore-dangle // eslint-disable-next-line no-underscore-dangle
return this.formScopes.filter(scope => !scope._destroy); return this.formScopes.filter(scope => !scope._destroy);
}, },
canUpdateFlag() {
return !this.permissionsFlag || (this.scopes || []).every(scope => scope.can_update);
},
permissionsFlag() {
return gon && gon.features && gon.features.featureFlagPermissions;
},
}, },
methods: { methods: {
isAllEnvironment(name) { isAllEnvironment(name) {
...@@ -108,11 +116,18 @@ export default { ...@@ -108,11 +116,18 @@ export default {
* @param {Boolean} value the toggle value * @param {Boolean} value the toggle value
*/ */
onChangeNewScopeStatus(value) { onChangeNewScopeStatus(value) {
this.formScopes.push({ const newScope = {
active: value, active: value,
environment_scope: this.newScope, environment_scope: this.newScope,
id: _.uniqueId(internalKeyID), id: _.uniqueId(internalKeyID),
}); };
if (this.permissionsFlag) {
newScope.can_update = true;
newScope.protected = false;
}
this.formScopes.push(newScope);
this.newScope = ''; this.newScope = '';
}, },
...@@ -160,6 +175,10 @@ export default { ...@@ -160,6 +175,10 @@ export default {
scopes: this.formScopes, scopes: this.formScopes,
}); });
}, },
canUpdateScope(scope) {
return !this.permissionsFlag || scope.can_update;
},
}, },
}; };
</script> </script>
...@@ -169,7 +188,12 @@ export default { ...@@ -169,7 +188,12 @@ export default {
<div class="row"> <div class="row">
<div class="form-group col-md-4"> <div class="form-group col-md-4">
<label for="feature-flag-name" class="label-bold">{{ s__('FeatureFlags|Name') }}</label> <label for="feature-flag-name" class="label-bold">{{ s__('FeatureFlags|Name') }}</label>
<input id="feature-flag-name" v-model="formName" class="form-control" /> <input
id="feature-flag-name"
v-model="formName"
:disabled="!canUpdateFlag"
class="form-control"
/>
</div> </div>
</div> </div>
...@@ -181,6 +205,7 @@ export default { ...@@ -181,6 +205,7 @@ export default {
<textarea <textarea
id="feature-flag-description" id="feature-flag-description"
v-model="formDescription" v-model="formDescription"
:disabled="!canUpdateFlag"
class="form-control" class="form-control"
rows="4" rows="4"
></textarea> ></textarea>
...@@ -212,7 +237,9 @@ export default { ...@@ -212,7 +237,9 @@ export default {
<div class="table-mobile-header" role="rowheader"> <div class="table-mobile-header" role="rowheader">
{{ s__('FeatureFlags|Environment Spec') }} {{ s__('FeatureFlags|Environment Spec') }}
</div> </div>
<div class="table-mobile-content js-feature-flag-status"> <div
class="table-mobile-content js-feature-flag-status d-flex align-items-center justify-content-start"
>
<p v-if="isAllEnvironment(scope.environment_scope)" class="js-scope-all pl-3"> <p v-if="isAllEnvironment(scope.environment_scope)" class="js-scope-all pl-3">
{{ $options.allEnvironments }} {{ $options.allEnvironments }}
</p> </p>
...@@ -222,10 +249,15 @@ export default { ...@@ -222,10 +249,15 @@ export default {
class="col-md-6" class="col-md-6"
:value="scope.environment_scope" :value="scope.environment_scope"
:endpoint="environmentsEndpoint" :endpoint="environmentsEndpoint"
:disabled="!canUpdateScope(scope)"
@selectEnvironment="env => updateScope(env, scope, index)" @selectEnvironment="env => updateScope(env, scope, index)"
@createClicked="env => updateScope(env, scope, index)" @createClicked="env => updateScope(env, scope, index)"
@clearInput="updateScope('', scope, index)" @clearInput="updateScope('', scope, index)"
/> />
<gl-badge v-if="permissionsFlag && scope.protected" variant="success">{{
s__('FeatureFlags|Protected')
}}</gl-badge>
</div> </div>
</div> </div>
...@@ -236,6 +268,7 @@ export default { ...@@ -236,6 +268,7 @@ export default {
<div class="table-mobile-content js-feature-flag-status"> <div class="table-mobile-content js-feature-flag-status">
<toggle-button <toggle-button
:value="scope.active" :value="scope.active"
:disabled-input="!canUpdateScope(scope)"
@change="status => onUpdateScopeStatus(scope, status)" @change="status => onUpdateScopeStatus(scope, status)"
/> />
</div> </div>
...@@ -247,7 +280,7 @@ export default { ...@@ -247,7 +280,7 @@ export default {
</div> </div>
<div class="table-mobile-content js-feature-flag-delete"> <div class="table-mobile-content js-feature-flag-delete">
<gl-button <gl-button
v-if="!isAllEnvironment(scope.environment_scope)" v-if="!isAllEnvironment(scope.environment_scope) && canUpdateScope(scope)"
class="js-delete-scope btn-transparent" class="js-delete-scope btn-transparent"
@click="removeScope(scope)" @click="removeScope(scope)"
> >
......
...@@ -10,6 +10,10 @@ class Projects::FeatureFlagsController < Projects::ApplicationController ...@@ -10,6 +10,10 @@ class Projects::FeatureFlagsController < Projects::ApplicationController
before_action :feature_flag, only: [:edit, :update, :destroy] before_action :feature_flag, only: [:edit, :update, :destroy]
before_action do
push_frontend_feature_flag(:feature_flag_permissions)
end
def index def index
@feature_flags = FeatureFlagsFinder @feature_flags = FeatureFlagsFinder
.new(project, current_user, scope: params[:scope]) .new(project, current_user, scope: params[:scope])
......
...@@ -11,6 +11,7 @@ describe 'User creates feature flag', :js do ...@@ -11,6 +11,7 @@ describe 'User creates feature flag', :js do
before do before do
project.add_developer(user) project.add_developer(user)
stub_licensed_features(feature_flags: true) stub_licensed_features(feature_flags: true)
stub_feature_flags(feature_flag_permissions: false)
sign_in(user) sign_in(user)
end end
......
...@@ -16,6 +16,7 @@ describe 'User deletes feature flag', :js do ...@@ -16,6 +16,7 @@ describe 'User deletes feature flag', :js do
before do before do
project.add_developer(user) project.add_developer(user)
stub_licensed_features(feature_flags: true) stub_licensed_features(feature_flags: true)
stub_feature_flags(feature_flag_permissions: false)
sign_in(user) sign_in(user)
visit(project_feature_flags_path(project)) visit(project_feature_flags_path(project))
......
...@@ -18,6 +18,7 @@ describe 'User updates feature flag', :js do ...@@ -18,6 +18,7 @@ describe 'User updates feature flag', :js do
before do before do
project.add_developer(user) project.add_developer(user)
stub_licensed_features(feature_flags: true) stub_licensed_features(feature_flags: true)
stub_feature_flags(feature_flag_permissions: false)
sign_in(user) sign_in(user)
visit(edit_project_feature_flag_path(project, feature_flag)) visit(edit_project_feature_flag_path(project, feature_flag))
......
...@@ -23,6 +23,14 @@ describe('feature flag form', () => { ...@@ -23,6 +23,14 @@ describe('feature flag form', () => {
}); });
}; };
beforeAll(() => {
gon.features = { featureFlagPermissions: true };
});
afterAll(() => {
gon.features = null;
});
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
}); });
...@@ -87,13 +95,24 @@ describe('feature flag form', () => { ...@@ -87,13 +95,24 @@ describe('feature flag form', () => {
{ {
environment_scope: 'production', environment_scope: 'production',
active: false, active: false,
can_update: true,
protected: true,
id: 2, id: 2,
}, },
{ {
environment_scope: 'review', environment_scope: 'review',
active: true, active: true,
can_update: true,
protected: false,
id: 4, id: 4,
}, },
{
environment_scope: 'staging',
active: true,
can_update: false,
protected: true,
id: 5,
},
], ],
}); });
}); });
...@@ -113,8 +132,27 @@ describe('feature flag form', () => { ...@@ -113,8 +132,27 @@ describe('feature flag form', () => {
wrapper.find(ToggleButton).vm.$emit('change', true); wrapper.find(ToggleButton).vm.$emit('change', true);
expect(wrapper.vm.formScopes).toEqual([ expect(wrapper.vm.formScopes).toEqual([
{ active: true, environment_scope: 'production', id: 2 }, {
{ active: true, environment_scope: 'review', id: 4 }, active: true,
environment_scope: 'production',
id: 2,
can_update: true,
protected: true,
},
{
active: true,
environment_scope: 'review',
id: 4,
can_update: true,
protected: false,
},
{
environment_scope: 'staging',
active: true,
can_update: false,
protected: true,
id: 5,
},
]); ]);
expect(wrapper.vm.newScope).toEqual(''); expect(wrapper.vm.newScope).toEqual('');
...@@ -134,14 +172,42 @@ describe('feature flag form', () => { ...@@ -134,14 +172,42 @@ describe('feature flag form', () => {
active: false, active: false,
_destroy: true, _destroy: true,
id: 2, id: 2,
can_update: true,
protected: true,
},
{
active: true,
environment_scope: 'review',
id: 4,
can_update: true,
protected: false,
},
{
environment_scope: 'staging',
active: true,
can_update: false,
protected: true,
id: 5,
}, },
{ active: true, environment_scope: 'review', id: 4 },
]); ]);
}); });
it('should not render deleted scopes', () => { it('should not render deleted scopes', () => {
expect(wrapper.vm.filteredScopes).toEqual([ expect(wrapper.vm.filteredScopes).toEqual([
{ active: true, environment_scope: 'review', id: 4 }, {
active: true,
environment_scope: 'review',
id: 4,
can_update: true,
protected: false,
},
{
environment_scope: 'staging',
active: true,
can_update: false,
protected: true,
id: 5,
},
]); ]);
}); });
}); });
...@@ -157,6 +223,8 @@ describe('feature flag form', () => { ...@@ -157,6 +223,8 @@ describe('feature flag form', () => {
environment_scope: 'new_scope', environment_scope: 'new_scope',
active: false, active: false,
id: _.uniqueId(internalKeyID), id: _.uniqueId(internalKeyID),
can_update: true,
protected: false,
}, },
], ],
}); });
...@@ -186,6 +254,28 @@ describe('feature flag form', () => { ...@@ -186,6 +254,28 @@ describe('feature flag form', () => {
expect(wrapper.find('.js-scope-all').exists()).toEqual(true); expect(wrapper.find('.js-scope-all').exists()).toEqual(true);
}); });
}); });
describe('without permission to update', () => {
it('should have the flag name input disabled', () => {
const input = wrapper.find('#feature-flag-name');
expect(input.element.disabled).toBe(true);
});
it('should have the flag discription text area disabled', () => {
const textarea = wrapper.find('#feature-flag-description');
expect(textarea.element.disabled).toBe(true);
});
it('should have the scope that cannot be updated be disabled', () => {
const row = wrapper.findAll('.gl-responsive-table-row').wrappers[3];
expect(row.find(EnvironmentsDropdown).vm.disabled).toBe(true);
expect(row.find(ToggleButton).vm.disabledInput).toBe(true);
expect(row.find('.js-delete-scope').exists()).toBe(false);
});
});
}); });
describe('on submit', () => { describe('on submit', () => {
...@@ -197,6 +287,8 @@ describe('feature flag form', () => { ...@@ -197,6 +287,8 @@ describe('feature flag form', () => {
scopes: [ scopes: [
{ {
environment_scope: 'production', environment_scope: 'production',
can_update: true,
protected: true,
active: false, active: false,
}, },
], ],
...@@ -226,6 +318,8 @@ describe('feature flag form', () => { ...@@ -226,6 +318,8 @@ describe('feature flag form', () => {
expect(data.scopes[0]).toEqual({ expect(data.scopes[0]).toEqual({
active: false, active: false,
environment_scope: 'production', environment_scope: 'production',
can_update: true,
protected: true,
}); });
}); });
}); });
......
...@@ -4468,6 +4468,9 @@ msgstr "" ...@@ -4468,6 +4468,9 @@ msgstr ""
msgid "FeatureFlags|New Feature Flag" msgid "FeatureFlags|New Feature Flag"
msgstr "" msgstr ""
msgid "FeatureFlags|Protected"
msgstr ""
msgid "FeatureFlags|Status" msgid "FeatureFlags|Status"
msgstr "" msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment