diff --git a/frontend/src/mixins/Application.js b/frontend/src/mixins/Application.js index 23173a28cd..9bf4b2cb64 100644 --- a/frontend/src/mixins/Application.js +++ b/frontend/src/mixins/Application.js @@ -5,8 +5,6 @@ export default { data () { return { application: {}, - applicationDevices: [], - deviceGroups: [], applicationInstances: new Map(), loading: { deleting: false, @@ -23,12 +21,6 @@ export default { return [] } return Array.from(this.applicationInstances.values()).filter(el => el) - }, - devicesArray () { - return this.applicationDevices - }, - deviceGroupsArray () { - return this.deviceGroups || [] } }, watch: { @@ -51,16 +43,7 @@ export default { await this.$store.dispatch('account/setTeam', this.application.team.slug) } const instancesPromise = ApplicationApi.getApplicationInstances(applicationId) // To-do needs to be enriched with instance state - const devicesPromise = ApplicationApi.getApplicationDevices(applicationId) - const deviceData = await devicesPromise - this.applicationDevices = deviceData?.devices const applicationInstances = await instancesPromise - if (this.features?.deviceGroups && this.team.type.properties.features?.deviceGroups) { - const deviceGroupsData = await ApplicationApi.getDeviceGroups(applicationId) - this.deviceGroups = deviceGroupsData?.groups || [] - } else { - this.deviceGroups = [] - } this.applicationInstances = new Map() applicationInstances.forEach(instance => { diff --git a/frontend/src/pages/application/Activity.vue b/frontend/src/pages/application/Activity.vue index ef5fa33a65..8deea4ac0b 100644 --- a/frontend/src/pages/application/Activity.vue +++ b/frontend/src/pages/application/Activity.vue @@ -36,6 +36,7 @@ import TeamAPI from '../../api/team.js' import FormHeading from '../../components/FormHeading.vue' import SectionTopMenu from '../../components/SectionTopMenu.vue' import AuditLogBrowser from '../../components/audit-log/AuditLogBrowser.vue' +import usePermissions from '../../composables/Permissions.js' import FfListbox from '../../ui-components/components/form/ListBox.vue' export default { @@ -53,6 +54,10 @@ export default { required: true } }, + setup () { + const { hasPermission } = usePermissions() + return { hasPermission } + }, data () { return { logEntries: [], @@ -65,7 +70,7 @@ export default { } }, computed: { - ...mapState('account', ['team']), + ...mapState('account', ['team', 'teamMembership']), instanceList () { return [ { name: 'This Application', id: '' }, @@ -87,6 +92,11 @@ export default { 'auditFilters.includeChildren': 'triggerLoad', team: function () { this.triggerLoad({ users: true, events: true }) + }, + teamMembership () { + if (!this.hasPermission('application:audit-log')) { + return this.$router.push({ name: 'Application', params: this.$route.params }) + } } }, created () { @@ -104,30 +114,34 @@ export default { * @param cursor - cursor to use for pagination */ async loadEntries (params = new URLSearchParams(), cursor = undefined) { - const paramScope = (params.has('scope') ? params.get('scope') : this.auditFilters.selectedEventScope) || 'application' - let includeChildren = this.auditFilters.includeChildren - if (params.has('includeChildren')) { - includeChildren = params.get('includeChildren') === 'true' - } - params.set('includeChildren', includeChildren) - params.set('scope', paramScope) - if (this.applicationId) { - let log - if (paramScope === 'application') { - log = (await ApplicationApi.getApplicationAuditLog(this.applicationId, params, cursor, 200)) - } else { - const instanceId = this.auditFilters.selectedEventScope - log = (await InstanceApi.getInstanceAuditLog(instanceId, params, cursor, 200)) + if (this.hasPermission('application:audit-log')) { + const paramScope = (params.has('scope') ? params.get('scope') : this.auditFilters.selectedEventScope) || 'application' + let includeChildren = this.auditFilters.includeChildren + if (params.has('includeChildren')) { + includeChildren = params.get('includeChildren') === 'true' + } + params.set('includeChildren', includeChildren) + params.set('scope', paramScope) + if (this.applicationId) { + let log + if (paramScope === 'application') { + log = (await ApplicationApi.getApplicationAuditLog(this.applicationId, params, cursor, 200)) + } else { + const instanceId = this.auditFilters.selectedEventScope + log = (await InstanceApi.getInstanceAuditLog(instanceId, params, cursor, 200)) + } + this.logEntries = log.log + this.associations = includeChildren ? log.associations : null } - this.logEntries = log.log - this.associations = includeChildren ? log.associations : null } }, triggerLoad ({ users = false, events = true } = {}) { - // if `events` is true, call AuditLogBrowser.loadEntries - this will emit 'load-entries' event which calls this.loadEntries with appropriate params - const scope = !this.auditFilters.selectedEventScope ? 'application' : 'project' - events && this.$refs.AuditLog?.loadEntries(scope, this.auditFilters.includeChildren, scope) - users && this.loadUsers() + if (this.hasPermission('application:audit-log')) { + // if `events` is true, call AuditLogBrowser.loadEntries - this will emit 'load-entries' event which calls this.loadEntries with appropriate params + const scope = !this.auditFilters.selectedEventScope ? 'application' : 'project' + events && this.$refs.AuditLog?.loadEntries(scope, this.auditFilters.includeChildren, scope) + users && this.loadUsers() + } } } } diff --git a/frontend/src/pages/application/DeviceGroups.vue b/frontend/src/pages/application/DeviceGroups.vue index 378215ecfb..a4d786494f 100644 --- a/frontend/src/pages/application/DeviceGroups.vue +++ b/frontend/src/pages/application/DeviceGroups.vue @@ -72,6 +72,7 @@ import ApplicationAPI from '../../api/application.js' import EmptyState from '../../components/EmptyState.vue' import FormRow from '../../components/FormRow.vue' import SectionTopMenu from '../../components/SectionTopMenu.vue' +import usePermissions from '../../composables/Permissions.js' import Alerts from '../../services/alerts.js' @@ -99,6 +100,10 @@ export default { required: true } }, + setup () { + const { hasPermission } = usePermissions() + return { hasPermission } + }, data () { return { loading: false, @@ -138,7 +143,7 @@ export default { } }, computed: { - ...mapState('account', ['features', 'team']), + ...mapState('account', ['features', 'team', 'teamMembership']), featureEnabledForTeam () { return !!this.team?.type?.properties?.features?.deviceGroups }, @@ -152,6 +157,11 @@ export default { watch: { featureEnabled: function (v) { this.loadDeviceGroups() + }, + teamMembership () { + if (!this.hasPermission('application:device-group:list')) { + return this.$router.push({ name: 'Application', params: this.$route.params }) + } } }, mounted () { @@ -191,22 +201,24 @@ export default { this.$router.push(route) }, async loadDeviceGroups () { - this.loading = true - ApplicationAPI.getDeviceGroups(this.application.id) - .then((groups) => { - this.deviceGroups = groups.groups - if (this.deviceGroups?.length > 0) { + if (this.hasPermission('application:device-group:list')) { + this.loading = true + ApplicationAPI.getDeviceGroups(this.application.id) + .then((groups) => { + this.deviceGroups = groups.groups + if (this.deviceGroups?.length > 0) { // if there is no target snapshot set, set it to an empty object so that the `markRaw` function renders _something_ in the table cell - this.deviceGroups.forEach((group) => { - group.targetSnapshot = group.targetSnapshot || {} - }) - } - }) - .catch((err) => { - console.error(err) - }).finally(() => { - this.loading = false - }) + this.deviceGroups.forEach((group) => { + group.targetSnapshot = group.targetSnapshot || {} + }) + } + }) + .catch((err) => { + console.error(err) + }).finally(() => { + this.loading = false + }) + } } } } diff --git a/frontend/src/pages/application/Pipeline/index.vue b/frontend/src/pages/application/Pipeline/index.vue index e3bdd0031f..859a3c40d7 100644 --- a/frontend/src/pages/application/Pipeline/index.vue +++ b/frontend/src/pages/application/Pipeline/index.vue @@ -27,55 +27,52 @@ export default { instances: { type: Object, required: true - }, - devices: { - type: Array, - required: true - }, - deviceGroups: { - type: Array, - required: true } }, data: function () { return { - pipeline: null + pipeline: null, + devices: [], + deviceGroups: [] } }, watch: { - 'application.id': 'loadPipeline' + 'application.id': 'fetchData', + '$route.params.pipelineId': 'fetchData' }, - created () { - this.loadPipeline() - - this.$watch( - () => this.$route.params.pipelineId, - () => { - if (!this.$route.params.pipelineId) { - return - } - - this.loadPipeline() - } - ) + async created () { + await this.fetchData() }, methods: { async loadPipeline () { if (!this.application.id) { - return + return Promise.resolve() } - try { - this.pipeline = await ApplicationApi.getPipeline(this.application.id, this.$route.params.pipelineId) - } catch (err) { - this.$router.push({ - name: 'page-not-found', - params: { pathMatch: this.$router.currentRoute.value.path.substring(1).split('/') }, - // preserve existing query and hash if any - query: this.$router.currentRoute.value.query, - hash: this.$router.currentRoute.value.hash + return ApplicationApi.getPipeline(this.application.id, this.$route.params.pipelineId) + .then(res => { + this.pipeline = res + }) + }, + async fetchData () { + return this.loadPipeline() + .then(() => ApplicationApi.getApplicationDevices(this.application.id)) + .then(res => { + this.devices = res.devices + }) + .then(() => ApplicationApi.getDeviceGroups(this.application.id)) + .then((res) => { + this.deviceGroups = res.groups + }) + .catch(() => { + this.$router.push({ + name: 'page-not-found', + params: { pathMatch: this.$router.currentRoute.value.path.substring(1).split('/') }, + // preserve existing query and hash if any + query: this.$router.currentRoute.value.query, + hash: this.$router.currentRoute.value.hash + }) }) - } } } } diff --git a/frontend/src/pages/application/Pipelines.vue b/frontend/src/pages/application/Pipelines.vue index 682a7d4e69..7cc388c748 100644 --- a/frontend/src/pages/application/Pipelines.vue +++ b/frontend/src/pages/application/Pipelines.vue @@ -102,6 +102,7 @@ import PipelineAPI from '../../api/pipeline.js' import EmptyState from '../../components/EmptyState.vue' import SectionTopMenu from '../../components/SectionTopMenu.vue' import PipelineRow from '../../components/pipelines/PipelineRow.vue' +import usePermissions from '../../composables/Permissions.js' import Alerts from '../../services/alerts.js' @@ -127,6 +128,10 @@ export default { required: true } }, + setup () { + const { hasPermission } = usePermissions() + return { hasPermission } + }, data () { return { loading: false, @@ -142,11 +147,24 @@ export default { } }, computed: { - ...mapState('account', ['features']), + ...mapState('account', ['features', 'teamMembership']), featureEnabled () { return this.features['devops-pipelines'] } }, + watch: { + teamMembership () { + if (!this.hasPermission('application:pipeline:list')) { + return this.$router.push({ name: 'Application', params: this.$route.params }) + } + + // Forces to load pipelines when teamMembership changes. When loading the page via url, teamMembership might not be + // loaded by the time the mounted loadPipelines is called and the hasPermission method will return false. + // todo This should be addressed by implementing an application service that bootstrap's the + // app and hydrates vuex stores before attempting to render any data + this.loadPipelines() + } + }, mounted () { if (this.featureEnabled) { this.loadPipelines() @@ -245,22 +263,24 @@ export default { } }, async loadPipelines () { - this.loading = true + if (this.hasPermission('application:pipeline:list')) { + this.loading = true - // getPipelines doesn't include full instance status information, kick this off async - // Not needed for devices as device status is returned as part of pipelines API - this.loadInstanceStatus() + // getPipelines doesn't include full instance status information, kick this off async + // Not needed for devices as device status is returned as part of pipelines API + this.loadInstanceStatus() - ApplicationAPI.getPipelines(this.application.id) - .then((pipelines) => { - this.pipelines = pipelines - this.loadDeviceGroupStatus(this.pipelines) - this.loading = false - }) - .catch((err) => { - console.error(err) - this.loading = false - }) + ApplicationAPI.getPipelines(this.application.id) + .then((pipelines) => { + this.pipelines = pipelines + this.loadDeviceGroupStatus(this.pipelines) + this.loading = false + }) + .catch((err) => { + console.error(err) + this.loading = false + }) + } }, async loadInstanceStatus () { ApplicationAPI.getApplicationInstancesStatuses(this.application.id) diff --git a/frontend/src/pages/application/Snapshots.vue b/frontend/src/pages/application/Snapshots.vue index c19ee72f4c..0da80200a9 100644 --- a/frontend/src/pages/application/Snapshots.vue +++ b/frontend/src/pages/application/Snapshots.vue @@ -87,6 +87,7 @@ export default { EmptyState }, mixins: [permissionsMixin], + inheritAttrs: false, props: { application: { type: Object, diff --git a/frontend/src/pages/application/index.vue b/frontend/src/pages/application/index.vue index 19fdfbf2ef..1dc27731d7 100644 --- a/frontend/src/pages/application/index.vue +++ b/frontend/src/pages/application/index.vue @@ -25,8 +25,6 @@ { it.skip('can create device-group', () => { // TODO }) + + it('should hide the device groups tab from users with viewer roles', () => { + cy.intercept('GET', '/api/v1/teams/*/user', { role: 10 }).as('getTeamRole') + + cy.visit(`/application/${application.id}`) + + cy.get('[data-nav="application-devices-groups-overview"]').should('not.exist') + }) + + it('should redirect users to the instances overview when accessing the device groups page', () => { + cy.intercept('GET', '/api/v1/teams/*/user', { role: 10 }).as('getTeamRole') + + cy.visit(`/application/${application.id}/devices`) + + cy.url().should('include', '/devices') + + cy.visit(`/application/${application.id}/device-groups`) + + cy.url().should('include', '/instances') + }) }) describe('Device Group', () => { diff --git a/test/e2e/frontend/cypress/tests-ee/applications/pipelines.spec.js b/test/e2e/frontend/cypress/tests-ee/applications/pipelines.spec.js index cf93facf66..9925a6291a 100644 --- a/test/e2e/frontend/cypress/tests-ee/applications/pipelines.spec.js +++ b/test/e2e/frontend/cypress/tests-ee/applications/pipelines.spec.js @@ -734,7 +734,7 @@ describe('FlowForge - Application - DevOps Pipelines', () => { // Protect Instance cy.visit(`/application/${application.id}`) cy.get('[data-el="cloud-instances"] table tbody tr:nth-of-type(2)').click() - cy.get('[data-nav="instance-settings"').click() + cy.get('[data-nav="instance-settings"]').click() cy.get('[data-el="section-side-menu"] li:nth-of-type(4)').click() cy.get('[data-nav="enable-protect"]').click() @@ -838,4 +838,24 @@ describe('FlowForge - Application - DevOps Pipelines', () => { cy.get('[data-el="section-side-menu"] li:nth-of-type(4)').click() cy.get('[data-nav="disable-protect"]').click() }) + + it('should hide the pipelines tab from users with viewer roles', () => { + cy.intercept('GET', '/api/v1/teams/*/user', { role: 10 }).as('getTeamRole') + + cy.visit(`/application/${application.id}`) + + cy.get('[data-nav="application-pipelines"]').should('not.exist') + }) + + it('should redirect users to the instances overview when accessing the applications pipelines page', () => { + cy.intercept('GET', '/api/v1/teams/*/user', { role: 10 }).as('getTeamRole') + + cy.visit(`/application/${application.id}/devices`) + + cy.url().should('include', '/devices') + + cy.visit(`/application/${application.id}/pipelines`) + + cy.url().should('include', '/instances') + }) })