/), so we keep those together.
+const splitBlocks = text => {
+ const lines = (text || '').split('\n');
+ const blocks = [];
+ let buffer = [];
+ let fence = null;
+ let inList = false;
+
+ const flush = () => {
+ const block = buffer.join('\n');
+ if (block.trim()) blocks.push(block);
+ buffer = [];
+ inList = false;
+ };
+
+ lines.forEach((line, index) => {
+ const marker = line.match(FENCE_RE)?.[1];
+ if (marker && !fence) fence = marker;
+ else if (fence && line.trimStart().startsWith(fence)) fence = null;
+
+ if (fence) {
+ buffer.push(line);
+ return;
+ }
+
+ if (LIST_ITEM_RE.test(line)) inList = true;
+
+ if (line.trim() !== '') {
+ buffer.push(line);
+ return;
+ }
+
+ // Blank line: keep it when the current list continues on the next non-blank
+ // line (another item or an indented continuation); otherwise end the block.
+ const next = lines.slice(index + 1).find(other => other.trim() !== '');
+ if (inList && next && (LIST_ITEM_RE.test(next) || /^\s/.test(next))) {
+ buffer.push(line);
+ } else {
+ flush();
+ }
+ });
+
+ flush();
+ return blocks;
+};
+
+const BLOCK_TYPE = { equal: 'equal', del: 'removed', ins: 'added' };
+
+// Diffs the body block by block. Blocks match when they render to the same HTML
+// (the check staging uses), so only edits that change the page show as a diff.
+export const buildDiffBlocks = (oldText, newText) => {
+ const toBlocks = text =>
+ splitBlocks(text).map(md => ({ md, key: commonmark.render(md) }));
+ const ops = diffSequence(toBlocks(oldText), toBlocks(newText), b => b.key);
+ return ops.map(op => ({ type: BLOCK_TYPE[op.type], md: op.item.md }));
+};
+
+export const hasPendingChanges = article =>
+ article?.draftTitle != null || article?.draftContent != null;
diff --git a/app/javascript/dashboard/helper/specs/articleDiffHelper.spec.js b/app/javascript/dashboard/helper/specs/articleDiffHelper.spec.js
new file mode 100644
index 000000000..9e9f0cea9
--- /dev/null
+++ b/app/javascript/dashboard/helper/specs/articleDiffHelper.spec.js
@@ -0,0 +1,170 @@
+import {
+ renderInlineDiff,
+ buildDiffBlocks,
+ hasPendingChanges,
+ rendersIdentically,
+} from '../articleDiffHelper';
+
+describe('articleDiffHelper', () => {
+ describe('renderInlineDiff', () => {
+ it('returns the text unchanged when there is no difference', () => {
+ const result = renderInlineDiff('hello world', 'hello world');
+ expect(result).toBe('hello world');
+ expect(result).not.toContain('', () => {
+ const result = renderInlineDiff('hello', 'hello there');
+ expect(result).toContain('hello');
+ expect(result).toContain('', () => {
+ const result = renderInlineDiff('hello there', 'hello');
+ expect(result).toContain(' {
+ const result = renderInlineDiff(
+ 'How to use Agent bots?',
+ 'How How to Agent bots?'
+ );
+ expect(result).toBe(
+ 'How How to use Agent bots?'
+ );
+ });
+
+ it('escapes markup when diffing plain text', () => {
+ const result = renderInlineDiff('a', 'a ');
+ expect(result).toContain('<b>');
+ expect(result).not.toContain('');
+ });
+
+ it('treats a cleared empty string as a full deletion', () => {
+ const result = renderInlineDiff('gone', '');
+ expect(result).toContain(' {
+ it('passes an unchanged block through as equal', () => {
+ const blocks = buildDiffBlocks('same para', 'same para');
+ expect(blocks).toEqual([{ type: 'equal', md: 'same para' }]);
+ });
+
+ it('marks an appended block as added', () => {
+ const blocks = buildDiffBlocks('a', 'a\n\nb');
+ expect(blocks).toContainEqual({ type: 'equal', md: 'a' });
+ expect(blocks).toContainEqual({ type: 'added', md: 'b' });
+ });
+
+ it('marks a deleted block as removed', () => {
+ const blocks = buildDiffBlocks('a\n\nb', 'a');
+ expect(blocks).toContainEqual({ type: 'removed', md: 'b' });
+ });
+
+ it('emits the old block then the new block for a reworded section', () => {
+ const blocks = buildDiffBlocks('hello world', 'hello there');
+ expect(blocks).toEqual([
+ { type: 'removed', md: 'hello world' },
+ { type: 'added', md: 'hello there' },
+ ]);
+ });
+
+ it('keeps a fenced code block whole when it contains blank lines', () => {
+ const code = '```\nline one\n\nline two\n```';
+ const blocks = buildDiffBlocks(code, code);
+ expect(blocks).toEqual([{ type: 'equal', md: code }]);
+ });
+
+ it('diffs an edited code block as one whole removed + added block', () => {
+ const live = '```\ncode line\n```';
+ const draft = '```\ncode line\n\nsd\n```';
+ const blocks = buildDiffBlocks(live, draft);
+ expect(blocks).toContainEqual({ type: 'removed', md: live });
+ expect(blocks).toContainEqual({ type: 'added', md: draft });
+ });
+
+ it('surfaces whitespace edits that change the rendered output', () => {
+ expect(
+ buildDiffBlocks('```\nx\n```', '```\n x\n```').some(
+ block => block.type !== 'equal'
+ )
+ ).toBe(true);
+ expect(
+ buildDiffBlocks('line one\nline two', 'line one \nline two').some(
+ block => block.type !== 'equal'
+ )
+ ).toBe(true);
+ });
+
+ it('surfaces an indented code block turning into a paragraph', () => {
+ const blocks = buildDiffBlocks(
+ ' curl example.com',
+ 'curl example.com'
+ );
+ expect(blocks).toContainEqual({
+ type: 'removed',
+ md: ' curl example.com',
+ });
+ expect(blocks).toContainEqual({ type: 'added', md: 'curl example.com' });
+ });
+
+ it('keeps spacing the renderer ignores as equal', () => {
+ const blocks = buildDiffBlocks('a\nb', 'a \nb');
+ expect(blocks.every(block => block.type === 'equal')).toBe(true);
+ });
+
+ it('keeps a loose list with item descriptions as one block', () => {
+ const list =
+ '1. **One**\n\n First item.\n\n2. **Two**\n\n Second item.';
+ const blocks = buildDiffBlocks(list, list);
+ expect(blocks).toEqual([{ type: 'equal', md: list }]);
+ });
+ });
+
+ describe('rendersIdentically', () => {
+ it('ignores blank-line / empty-paragraph differences', () => {
+ expect(rendersIdentically('a\n\nb', 'a\n\n\nb')).toBe(true);
+ expect(rendersIdentically('hello', 'hello\n\n')).toBe(true);
+ });
+
+ it('counts code-block indentation changes', () => {
+ expect(rendersIdentically('```\n x\n```', '```\nx\n```')).toBe(false);
+ });
+
+ it('counts smart vs straight quotes (no typographer)', () => {
+ expect(rendersIdentically('"hi"', '“hi”')).toBe(false);
+ });
+
+ it('counts real text changes', () => {
+ expect(rendersIdentically('hello world', 'hello there')).toBe(false);
+ });
+
+ it('treats nullish input as empty', () => {
+ expect(rendersIdentically(null, '')).toBe(true);
+ expect(rendersIdentically(undefined, 'x')).toBe(false);
+ });
+ });
+
+ describe('hasPendingChanges', () => {
+ it('is true when a draft title or content is staged', () => {
+ expect(hasPendingChanges({ draftContent: 'edit' })).toBe(true);
+ expect(hasPendingChanges({ draftTitle: 'edit' })).toBe(true);
+ });
+
+ it('treats a cleared empty-string draft as a pending change', () => {
+ expect(hasPendingChanges({ draftTitle: '' })).toBe(true);
+ });
+
+ it('is false with no draft columns', () => {
+ expect(hasPendingChanges({ title: 'live' })).toBe(false);
+ expect(hasPendingChanges({})).toBe(false);
+ expect(hasPendingChanges(null)).toBe(false);
+ });
+ });
+});
diff --git a/app/javascript/dashboard/i18n/locale/en/helpCenter.json b/app/javascript/dashboard/i18n/locale/en/helpCenter.json
index ae099a7a9..53a745dc2 100644
--- a/app/javascript/dashboard/i18n/locale/en/helpCenter.json
+++ b/app/javascript/dashboard/i18n/locale/en/helpCenter.json
@@ -533,6 +533,8 @@
"PUBLISHED": "Published",
"ARCHIVED": "Archived"
},
+ "PENDING_EDITS": "Unpublished edits",
+ "PENDING_EDITS_TOOLTIP": "This published article has unpublished edits",
"CATEGORY": {
"UNCATEGORISED": "Uncategorised"
}
@@ -616,6 +618,8 @@
"DELETE": "Delete",
"STATUS_SUCCESS": "Articles updated successfully",
"STATUS_ERROR": "Failed to update articles",
+ "STATUS_SKIPPED": "1 article with unpublished edits was skipped — open it to publish or discard. | {count} articles with unpublished edits were skipped — open them to publish or discard.",
+ "STATUS_SKIPPED_ALL": "These articles have unpublished edits — open each to publish or discard.",
"CATEGORY_SUCCESS": "Articles moved successfully",
"CATEGORY_ERROR": "Failed to move articles",
"DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles",
@@ -763,10 +767,30 @@
},
"PREVIEW": "Preview",
"PUBLISH": "Publish",
+ "PUBLISH_CHANGES": "Publish changes",
+ "PUBLISH_CHANGES_SUCCESS": "Changes published successfully",
+ "PUBLISH_CHANGES_ERROR": "Could not publish changes",
+ "SAVE_IN_PROGRESS": "Still saving your latest changes — please try again in a moment.",
+ "DISCARD_CHANGES": "Discard changes",
+ "DISCARD_CHANGES_SUCCESS": "Changes discarded",
+ "DISCARD_CHANGES_ERROR": "Could not discard changes",
+ "PENDING_CHANGES": "Pending changes",
+ "VIEW_CHANGES": "View unpublished changes",
"DRAFT": "Draft",
"ARCHIVE": "Archive",
"BACK_TO_ARTICLES": "Back to articles"
},
+ "PENDING_CHANGES_POPOVER": {
+ "TITLE": "Unpublished changes",
+ "DESCRIPTION": "This article has draft changes that aren't live yet. Apply them before changing the status, or discard them?",
+ "APPLY": "Apply changes",
+ "DISCARD": "Discard changes"
+ },
+ "DIFF_DIALOG": {
+ "TITLE": "Unpublished changes",
+ "DESCRIPTION": "Compare your draft against the version that's currently live.",
+ "TITLE_LABEL": "Title"
+ },
"EDIT_ARTICLE": {
"MORE_PROPERTIES": "More properties",
"UNCATEGORIZED": "Uncategorized",
diff --git a/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesEditPage.vue b/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesEditPage.vue
index 36508ded5..794f8c839 100644
--- a/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesEditPage.vue
+++ b/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesEditPage.vue
@@ -4,8 +4,12 @@ import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert, useTrack } from 'dashboard/composables';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
-import { buildPortalArticleURL } from 'dashboard/helper/portalHelper';
+import {
+ buildPortalArticleURL,
+ ARTICLE_STATUSES,
+} from 'dashboard/helper/portalHelper';
import { useStore, useMapGetter } from 'dashboard/composables/store';
+import { rendersIdentically } from 'dashboard/helper/articleDiffHelper';
import ArticleEditor from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue';
@@ -40,13 +44,60 @@ const articleLink = computed(() => {
);
});
+// On a published article, title/content edits stage into draft_* columns (kept
+// off the live site). Anywhere else they save straight to the live record — and
+// we drop any leftover draft (e.g. left behind when the card/bulk menu moved a
+// published article to draft) so a later publish can't resurrect stale content.
+const stageDraftFields = values => {
+ if (article.value?.status !== ARTICLE_STATUSES.PUBLISHED) {
+ const hasStaleDraft =
+ article.value?.draftTitle != null || article.value?.draftContent != null;
+ if (!hasStaleDraft) return values;
+ // The editor is showing the staged draft, so promote both fields to the live
+ // record (the field being autosaved wins) before dropping the drafts —
+ // otherwise saving one field would snap the other back to the old live value.
+ return {
+ ...values,
+ title: values.title ?? article.value.draftTitle ?? article.value.title,
+ content:
+ values.content ?? article.value.draftContent ?? article.value.content,
+ draft_title: null,
+ draft_content: null,
+ };
+ }
+
+ const staged = { ...values };
+ ['title', 'content'].forEach(field => {
+ if (field in staged) {
+ staged[`draft_${field}`] = staged[field];
+ delete staged[field];
+ }
+ });
+
+ // Clear the draft when it matches the live version (a revert, or a body edit
+ // the renderer ignores like a blank line) so it doesn't leave a "pending
+ // changes" badge with nothing to compare. The title is shown as raw escaped
+ // text, so compare it exactly; only the body is Markdown, so compare its render.
+ const liveTitle = article.value.title ?? '';
+ const liveContent = article.value.content ?? '';
+ const nextTitle = staged.draft_title ?? article.value.draftTitle ?? liveTitle;
+ const nextContent =
+ staged.draft_content ?? article.value.draftContent ?? liveContent;
+ if (nextTitle === liveTitle && rendersIdentically(liveContent, nextContent)) {
+ staged.draft_title = null;
+ staged.draft_content = null;
+ }
+
+ return staged;
+};
+
const saveArticle = async ({ ...values }) => {
isUpdating.value = true;
try {
await store.dispatch('articles/update', {
portalSlug,
articleId: articleSlug,
- ...values,
+ ...stageDraftFields(values),
});
isSaved.value = true;
} catch (error) {
diff --git a/app/javascript/dashboard/store/modules/helpCenterArticles/actions.js b/app/javascript/dashboard/store/modules/helpCenterArticles/actions.js
index 7dd42e749..a9da13106 100644
--- a/app/javascript/dashboard/store/modules/helpCenterArticles/actions.js
+++ b/app/javascript/dashboard/store/modules/helpCenterArticles/actions.js
@@ -96,6 +96,32 @@ export const actions = {
}
},
+ // Push the draft to live and clear it, optionally changing status in the same
+ // update. Only edited fields are sent so an untouched live value survives.
+ publishDraft: ({ dispatch, state }, { portalSlug, articleId, status }) => {
+ const article = state.articles.byId[articleId];
+ const payload = {
+ portalSlug,
+ articleId,
+ status,
+ draft_title: null,
+ draft_content: null,
+ };
+ if (article?.draftTitle != null) payload.title = article.draftTitle;
+ if (article?.draftContent != null) payload.content = article.draftContent;
+ return dispatch('update', payload);
+ },
+
+ // Clear the draft (optionally changing status); live content is left untouched.
+ discardDraft: ({ dispatch }, { portalSlug, articleId, status }) =>
+ dispatch('update', {
+ portalSlug,
+ articleId,
+ status,
+ draft_title: null,
+ draft_content: null,
+ }),
+
updateArticleMeta: async ({ commit }, { portalSlug, locale }) => {
try {
const { data } = await articlesAPI.getArticles({
diff --git a/app/javascript/dashboard/store/modules/helpCenterArticles/specs/action.spec.js b/app/javascript/dashboard/store/modules/helpCenterArticles/specs/action.spec.js
index 6517f44fd..5f3215fab 100644
--- a/app/javascript/dashboard/store/modules/helpCenterArticles/specs/action.spec.js
+++ b/app/javascript/dashboard/store/modules/helpCenterArticles/specs/action.spec.js
@@ -150,6 +150,101 @@ describe('#actions', () => {
});
});
+ describe('#publishDraft', () => {
+ const state = {
+ articles: {
+ byId: {
+ 1: {
+ id: 1,
+ draftTitle: 'Draft title',
+ draftContent: 'Draft content',
+ },
+ },
+ },
+ };
+
+ it('dispatches update promoting the edited fields and clearing the draft', async () => {
+ await actions.publishDraft(
+ { dispatch, state },
+ { portalSlug: 'room-rental', articleId: 1 }
+ );
+ expect(dispatch).toHaveBeenCalledWith('update', {
+ portalSlug: 'room-rental',
+ articleId: 1,
+ status: undefined,
+ draft_title: null,
+ draft_content: null,
+ title: 'Draft title',
+ content: 'Draft content',
+ });
+ });
+
+ it('only sends the fields that were actually edited', async () => {
+ const partialState = {
+ articles: { byId: { 1: { id: 1, draftContent: 'Only content' } } },
+ };
+ await actions.publishDraft(
+ { dispatch, state: partialState },
+ { portalSlug: 'room-rental', articleId: 1 }
+ );
+ expect(dispatch).toHaveBeenCalledWith('update', {
+ portalSlug: 'room-rental',
+ articleId: 1,
+ status: undefined,
+ draft_title: null,
+ draft_content: null,
+ content: 'Only content',
+ });
+ });
+
+ it('forwards a status to change it in the same update', async () => {
+ await actions.publishDraft(
+ { dispatch, state },
+ { portalSlug: 'room-rental', articleId: 1, status: 'archived' }
+ );
+ expect(dispatch).toHaveBeenCalledWith(
+ 'update',
+ expect.objectContaining({
+ status: 'archived',
+ title: 'Draft title',
+ content: 'Draft content',
+ draft_title: null,
+ draft_content: null,
+ })
+ );
+ });
+ });
+
+ describe('#discardDraft', () => {
+ it('dispatches update clearing the draft columns', async () => {
+ await actions.discardDraft(
+ { dispatch },
+ { portalSlug: 'room-rental', articleId: 1 }
+ );
+ expect(dispatch).toHaveBeenCalledWith('update', {
+ portalSlug: 'room-rental',
+ articleId: 1,
+ status: undefined,
+ draft_title: null,
+ draft_content: null,
+ });
+ });
+
+ it('forwards a status to change it in the same update', async () => {
+ await actions.discardDraft(
+ { dispatch },
+ { portalSlug: 'room-rental', articleId: 1, status: 'draft' }
+ );
+ expect(dispatch).toHaveBeenCalledWith('update', {
+ portalSlug: 'room-rental',
+ articleId: 1,
+ status: 'draft',
+ draft_title: null,
+ draft_content: null,
+ });
+ });
+ });
+
describe('#updateArticleMeta', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({
diff --git a/app/javascript/shared/helpers/MessageFormatter.js b/app/javascript/shared/helpers/MessageFormatter.js
index 1a14266f0..b5dc61702 100644
--- a/app/javascript/shared/helpers/MessageFormatter.js
+++ b/app/javascript/shared/helpers/MessageFormatter.js
@@ -67,8 +67,11 @@ const createMarkdownInstance = (linkify = true) => {
// `` comment before the table. It exists only for the
// editor's markdown round-trip and must never surface as text — markdown-it runs
// with `html: false`, which would otherwise escape it into a visible comment in
-// rendered/plain output (e.g. dashboard search snippets). Strip it on the way in.
-const COLWIDTHS_MARKER_REGEX = /\r?\n?/g;
+// rendered/plain output (e.g. dashboard search snippets). Strip the whole marker
+// line, including any blockquote prefix, so a quoted table's `>` prefixes don't
+// collapse together and break table parsing.
+const COLWIDTHS_MARKER_REGEX =
+ /^[ \t>]*[ \t]*\r?\n?/gm;
const TWITTER_USERNAME_REGEX = /(^|[^@\w])@(\w{1,15})\b/g;
const TWITTER_USERNAME_REPLACEMENT = '$1[@$2](http://twitter.com/$2)';
diff --git a/app/javascript/shared/helpers/specs/MessageFormatter.spec.js b/app/javascript/shared/helpers/specs/MessageFormatter.spec.js
index 20d64005a..70e71c46d 100644
--- a/app/javascript/shared/helpers/specs/MessageFormatter.spec.js
+++ b/app/javascript/shared/helpers/specs/MessageFormatter.spec.js
@@ -153,6 +153,15 @@ After`;
expect(formatter.formattedMessage).not.toContain('cw-colwidths');
expect(formatter.plainText).not.toContain('cw-colwidths');
});
+
+ it('strips a blockquote-prefixed marker so the quoted table still renders', () => {
+ const message =
+ '> \n> | A | B |\n> | --- | --- |\n> | 1 | 2 |';
+ const { formattedMessage } = new MessageFormatter(message);
+ expect(formattedMessage).not.toContain('cw-colwidths');
+ expect(formattedMessage).toContain('');
+ expect(formattedMessage).toContain('');
+ });
});
describe('#sanitize', () => {
diff --git a/app/models/article.rb b/app/models/article.rb
index 9d1247e8b..eb843493a 100644
--- a/app/models/article.rb
+++ b/app/models/article.rb
@@ -5,6 +5,8 @@
# id :bigint not null, primary key
# content :text
# description :text
+# draft_content :text
+# draft_title :string
# locale :string default("en"), not null
# meta :jsonb
# position :integer
diff --git a/app/views/api/v1/accounts/articles/_article.json.jbuilder b/app/views/api/v1/accounts/articles/_article.json.jbuilder
index a6eb7a551..426b24afe 100644
--- a/app/views/api/v1/accounts/articles/_article.json.jbuilder
+++ b/app/views/api/v1/accounts/articles/_article.json.jbuilder
@@ -4,6 +4,8 @@ json.title article.title
json.content article.content
json.description article.description
json.status article.status
+json.draft_title article.draft_title
+json.draft_content article.draft_content
json.position article.position
json.account_id article.account_id
json.updated_at article.updated_at.to_i
diff --git a/db/migrate/20260623000000_add_draft_columns_to_articles.rb b/db/migrate/20260623000000_add_draft_columns_to_articles.rb
new file mode 100644
index 000000000..a6f5e0cd7
--- /dev/null
+++ b/db/migrate/20260623000000_add_draft_columns_to_articles.rb
@@ -0,0 +1,6 @@
+class AddDraftColumnsToArticles < ActiveRecord::Migration[7.1]
+ def change
+ add_column :articles, :draft_title, :string
+ add_column :articles, :draft_content, :text
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 43e7135b9..14b634c30 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -211,6 +211,8 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do
t.string "slug", null: false
t.integer "position"
t.string "locale", default: "en", null: false
+ t.string "draft_title"
+ t.text "draft_content"
t.index ["account_id"], name: "index_articles_on_account_id"
t.index ["associated_article_id"], name: "index_articles_on_associated_article_id"
t.index ["author_id"], name: "index_articles_on_author_id"
diff --git a/spec/controllers/api/v1/accounts/articles_controller_spec.rb b/spec/controllers/api/v1/accounts/articles_controller_spec.rb
index bf3ae2aa9..6651a5b20 100644
--- a/spec/controllers/api/v1/accounts/articles_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/articles_controller_spec.rb
@@ -170,6 +170,29 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
expect(json_response['payload']['status']).to eql(article_params[:article][:status])
expect(json_response['payload']['position']).to eql(article_params[:article][:position])
end
+
+ it 'stages draft-only fields without bumping updated_at' do
+ expect do
+ put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/#{article.id}",
+ params: { article: { draft_title: 'Draft title', draft_content: 'Draft body' } },
+ headers: admin.create_new_auth_token
+ end.not_to(change { article.reload.updated_at })
+
+ expect(response).to have_http_status(:success)
+ expect(article.draft_title).to eq('Draft title')
+ expect(article.draft_content).to eq('Draft body')
+ end
+
+ it 'rejects an over-length draft without persisting it' do
+ expect do
+ put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/#{article.id}",
+ params: { article: { draft_content: 'a' * 20_001 } },
+ headers: admin.create_new_auth_token
+ end.not_to(change { article.reload.draft_content })
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['message']).to include('too long')
+ end
end
end
From 0a25a0ef664ef71be58065ab487862144d792bc9 Mon Sep 17 00:00:00 2001
From: Muhsin Keloth
Date: Thu, 16 Jul 2026 13:35:20 +0400
Subject: [PATCH 02/44] fix(whatsapp): log manual transfer only for embedded
signup migrations (#15032)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The `[WHATSAPP_MANUAL_TRANSFER] success` log introduced in #14975 to
track embedded signup → manual migrations was firing on any WhatsApp
credential change — including routine `api_key` rotations on inboxes
that were already manually configured — inflating the migration count.
The log now fires only for the actual migration, and uses a new tag so
log searches don't match the older over-counted entries.
## How to reproduce
1. On a manually configured WhatsApp Cloud inbox, update the API key
from inbox settings → Configuration.
2. Before this change, the app log records a `[WHATSAPP_MANUAL_TRANSFER]
success` line even though no migration happened; after this change it
stays silent.
3. Switching an embedded signup inbox to manual setup still logs the
migration (now as `[WHATSAPP_EMBEDDED_TO_MANUAL] success`).
## What changed
- `Channel::Whatsapp#log_credentials_transfer` now keys off the
migration's unique signal — `provider_config['source']` changing from
`embedded_signup` to absent — instead of diffing credential keys.
- Renamed the log tag from `WHATSAPP_MANUAL_TRANSFER` to
`WHATSAPP_EMBEDDED_TO_MANUAL` (success and failure lines) so the
corrected entries are searchable without matching pre-fix false
positives.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
---
app/models/channel/whatsapp.rb | 16 ++++++++++++----
.../whatsapp/providers/whatsapp_cloud_service.rb | 6 +++---
2 files changed, 15 insertions(+), 7 deletions(-)
diff --git a/app/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb
index 7a109c455..87ff199d5 100644
--- a/app/models/channel/whatsapp.rb
+++ b/app/models/channel/whatsapp.rb
@@ -101,6 +101,13 @@ class Channel::Whatsapp < ApplicationRecord
end
end
+ # Whether the pending (unsaved) provider_config change drops the embedded_signup
+ # source marker, i.e. this save is an embedded signup → manual setup transfer.
+ def embedded_to_manual_transfer_pending?
+ before, after = provider_config_change
+ before&.dig('source') == 'embedded_signup' && after['source'] != 'embedded_signup'
+ end
+
def mark_message_templates_updated
# rubocop:disable Rails/SkipsModelValidations
update_column(:message_templates_last_updated, Time.zone.now)
@@ -130,13 +137,14 @@ class Channel::Whatsapp < ApplicationRecord
errors.add(:provider_config, 'Invalid Credentials') unless provider_service.validate_provider_config?
end
- # Logs only credential changes, so config-only saves (e.g. calling toggles) stay silent.
+ # Logs only the embedded signup → manual migration (the save drops the
+ # embedded_signup source marker), so credential rotations on inboxes that are
+ # already manual stay silent.
def log_credentials_transfer
before, after = saved_change_to_provider_config
- keys = %w[api_key phone_number_id business_account_id]
- return if before.nil? || before.values_at(*keys) == after.values_at(*keys)
+ return unless before&.dig('source') == 'embedded_signup' && after['source'] != 'embedded_signup'
- Rails.logger.info("[WHATSAPP_MANUAL_TRANSFER] success account_id=#{account_id} channel_id=#{id}")
+ Rails.logger.info("[WHATSAPP_EMBEDDED_TO_MANUAL] success account_id=#{account_id} channel_id=#{id}")
end
def perform_webhook_setup
diff --git a/app/services/whatsapp/providers/whatsapp_cloud_service.rb b/app/services/whatsapp/providers/whatsapp_cloud_service.rb
index 373e47b3c..d65c6cc62 100644
--- a/app/services/whatsapp/providers/whatsapp_cloud_service.rb
+++ b/app/services/whatsapp/providers/whatsapp_cloud_service.rb
@@ -94,12 +94,12 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
private
- # Only credential updates on existing channels are transfer attempts; creation failures are regular setup errors. Returns false.
+ # Only saves dropping the embedded_signup source marker are transfer attempts; creation/rotation failures are setup errors. Returns false.
def log_transfer_failure(check, response)
- return false unless whatsapp_channel.persisted? && whatsapp_channel.provider_config_changed?
+ return false unless whatsapp_channel.embedded_to_manual_transfer_pending?
error_message = response.parsed_response.is_a?(Hash) ? response.parsed_response.dig('error', 'message') : nil
- Rails.logger.warn("[WHATSAPP_MANUAL_TRANSFER] failure account_id=#{whatsapp_channel.account_id} channel_id=#{whatsapp_channel.id} " \
+ Rails.logger.warn("[WHATSAPP_EMBEDDED_TO_MANUAL] failure account_id=#{whatsapp_channel.account_id} channel_id=#{whatsapp_channel.id} " \
"check=#{check} http_status=#{response.code} meta_error=#{error_message}")
false
end
From 522e3c4d3ffdcb76854632d6d3975f24315a9500 Mon Sep 17 00:00:00 2001
From: Shivam Mishra
Date: Thu, 16 Jul 2026 15:13:49 +0530
Subject: [PATCH 03/44] feat: enforce `api_and_webhooks` feature for token API
and account webhooks (#14973)
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
---------
Co-authored-by: Muhsin Keloth
---
.../api/v1/accounts/base_controller.rb | 9 +++
app/controllers/api/v1/accounts_controller.rb | 7 ++
.../components-next/button/ConfirmButton.vue | 2 +
app/javascript/dashboard/featureFlags.js | 1 +
.../i18n/locale/en/integrations.json | 7 ++
.../dashboard/i18n/locale/en/settings.json | 1 +
.../settings/integrations/Webhooks/Index.vue | 51 +++++++++++---
.../integrations/Webhooks/WebhookPaywall.vue | 27 ++++++++
.../settings/profile/AccessToken.vue | 5 ++
.../dashboard/settings/profile/Index.vue | 26 +++++++-
app/listeners/webhook_listener.rb | 2 +
app/models/account.rb | 4 ++
app/views/api/v1/models/_user.json.jbuilder | 3 +-
.../enterprise/api/v1/accounts_controller.rb | 7 ++
enterprise/app/models/enterprise/account.rb | 6 ++
spec/controllers/api/base_controller_spec.rb | 61 +++++++++++++++++
.../v1/accounts/webhook_controller_spec.rb | 11 ++++
.../api/v1/accounts_controller_spec.rb | 66 +++++++++++++++++++
.../api/v1/profiles_controller_spec.rb | 58 ++++++++++++++++
.../api/v1/accounts_controller_spec.rb | 24 +++++++
spec/enterprise/models/account_spec.rb | 22 +++++++
spec/listeners/webhook_listener_spec.rb | 44 +++++++++++++
spec/models/account_spec.rb | 9 +++
23 files changed, 441 insertions(+), 12 deletions(-)
create mode 100644 app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/WebhookPaywall.vue
diff --git a/app/controllers/api/v1/accounts/base_controller.rb b/app/controllers/api/v1/accounts/base_controller.rb
index e30effc59..f08b87e60 100644
--- a/app/controllers/api/v1/accounts/base_controller.rb
+++ b/app/controllers/api/v1/accounts/base_controller.rb
@@ -2,5 +2,14 @@ class Api::V1::Accounts::BaseController < Api::BaseController
include SwitchLocale
include EnsureCurrentAccountHelper
before_action :current_account
+ before_action :validate_token_api_access, if: :authenticate_by_access_token?
around_action :switch_locale_using_account_locale
+
+ private
+
+ def validate_token_api_access
+ return if Current.account.api_and_webhooks_enabled?
+
+ render json: { error: 'API access is not enabled for this account' }, status: :forbidden
+ end
end
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index fb991949a..9438a1660 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -8,6 +8,7 @@ class Api::V1::AccountsController < Api::BaseController
before_action :ensure_account_name, only: [:create]
before_action :validate_captcha, only: [:create]
before_action :fetch_account, except: [:create]
+ before_action :validate_token_api_access, if: :authenticate_by_access_token?, except: [:create]
before_action :check_authorization, except: [:create]
rescue_from CustomExceptions::Account::InvalidEmail,
@@ -105,6 +106,12 @@ class Api::V1::AccountsController < Api::BaseController
@current_account_user = @account.account_users.find_by(user_id: current_user.id)
end
+ def validate_token_api_access
+ return if @account.api_and_webhooks_enabled?
+
+ render json: { error: 'API access is not enabled for this account' }, status: :forbidden
+ end
+
def account_params
params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :user_full_name)
end
diff --git a/app/javascript/dashboard/components-next/button/ConfirmButton.vue b/app/javascript/dashboard/components-next/button/ConfirmButton.vue
index 854d5d452..00b9300c6 100644
--- a/app/javascript/dashboard/components-next/button/ConfirmButton.vue
+++ b/app/javascript/dashboard/components-next/button/ConfirmButton.vue
@@ -14,6 +14,7 @@ const props = defineProps({
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']);
@@ -61,6 +62,7 @@ const handleClick = () => {
:icon="icon"
:trailing-icon="trailingIcon"
:is-loading="isLoading"
+ :disabled="disabled"
@click="handleClick"
@blur="resetConfirmMode"
>
diff --git a/app/javascript/dashboard/featureFlags.js b/app/javascript/dashboard/featureFlags.js
index 058921eea..e3c57e99d 100644
--- a/app/javascript/dashboard/featureFlags.js
+++ b/app/javascript/dashboard/featureFlags.js
@@ -12,6 +12,7 @@ export const FEATURE_FLAGS = {
CRM: 'crm',
CUSTOM_ATTRIBUTES: 'custom_attributes',
DATA_IMPORT: 'data_import',
+ API_AND_WEBHOOKS: 'api_and_webhooks',
INBOX_MANAGEMENT: 'inbox_management',
INTEGRATIONS: 'integrations',
LABELS: 'labels',
diff --git a/app/javascript/dashboard/i18n/locale/en/integrations.json b/app/javascript/dashboard/i18n/locale/en/integrations.json
index 629dbd27c..82782988f 100644
--- a/app/javascript/dashboard/i18n/locale/en/integrations.json
+++ b/app/javascript/dashboard/i18n/locale/en/integrations.json
@@ -31,6 +31,13 @@
"WEBHOOK": {
"SUBSCRIBED_EVENTS": "Subscribed Events",
"LEARN_MORE": "Learn more about webhooks",
+ "PAYWALL": {
+ "TITLE": "Webhooks are available on paid plans",
+ "AVAILABLE_ON": "Use webhooks to receive real-time events from your Chatwoot account.",
+ "UPGRADE_PROMPT": "Upgrade to the Startups, Business, or Enterprise plan to use webhooks.",
+ "UPGRADE_NOW": "Upgrade now",
+ "CANCEL_ANYTIME": "Change or cancel your plan anytime."
+ },
"SECRET": {
"LABEL": "Secret",
"COPY": "Copy secret to clipboard",
diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json
index eaecd7b80..ceb0438b1 100644
--- a/app/javascript/dashboard/i18n/locale/en/settings.json
+++ b/app/javascript/dashboard/i18n/locale/en/settings.json
@@ -100,6 +100,7 @@
"ACCESS_TOKEN": {
"TITLE": "Access Token",
"NOTE": "This token can be used if you are building an API based integration",
+ "PAID_PLAN_NOTE": "API access tokens are available on paid plans.",
"COPY": "Copy",
"RESET": "Reset",
"CONFIRM_RESET": "Are you sure?",
diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/Index.vue
index 7213c735a..75dc30812 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/Index.vue
@@ -5,9 +5,11 @@ import { useBranding } from 'shared/composables/useBranding';
import { picoSearch } from '@scmmishra/pico-search';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { BaseTable } from 'dashboard/components-next/table';
+import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import NewWebhook from './NewWebHook.vue';
import EditWebhook from './EditWebHook.vue';
import WebhookRow from './WebhookRow.vue';
+import WebhookPaywall from './WebhookPaywall.vue';
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
import SettingsLayout from '../../SettingsLayout.vue';
@@ -20,6 +22,7 @@ export default {
NewWebhook,
EditWebhook,
WebhookRow,
+ WebhookPaywall,
},
setup() {
const { replaceInstallationName } = useBranding();
@@ -39,7 +42,19 @@ export default {
...mapGetters({
records: 'webhooks/getWebhooks',
uiFlags: 'webhooks/getUIFlags',
+ accountId: 'getCurrentAccountId',
+ isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
+ isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
}),
+ apiAndWebhooksEnabled() {
+ return (
+ !this.isOnChatwootCloud ||
+ this.isFeatureEnabledonAccount(
+ this.accountId,
+ FEATURE_FLAGS.API_AND_WEBHOOKS
+ )
+ );
+ },
integration() {
return this.$store.getters['integrations/getIntegration']('webhook');
},
@@ -57,9 +72,16 @@ export default {
];
},
},
+ watch: {
+ apiAndWebhooksEnabled: {
+ immediate: true,
+ handler(enabled) {
+ if (enabled) this.$store.dispatch('webhooks/get');
+ },
+ },
+ },
mounted() {
this.$store.dispatch('integrations/get', 'webhook');
- this.$store.dispatch('webhooks/get');
},
methods: {
openAddPopup() {
@@ -105,10 +127,10 @@ export default {
-
+
{{
$t('INTEGRATION_SETTINGS.WEBHOOK.COUNT', { n: records.length })
}}
-
+
+
+
-
+
+import { useRouter } from 'vue-router';
+import { useMapGetter } from 'dashboard/composables/store';
+import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue';
+
+const router = useRouter();
+const accountId = useMapGetter('getCurrentAccountId');
+
+const openBilling = () => {
+ router.push({
+ name: 'billing_settings_index',
+ params: { accountId: accountId.value },
+ });
+};
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/AccessToken.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/AccessToken.vue
index 027492629..a2e7db1f0 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/profile/AccessToken.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/profile/AccessToken.vue
@@ -6,6 +6,7 @@ import ConfirmButton from 'dashboard/components-next/button/ConfirmButton.vue';
const props = defineProps({
value: { type: String, default: '' },
showResetButton: { type: Boolean, default: true },
+ disabled: { type: Boolean, default: false },
});
const emit = defineEmits(['onCopy', 'onReset']);
@@ -41,12 +42,14 @@ const onReset = () => {
}"
:type="inputType"
:model-value="value"
+ :disabled="disabled"
readonly
>