diff --git a/app/controllers/concerns/ensure_current_account_helper.rb b/app/controllers/concerns/ensure_current_account_helper.rb index dccc64350..eb781dbfe 100644 --- a/app/controllers/concerns/ensure_current_account_helper.rb +++ b/app/controllers/concerns/ensure_current_account_helper.rb @@ -8,6 +8,8 @@ module EnsureCurrentAccountHelper def ensure_current_account account = Account.find(params[:account_id]) + ensure_account_is_active?(account) + if current_user account_accessible_for_user?(account) elsif @resource.is_a?(AgentBot) @@ -25,4 +27,8 @@ module EnsureCurrentAccountHelper def account_accessible_for_bot?(account) render_unauthorized('You are not authorized to access this account') unless @resource.agent_bot_inboxes.find_by(account_id: account.id) end + + def ensure_account_is_active?(account) + render_unauthorized('Account is suspended') unless account.active? + end end diff --git a/app/controllers/concerns/website_token_helper.rb b/app/controllers/concerns/website_token_helper.rb index 0158a4107..ea53a26ad 100644 --- a/app/controllers/concerns/website_token_helper.rb +++ b/app/controllers/concerns/website_token_helper.rb @@ -5,7 +5,9 @@ module WebsiteTokenHelper def set_web_widget @web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token]) - @current_account = @web_widget.account + @current_account = @web_widget.inbox.account + + render json: { error: 'Account is suspended' }, status: :unauthorized unless @current_account.active? end def set_contact diff --git a/app/controllers/widgets_controller.rb b/app/controllers/widgets_controller.rb index 653d3360f..9639b28b2 100644 --- a/app/controllers/widgets_controller.rb +++ b/app/controllers/widgets_controller.rb @@ -4,6 +4,7 @@ class WidgetsController < ActionController::Base before_action :set_global_config before_action :set_web_widget + before_action :ensure_account_is_active before_action :set_token before_action :set_contact before_action :build_contact @@ -46,6 +47,10 @@ class WidgetsController < ActionController::Base @contact = @contact_inbox.contact end + def ensure_account_is_active + render json: { error: 'Account is suspended' }, status: :unauthorized unless @web_widget.inbox.account.active? + end + def additional_attributes if @web_widget.inbox.account.feature_enabled?('ip_lookup') { created_at_ip: request.remote_ip } diff --git a/app/dashboards/account_dashboard.rb b/app/dashboards/account_dashboard.rb index 408589597..0dfe7bbcd 100644 --- a/app/dashboards/account_dashboard.rb +++ b/app/dashboards/account_dashboard.rb @@ -17,6 +17,7 @@ class AccountDashboard < Administrate::BaseDashboard users: CountField, conversations: CountField, locale: Field::Select.with_options(collection: LANGUAGES_CONFIG.map { |_x, y| y[:iso_639_1_code] }), + status: Field::Select.with_options(collection: [%w[Active active], %w[Suspended suspended]]), account_users: Field::HasMany }.merge(enterprise_attribute_types).freeze @@ -31,6 +32,7 @@ class AccountDashboard < Administrate::BaseDashboard locale users conversations + status ].freeze # SHOW_PAGE_ATTRIBUTES @@ -42,6 +44,7 @@ class AccountDashboard < Administrate::BaseDashboard created_at updated_at locale + status conversations account_users ] + enterprise_show_page_attributes).freeze @@ -53,6 +56,7 @@ class AccountDashboard < Administrate::BaseDashboard FORM_ATTRIBUTES = (%i[ name locale + status ] + enterprise_form_attributes).freeze # COLLECTION_FILTERS diff --git a/app/javascript/dashboard/helper/routeHelpers.js b/app/javascript/dashboard/helper/routeHelpers.js new file mode 100644 index 000000000..c0abccc00 --- /dev/null +++ b/app/javascript/dashboard/helper/routeHelpers.js @@ -0,0 +1,51 @@ +export const getCurrentAccount = ({ accounts } = {}, accountId) => { + return accounts.find(account => account.id === accountId); +}; + +export const getUserRole = ({ accounts } = {}, accountId) => { + const currentAccount = getCurrentAccount({ accounts }, accountId) || {}; + return currentAccount.role || null; +}; + +export const routeIsAccessibleFor = (route, role, roleWiseRoutes) => { + return roleWiseRoutes[role].includes(route); +}; + +const validateActiveAccountRoutes = (to, user, roleWiseRoutes) => { + // If the current account is active, then check for the route permissions + const accountDashboardURL = `accounts/${to.params.accountId}/dashboard`; + + // If the user is trying to access suspended route, redirect them to dashboard + if (to.name === 'account_suspended') { + return accountDashboardURL; + } + + const userRole = getUserRole(user, Number(to.params.accountId)); + const isAccessible = routeIsAccessibleFor(to.name, userRole, roleWiseRoutes); + // If the route is not accessible for the user, return to dashboard screen + return isAccessible ? null : accountDashboardURL; +}; + +export const validateLoggedInRoutes = (to, user, roleWiseRoutes) => { + const currentAccount = getCurrentAccount(user, Number(to.params.accountId)); + + // If current account is missing, either user does not have + // access to the account or the account is deleted, return to login screen + if (!currentAccount) { + return `app/login`; + } + + const isCurrentAccountActive = currentAccount.status === 'active'; + + if (isCurrentAccountActive) { + return validateActiveAccountRoutes(to, user, roleWiseRoutes); + } + + // If the current account is not active, then redirect the user to the suspended screen + if (to.name !== 'account_suspended') { + return `accounts/${to.params.accountId}/suspended`; + } + + // Proceed to the route if none of the above conditions are met + return null; +}; diff --git a/app/javascript/dashboard/helper/specs/routeHelpers.spec.js b/app/javascript/dashboard/helper/specs/routeHelpers.spec.js new file mode 100644 index 000000000..f3417111a --- /dev/null +++ b/app/javascript/dashboard/helper/specs/routeHelpers.spec.js @@ -0,0 +1,96 @@ +import { + getCurrentAccount, + getUserRole, + routeIsAccessibleFor, + validateLoggedInRoutes, +} from '../routeHelpers'; + +describe('#getCurrentAccount', () => { + it('should return the current account', () => { + expect(getCurrentAccount({ accounts: [{ id: 1 }] }, 1)).toEqual({ id: 1 }); + expect(getCurrentAccount({ accounts: [] }, 1)).toEqual(undefined); + }); +}); + +describe('#getUserRole', () => { + it('should return the current role', () => { + expect( + getUserRole({ accounts: [{ id: 1, role: 'administrator' }] }, 1) + ).toEqual('administrator'); + expect(getUserRole({ accounts: [] }, 1)).toEqual(null); + }); +}); + +describe('#routeIsAccessibleFor', () => { + it('should return the correct access', () => { + const roleWiseRoutes = { agent: ['conversations'], admin: ['billing'] }; + expect(routeIsAccessibleFor('billing', 'agent', roleWiseRoutes)).toEqual( + false + ); + expect(routeIsAccessibleFor('billing', 'admin', roleWiseRoutes)).toEqual( + true + ); + }); +}); + +describe('#validateLoggedInRoutes', () => { + describe('when account access is missing', () => { + it('should return the login route', () => { + expect( + validateLoggedInRoutes( + { params: { accountId: 1 } }, + { accounts: [] }, + {} + ) + ).toEqual(`app/login`); + }); + }); + describe('when account access is available', () => { + describe('when account is suspended', () => { + it('return suspended route', () => { + expect( + validateLoggedInRoutes( + { name: 'conversations', params: { accountId: 1 } }, + { accounts: [{ id: 1, role: 'agent', status: 'suspended' }] }, + { agent: ['conversations'] } + ) + ).toEqual(`accounts/1/suspended`); + }); + }); + describe('when account is active', () => { + describe('when route is accessible', () => { + it('returns null (no action required)', () => { + expect( + validateLoggedInRoutes( + { name: 'conversations', params: { accountId: 1 } }, + { accounts: [{ id: 1, role: 'agent', status: 'active' }] }, + { agent: ['conversations'] } + ) + ).toEqual(null); + }); + }); + describe('when route is not accessible', () => { + it('returns dashboard url', () => { + expect( + validateLoggedInRoutes( + { name: 'conversations', params: { accountId: 1 } }, + { accounts: [{ id: 1, role: 'agent', status: 'active' }] }, + { admin: ['conversations'], agent: [] } + ) + ).toEqual(`accounts/1/dashboard`); + }); + }); + describe('when route is suspended route', () => { + it('returns dashboard url', () => { + expect( + validateLoggedInRoutes( + { name: 'account_suspended', params: { accountId: 1 } }, + { accounts: [{ id: 1, role: 'agent', status: 'active' }] }, + { agent: ['account_suspended'] } + ) + ).toEqual(`accounts/1/dashboard`); + }); + }); + }); + }); +}); diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 73b02b89d..0db8e393d 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -120,7 +120,11 @@ "APP_GLOBAL": { "TRIAL_MESSAGE": "days trial remaining.", "TRAIL_BUTTON": "Buy Now", - "DELETED_USER": "Deleted User" + "DELETED_USER": "Deleted User", + "ACCOUNT_SUSPENDED": { + "TITLE": "Account Suspended", + "MESSAGE": "Your account is suspended. Please reach out to the support team for more information." + } }, "COMPONENTS": { "CODE": { @@ -199,7 +203,7 @@ }, "BILLING_SETTINGS": { "TITLE": "Billing", - "CURRENT_PLAN" : { + "CURRENT_PLAN": { "TITLE": "Current Plan", "PLAN_NOTE": "You are currently subscribed to the **%{plan}** plan with **%{quantity}** licenses" }, diff --git a/app/javascript/dashboard/routes/dashboard/dashboard.routes.js b/app/javascript/dashboard/routes/dashboard/dashboard.routes.js index 343b11c27..8a7ebee72 100644 --- a/app/javascript/dashboard/routes/dashboard/dashboard.routes.js +++ b/app/javascript/dashboard/routes/dashboard/dashboard.routes.js @@ -6,6 +6,8 @@ import { routes as notificationRoutes } from './notifications/routes'; import { frontendURL } from '../../helper/URLHelper'; import helpcenterRoutes from './helpcenter/helpcenter.routes'; +const Suspended = () => import('./suspended/Index'); + export default { routes: [ ...helpcenterRoutes.routes, @@ -19,5 +21,11 @@ export default { ...notificationRoutes, ], }, + { + path: frontendURL('accounts/:accountId/suspended'), + name: 'account_suspended', + roles: ['administrator', 'agent'], + component: Suspended, + }, ], }; diff --git a/app/javascript/dashboard/routes/dashboard/suspended/Index.vue b/app/javascript/dashboard/routes/dashboard/suspended/Index.vue new file mode 100644 index 000000000..c25a8a7eb --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/suspended/Index.vue @@ -0,0 +1,24 @@ + + + diff --git a/app/javascript/dashboard/routes/index.js b/app/javascript/dashboard/routes/index.js index 2557538ec..586f14804 100644 --- a/app/javascript/dashboard/routes/index.js +++ b/app/javascript/dashboard/routes/index.js @@ -6,6 +6,7 @@ import authRoute from './auth/auth.routes'; import dashboard from './dashboard/dashboard.routes'; import login from './login/login.routes'; import store from '../store'; +import { validateLoggedInRoutes } from '../helper/routeHelpers'; const routes = [...login.routes, ...dashboard.routes, ...authRoute.routes]; @@ -14,11 +15,6 @@ window.roleWiseRoutes = { administrator: [], }; -const getUserRole = ({ accounts } = {}, accountId) => { - const currentAccount = accounts.find(account => account.id === accountId); - return currentAccount ? currentAccount.role : null; -}; - // generateRoleWiseRoute - updates window object with agent/admin route const generateRoleWiseRoute = route => { route.forEach(element => { @@ -47,10 +43,6 @@ const authIgnoreRoutes = [ 'auth_password_edit', ]; -function routeIsAccessibleFor(route, role) { - return window.roleWiseRoutes[role].includes(route); -} - const routeValidators = [ { protected: false, @@ -68,12 +60,8 @@ const routeValidators = [ { protected: true, loggedIn: true, - handler: (to, getters) => { - const user = getters.getCurrentUser; - const userRole = getUserRole(user, Number(to.params.accountId)); - const isAccessible = routeIsAccessibleFor(to.name, userRole); - return isAccessible ? null : `accounts/${to.params.accountId}/dashboard`; - }, + handler: (to, getters) => + validateLoggedInRoutes(to, getters.getCurrentUser, window.roleWiseRoutes), }, { protected: false, diff --git a/app/javascript/dashboard/routes/index.spec.js b/app/javascript/dashboard/routes/index.spec.js index ae8b21d46..b9bf3c251 100644 --- a/app/javascript/dashboard/routes/index.spec.js +++ b/app/javascript/dashboard/routes/index.spec.js @@ -26,7 +26,7 @@ describe('#validateAuthenticateRoutePermission', () => { getCurrentUser: { account_id: 1, id: 1, - accounts: [{ id: 1, role: 'admin' }], + accounts: [{ id: 1, role: 'admin', status: 'active' }], }, }; validateAuthenticateRoutePermission(to, from, next, { getters }); @@ -72,7 +72,7 @@ describe('#validateAuthenticateRoutePermission', () => { getCurrentUser: { account_id: 1, id: 1, - accounts: [{ id: 1, role: 'agent' }], + accounts: [{ id: 1, role: 'agent', status: 'active' }], }, }; validateAuthenticateRoutePermission(to, from, next, { getters }); @@ -90,7 +90,7 @@ describe('#validateAuthenticateRoutePermission', () => { getCurrentUser: { account_id: 1, id: 1, - accounts: [{ id: 1, role: 'agent' }], + accounts: [{ id: 1, role: 'agent', status: 'active' }], }, }; validateAuthenticateRoutePermission(to, from, next, { getters }); diff --git a/app/models/account.rb b/app/models/account.rb index d7c4718b0..9a719f881 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -11,10 +11,15 @@ # locale :integer default("en") # name :string not null # settings_flags :integer default(0), not null +# status :integer default("active") # support_email :string(100) # created_at :datetime not null # updated_at :datetime not null # +# Indexes +# +# index_accounts_on_status (status) +# class Account < ApplicationRecord # used for single column multi flags @@ -79,6 +84,7 @@ class Account < ApplicationRecord has_flags ACCOUNT_SETTINGS_FLAGS.merge(column: 'settings_flags').merge(DEFAULT_QUERY_SETTING) enum locale: LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h + enum status: { active: 0, suspended: 1 } before_validation :validate_limit_keys after_create_commit :notify_creation diff --git a/app/views/api/v1/models/_account.json.jbuilder b/app/views/api/v1/models/_account.json.jbuilder index ddf366597..3d258265a 100644 --- a/app/views/api/v1/models/_account.json.jbuilder +++ b/app/views/api/v1/models/_account.json.jbuilder @@ -13,3 +13,4 @@ json.id @account.id json.locale @account.locale json.name @account.name json.support_email @account.support_email +json.status @account.status diff --git a/app/views/api/v1/models/_user.json.jbuilder b/app/views/api/v1/models/_user.json.jbuilder index 4fc857ae7..4e7075e39 100644 --- a/app/views/api/v1/models/_user.json.jbuilder +++ b/app/views/api/v1/models/_user.json.jbuilder @@ -20,6 +20,7 @@ json.accounts do json.array! resource.account_users do |account_user| json.id account_user.account_id json.name account_user.account.name + json.status account_user.account.status json.active_at account_user.active_at json.role account_user.role # the actual availability user has configured diff --git a/db/migrate/20220802133722_add_status_to_accounts.rb b/db/migrate/20220802133722_add_status_to_accounts.rb new file mode 100644 index 000000000..b94e8ee74 --- /dev/null +++ b/db/migrate/20220802133722_add_status_to_accounts.rb @@ -0,0 +1,6 @@ +class AddStatusToAccounts < ActiveRecord::Migration[6.1] + def change + add_column :accounts, :status, :integer, default: 0 + add_index :accounts, :status + end +end diff --git a/db/schema.rb b/db/schema.rb index 3a78d5413..17271fa65 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2022_07_20_123615) do +ActiveRecord::Schema.define(version: 2022_08_02_133722) do # These are extensions that must be enabled in order to support this database enable_extension "pg_stat_statements" @@ -54,6 +54,8 @@ ActiveRecord::Schema.define(version: 2022_07_20_123615) do t.integer "auto_resolve_duration" t.jsonb "limits", default: {} t.jsonb "custom_attributes", default: {} + t.integer "status", default: 0 + t.index ["status"], name: "index_accounts_on_status" end create_table "action_mailbox_inbound_emails", force: :cascade do |t| diff --git a/spec/controllers/api/base_controller_spec.rb b/spec/controllers/api/base_controller_spec.rb index 969b83f68..c1dc781e9 100644 --- a/spec/controllers/api/base_controller_spec.rb +++ b/spec/controllers/api/base_controller_spec.rb @@ -56,5 +56,17 @@ RSpec.describe 'API Base', type: :request do expect(conversation.reload.status).to eq('open') end end + + context 'when the account is suspended' do + it 'returns 401 unauthorized' do + account.update!(status: :suspended) + + post "/api/v1/accounts/#{account.id}/canned_responses", + headers: { api_access_token: user.access_token.token }, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + end end end diff --git a/spec/controllers/api/v1/widget/configs_controller_spec.rb b/spec/controllers/api/v1/widget/configs_controller_spec.rb index 8101885bd..4c4cef2c9 100644 --- a/spec/controllers/api/v1/widget/configs_controller_spec.rb +++ b/spec/controllers/api/v1/widget/configs_controller_spec.rb @@ -47,6 +47,17 @@ RSpec.describe '/api/v1/widget/config', type: :request do expect(response_data.keys).to include(*response_keys) expect(response_data['contact']['pubsub_token']).to eq(contact_inbox.pubsub_token) end + + it 'returns 401 if account is suspended' do + account.update!(status: :suspended) + + post '/api/v1/widget/config', + params: params, + headers: { 'X-Auth-Token' => token }, + as: :json + + expect(response).to have_http_status(:unauthorized) + end end context 'with correct website token and invalid X-Auth-Token' do diff --git a/spec/controllers/widgets_controller_spec.rb b/spec/controllers/widgets_controller_spec.rb index 07624e03e..8b955b6f6 100644 --- a/spec/controllers/widgets_controller_spec.rb +++ b/spec/controllers/widgets_controller_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' describe '/widget', type: :request do let(:account) { create(:account) } - let(:web_widget) { create(:channel_widget) } + let(:web_widget) { create(:channel_widget, account: account) } let(:contact) { create(:contact, account: account) } let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: web_widget.inbox) } let(:payload) { { source_id: contact_inbox.source_id, inbox_id: web_widget.inbox.id } } @@ -25,5 +25,13 @@ describe '/widget', type: :request do get widget_url expect(response).to have_http_status(:not_found) end + + it 'returns 401 if the account is suspended' do + account.update!(status: :suspended) + + get widget_url(website_token: web_widget.website_token) + expect(response).to have_http_status(:unauthorized) + expect(response.body).to include('Account is suspended') + end end end diff --git a/spec/factories/accounts.rb b/spec/factories/accounts.rb index aa8a9b7e6..968962cff 100644 --- a/spec/factories/accounts.rb +++ b/spec/factories/accounts.rb @@ -3,6 +3,7 @@ FactoryBot.define do factory :account do sequence(:name) { |n| "Account #{n}" } + status { 'active' } custom_email_domain_enabled { false } domain { 'test.com' } support_email { 'support@test.com' }