From 4b9933156544e8c504a668ee23eaf7bb7afc3152 Mon Sep 17 00:00:00 2001 From: Aditya Bhardwaj Date: Mon, 8 Jul 2019 08:45:12 +0530 Subject: [PATCH] Service accounts feature (#62) * [NEW] Service Account Admin Settings and Configuration files added * [NEW] Service Account Admin Settings and Configuration files added * [NEW] Service Account Admin Settings and Configuration files added * [NEW] Service Account Admin Settings and Configuration files added * Service Account Creation dialog added * [NEW] Service Account Creation method * Service Account owner username update method added * Fixed CLI errors * Fixed CLI errors * Service Account creation heading fixed * Service Account broadcast room callback added * Service Account creation method refactored * Service Account Callback completed * Typos fixed * CLI errors fixed * [NEW] Service Account one-tap login complete * Callbacks modified * Service Accounts directory tab added * Refactored creation method and added tests * CLI errors fixed * CLI errors fixed * Bugs fixed * [NEW] Service Accounts Login method * Typo fixed * CLI errors fixed * CLI errors fixed * [New] Service Account directory feature * CLI errors fixed * UsernameExists meteor method fixed * Sync commit * [NEW] Service Account subscription method added * [NEW] Service account subscription sidenav type * Broadcast Room name change handled * Lint errors fixed * getLoginToken method refactored * Console statements removed * Sidebar header permission modified * Merge branch service-accounts * Added service account directory search translation key * Subscribers count added * [NEW] Service Account sidenav type * Unread counter added in popver * Get linked service account method added --- app/api/server/v1/users.js | 1 + app/lib/server/functions/saveUser.js | 12 +- app/lib/server/functions/setUsername.js | 3 + app/models/server/models/Rooms.js | 12 ++ app/models/server/models/Subscriptions.js | 12 ++ app/models/server/models/Users.js | 86 +++++++++++++ app/service-accounts/client/index.js | 10 ++ app/service-accounts/client/route.js | 14 +++ app/service-accounts/client/startup.js | 11 ++ .../creationDialog/createServiceAccount.html | 86 +++++++++++++ .../creationDialog/createServiceAccount.js | 117 ++++++++++++++++++ .../client/views/serviceAccountDashboard.html | 90 ++++++++++++++ .../client/views/serviceAccountDashboard.js | 89 +++++++++++++ .../views/serviceAccountSidebarLogin.html | 32 +++++ .../views/serviceAccountSidebarLogin.js | 70 +++++++++++ .../client/views/serviceAccountsList.html | 10 ++ .../client/views/serviceAccountsList.js | 29 +++++ .../lib/serviceAccountRoomType.js | 22 ++++ app/service-accounts/server/api/rest.js | 1 + .../server/api/v1/serviceAccounts.js | 20 +++ app/service-accounts/server/config.js | 29 +++++ .../server/hooks/serviceAccountCallback.js | 19 +++ app/service-accounts/server/index.js | 15 +++ .../server/methods/addServiceAccount.js | 55 ++++++++ .../methods/getLinkedServiceAccounts.js | 22 ++++ .../server/methods/getLoginToken.js | 25 ++++ .../server/methods/usernameExists.js | 17 +++ app/service-accounts/server/permissions.js | 17 +++ .../publications/fullServiceAccountData.js | 33 +++++ app/ui-account/client/accountPreferences.html | 7 ++ app/ui-account/client/accountPreferences.js | 1 + app/ui-account/client/accountProfile.js | 4 +- app/ui-sidenav/client/roomList.js | 5 + app/ui-sidenav/client/sidebarHeader.js | 98 ++++++++++----- app/ui-sidenav/client/sortlist.html | 9 ++ app/ui-sidenav/client/sortlist.js | 3 + app/ui/client/views/app/directory.html | 61 ++++++++- app/ui/client/views/app/directory.js | 23 +++- .../server/functions/getDefaultUserFields.js | 1 + client/importPackages.js | 1 + packages/rocketchat-i18n/i18n/en.i18n.json | 17 +++ server/importPackages.js | 1 + server/lib/accounts.js | 9 +- server/methods/browseChannels.js | 45 ++++++- server/methods/createDirectMessage.js | 4 + server/methods/saveUserPreferences.js | 1 + server/publications/subscription/index.js | 1 + tests/end-to-end/api/00-miscellaneous.js | 1 + tests/end-to-end/api/01-users.js | 31 +++++ 49 files changed, 1246 insertions(+), 36 deletions(-) create mode 100644 app/service-accounts/client/index.js create mode 100644 app/service-accounts/client/route.js create mode 100644 app/service-accounts/client/startup.js create mode 100644 app/service-accounts/client/views/creationDialog/createServiceAccount.html create mode 100644 app/service-accounts/client/views/creationDialog/createServiceAccount.js create mode 100644 app/service-accounts/client/views/serviceAccountDashboard.html create mode 100644 app/service-accounts/client/views/serviceAccountDashboard.js create mode 100644 app/service-accounts/client/views/serviceAccountSidebarLogin.html create mode 100644 app/service-accounts/client/views/serviceAccountSidebarLogin.js create mode 100644 app/service-accounts/client/views/serviceAccountsList.html create mode 100644 app/service-accounts/client/views/serviceAccountsList.js create mode 100644 app/service-accounts/lib/serviceAccountRoomType.js create mode 100644 app/service-accounts/server/api/rest.js create mode 100644 app/service-accounts/server/api/v1/serviceAccounts.js create mode 100644 app/service-accounts/server/config.js create mode 100644 app/service-accounts/server/hooks/serviceAccountCallback.js create mode 100644 app/service-accounts/server/index.js create mode 100644 app/service-accounts/server/methods/addServiceAccount.js create mode 100644 app/service-accounts/server/methods/getLinkedServiceAccounts.js create mode 100644 app/service-accounts/server/methods/getLoginToken.js create mode 100644 app/service-accounts/server/methods/usernameExists.js create mode 100644 app/service-accounts/server/permissions.js create mode 100644 app/service-accounts/server/publications/fullServiceAccountData.js diff --git a/app/api/server/v1/users.js b/app/api/server/v1/users.js index 8512a5032526..47eb8a62a415 100644 --- a/app/api/server/v1/users.js +++ b/app/api/server/v1/users.js @@ -522,6 +522,7 @@ API.v1.addRoute('users.setPreferences', { authRequired: true }, { sidebarHideAvatar: Match.Optional(Boolean), sidebarGroupByType: Match.Optional(Boolean), sidebarShowDiscussion: Match.Optional(Boolean), + sidebarShowServiceAccounts: Match.Optional(Boolean), muteFocusedConversations: Match.Optional(Boolean), }), }); diff --git a/app/lib/server/functions/saveUser.js b/app/lib/server/functions/saveUser.js index 081dc7755996..5d1083a15fce 100644 --- a/app/lib/server/functions/saveUser.js +++ b/app/lib/server/functions/saveUser.js @@ -166,7 +166,9 @@ export const saveUser = function(userId, userData) { validateUserData(userId, userData); if (!userData._id) { - validateEmailDomain(userData.email); + if (userData.email) { + validateEmailDomain(userData.email); + } // insert user const createUser = { @@ -177,6 +179,10 @@ export const saveUser = function(userId, userData) { if (userData.email) { createUser.email = userData.email; } + if (userData.u) { + createUser.u = userData.u; + createUser.active = userData.active; + } const _id = Accounts.createUser(createUser); @@ -199,6 +205,10 @@ export const saveUser = function(userId, userData) { updateUser.$set['emails.0.verified'] = userData.verified; } + if (typeof userData.description !== 'undefined') { + updateUser.$set.description = userData.description; + } + Meteor.users.update({ _id }, updateUser); if (userData.sendWelcomeEmail) { diff --git a/app/lib/server/functions/setUsername.js b/app/lib/server/functions/setUsername.js index baed79351cf8..24e58416102a 100644 --- a/app/lib/server/functions/setUsername.js +++ b/app/lib/server/functions/setUsername.js @@ -76,9 +76,12 @@ export const _setUsername = function(userId, u) { Rooms.replaceUsername(previousUsername, username); Rooms.replaceMutedUsername(previousUsername, username); Rooms.replaceUsernameOfUserByUserId(user._id, username); + Rooms.replaceServiceAccountBroadcastRoomName(previousUsername, username); Subscriptions.setUserUsernameByUserId(user._id, username); Subscriptions.setNameForDirectRoomsWithOldName(previousUsername, username); + Subscriptions.replaceServiceAccountBroadcastRoomName(previousUsername, username); LivechatDepartmentAgents.replaceUsernameOfAgentByUserId(user._id, username); + Users.setOwnerUsernameByUserId(user._id, username); const fileStore = FileUpload.getStore('Avatars'); const file = fileStore.model.findOneByName(previousUsername); diff --git a/app/models/server/models/Rooms.js b/app/models/server/models/Rooms.js index 2d7f38770932..24b205dcf97e 100644 --- a/app/models/server/models/Rooms.js +++ b/app/models/server/models/Rooms.js @@ -1072,6 +1072,18 @@ export class Rooms extends Base { return this.update(query, update, { multi: true }); } + replaceServiceAccountBroadcastRoomName(previousUsername, username) { + const query = { name: `broadcast_${ previousUsername }` }; + + const update = { + $set: { + name: `broadcast_${ username }`, + }, + }; + + return this.update(query, update); + } + setJoinCodeById(_id, joinCode) { let update; const query = { _id }; diff --git a/app/models/server/models/Subscriptions.js b/app/models/server/models/Subscriptions.js index 93813847b88a..03fb3e9c6a01 100644 --- a/app/models/server/models/Subscriptions.js +++ b/app/models/server/models/Subscriptions.js @@ -1206,6 +1206,18 @@ export class Subscriptions extends Base { return this.update(query, update, { multi: true }); } + replaceServiceAccountBroadcastRoomName(previousUsername, username) { + const query = { name: `broadcast_${ previousUsername }` }; + + const update = { + $set: { + name: `broadcast_${ username }`, + }, + }; + + return this.update(query, update); + } + // INSERT createWithRoomAndUser(room, user, extraData) { const subscription = { diff --git a/app/models/server/models/Users.js b/app/models/server/models/Users.js index 734370cb2da5..c28def2a928a 100644 --- a/app/models/server/models/Users.js +++ b/app/models/server/models/Users.js @@ -21,6 +21,7 @@ export class Users extends Base { this.tryEnsureIndex({ type: 1 }); this.tryEnsureIndex({ 'visitorEmails.address': 1 }); this.tryEnsureIndex({ federation: 1 }, { sparse: true }); + this.tryEnsureIndex({ 'u._id': 1 }); } getLoginTokensByUserId(userId) { @@ -518,6 +519,9 @@ export class Users extends Base { { username: { $exists: true, $nin: exceptions }, }, + { + u: { $exists: false }, + }, ...extraQuery, ], }; @@ -534,6 +538,9 @@ export class Users extends Base { { 'federation.peer': localPeer }, ], }, + { + u: { $exists: false }, + }, ]; return this.findByActiveUsersExcept(searchTerm, exceptions, options, forcedSearchFields, extraQuery); } @@ -542,10 +549,72 @@ export class Users extends Base { const extraQuery = [ { federation: { $exists: true } }, { 'federation.peer': { $ne: localPeer } }, + { + u: { $exists: false }, + }, ]; return this.findByActiveUsersExcept(searchTerm, exceptions, options, forcedSearchFields, extraQuery); } + findByActiveServiceAccountsExcept(searchTerm, exceptions, options, forcedSearchFields, extraQuery = []) { + if (exceptions == null) { exceptions = []; } + if (options == null) { options = {}; } + if (!_.isArray(exceptions)) { + exceptions = [exceptions]; + } + + const termRegex = new RegExp(s.escapeRegExp(searchTerm), 'i'); + const searchFields = forcedSearchFields || settings.get('Service_Accounts_SearchFields').trim().split(','); + const orStmt = _.reduce(searchFields, function(acc, el) { + acc.push({ [el.trim()]: termRegex }); + return acc; + }, []); + + const query = { + $and: [ + { + active: true, + $or: orStmt, + }, + { + username: { $exists: true, $nin: exceptions }, + }, + { + u: { $exists: true }, + }, + ...extraQuery, + ], + }; + + return this._db.find(query, options); + } + + findByActiveExternalServiceAccountsExcept(searchTerm, exceptions, options, forcedSearchFields, localPeer) { + const extraQuery = [ + { federation: { $exists: true } }, + { 'federation.peer': { $ne: localPeer } }, + { + u: { $exists: true }, + }, + ]; + return this.findByActiveServiceAccountsExcept(searchTerm, exceptions, options, forcedSearchFields, extraQuery); + } + + findByActiveLocalServiceAccountsExcept(searchTerm, exceptions, options, forcedSearchFields, localPeer) { + const extraQuery = [ + { + $or: [ + { federation: { $exists: false } }, + { 'federation.peer': localPeer }, + ], + }, + { + u: { $exists: true }, + }, + ]; + return this.findByActiveServiceAccountsExcept(searchTerm, exceptions, options, forcedSearchFields, extraQuery); + } + findUsersByNameOrUsername(nameOrUsername, options) { const query = { username: { @@ -666,6 +735,12 @@ export class Users extends Base { return this.findOne(query, options); } + findLinkedServiceAccounts(_id, options) { + const query = { 'u._id': _id }; + + return this.find(query, options); + } + // UPDATE addImportIds(_id, importIds) { importIds = [].concat(importIds); @@ -1057,6 +1132,17 @@ export class Users extends Base { }); } + setOwnerUsernameByUserId(userId, username) { + const query = { 'u._id': userId }; + const update = { + $set: { + 'u.username': username, + }, + }; + + return this.update(query, update, { multi: true }); + } + // INSERT create(data) { const user = { diff --git a/app/service-accounts/client/index.js b/app/service-accounts/client/index.js new file mode 100644 index 000000000000..35078e74e4c0 --- /dev/null +++ b/app/service-accounts/client/index.js @@ -0,0 +1,10 @@ +import './startup'; +import './route'; + +// views +import './views/serviceAccountDashboard'; +import './views/creationDialog/createServiceAccount'; +import './views/serviceAccountsList'; + +import '../lib/serviceAccountRoomType'; +import './views/serviceAccountSidebarLogin'; diff --git a/app/service-accounts/client/route.js b/app/service-accounts/client/route.js new file mode 100644 index 000000000000..427712b18604 --- /dev/null +++ b/app/service-accounts/client/route.js @@ -0,0 +1,14 @@ +import { FlowRouter } from 'meteor/kadira:flow-router'; +import { BlazeLayout } from 'meteor/kadira:blaze-layout'; + +import { t } from '../../utils'; + +FlowRouter.route('/admin/serviceaccount', { + name: 'admin-serviceaccount', + action() { + return BlazeLayout.render('main', { + center: 'serviceAccountDashboard', + pageTitle: t('Service_account_applied'), + }); + }, +}); diff --git a/app/service-accounts/client/startup.js b/app/service-accounts/client/startup.js new file mode 100644 index 000000000000..410b0d452ec1 --- /dev/null +++ b/app/service-accounts/client/startup.js @@ -0,0 +1,11 @@ +import { AdminBox } from '../../ui-utils'; +import { hasAtLeastOnePermission } from '../../authorization'; + +AdminBox.addOption({ + icon: 'discover', + href: 'admin/serviceaccount', + i18nLabel: 'Service_account_dashboard', + permissionGranted() { + return hasAtLeastOnePermission(['view-sa-request']); + }, +}); diff --git a/app/service-accounts/client/views/creationDialog/createServiceAccount.html b/app/service-accounts/client/views/creationDialog/createServiceAccount.html new file mode 100644 index 000000000000..3d8b968cb879 --- /dev/null +++ b/app/service-accounts/client/views/creationDialog/createServiceAccount.html @@ -0,0 +1,86 @@ + \ No newline at end of file diff --git a/app/service-accounts/client/views/creationDialog/createServiceAccount.js b/app/service-accounts/client/views/creationDialog/createServiceAccount.js new file mode 100644 index 000000000000..9384605c9683 --- /dev/null +++ b/app/service-accounts/client/views/creationDialog/createServiceAccount.js @@ -0,0 +1,117 @@ +import { Meteor } from 'meteor/meteor'; +import { ReactiveVar } from 'meteor/reactive-var'; +import { Template } from 'meteor/templating'; +import toastr from 'toastr'; +import _ from 'underscore'; + +import { t, handleError } from '../../../../utils'; +import { modal } from '../../../../ui-utils'; + +import './createServiceAccount.html'; + +Template.createServiceAccount.helpers({ + inUse() { + return Template.instance().inUse.get(); + }, + notMatch() { + return Template.instance().notMatch.get(); + }, + createIsDisabled() { + const instance = Template.instance(); + const username = instance.username.get(); + const name = instance.name.get(); + const password = instance.password.get(); + const description = instance.description.get(); + const inUse = instance.inUse.get(); + const notMatch = instance.notMatch.get(); + const confirmPassword = instance.confirmPassword.get(); + + if (username.length === 0 || name.length === 0 || password.length === 0 || confirmPassword.length === 0 || description.length === 0 || inUse || notMatch) { + return 'disabled'; + } + return ''; + }, +}); + +Template.createServiceAccount.events({ + 'input [name="username"]'(e, t) { + const { value } = e.target; + t.inUse.set(undefined); + t.checkUsername(value.trim()); + t.username.set(value.trim()); + }, + 'input [name="name"]'(e, t) { + const { value } = e.target; + t.name.set(value); + }, + 'input [name="password"]'(e, t) { + const { value } = e.target; + t.password.set(value); + }, + 'input [name="confirmPassword"]'(e, t) { + const { value } = e.target; + t.confirmPassword.set(value); + t.matchPassword(t.password.get(), value); + }, + 'mouseover [name="password"]'(e) { + e.target.type = 'text'; + }, + 'mouseover [name="confirmPassword"]'(e) { + e.target.type = 'text'; + }, + 'mouseout [name="password"]'(e) { + e.target.type = 'password'; + }, + 'mouseout [name="confirmPassword"]'(e) { + e.target.type = 'password'; + }, + 'input [name="description"]'(e, t) { + const { value } = e.target; + t.description.set(value); + }, + async 'submit #create-service-account'(event, instance) { + event.preventDefault(); + event.stopPropagation(); + const username = instance.username.get(); + const name = instance.name.get(); + const password = instance.password.get(); + const description = instance.description.get(); + const userData = {}; + userData.username = username; + userData.name = name; + userData.password = password; + userData.description = description; + Meteor.call('addServiceAccount', userData, (error) => { + if (error) { + return handleError(error); + } + toastr.success(t('Service_account_created_successfully')); + modal.close(); + }); + }, +}); + +Template.createServiceAccount.onCreated(function() { + this.username = new ReactiveVar(''); + this.name = new ReactiveVar(''); + this.password = new ReactiveVar(''); + this.confirmPassword = new ReactiveVar(''); + this.description = new ReactiveVar(''); + this.inUse = new ReactiveVar(undefined); + this.notMatch = new ReactiveVar(false); + this.checkUsername = _.debounce(function(name) { + return Meteor.call('usernameExists', name, (error, result) => { + if (error) { + return; + } + this.inUse.set(result); + }); + }, 1000); + this.matchPassword = function(password, confirmPassword) { + if (password !== confirmPassword) { + this.notMatch.set(true); + } else { + this.notMatch.set(false); + } + }; +}); diff --git a/app/service-accounts/client/views/serviceAccountDashboard.html b/app/service-accounts/client/views/serviceAccountDashboard.html new file mode 100644 index 000000000000..ae968a254501 --- /dev/null +++ b/app/service-accounts/client/views/serviceAccountDashboard.html @@ -0,0 +1,90 @@ + \ No newline at end of file diff --git a/app/service-accounts/client/views/serviceAccountDashboard.js b/app/service-accounts/client/views/serviceAccountDashboard.js new file mode 100644 index 000000000000..aaaffc1483d7 --- /dev/null +++ b/app/service-accounts/client/views/serviceAccountDashboard.js @@ -0,0 +1,89 @@ +import { Meteor } from 'meteor/meteor'; +import { ReactiveVar } from 'meteor/reactive-var'; +import { Template } from 'meteor/templating'; +import toastr from 'toastr'; +import { Tracker } from 'meteor/tracker'; +import _ from 'underscore'; + +import { t } from '../../../utils/client'; +import { handleError } from '../../../utils/client/lib/handleError'; +import { SideNav } from '../../../ui-utils'; +import { hasAllPermission } from '../../../authorization/client/hasPermission'; +import FullUser from '../../../models/client/models/FullUser'; +import './serviceAccountDashboard.html'; + +const success = (fn) => function(error, result) { + if (error) { + return handleError(error); + } + if (result) { + fn.call(this, result); + } +}; + +Template.serviceAccountDashboard.helpers({ + isReady() { + const instance = Template.instance(); + return instance.ready && instance.ready.get(); + }, + users() { + return Template.instance().users(); + }, + hasPermission() { + return hasAllPermission('view-sa-request'); + }, + hasUsers() { + return Template.instance().users() && Template.instance().users().length > 0; + }, + emailAddress() { + return _.map(this.emails, function(e) { return e.address; }).join(', '); + }, + hasMore() { + const instance = Template.instance(); + const users = instance.users(); + if (instance.limit && instance.limit.get() && users && users.length) { + return instance.limit.get() === users.length; + } + }, +}); + +Template.serviceAccountDashboard.events({ + 'click .accept-service-account'(e) { + e.preventDefault(); + Meteor.call('authorization:addUserToRole', 'service-account-approved', this.u.username, null, success(() => { + Meteor.call('setUserActiveStatus', this._id, true, success(() => toastr.success(t('User_has_been_activated')))); + })); + }, + 'click .reject-service-account'(e) { + e.preventDefault(); + Meteor.call('deleteUser', this._id, success(() => { + toastr.success(t('User_has_been_deleted')); + })); + }, +}); + +Template.serviceAccountDashboard.onCreated(function() { + const instance = this; + this.ready = new ReactiveVar(true); + + this.autorun(() => { + const subscription = instance.subscribe('fullServiceAccountData'); + instance.ready.set(subscription.ready()); + }); + this.users = function() { + const query = { + u: { + $exists: true, + }, + active: false, + }; + return FullUser.find(query, { sort: { username: 1, name: 1 } }).fetch(); + }; +}); + +Template.serviceAccountDashboard.onRendered(() => { + Tracker.afterFlush(() => { + SideNav.setFlex('adminFlex'); + SideNav.openFlex(); + }); +}); diff --git a/app/service-accounts/client/views/serviceAccountSidebarLogin.html b/app/service-accounts/client/views/serviceAccountSidebarLogin.html new file mode 100644 index 000000000000..914863701fd6 --- /dev/null +++ b/app/service-accounts/client/views/serviceAccountSidebarLogin.html @@ -0,0 +1,32 @@ + diff --git a/app/service-accounts/client/views/serviceAccountSidebarLogin.js b/app/service-accounts/client/views/serviceAccountSidebarLogin.js new file mode 100644 index 000000000000..98a86d213ba5 --- /dev/null +++ b/app/service-accounts/client/views/serviceAccountSidebarLogin.js @@ -0,0 +1,70 @@ +import { Meteor } from 'meteor/meteor'; +import { ReactiveVar } from 'meteor/reactive-var'; +import { Template } from 'meteor/templating'; +import { FlowRouter } from 'meteor/kadira:flow-router'; + +import { handleError } from '../../../utils'; +import './serviceAccountSidebarLogin.html'; + +Template.serviceAccountSidebarLogin.helpers({ + loading() { + return Template.instance().loading.get(); + }, + users() { + return Template.instance().users.get(); + }, + hasServiceAccounts() { + return Template.instance().users.get() && Template.instance().users.get().length > 0; + }, + owner() { + return Meteor.user().u; + }, + showOwnerAccountLink() { + return localStorage.getItem('serviceAccountForceLogin') && !!Meteor.user().u; + }, +}); + +Template.serviceAccountSidebarLogin.events({ + 'click .js-login'(e) { + e.preventDefault(); + let { username } = this; + if (Meteor.user().u) { + username = Meteor.user().u.username; + } + Meteor.call('getLoginToken', username, function(error, token) { + if (error) { + return handleError(error); + } + FlowRouter.go('/home'); + Meteor.loginWithToken(token.token, (err) => { + if (err) { + return handleError(err); + } + document.location.reload(true); + if (Meteor.user().u) { + localStorage.setItem('serviceAccountForceLogin', true); + } else { + localStorage.removeItem('serviceAccountForceLogin'); + } + }); + }); + }, +}); + +Template.serviceAccountSidebarLogin.onCreated(function() { + const instance = this; + this.ready = new ReactiveVar(true); + this.users = new ReactiveVar([]); + this.loading = new ReactiveVar(true); + this.autorun(() => { + instance.loading.set(true); + Meteor.call('getLinkedServiceAccounts', function(err, serviceAccounts) { + if (err) { + this.loading.set(false); + return handleError(err); + } + instance.users.set(serviceAccounts); + instance.loading.set(false); + }); + }); +}); diff --git a/app/service-accounts/client/views/serviceAccountsList.html b/app/service-accounts/client/views/serviceAccountsList.html new file mode 100644 index 000000000000..98819ed29d0c --- /dev/null +++ b/app/service-accounts/client/views/serviceAccountsList.html @@ -0,0 +1,10 @@ + diff --git a/app/service-accounts/client/views/serviceAccountsList.js b/app/service-accounts/client/views/serviceAccountsList.js new file mode 100644 index 000000000000..9ccc500b69b4 --- /dev/null +++ b/app/service-accounts/client/views/serviceAccountsList.js @@ -0,0 +1,29 @@ +import { Meteor } from 'meteor/meteor'; +import { Template } from 'meteor/templating'; + +import { ChatSubscription } from '../../../models/client'; +import { getUserPreference } from '../../../utils/client'; +import { settings } from '../../../settings/client'; + +import './serviceAccountsList.html'; + +Template.serviceAccountsList.helpers({ + rooms() { + const user = Meteor.userId(); + const sortBy = getUserPreference(user, 'sidebarSortby') || 'alphabetical'; + const query = { + open: true, + }; + + const sort = {}; + + if (sortBy === 'activity') { + sort.lm = -1; + } else { // alphabetical + sort[this.identifier === 'd' && settings.get('UI_Use_Real_Name') ? 'lowerCaseFName' : 'lowerCaseName'] = /descending/.test(sortBy) ? -1 : 1; + } + + query.sa = { $exists: true }; + return ChatSubscription.find(query, { sort }); + }, +}); diff --git a/app/service-accounts/lib/serviceAccountRoomType.js b/app/service-accounts/lib/serviceAccountRoomType.js new file mode 100644 index 000000000000..f3f18c3e6f25 --- /dev/null +++ b/app/service-accounts/lib/serviceAccountRoomType.js @@ -0,0 +1,22 @@ +import { Meteor } from 'meteor/meteor'; + +import { RoomTypeConfig, roomTypes, getUserPreference } from '../../utils'; + +export class ServiceAccountRoomType extends RoomTypeConfig { + constructor() { + super({ + identifier: 'sa', + order: 60, + label: 'Subscriptions', + }); + + // we need a custom template in order to have a custom query showing the subscriptions to serviceAccounts + this.customTemplate = 'serviceAccountsList'; + } + + condition() { + return getUserPreference(Meteor.userId(), 'sidebarShowServiceAccounts'); + } +} + +roomTypes.add(new ServiceAccountRoomType()); diff --git a/app/service-accounts/server/api/rest.js b/app/service-accounts/server/api/rest.js new file mode 100644 index 000000000000..6a6beb89e4a8 --- /dev/null +++ b/app/service-accounts/server/api/rest.js @@ -0,0 +1 @@ +import './v1/serviceAccounts'; diff --git a/app/service-accounts/server/api/v1/serviceAccounts.js b/app/service-accounts/server/api/v1/serviceAccounts.js new file mode 100644 index 000000000000..e7dc3bc0ee2c --- /dev/null +++ b/app/service-accounts/server/api/v1/serviceAccounts.js @@ -0,0 +1,20 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; + +import { API } from '../../../../api'; +import { Users } from '../../../../models'; + +API.v1.addRoute('serviceAccounts.create', { authRequired: true }, { + post() { + check(this.bodyParams, { + name: String, + password: String, + username: String, + description: String, + }); + Meteor.runAsUser(this.userId, () => { + Meteor.call('addServiceAccount', this.bodyParams); + }); + return API.v1.success({ user: Users.findOneByUsername(this.bodyParams.username, { fields: API.v1.defaultFieldsToExclude }) }); + }, +}); diff --git a/app/service-accounts/server/config.js b/app/service-accounts/server/config.js new file mode 100644 index 000000000000..45f2ab361af5 --- /dev/null +++ b/app/service-accounts/server/config.js @@ -0,0 +1,29 @@ +import { Meteor } from 'meteor/meteor'; + +import { settings } from '../../settings'; + +Meteor.startup(() => { + settings.addGroup('Service Accounts', function() { + this.add('Service_account_enabled', true, { + group: 'Service Accounts', + i18nLabel: 'Enable', + type: 'boolean', + public: true, + }); + this.add('Service_account_limit', 3, { + type: 'int', + public: true, + }); + this.add('Service_Accounts_SearchFields', 'username, name, description', { + type: 'string', + public: true, + }); + }); + settings.add('Accounts_Default_User_Preferences_sidebarShowServiceAccounts', true, { + group: 'Accounts', + section: 'Accounts_Default_User_Preferences', + type: 'boolean', + public: true, + i18nLabel: 'Group_subscriptions', + }); +}); diff --git a/app/service-accounts/server/hooks/serviceAccountCallback.js b/app/service-accounts/server/hooks/serviceAccountCallback.js new file mode 100644 index 000000000000..c97766eb5f5f --- /dev/null +++ b/app/service-accounts/server/hooks/serviceAccountCallback.js @@ -0,0 +1,19 @@ +import { callbacks } from '../../../callbacks/server'; +import { Rooms } from '../../../models/server'; +import { createRoom } from '../../../lib/server/functions'; + +callbacks.add('afterCreateUser', (user) => { + if (!user || !user.u) { + return user; + } + + const extraData = { + sa: true, + }; + + if (!Rooms.findOneByName(`broadcast_${ user.username }`)) { + createRoom('p', `broadcast_${ user.username }`, user.username, [], false, extraData, {}); + } + + return user; +}); diff --git a/app/service-accounts/server/index.js b/app/service-accounts/server/index.js new file mode 100644 index 000000000000..82ecebac65c1 --- /dev/null +++ b/app/service-accounts/server/index.js @@ -0,0 +1,15 @@ +import './config'; +import './permissions'; +import './api/rest'; + +// methods +import './methods/usernameExists'; +import './methods/addServiceAccount'; +import './methods/getLoginToken'; +import './methods/getLinkedServiceAccounts'; + +import './hooks/serviceAccountCallback'; + +import './publications/fullServiceAccountData'; + +import '../lib/serviceAccountRoomType'; diff --git a/app/service-accounts/server/methods/addServiceAccount.js b/app/service-accounts/server/methods/addServiceAccount.js new file mode 100644 index 000000000000..596bf9e8d312 --- /dev/null +++ b/app/service-accounts/server/methods/addServiceAccount.js @@ -0,0 +1,55 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import _ from 'underscore'; + +import { saveUser, checkUsernameAvailability } from '../../../lib/server/functions'; +import { Users } from '../../../models'; +import { settings } from '../../../settings'; +import { hasPermission, hasRole } from '../../../authorization/server'; + +Meteor.methods({ + addServiceAccount(userData) { + check(userData, Object); + + if (!Meteor.userId()) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'addServiceAccount' }); + } + + if (!hasPermission(Meteor.userId(), 'create-service-account')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'addServiceAccount' }); + } + + let nameValidation; + try { + nameValidation = new RegExp(`^${ settings.get('UTF8_Names_Validation') }$`); + } catch (error) { + nameValidation = new RegExp('^[0-9a-zA-Z-_.]+$'); + } + + if (!nameValidation.test(userData.username)) { + throw new Meteor.Error('username-invalid', `${ _.escape(userData.username) } is not a valid username, use only letters, numbers, dots, hyphens and underscores`); + } + + if (!checkUsernameAvailability(userData.username)) { + throw new Meteor.Error('error-field-unavailable', `${ _.escape(userData.username) } is already in use :(`, { method: 'addServiceAccount' }); + } + + const user = Meteor.user(); + const serviceAccounts = Users.findLinkedServiceAccounts(user._id, {}); + const limit = settings.get('Service_account_limit'); + + if (serviceAccounts.count() >= limit) { + throw new Meteor.Error('error-not-allowed', 'Max service account limit reached', { method: 'addServiceAccount' }); + } + + userData.u = { + _id: user._id, + username: user.username, + }; + userData.joinDefaultChannels = false; + userData.roles = ['user']; + + userData.active = hasRole(user._id, 'service-account-approved'); + return saveUser(Meteor.userId(), userData); + }, +}); diff --git a/app/service-accounts/server/methods/getLinkedServiceAccounts.js b/app/service-accounts/server/methods/getLinkedServiceAccounts.js new file mode 100644 index 000000000000..82390927adee --- /dev/null +++ b/app/service-accounts/server/methods/getLinkedServiceAccounts.js @@ -0,0 +1,22 @@ +import { Meteor } from 'meteor/meteor'; + +import { Users, Subscriptions } from '../../../models'; +import { getDefaultUserFields } from '../../../utils/server/functions/getDefaultUserFields'; + +Meteor.methods({ + getLinkedServiceAccounts() { + if (!Meteor.userId()) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'addServiceAccount' }); + } + + const query = { + 'u._id': Meteor.userId(), + active: true, + }; + const result = Users.find(query, { fields: getDefaultUserFields() }).fetch(); + result.forEach((serviceAccount) => { + serviceAccount.unread = Subscriptions.findUnreadByUserId(serviceAccount._id).count(); + }); + return result; + }, +}); diff --git a/app/service-accounts/server/methods/getLoginToken.js b/app/service-accounts/server/methods/getLoginToken.js new file mode 100644 index 000000000000..ad8a0bf6919b --- /dev/null +++ b/app/service-accounts/server/methods/getLoginToken.js @@ -0,0 +1,25 @@ +import { Meteor } from 'meteor/meteor'; +import { Accounts } from 'meteor/accounts-base'; + +import { Users } from '../../../models'; + +Meteor.methods({ + getLoginToken(username) { + if (!Meteor.userId()) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'getLoginToken' }); + } + + let stampedToken = {}; + const user = Users.findOneByUsername(username, {}); + const isOwnerAccount = user.u && user.u._id === Meteor.userId(); // check if the requested account is owned by the user + const isServiceAccount = Meteor.user().u && user._id === Meteor.user().u._id; // check if the service account is requesting owner account login token + + if (isOwnerAccount || isServiceAccount) { + stampedToken = Accounts._generateStampedLoginToken(); + Accounts._insertLoginToken(user._id, stampedToken); + } else { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'getLoginToken' }); + } + return stampedToken; + }, +}); diff --git a/app/service-accounts/server/methods/usernameExists.js b/app/service-accounts/server/methods/usernameExists.js new file mode 100644 index 000000000000..50733b56574c --- /dev/null +++ b/app/service-accounts/server/methods/usernameExists.js @@ -0,0 +1,17 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; + +import { checkUsernameAvailability } from '../../../lib/server'; + +Meteor.methods({ + usernameExists(username) { + check(username, String); + + if (!Meteor.userId()) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'roomExists', + }); + } + return !checkUsernameAvailability(username); + }, +}); diff --git a/app/service-accounts/server/permissions.js b/app/service-accounts/server/permissions.js new file mode 100644 index 000000000000..7a24e94afe03 --- /dev/null +++ b/app/service-accounts/server/permissions.js @@ -0,0 +1,17 @@ +import { Meteor } from 'meteor/meteor'; +import _ from 'underscore'; + +import { Roles, Permissions } from '../../models'; + +Meteor.startup(() => { + const roles = _.pluck(Roles.find().fetch(), 'name'); + + if (roles.indexOf('service-account-approved') === -1) { + Roles.createOrUpdate('service-account-approved'); + } + if (Permissions) { + Permissions.createOrUpdate('view-sa-request', ['admin']); + Permissions.createOrUpdate('create-service-account', ['user', 'admin']); + Permissions.createOrUpdate('delete-service-account', ['admin']); + } +}); diff --git a/app/service-accounts/server/publications/fullServiceAccountData.js b/app/service-accounts/server/publications/fullServiceAccountData.js new file mode 100644 index 000000000000..53816ff4ad6c --- /dev/null +++ b/app/service-accounts/server/publications/fullServiceAccountData.js @@ -0,0 +1,33 @@ +import { Meteor } from 'meteor/meteor'; + +import { Users } from '../../../models'; + +Meteor.publish('fullServiceAccountData', function() { + if (!this.userId) { + return this.ready(); + } + + const query = { + u: { + $exists: true, + }, + active: false, + }; + + const handle = Users.find(query, {}).observeChanges({ + added: (id, fields) => { + this.added('rocketchat_full_user', id, fields); + }, + + changed: (id, fields) => { + this.changed('rocketchat_full_user', id, fields); + }, + + removed: (id) => { + this.removed('rocketchat_full_user', id); + }, + }); + + this.ready(); + this.onStop(() => handle.stop()); +}); diff --git a/app/ui-account/client/accountPreferences.html b/app/ui-account/client/accountPreferences.html index 76e95a26c038..8f73514889ad 100644 --- a/app/ui-account/client/accountPreferences.html +++ b/app/ui-account/client/accountPreferences.html @@ -278,6 +278,13 @@

{{_ "Sidebar"}}

+
+ +
+ + +
+
diff --git a/app/ui-account/client/accountPreferences.js b/app/ui-account/client/accountPreferences.js index cd4058b3664d..3e649c6e2bdb 100644 --- a/app/ui-account/client/accountPreferences.js +++ b/app/ui-account/client/accountPreferences.js @@ -169,6 +169,7 @@ Template.accountPreferences.onCreated(function() { data.mobileNotifications = $('#mobileNotifications').find('select').val(); data.unreadAlert = JSON.parse($('#unreadAlert').find('input:checked').val()); data.sidebarShowDiscussion = JSON.parse($('#sidebarShowDiscussion').find('input:checked').val()); + data.sidebarShowServiceAccounts = JSON.parse($('#sidebarShowServiceAccounts').find('input:checked').val()); data.notificationsSoundVolume = parseInt($('#notificationsSoundVolume').val()); data.roomCounterSidebar = JSON.parse($('#roomCounterSidebar').find('input:checked').val()); data.highlights = _.compact(_.map($('[name=highlights]').val().split(/,|\n/), function(e) { diff --git a/app/ui-account/client/accountProfile.js b/app/ui-account/client/accountProfile.js index 4fdd494bb250..73f65a96e351 100644 --- a/app/ui-account/client/accountProfile.js +++ b/app/ui-account/client/accountProfile.js @@ -135,10 +135,10 @@ Template.accountProfile.helpers({ return; } } - if (!avatar && user.name === realname && user.username === username && getUserEmailAddress(user) === email === email && (!password || password !== confirmationPassword)) { + if (!avatar && user.name === realname && user.username === username && (!!user.u || getUserEmailAddress(user) === email === email) && (!password || password !== confirmationPassword)) { return ret; } - if (!validateEmail(email) || (!validateUsername(username) || usernameAvaliable !== true) || !validateName(realname) || !validateStatusMessage(statusText)) { + if ((!validateEmail(email) && !user.u) || (!validateUsername(username) || usernameAvaliable !== true) || !validateName(realname) || !validateStatusMessage(statusText)) { return ret; } }, diff --git a/app/ui-sidenav/client/roomList.js b/app/ui-sidenav/client/roomList.js index ba946ed7eeee..f7d282b6d6d3 100644 --- a/app/ui-sidenav/client/roomList.js +++ b/app/ui-sidenav/client/roomList.js @@ -25,6 +25,7 @@ Template.roomList.helpers({ 'settings.preferences.sidebarShowFavorites': 1, 'settings.preferences.sidebarShowUnread': 1, 'settings.preferences.sidebarShowDiscussion': 1, + 'settings.preferences.sidebarShowServiceAccounts': 1, 'services.tokenpass': 1, messageViewMode: 1, }, @@ -84,6 +85,10 @@ Template.roomList.helpers({ query.prid = { $exists: false }; } + if (getUserPreference(user, 'sidebarShowServiceAccounts')) { + query.sa = { $exists: false }; + } + if (getUserPreference(user, 'sidebarShowUnread')) { query.$or = [ { alert: { $ne: true } }, diff --git a/app/ui-sidenav/client/sidebarHeader.js b/app/ui-sidenav/client/sidebarHeader.js index 7fc2eb4a343f..ed2de621991f 100644 --- a/app/ui-sidenav/client/sidebarHeader.js +++ b/app/ui-sidenav/client/sidebarHeader.js @@ -71,6 +71,23 @@ const toolbarButtons = (user) => [{ FlowRouter.go('directory'); }, }, +{ + name: t('Service_account_login'), + icon: 'reload', + condition: () => !Meteor.user().u || (Meteor.user().u && localStorage.getItem('serviceAccountForceLogin')), + action: (e) => { + const options = []; + const config = { + template: 'serviceAccountSidebarLogin', + currentTarget: e.currentTarget, + data: { + options, + }, + offsetVertical: e.currentTarget.clientHeight + 10, + }; + popover.open(config); + }, +}, { name: t('View_mode'), icon: () => viewModeIcon[getUserPreference(user, 'sidebarViewMode') || 'condensed'], @@ -173,41 +190,64 @@ const toolbarButtons = (user) => [{ }; const discussionEnabled = settings.get('Discussion_enabled'); - if (!discussionEnabled) { - return createChannel(e); + const serviceAccountEnabled = settings.get('Service_account_enabled'); + const items = [{ + icon: 'hashtag', + name: t('Channel'), + action: createChannel, + }]; + if (discussionEnabled) { + items.push({ + icon: 'discussion', + name: t('Discussion'), + action: (e) => { + e.preventDefault(); + modal.open({ + title: t('Discussion_title'), + content: 'CreateDiscussion', + data: { + onCreate() { + modal.close(); + }, + }, + modifier: 'modal', + showConfirmButton: false, + showCancelButton: false, + confirmOnEnter: false, + }); + }, + }); } + + if (serviceAccountEnabled && hasAtLeastOnePermission(['create-service-account'])) { + items.push({ + icon: 'user', + name: t('Service_account'), + action: (e) => { + e.preventDefault(); + modal.open({ + title: t('Service_account_title'), + content: 'createServiceAccount', + data: { + onCreate() { + modal.close(); + }, + }, + modifier: 'modal', + showConfirmButton: false, + showCancelButton: false, + confirmOnEnter: false, + }); + }, + }); + } + const config = { columns: [ { groups: [ { - items: [ - { - icon: 'hashtag', - name: t('Channel'), - action: createChannel, - }, - { - icon: 'discussion', - name: t('Discussion'), - action: (e) => { - e.preventDefault(); - modal.open({ - title: t('Discussion_title'), - content: 'CreateDiscussion', - data: { - onCreate() { - modal.close(); - }, - }, - modifier: 'modal', - showConfirmButton: false, - showCancelButton: false, - confirmOnEnter: false, - }); - }, - }, - ], + items, }, ], }, diff --git a/app/ui-sidenav/client/sortlist.html b/app/ui-sidenav/client/sortlist.html index fa8efb05de23..40b58017368d 100644 --- a/app/ui-sidenav/client/sortlist.html +++ b/app/ui-sidenav/client/sortlist.html @@ -31,6 +31,15 @@ {{_ "Group_discussions"}} +
  • + +
  • {{#if $eq searchType 'users'}} @@ -164,6 +164,65 @@ {{/table}} {{/if}} + {{#if $eq searchType 'serviceAccounts'}} + {{#table onItemClick=onTableItemClick onScroll=onTableScroll onResize=onTableResize onSort=onTableSort}} + + + +
    {{_ "Name"}} {{> icon icon=(sortIcon 'name') }}
    + + +
    {{_ "Username"}} {{> icon icon=(sortIcon 'username') }}
    + + +
    {{_ "Subscribers"}} {{> icon icon=(sortIcon 'subscribers') }}
    + + +
    {{_ "Created_at"}} {{> icon icon=(sortIcon 'createdAt') }}
    + + +
    {{_ "Description"}}
    + + + + + {{#each searchResults}} + + +
    +
    {{> avatar username=username}}
    +
    + + {{name}} + +
    +
    + + {{username}} + {{subscribers}} + {{createdAt}} + {{description}} + + {{else}} + {{# with searchText}} + + + {{_ "No_results_found_for"}} {{.}} + + + {{/with}} + {{/each}} + {{#if isLoading}} + + + {{> loading}} + + + {{/if}} + + {{/table}} + {{/if}} diff --git a/app/ui/client/views/app/directory.js b/app/ui/client/views/app/directory.js index ba4af117e7f0..19215393efe1 100644 --- a/app/ui/client/views/app/directory.js +++ b/app/ui/client/views/app/directory.js @@ -36,6 +36,17 @@ function directorySearch(config, cb) { domain: result.federation && result.federation.peer, }; } + + if (config.type === 'serviceAccounts') { + return { + name: result.name, + username: result.username, + createdAt: timeAgo(result.createdAt, t), + description: result.description, + subscribers: result.subscribers || 0, + domain: result.federation && result.federation.peer, + }; + } return null; })); }); @@ -96,13 +107,20 @@ Template.directory.helpers({ return true; }, }; + const serviceAccountsTab = { + label: t('Service_accounts'), + value: 'serviceAccounts', + condition() { + return true; + }, + }; if (searchType.get() === 'channels') { channelsTab.active = true; } else { usersTab.active = true; } return { - tabs: [channelsTab, usersTab], + tabs: [channelsTab, usersTab, serviceAccountsTab], onChange(value) { results.set([]); end.set(false); @@ -130,6 +148,9 @@ Template.directory.helpers({ if (searchType.get() === 'channels') { type = 'c'; routeConfig = { name: item.name }; + } else if (searchType.get() === 'users') { + type = 'd'; + routeConfig = { name: item.username }; } else { type = 'd'; routeConfig = { name: item.username }; diff --git a/app/utils/server/functions/getDefaultUserFields.js b/app/utils/server/functions/getDefaultUserFields.js index e7aaddf1b816..efba29e14643 100644 --- a/app/utils/server/functions/getDefaultUserFields.js +++ b/app/utils/server/functions/getDefaultUserFields.js @@ -26,4 +26,5 @@ export const getDefaultUserFields = () => ({ 'services.totp.enabled': 1, statusLivechat: 1, banners: 1, + u: 1, }); diff --git a/client/importPackages.js b/client/importPackages.js index e26dde77f6f7..1f087eb566f3 100644 --- a/client/importPackages.js +++ b/client/importPackages.js @@ -55,6 +55,7 @@ import '../app/otr/client'; import '../app/push-notifications/client'; import '../app/apps/client'; import '../app/setup-wizard/client'; +import '../app/service-accounts/client'; import '../app/slackbridge/client'; import '../app/slashcommands-archiveroom/client'; import '../app/slashcommand-asciiarts/client'; diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index b9a8ac3c7461..135b3f3fa6b3 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -1493,6 +1493,7 @@ "Group_favorites": "Group favorites", "Group_mentions_disabled_x_members": "Group mentions `@all` and `@here` have been disabled for rooms with more than __total__ members.", "Group_mentions_only": "Group mentions only", + "Group_subscriptions": "Group subscriptions", "Guest_Pool": "Guest Pool", "Hash": "Hash", "Header": "Header", @@ -2605,6 +2606,7 @@ "Search_Page_Size": "Page Size", "Search_Private_Groups": "Search Private Groups", "Search_Provider": "Search Provider", + "Search_ServiceAccounts": "Search Service Accounts", "Search_Users": "Search Users", "seconds": "seconds", "Secret_token": "Secret Token", @@ -2648,7 +2650,19 @@ "Server_Info": "Server Info", "Server_Type": "Server Type", "Service": "Service", + "Service_account": "Service Account", + "Service_accounts": "Service Accounts", "Service_account_key": "Service account key", + "Service_account_applied": "Service Accounts approval applications", + "Service_account_created_successfully": "Service Account created successfully", + "Service_account_dashboard": "Service Account Dashboard", + "Service_account_description": "Service Accounts are an upgrade to existing user accounts. You can connect to a large number of users using service account with exclusive features such as broadcast message to all your subscribers at once", + "Service_account_limit": "Service Accounts per user", + "Service_account_login": "Service Account login", + "Service_Accounts_SearchFields": "Fields to consider for service account search", + "Service_account_name_placeholder": "Service Account name", + "Service_account_username_placeholder": "Service Account username", + "Service_account_title": "Create a new Service Account", "set-leader": "Set Leader", "set-moderator": "Set Moderator", "set-moderator_description": "Permission to set other users as moderator of a channel", @@ -2845,6 +2859,7 @@ "The_server_will_restart_in_s_seconds": "The server will restart in %s seconds", "The_setting_s_is_configured_to_s_and_you_are_accessing_from_s": "The setting %s is configured to %s and you are accessing from %s!", "The_user_will_be_removed_from_s": "The user will be removed from %s", + "The_user_s_will_be_allowed_to_create_service_accounts": "This user %s will be allowed to create Service Accounts", "The_user_s_will_be_removed_from_role_s": "The user %s will be removed from role %s", "The_user_wont_be_able_to_type_in_s": "The user won't be able to type in %s", "Theme": "Theme", @@ -2905,6 +2920,7 @@ "There_are_no_applications_installed": "There are currently no Rocket.Chat Applications installed.", "There_are_no_integrations": "There are no integrations", "There_are_no_personal_access_tokens_created_yet": "There are no Personal Access Tokens created yet.", + "There_are_no_service_accounts_to_approve": "There are no service accounts to approve", "There_are_no_users_in_this_role": "There are no users in this role.", "This_conversation_is_already_closed": "This conversation is already closed.", "This_email_has_already_been_used_and_has_not_been_verified__Please_change_your_password": "This email has already been used and has not been verified. Please change your password.", @@ -3260,6 +3276,7 @@ "You_cant_leave_a_livechat_room_Please_use_the_close_button": "You can't leave a livechat room. Please, use the close button.", "You_have_been_muted": "You have been muted and cannot speak in this room", "You_have_n_codes_remaining": "You have __number__ codes remaining.", + "You_have_no_service_accounts": "You have no linked service accounts", "You_have_not_verified_your_email": "You have not verified your email.", "You_have_successfully_unsubscribed": "You have successfully unsubscribed from our Mailling List.", "You_have_to_set_an_API_token_first_in_order_to_use_the_integration": "You have to set an API token first in order to use the integration.", diff --git a/server/importPackages.js b/server/importPackages.js index 35d4dbac90ea..416700da4436 100644 --- a/server/importPackages.js +++ b/server/importPackages.js @@ -67,6 +67,7 @@ import '../app/push-notifications/server'; import '../app/retention-policy'; import '../app/apps/server'; import '../app/setup-wizard/server'; +import '../app/service-accounts/server'; import '../app/slackbridge/server'; import '../app/slashcommands-archiveroom/server'; import '../app/slashcommand-asciiarts/server'; diff --git a/server/lib/accounts.js b/server/lib/accounts.js index 9773695e2cf9..a732c38a97ed 100644 --- a/server/lib/accounts.js +++ b/server/lib/accounts.js @@ -99,10 +99,13 @@ Accounts.emailTemplates.enrollAccount.html = function(user = {}/* , url*/) { Accounts.onCreateUser(function(options, user = {}) { callbacks.run('beforeCreateUser', options, user); - user.status = 'offline'; user.active = !settings.get('Accounts_ManuallyApproveNewUsers'); + if (options.active !== undefined) { + user.active = options.active; + } + if (!user.name) { if (options.profile) { if (options.profile.name) { @@ -176,6 +179,10 @@ Accounts.insertUserDoc = _.wrap(Accounts.insertUserDoc, function(insertUserDoc, user.type = 'user'; } + if (!user.u && options.u) { + user.u = options.u; + } + const _id = insertUserDoc.call(Accounts, options, user); user = Meteor.users.findOne({ diff --git a/server/methods/browseChannels.js b/server/methods/browseChannels.js index 64ab04e377d0..53b09e32755a 100644 --- a/server/methods/browseChannels.js +++ b/server/methods/browseChannels.js @@ -29,11 +29,20 @@ const sortUsers = function(field, direction) { } }; +const sortServiceAccounts = function(field, direction) { + switch (field) { + default: + return { + [field]: direction === 'asc' ? 1 : -1, + }; + } +}; + Meteor.methods({ browseChannels({ text = '', workspace = '', type = 'channels', sortBy = 'name', sortDirection = 'asc', page, offset, limit = 10 }) { const regex = new RegExp(s.trim(s.escapeRegExp(text)), 'i'); - if (!['channels', 'users'].includes(type)) { + if (!['channels', 'users', 'serviceAccounts'].includes(type)) { return; } @@ -88,6 +97,40 @@ Meteor.methods({ }; } + if (type === 'serviceAccounts') { + const options = { + ...pagination, + sort: sortServiceAccounts(sortBy, sortDirection), + fields: { + username: 1, + name: 1, + createdAt: 1, + description: 1, + federation: 1, + }, + }; + + const exceptions = [user.username]; + const forcedSearchFields = workspace === 'all' && ['username', 'name', 'description']; + + let result; + if (workspace === 'all') { + result = Users.findByActiveServiceAccountsExcept(text, exceptions, forcedSearchFields, options); + } else if (workspace === 'external') { + result = Users.findByActiveExternalServiceAccountsExcept(text, exceptions, options, forcedSearchFields, Federation.localIdentifier); + } else { + result = Users.findByActiveLocalServiceAccountsExcept(text, exceptions, options, forcedSearchFields, Federation.localIdentifier); + } + const total = result.count(); + const results = result.fetch(); + results.forEach((account) => { + account.subscribers = Rooms.findDirectRoomContainingUsername(account.username).count(); + }); + return { + total, + results, + }; + } // non-logged id user if (!user) { return; diff --git a/server/methods/createDirectMessage.js b/server/methods/createDirectMessage.js index 2fb895435984..e4f022033686 100644 --- a/server/methods/createDirectMessage.js +++ b/server/methods/createDirectMessage.js @@ -109,6 +109,10 @@ Meteor.methods({ upsertSubscription.$set.archived = true; } + if (to.u !== undefined) { + upsertSubscription.$set.sa = true; + } + Subscriptions.upsert({ rid, $and: [{ 'u._id': me._id }], // work around to solve problems with upsert and dot diff --git a/server/methods/saveUserPreferences.js b/server/methods/saveUserPreferences.js index f9e0d6c97683..cc3fc86a8f08 100644 --- a/server/methods/saveUserPreferences.js +++ b/server/methods/saveUserPreferences.js @@ -38,6 +38,7 @@ Meteor.methods({ sidebarHideAvatar: Match.Optional(Boolean), sidebarGroupByType: Match.Optional(Boolean), sidebarShowDiscussion: Match.Optional(Boolean), + sidebarShowServiceAccounts: Match.Optional(Boolean), muteFocusedConversations: Match.Optional(Boolean), }; check(settings, Match.ObjectIncluding(keys)); diff --git a/server/publications/subscription/index.js b/server/publications/subscription/index.js index 8001eb21eeaf..bb773c2c7dc7 100644 --- a/server/publications/subscription/index.js +++ b/server/publications/subscription/index.js @@ -18,6 +18,7 @@ export const fields = { roles: 1, unread: 1, prid: 1, + sa: 1, userMentions: 1, groupMentions: 1, archived: 1, diff --git a/tests/end-to-end/api/00-miscellaneous.js b/tests/end-to-end/api/00-miscellaneous.js index 3a6773fa2bac..91e09e3ca510 100644 --- a/tests/end-to-end/api/00-miscellaneous.js +++ b/tests/end-to-end/api/00-miscellaneous.js @@ -141,6 +141,7 @@ describe('miscellaneous', function() { 'sidebarGroupByType', 'muteFocusedConversations', 'sidebarShowDiscussion', + 'sidebarShowServiceAccounts', ]; expect(res.body).to.have.property('success', true); diff --git a/tests/end-to-end/api/01-users.js b/tests/end-to-end/api/01-users.js index 5b11589e7748..b16f0a15ca5d 100644 --- a/tests/end-to-end/api/01-users.js +++ b/tests/end-to-end/api/01-users.js @@ -1699,4 +1699,35 @@ describe('[Users]', function() { }); }); }); + describe('[Service Accounts]', () => { + it('should create a new service account', (done) => { + const username = `serviceAccount_${ apiUsername }`; + const description = 'Test Service Account'; + + request.post(api('serviceAccounts.create')) + .set(credentials) + .send({ + name: username, + username, + description, + password, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('user.username', username); + expect(res.body).to.have.nested.property('user.u.username'); + expect(res.body).to.have.nested.property('user.active', false); + expect(res.body).to.have.nested.property('user.name', username); + expect(res.body).to.have.nested.property('user.description', description); + expect(res.body).to.not.have.nested.property('user.e2e'); + expect(res.body).to.not.have.nested.property('user.customFields'); + + targetUser._id = res.body.user._id; + targetUser.username = res.body.user.username; + }) + .end(done); + }); + }); });