Gitter migration: Point people to app.gitter.im (rollout pt. 1)
[gitter.git] / public / js / views / modals / permissions-view.js
blobd111bf79fd474ac15b1f7ba7c177f561d0153545
1 'use strict';
3 var _ = require('lodash');
4 var Backbone = require('backbone');
5 var Marionette = require('backbone.marionette');
6 var $ = require('jquery');
7 var fuzzysearch = require('fuzzysearch');
8 var urlJoin = require('url-join');
9 var avatars = require('gitter-web-avatars');
10 var toggleClass = require('../../utils/toggle-class');
11 var apiClient = require('../../components/api-client');
12 const isGitlabSecurityDescriptorType = require('gitter-web-shared/is-gitlab-security-descriptor-type');
14 var ModalView = require('./modal');
15 var Typeahead = require('../controls/typeahead');
16 var userSearchModels = require('../../collections/user-search');
17 var userSearchItemTemplate = require('../app/tmpl/userSearchItem.hbs');
18 var PermissionsPeopleListView = require('./permissions/permissions-people-list-view');
19 var requestingSecurityDescriptorStatusConstants = require('./permissions/requesting-security-descriptor-status-constants');
20 var submitSecurityDescriptorStatusConstants = require('./permissions/requesting-security-descriptor-status-constants');
21 var template = require('./tmpl/permissions-view.hbs');
23 require('../behaviors/isomorphic');
25 var PermissionsView = Marionette.LayoutView.extend({
26   template: template,
28   ui: {
29     peopleInput: '.js-permissions-people-input',
30     permissionsOptionsWrapper: '.js-permissions-options-wrapper',
31     permissionsOptionsSelect: '.js-permissions-options-select',
32     permissionsOptionsSpinner: '.js-permissions-options-spinner',
33     permissionsOptionsErrorIcon: '.js-permissions-options-error-icon',
34     permissionsOptionsGithubIcon: '.js-permissions-options-github-icon',
35     permissionsOptionsGitlabIcon: '.js-permissions-options-gitlab-icon',
36     permissionsOptionsGitterIcon: '.js-permissions-options-gitter-icon',
37     extraAdminsNote: '.js-permissions-extra-admins-note',
38     extraAdminsList: '.js-permissions-admin-list',
39     sdWarning: '.js-permissions-sd-warning',
40     modelError: '.js-permissions-model-error',
41     submissionError: '.js-permissions-submission-error'
42   },
44   behaviors: {
45     Isomorphic: {
46       adminListView: { el: '.js-permissions-admin-list-root', init: 'initAdminListView' }
47     }
48   },
50   initAdminListView: function(optionsForRegion) {
51     this.adminListView = new PermissionsPeopleListView(
52       optionsForRegion({
53         collection: this.model.adminCollection
54       })
55     );
57     this.listenTo(this.adminListView, 'user:remove', this.onAdminRemoved, this);
58     return this.adminListView;
59   },
61   events: {
62     'change @ui.permissionsOptionsSelect': 'onPermissionsOptionsSelectChange'
63   },
65   modelEvents: {
66     'change:entity': 'onEntityChange',
67     'change:securityDescriptor': 'onSecurityDescriptorChange',
68     'change:requestingSecurityDescriptorStatus': 'onRequestingSecurityDescriptorStatusChange',
69     'change:submitSecurityDescriptorStatus': 'onSubmitSecurityDescriptorStatusChange'
70   },
72   initialize: function(/*attrs, options*/) {
73     this.initializeForEntity();
74     this.listenTo(this, 'menuItemClicked', this.menuItemClicked);
75     this.listenTo(this.model.adminCollection, 'add remove', this.onAdminCollectionChange);
76     this.listenTo(this.model.groupCollection, 'add remove', this.onGroupCollectionChange);
77   },
79   menuItemClicked: function(button) {
80     switch (button) {
81       case 'switch-to-group-entity':
82         var entity = this.model.get('entity');
83         var groupId = entity && entity.get('groupId');
85         var group = entity && entity.get('group');
86         if (groupId) {
87           group = this.model.groupCollection.get(groupId);
88         }
90         this.model.set({
91           entity: group
92         });
93         break;
94       case 'done':
95         this.submitNewSecurityDescriptor();
96         break;
97     }
98   },
100   serializeData: function() {
101     var data = this.model.toJSON();
102     data.entity = data.entity && data.entity.toJSON();
104     data.groupAvatarUrl = avatars.getForGroupId(data.entity.groupId || data.entity.id);
105     data.permissionOpts = this.getPermissionOptions();
107     return data;
108   },
110   onRender: function() {
111     this.typeahead = new Typeahead({
112       collection: new userSearchModels.Collection(),
113       itemTemplate: userSearchItemTemplate,
114       el: this.ui.peopleInput[0],
115       autoSelector: function(input) {
116         return function(m) {
117           var displayName = (m.get('displayName') || '').toLowerCase();
118           var username = (m.get('username') || '').toLowerCase();
120           return (
121             fuzzysearch(input.toLowerCase(), displayName) ||
122             fuzzysearch(input.toLowerCase(), username)
123           );
124         };
125       },
126       fetch: function(input, collection, fetchSuccess) {
127         this.collection.fetch(
128           {
129             data: {
130               q: input,
131               type: 'gitter'
132             }
133           },
134           {
135             add: true,
136             remove: true,
137             merge: true,
138             success: fetchSuccess
139           }
140         );
141       }
142     });
144     this.listenTo(this.typeahead, 'selected', this.onAdminSelected);
146     this.onRequestingSecurityDescriptorStatusChange();
147     this.onAdminCollectionChange();
149     this.rendered = true;
150   },
152   onPermissionsOptionsSelectChange: function() {
153     var currentSd = this.model.get('securityDescriptor');
154     var selectValue = this.ui.permissionsOptionsSelect.val();
156     this.model.set({
157       securityDescriptor: _.extend({}, currentSd, {
158         type: selectValue === 'null' ? null : selectValue
159       })
160     });
161   },
163   onGroupCollectionChange: function() {
164     if (this.rendered) {
165       this.updatePermissionSelect();
166     }
167   },
169   onAdminCollectionChange: function() {
170     toggleClass(this.ui.extraAdminsList[0], 'hidden', this.model.adminCollection.length === 0);
171     this.updateModelErrors();
172   },
174   onAdminSelected: function(user) {
175     this.ui.peopleInput.val('');
176     this.model.adminCollection.add([user]);
177     this.typeahead.dropdown.hide();
178   },
180   onAdminRemoved: function(user) {
181     this.model.adminCollection.remove(user);
182   },
184   initializeForEntity: function() {
185     this.fetchSecurityDescriptor();
186     this.fetchAdminUsers();
187   },
189   onEntityChange: function() {
190     this.model.set({
191       securityDescriptor: null
192     });
193     this.model.adminCollection.reset();
194     this.initializeForEntity();
195     this.updatePermissionSelect();
196   },
198   // eslint-disable-next-line complexity
199   onSecurityDescriptorChange: function() {
200     var sd = this.model.get('securityDescriptor');
201     var sdType = sd && sd.type;
202     toggleClass(this.ui.extraAdminsNote[0], 'hidden', !sdType);
204     var sdWarningString = '';
205     var initialSdType = this.model.get('initialSecurityDescriptorType');
206     var isInitialSdTypeNonReversable =
207       initialSdType === 'GL_GROUP' ||
208       initialSdType === 'GL_PROJECT' ||
209       initialSdType === 'GL_USER' ||
210       initialSdType === 'GH_ORG' ||
211       initialSdType === 'GH_REPO' ||
212       initialSdType === 'GH_USER';
213     if (isInitialSdTypeNonReversable && initialSdType !== sdType) {
214       let backendString = '';
215       if (
216         initialSdType === 'GH_ORG' ||
217         initialSdType === 'GH_REPO' ||
218         initialSdType === 'GH_USER'
219       ) {
220         backendString = 'GitHub';
221       } else if (isGitlabSecurityDescriptorType(initialSdType)) {
222         backendString = 'GitLab';
223       }
225       sdWarningString = `Warning, switching away from ${backendString}-based administrators is permanent. Once you have applied these changes, you cannot go back to ${backendString} based administrators.`;
226     }
227     this.ui.sdWarning.text(sdWarningString);
228     toggleClass(this.ui.sdWarning[0], 'hidden', sdWarningString.length === 0);
230     this.updatePermissionSelect();
231     this.updatePermissionOptionsIcons();
232     this.updateModelErrors();
233   },
235   onRequestingSecurityDescriptorStatusChange: function() {
236     this.updatePermissionOptionsIcons();
237   },
239   onSubmitSecurityDescriptorStatusChange: function() {
240     var status = this.model.get('submitSecurityDescriptorStatus');
241     var statusString = '';
243     if (status === submitSecurityDescriptorStatusConstants.ERROR) {
244       statusString = 'Problem submitting security descriptor';
245     }
247     this.ui.submissionError.text(statusString);
248     toggleClass(this.ui.submissionError[0], 'hidden', statusString.length === 0);
249   },
251   updateModelErrors: function() {
252     var modelIsValid = this.model.isValid();
253     var errors = !modelIsValid && this.model.validationError;
255     var errorStrings = (errors || []).map(function(error) {
256       return error.message;
257     });
258     var errorMessage = errorStrings.join('\n');
260     this.ui.modelError.text(errorMessage);
261     toggleClass(this.ui.modelError[0], 'hidden', errorMessage.length === 0);
262   },
264   updatePermissionSelect: function() {
265     var sd = this.model.get('securityDescriptor');
266     this.model.set('initialSecurityDescriptorType', sd && sd.type);
268     var permissionOpts = this.getPermissionOptions();
270     this.ui.permissionsOptionsSelect.html('');
271     permissionOpts.forEach(
272       function(opt) {
273         var optionEl = $('<option></option>');
274         optionEl.text(opt.label);
275         optionEl.attr('value', opt.value);
276         if (opt.selected) {
277           optionEl.attr('selected', opt.selected);
278         }
279         optionEl.appendTo(this.ui.permissionsOptionsSelect);
280       }.bind(this)
281     );
282   },
284   updatePermissionOptionsIcons: function() {
285     var state = this.model.get('requestingSecurityDescriptorStatus');
286     var isSpinnerHidden = state !== requestingSecurityDescriptorStatusConstants.PENDING;
287     var isErrorIconHidden = state !== requestingSecurityDescriptorStatusConstants.ERROR;
288     toggleClass(this.ui.permissionsOptionsSpinner[0], 'hidden', isSpinnerHidden);
289     toggleClass(this.ui.permissionsOptionsErrorIcon[0], 'hidden', isErrorIconHidden);
291     var sd = this.model.get('securityDescriptor');
292     var sdType = sd && sd.type;
294     if (isSpinnerHidden && isErrorIconHidden) {
295       toggleClass(
296         this.ui.permissionsOptionsGithubIcon[0],
297         'hidden',
298         sdType !== 'GH_ORG' && sdType !== 'GH_REPO' && sdType !== 'GH_USER'
299       );
300       toggleClass(
301         this.ui.permissionsOptionsGitlabIcon[0],
302         'hidden',
303         !isGitlabSecurityDescriptorType(sdType)
304       );
305       toggleClass(this.ui.permissionsOptionsGitterIcon[0], 'hidden', sdType !== 'GROUP' && sdType);
306     }
307   },
309   // eslint-disable-next-line complexity
310   getPermissionOptions: function() {
311     var entity = this.model.get('entity');
312     var sd = this.model.get('securityDescriptor');
313     var permissionOpts = [];
315     if (sd && sd.type === 'GH_ORG') {
316       permissionOpts.push({
317         value: 'GH_ORG',
318         label: 'Any member of the ' + sd.linkPath + ' organization on GitHub',
319         selected: sd.type === 'GH_ORG'
320       });
321     } else if (sd && sd.type === 'GH_REPO') {
322       permissionOpts.push({
323         value: 'GH_REPO',
324         label: 'Anyone with push access to the ' + sd.linkPath + ' repo on GitHub',
325         selected: sd.type === 'GH_REPO'
326       });
327     } else if (sd && sd.type === 'GH_USER') {
328       permissionOpts.push({
329         value: 'GH_USER',
330         label: `The @${sd.linkPath} user on GitHub`,
331         selected: sd.type === 'GL_PROJECT'
332       });
333     } else if (sd && sd.type === 'GL_GROUP') {
334       permissionOpts.push({
335         value: 'GL_GROUP',
336         label: `Anyone with maintainer access to the ${sd.linkPath} group on GitLab`,
337         selected: sd.type === 'GL_GROUP'
338       });
339     } else if (sd && sd.type === 'GL_PROJECT') {
340       permissionOpts.push({
341         value: 'GL_PROJECT',
342         label: `Anyone with maintainer access to the ${sd.linkPath} project on GitLab`,
343         selected: sd.type === 'GL_PROJECT'
344       });
345     } else if (sd && sd.type === 'GL_USER') {
346       permissionOpts.push({
347         value: 'GL_USER',
348         label: `The @${sd.linkPath} user on GitLab`,
349         selected: sd.type === 'GL_PROJECT'
350       });
351     }
353     var hasGitHubOpts = permissionOpts.length > 0;
355     var groupId = entity && entity.get('groupId');
356     var group = entity && entity.get('group');
357     if (groupId) {
358       group = this.model.groupCollection.get(groupId);
359     }
360     if (sd && group) {
361       permissionOpts.push({
362         value: 'GROUP',
363         label:
364           'Any administrator of the ' +
365           (group ? group.get('name') + ' ' : '') +
366           'community on Gitter',
367         selected: !hasGitHubOpts && sd.type === 'GROUP'
368       });
369     }
371     if (sd) {
372       permissionOpts.push({
373         value: 'null',
374         label: 'Only the users listed below',
375         // This accounts for undefined or null
376         selected: !sd.type
377       });
378     }
380     return permissionOpts;
381   },
383   getBaseApiEndpointForEntity: function() {
384     var entity = this.model.get('entity');
385     var baseEntityApiUrl = '/v1/groups';
386     // TODO: Better way to tell if it is a room or how to determine associated endpoint???
387     var isRoom = entity && entity.get('groupId');
388     if (isRoom) {
389       baseEntityApiUrl = '/v1/rooms';
390     }
392     return baseEntityApiUrl;
393   },
395   getApiEndpointForEntity: function() {
396     var entity = this.model.get('entity');
397     if (entity) {
398       return urlJoin(this.getBaseApiEndpointForEntity(), entity.get('id'));
399     }
400   },
402   fetchSecurityDescriptor: function() {
403     this.model.set(
404       'requestingSecurityDescriptorStatus',
405       requestingSecurityDescriptorStatusConstants.PENDING
406     );
407     var securityApiUrl = urlJoin(this.getApiEndpointForEntity(), 'security');
408     return apiClient
409       .get(securityApiUrl)
410       .bind(this)
411       .then(function(sd) {
412         this.model.set({
413           securityDescriptor: sd,
414           requestingSecurityDescriptorStatus: requestingSecurityDescriptorStatusConstants.COMPLETE
415         });
416       })
417       .catch(function() {
418         this.model.set(
419           'requestingSecurityDescriptorStatus',
420           requestingSecurityDescriptorStatusConstants.ERROR
421         );
422       });
423   },
425   fetchAdminUsers: function() {
426     var entity = this.model.get('entity');
428     if (entity) {
429       this.model.adminCollection.url = urlJoin(
430         this.getApiEndpointForEntity(),
431         'security/extraAdmins'
432       );
433       this.model.adminCollection.fetch();
434     }
435   },
437   submitNewSecurityDescriptor: function() {
438     var modelIsValid = this.model.isValid();
439     var errors = !modelIsValid && this.model.validationError;
441     if (errors) {
442       this.updateModelErrors();
443       return;
444     }
446     var securityApiUrl = urlJoin(this.getApiEndpointForEntity(), 'security');
447     var sd = this.model.get('securityDescriptor');
448     sd.extraAdmins = this.model.adminCollection.map(function(user) {
449       return user.get('id');
450     });
452     this.model.set(
453       'submitSecurityDescriptorStatus',
454       submitSecurityDescriptorStatusConstants.PENDING
455     );
456     return apiClient
457       .put(securityApiUrl, sd)
458       .bind(this)
459       .then(function(updatedSd) {
460         this.model.set({
461           securityDescriptor: updatedSd,
462           submitSecurityDescriptorStatus: submitSecurityDescriptorStatusConstants.COMPLETE
463         });
465         // Close the modal
466         this.dialog.hide();
467         this.dialog = null;
468       })
469       .catch(function() {
470         this.model.set(
471           'submitSecurityDescriptorStatus',
472           submitSecurityDescriptorStatusConstants.ERROR
473         );
474       });
475   }
478 var Modal = ModalView.extend({
479   disableAutoFocus: true,
481   modelEvents: {
482     'change:entity': 'onEntityChange'
483   },
485   initialize: function(options) {
486     options = options || {};
487     options.title = this.getModalTitle();
488     this.options = options;
490     options.menuItems = new Backbone.Collection(
491       this.generateMenuItems().concat(options.menuItems || [])
492     );
494     ModalView.prototype.initialize.call(this, options);
495     this.view = new PermissionsView(
496       _.extend({}, options, {
497         menuItemCollection: options.menuItems
498       })
499     );
500   },
502   onEntityChange: function() {
503     this.updateModalTitle();
504     this.updateMenuItems();
505   },
507   updateModalTitle: function() {
508     this.ui.title.text(this.getModalTitle());
509   },
511   updateMenuItems: function() {
512     this.options.menuItems.set(this.generateMenuItems(), { merge: true });
513   },
515   getModalTitle: function() {
516     var model = this.model;
517     var entity = model && model.get('entity');
519     var title = 'Community Permissions';
521     if (entity) {
522       // TODO: Better way to tell if it is a room or how to determine associated endpoint???
523       var isRoom = entity && entity.get('groupId');
525       title = entity.get('name') + ' ' + (isRoom ? 'Room' : 'Community') + ' Permissions';
526     }
528     return title;
529   },
531   generateMenuItems: function() {
532     var model = this.model;
533     var entity = model && model.get('entity');
535     var items = [];
537     if (entity) {
538       var groupId = entity.get('groupId');
539       if (groupId) {
540         items.push({
541           action: 'switch-to-group-entity',
542           pull: 'left',
543           text: 'Edit Community Permissions',
544           className: 'modal--default__footer__link'
545         });
546       }
547     }
549     items.push({
550       action: 'done',
551       pull: 'right',
552       text: 'Done',
553       className: 'modal--default__footer__btn'
554     });
556     return items;
557   }
560 module.exports = {
561   View: PermissionsView,
562   Modal: Modal