This gates API-token access and outgoing account webhooks behind the `api_and_webhooks` account feature introduced in #14972. On Chatwoot Cloud, Hacker accounts lose token-authenticated account API access and account webhook delivery, while paid accounts retain them through the billing-plan feature reconcile. Community and self-hosted installations continue to work without any upgrade-time interruption. ## What changed - Added `Account#api_and_webhooks_enabled?` as the single backend kill switch. Core returns enabled; the Enterprise override consults the account flag on Chatwoot Cloud and remains enabled off-Cloud. - Account-scoped v1 and v2 requests authenticated with a user or agent-bot API token now return `403 Forbidden` when the feature is disabled. Invalid tokens still return 401, and dashboard session requests are unaffected. - Profile responses return an empty access token when none of the user's accounts has access. The stored token is preserved, and the profile UI disables its token controls with paid-plan copy on Cloud. - Account webhook delivery stops when the feature is disabled. Webhook CRUD remains available to session-authenticated dashboard requests, API-inbox webhooks continue to be delivered, and the Cloud dashboard shows a webhook paywall instead of the webhook list. - Removed the database backfill migration. Existing paid Cloud accounts should be enabled with the one-off script below before enforcement is deployed. ## Existing paid-account rollout Run this as an ad-hoc Rails runner script on Chatwoot Cloud. It intentionally targets only the Startups, Business, and Enterprise plans and does not add `api_and_webhooks` to `manually_managed_features`, so future billing reconciles remain authoritative. ```rb paid_plan_names = %w[Startups Business Enterprise] accounts = Account.where("custom_attributes ->> 'plan_name' IN (?)", paid_plan_names) total = accounts.count enabled = 0 skipped = 0 puts "Enabling api_and_webhooks for #{total} paid account(s)..." accounts.find_each(batch_size: 500).with_index(1) do |account, processed| if account.feature_enabled?('api_and_webhooks') skipped += 1 else account.enable_features!('api_and_webhooks') enabled += 1 end puts "Processed #{processed}/#{total}..." if (processed % 1000).zero? end puts "Done! Enabled: #{enabled}, Skipped: #{skipped}, Total: #{total}" ``` For example, save the snippet outside the repository as `enable_api_and_webhooks.rb`, then run: ```sh bundle exec rails runner /path/to/enable_api_and_webhooks.rb ``` ## How to test - On Cloud, use a Hacker account and confirm token-authenticated requests to account-scoped v1 and v2 endpoints return 403, while the same dashboard actions continue to work through session authentication. - Confirm profile access-token controls are disabled with paid-plan copy when all accounts are ineligible, and remain available when at least one account has the feature. - Confirm the Webhooks settings page shows the billing paywall for a Cloud account without the feature; admins get the billing action and agents get the existing ask-an-admin message. - Confirm outgoing account webhooks stop for an ineligible Cloud account while API-inbox webhooks still deliver. - Confirm community and self-hosted installations retain API and webhook behavior after upgrading, even when an existing account does not have the stored feature bit. ### Screenshots ## Cloud <img width="2590" height="642" alt="CleanShot 2026-07-15 at 15 13 14@2x" src="https://github.com/user-attachments/assets/431a7bd8-1742-4e7a-b312-d3ad92015f9b" /> <img width="2152" height="994" alt="CleanShot 2026-07-15 at 15 14 37@2x" src="https://github.com/user-attachments/assets/475dda48-d1c5-4be5-a3c3-7a96b9713724" /> --------- Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
102 lines
2.4 KiB
Vue
102 lines
2.4 KiB
Vue
<script setup>
|
|
import { ref, computed } from 'vue';
|
|
import Button from './Button.vue';
|
|
|
|
const props = defineProps({
|
|
label: { type: [String, Number], default: '' },
|
|
confirmLabel: { type: [String, Number], default: '' },
|
|
color: { type: String, default: 'blue' },
|
|
confirmColor: { type: String, default: 'ruby' },
|
|
confirmHint: { type: String, default: '' },
|
|
variant: { type: String, default: null },
|
|
size: { type: String, default: null },
|
|
justify: { type: String, default: null },
|
|
icon: { type: [String, Object, Function], default: '' },
|
|
trailingIcon: { type: Boolean, default: false },
|
|
isLoading: { type: Boolean, default: false },
|
|
disabled: { type: Boolean, default: false },
|
|
});
|
|
|
|
const emit = defineEmits(['click']);
|
|
|
|
const isConfirmMode = ref(false);
|
|
const isClicked = ref(false);
|
|
|
|
const currentLabel = computed(() => {
|
|
return isConfirmMode.value ? props.confirmLabel : props.label;
|
|
});
|
|
|
|
const currentColor = computed(() => {
|
|
return isConfirmMode.value ? props.confirmColor : props.color;
|
|
});
|
|
const resetConfirmMode = () => {
|
|
isConfirmMode.value = false;
|
|
isClicked.value = false;
|
|
};
|
|
|
|
const handleClick = () => {
|
|
if (!isConfirmMode.value) {
|
|
isConfirmMode.value = true;
|
|
} else {
|
|
isClicked.value = true;
|
|
emit('click');
|
|
setTimeout(resetConfirmMode, 400);
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
class="relative"
|
|
:class="{
|
|
'animate-bounce-complete': isClicked,
|
|
}"
|
|
>
|
|
<Button
|
|
type="button"
|
|
:label="currentLabel"
|
|
:color="currentColor"
|
|
:variant="variant"
|
|
:size="size"
|
|
:justify="justify"
|
|
:icon="icon"
|
|
:trailing-icon="trailingIcon"
|
|
:is-loading="isLoading"
|
|
:disabled="disabled"
|
|
@click="handleClick"
|
|
@blur="resetConfirmMode"
|
|
>
|
|
<template v-if="$slots.default" #default>
|
|
<slot />
|
|
</template>
|
|
<template v-if="$slots.icon" #icon>
|
|
<slot name="icon" />
|
|
</template>
|
|
</Button>
|
|
<div
|
|
v-if="isConfirmMode && confirmHint"
|
|
class="absolute mt-1 w-full text-[10px] text-center text-n-slate-10"
|
|
>
|
|
{{ confirmHint }}
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
@keyframes bounce-complete {
|
|
0% {
|
|
transform: scale(0.95);
|
|
}
|
|
50% {
|
|
transform: scale(1.02);
|
|
}
|
|
100% {
|
|
transform: scale(1);
|
|
}
|
|
}
|
|
|
|
.animate-bounce-complete {
|
|
animation: bounce-complete 0.2s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
|
}
|
|
</style>
|