mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-02 03:57:52 +00:00
chore: Allow super admin to suspend an account (#5174)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
51
app/javascript/dashboard/helper/routeHelpers.js
Normal file
51
app/javascript/dashboard/helper/routeHelpers.js
Normal file
@@ -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;
|
||||
};
|
||||
96
app/javascript/dashboard/helper/specs/routeHelpers.spec.js
Normal file
96
app/javascript/dashboard/helper/specs/routeHelpers.spec.js
Normal file
@@ -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`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div class="suspended-page">
|
||||
<empty-state
|
||||
:title="$t('APP_GLOBAL.ACCOUNT_SUSPENDED.TITLE')"
|
||||
:message="$t('APP_GLOBAL.ACCOUNT_SUSPENDED.MESSAGE')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import EmptyState from 'dashboard/components/widgets/EmptyState';
|
||||
export default {
|
||||
components: { EmptyState },
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
.suspended-page {
|
||||
align-items: center;
|
||||
background: var(--s-50);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
6
db/migrate/20220802133722_add_status_to_accounts.rb
Normal file
6
db/migrate/20220802133722_add_status_to_accounts.rb
Normal file
@@ -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
|
||||
@@ -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|
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }
|
||||
|
||||
Reference in New Issue
Block a user