From 22759b8d3243d915eb9629e0747a749eca4eebf7 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Mon, 30 Mar 2026 15:57:11 -0400 Subject: [PATCH 1/5] feat(integration-platform): add bodyEncoding option to fetch step Add `bodyEncoding: "json" | "form"` field to the fetch step schema. Default is "json" (current behavior, nothing breaks). When "form": - Serializes body as URL-encoded key=value pairs (URLSearchParams) - Sets Content-Type to application/x-www-form-urlencoded - Template interpolation ({{variables}}) still resolves before encoding Also updated httpPost/httpPut/httpPatch in check-context to: - Respect Content-Type from passed headers (don't override with JSON) - Send string bodies as-is (don't double-stringify form-encoded data) This unblocks OAuth2 client_credentials flows (Sophos, ADP, ServiceNow, etc.) and any legacy API requiring form-encoded POST. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/dsl/interpreter.ts | 31 +++++++++++++++---- .../integration-platform/src/dsl/types.ts | 1 + .../src/runtime/check-context.ts | 25 +++++++++++---- 3 files changed, 45 insertions(+), 12 deletions(-) diff --git a/packages/integration-platform/src/dsl/interpreter.ts b/packages/integration-platform/src/dsl/interpreter.ts index 0d65b0ad3..1e3ac0422 100644 --- a/packages/integration-platform/src/dsl/interpreter.ts +++ b/packages/integration-platform/src/dsl/interpreter.ts @@ -173,17 +173,36 @@ async function executeFetch( let data: unknown; const method = step.method || 'GET'; + // Resolve body with interpolation + const resolveBody = (): unknown => { + if (!step.body) return undefined; + const interpolated = JSON.parse(interpolate(JSON.stringify(step.body), scope)); + + // Form-encode if bodyEncoding is 'form' + if (step.bodyEncoding === 'form' && interpolated && typeof interpolated === 'object') { + const formParams = new URLSearchParams(); + for (const [key, value] of Object.entries(interpolated)) { + formParams.append(key, String(value ?? '')); + } + return formParams.toString(); + } + + return interpolated; + }; + + // Set Content-Type header for form encoding + const bodyHeaders = step.bodyEncoding === 'form' + ? { 'Content-Type': 'application/x-www-form-urlencoded', ...headers } + : headers; + if (method === 'GET') { data = await ctx.fetch(path, { params, headers }); } else if (method === 'POST') { - const body = step.body ? JSON.parse(interpolate(JSON.stringify(step.body), scope)) : undefined; - data = await ctx.post(path, body, { headers }); + data = await ctx.post(path, resolveBody(), { headers: bodyHeaders }); } else if (method === 'PUT') { - const body = step.body ? JSON.parse(interpolate(JSON.stringify(step.body), scope)) : undefined; - data = await ctx.put(path, body, { headers }); + data = await ctx.put(path, resolveBody(), { headers: bodyHeaders }); } else if (method === 'PATCH') { - const body = step.body ? JSON.parse(interpolate(JSON.stringify(step.body), scope)) : undefined; - data = await ctx.patch(path, body, { headers }); + data = await ctx.patch(path, resolveBody(), { headers: bodyHeaders }); } else if (method === 'DELETE') { data = await ctx.delete(path, { headers }); } diff --git a/packages/integration-platform/src/dsl/types.ts b/packages/integration-platform/src/dsl/types.ts index 15c0dc075..f45af9761 100644 --- a/packages/integration-platform/src/dsl/types.ts +++ b/packages/integration-platform/src/dsl/types.ts @@ -120,6 +120,7 @@ export const FetchStepSchema = z.object({ method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).optional(), params: z.record(z.string(), z.string()).optional(), body: z.unknown().optional(), + bodyEncoding: z.enum(['json', 'form']).optional(), headers: z.record(z.string(), z.string()).optional(), dataPath: z.string().optional(), onError: z.enum(['fail', 'skip', 'empty']).optional(), diff --git a/packages/integration-platform/src/runtime/check-context.ts b/packages/integration-platform/src/runtime/check-context.ts index 56e559b06..64a7bac16 100644 --- a/packages/integration-platform/src/runtime/check-context.ts +++ b/packages/integration-platform/src/runtime/check-context.ts @@ -253,11 +253,16 @@ export function createCheckContext(options: CheckContextOptions): { opts?: { baseUrl?: string; headers?: Record }, ): Promise { const url = buildUrl(path, opts?.baseUrl); + const merged = buildHeaders(opts?.headers); + // Only set Content-Type to JSON if not already specified (allows form-encoded) + if (!merged['Content-Type'] && !merged['content-type']) { + merged['Content-Type'] = 'application/json'; + } return executeRequest(() => fetch(url.toString(), { method: 'POST', - headers: { ...buildHeaders(opts?.headers), 'Content-Type': 'application/json' }, - body: body ? JSON.stringify(body) : undefined, + headers: merged, + body: body != null ? (typeof body === 'string' ? body : JSON.stringify(body)) : undefined, }), ); } @@ -268,11 +273,15 @@ export function createCheckContext(options: CheckContextOptions): { opts?: { baseUrl?: string; headers?: Record }, ): Promise { const url = buildUrl(path, opts?.baseUrl); + const merged = buildHeaders(opts?.headers); + if (!merged['Content-Type'] && !merged['content-type']) { + merged['Content-Type'] = 'application/json'; + } return executeRequest(() => fetch(url.toString(), { method: 'PUT', - headers: { ...buildHeaders(opts?.headers), 'Content-Type': 'application/json' }, - body: body ? JSON.stringify(body) : undefined, + headers: merged, + body: body != null ? (typeof body === 'string' ? body : JSON.stringify(body)) : undefined, }), ); } @@ -283,11 +292,15 @@ export function createCheckContext(options: CheckContextOptions): { opts?: { baseUrl?: string; headers?: Record }, ): Promise { const url = buildUrl(path, opts?.baseUrl); + const merged = buildHeaders(opts?.headers); + if (!merged['Content-Type'] && !merged['content-type']) { + merged['Content-Type'] = 'application/json'; + } return executeRequest(() => fetch(url.toString(), { method: 'PATCH', - headers: { ...buildHeaders(opts?.headers), 'Content-Type': 'application/json' }, - body: body ? JSON.stringify(body) : undefined, + headers: merged, + body: body != null ? (typeof body === 'string' ? body : JSON.stringify(body)) : undefined, }), ); } From e9cd5673a2605ab09182a76d1d4217d8b6b4a4dd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 16:14:33 -0400 Subject: [PATCH 2/5] fix: fix error with policy rendering [dev] [Marfuen] mariano/eng-197-when-going-in-a-policy-getting-a-node-with-invalid-content --- .../editor/components/PolicyDetails.tsx | 7 +- .../app/components/editor/PolicyEditor.tsx | 28 +--- .../editor/utils/validate-content.test.ts | 155 ++++++++++++++++++ .../editor/utils/validate-content.ts | 91 +++++++++- 4 files changed, 249 insertions(+), 32 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx index 305c07b5f..6c93e1eaa 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx @@ -1117,12 +1117,7 @@ function PolicyEditorWrapper({ const formattedContent = Array.isArray(policyContent) ? policyContent : [policyContent as JSONContent]; - const sanitizedContent = formattedContent.map((node) => { - if (node.marks) node.marks = node.marks.filter((mark) => mark.type !== 'textStyle'); - if (node.content) node.content = node.content.map((child) => child); - return node; - }); - const validatedDoc = validateAndFixTipTapContent(sanitizedContent); + const validatedDoc = validateAndFixTipTapContent(formattedContent); const normalizedContent = (validatedDoc.content || []) as Array; async function savePolicy(content: Array): Promise { diff --git a/apps/framework-editor/app/components/editor/PolicyEditor.tsx b/apps/framework-editor/app/components/editor/PolicyEditor.tsx index d926cd2d1..92a6cb2fc 100644 --- a/apps/framework-editor/app/components/editor/PolicyEditor.tsx +++ b/apps/framework-editor/app/components/editor/PolicyEditor.tsx @@ -3,6 +3,7 @@ import type { JSONContent } from '@tiptap/react'; import { useMemo } from 'react'; import AdvancedEditor from './AdvancedEditor'; // Use local AdvancedEditor +import { validateAndFixTipTapContent } from '@trycompai/ui'; interface PolicyEditorProps { // Accept raw JSONContent or array from DB @@ -12,27 +13,12 @@ interface PolicyEditorProps { } export function PolicyEditor({ initialDbContent, readOnly = false, onSave }: PolicyEditorProps) { - // AdvancedEditor expects a single Tiptap document (JSONContent) - // Convert the DB format (potentially null, array, or object) to the expected format. - const initialEditorContent = useMemo(() => { - if (!initialDbContent) { - return { type: 'doc', content: [] }; // Default empty doc - } - if (Array.isArray(initialDbContent)) { - // If DB stores array, wrap it in a doc node - return { type: 'doc', content: initialDbContent }; - } - if (typeof initialDbContent === 'object' && initialDbContent !== null) { - // If DB stores a valid JSON object, use it directly - // Add basic validation if needed - if (initialDbContent.type === 'doc') { - return initialDbContent as JSONContent; - } - } - // Fallback for unexpected formats - console.warn('Unexpected initialDbContent format, using default empty doc.', initialDbContent); - return { type: 'doc', content: [] }; - }, [initialDbContent]); + // Use the shared validation function for consistent content handling + // across all editors (handles stringified JSON, invalid lists, etc.) + const initialEditorContent = useMemo( + () => validateAndFixTipTapContent(initialDbContent), + [initialDbContent], + ); // No internal state needed for content, pass directly to AdvancedEditor diff --git a/packages/ui/src/components/editor/utils/validate-content.test.ts b/packages/ui/src/components/editor/utils/validate-content.test.ts index 6fdafe336..6c748418e 100644 --- a/packages/ui/src/components/editor/utils/validate-content.test.ts +++ b/packages/ui/src/components/editor/utils/validate-content.test.ts @@ -182,6 +182,161 @@ describe('validateAndFixTipTapContent', () => { }); }); + describe('stringified JSON nodes', () => { + it('should parse stringified JSON nodes in an array', () => { + const content = [ + JSON.stringify({ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Purpose' }] }), + JSON.stringify({ type: 'paragraph', attrs: { textAlign: null }, content: [{ type: 'text', text: 'Some policy text.' }] }), + ]; + + const fixed = validateAndFixTipTapContent(content); + expect(fixed.type).toBe('doc'); + const nodes = fixed.content as any[]; + expect(nodes).toHaveLength(2); + expect(nodes[0].type).toBe('heading'); + expect(nodes[0].content[0].text).toBe('Purpose'); + expect(nodes[1].type).toBe('paragraph'); + expect(nodes[1].content[0].text).toBe('Some policy text.'); + }); + + it('should handle mixed stringified and object nodes', () => { + const content = [ + JSON.stringify({ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Title' }] }), + { type: 'paragraph', content: [{ type: 'text', text: 'Body text' }] }, + ]; + + const fixed = validateAndFixTipTapContent(content); + const nodes = fixed.content as any[]; + expect(nodes).toHaveLength(2); + expect(nodes[0].type).toBe('heading'); + expect(nodes[1].type).toBe('paragraph'); + }); + + it('should skip invalid stringified JSON', () => { + const content = [ + 'not valid json', + JSON.stringify({ type: 'paragraph', content: [{ type: 'text', text: 'Valid' }] }), + ]; + + const fixed = validateAndFixTipTapContent(content); + const nodes = fixed.content as any[]; + expect(nodes).toHaveLength(1); + expect(nodes[0].type).toBe('paragraph'); + }); + }); + + describe('orphaned listItem handling', () => { + it('should wrap orphaned listItems in a bulletList', () => { + const content = [ + { type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Title' }] }, + { type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item 1' }] }] }, + { type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item 2' }] }] }, + ]; + + const fixed = validateAndFixTipTapContent(content); + const nodes = fixed.content as any[]; + expect(nodes).toHaveLength(2); + expect(nodes[0].type).toBe('heading'); + expect(nodes[1].type).toBe('bulletList'); + expect(nodes[1].content).toHaveLength(2); + expect(nodes[1].content[0].type).toBe('listItem'); + expect(nodes[1].content[1].type).toBe('listItem'); + }); + + it('should append orphaned listItems to a preceding list', () => { + const content = [ + { type: 'bulletList', content: [ + { type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'First' }] }] }, + ]}, + { type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Second' }] }] }, + { type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Third' }] }] }, + ]; + + const fixed = validateAndFixTipTapContent(content); + const nodes = fixed.content as any[]; + expect(nodes).toHaveLength(1); + expect(nodes[0].type).toBe('bulletList'); + expect(nodes[0].content).toHaveLength(3); + }); + }); + + describe('list with non-listItem children', () => { + it('should wrap bare paragraphs inside a bulletList in listItems', () => { + const content = [ + { type: 'bulletList', content: [ + { type: 'paragraph', attrs: { textAlign: null }, content: [{ type: 'text', text: 'Bare paragraph' }] }, + ]}, + ]; + + const fixed = validateAndFixTipTapContent(content); + const nodes = fixed.content as any[]; + expect(nodes[0].type).toBe('bulletList'); + expect(nodes[0].content[0].type).toBe('listItem'); + expect(nodes[0].content[0].content[0].type).toBe('paragraph'); + expect(nodes[0].content[0].content[0].content[0].text).toBe('Bare paragraph'); + }); + }); + + describe('textStyle mark removal', () => { + it('should strip textStyle marks from content', () => { + const content = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Styled text', + marks: [ + { type: 'textStyle', attrs: { color: 'red' } }, + { type: 'bold' }, + ], + }, + ], + }, + ], + }; + + const fixed = validateAndFixTipTapContent(content); + const textNode = (fixed.content as any[])[0].content[0]; + expect(textNode.marks).toHaveLength(1); + expect(textNode.marks[0].type).toBe('bold'); + }); + }); + + describe('real-world AI-generated malformed content', () => { + it('should fix the exact content from ENG-197', () => { + // This is the actual content from the bug report — each node is a + // JSON string, the bulletList contains a bare paragraph, and + // listItems are orphaned at the top level. + const content = [ + JSON.stringify({ type: 'heading', attrs: { level: 2, textAlign: null }, content: [{ text: 'Purpose', type: 'text' }] }), + JSON.stringify({ type: 'paragraph', attrs: { textAlign: null }, content: [{ text: 'Ensure all governance...', type: 'text' }] }), + JSON.stringify({ type: 'heading', attrs: { level: 2, textAlign: null }, content: [{ text: 'Version Control & Distribution', type: 'text' }] }), + JSON.stringify({ type: 'bulletList', content: [{ type: 'paragraph', attrs: { textAlign: null }, content: [{ text: 'Keep policies under version control.', type: 'text' }] }] }), + JSON.stringify({ type: 'listItem', content: [{ type: 'paragraph', attrs: { textAlign: null }, content: [{ text: 'Include a version number.', type: 'text' }] }] }), + JSON.stringify({ type: 'listItem', content: [{ type: 'paragraph', attrs: { textAlign: null }, content: [{ text: 'Notify personnel.', type: 'text' }] }] }), + ]; + + const fixed = validateAndFixTipTapContent(content); + expect(fixed.type).toBe('doc'); + const nodes = fixed.content as any[]; + + // heading, paragraph, heading, bulletList (merged) + expect(nodes).toHaveLength(4); + expect(nodes[0].type).toBe('heading'); + expect(nodes[1].type).toBe('paragraph'); + expect(nodes[2].type).toBe('heading'); + expect(nodes[3].type).toBe('bulletList'); + + // The bulletList should contain 3 listItems: + // 1 from the bare paragraph wrapped in listItem + 2 orphaned listItems + expect(nodes[3].content).toHaveLength(3); + expect(nodes[3].content.every((n: any) => n.type === 'listItem')).toBe(true); + }); + }); + describe('empty text node handling', () => { const strip = (s: string) => s.replace(/[\u00A0\u200B\u202F]/g, '').trim(); diff --git a/packages/ui/src/components/editor/utils/validate-content.ts b/packages/ui/src/components/editor/utils/validate-content.ts index 04769b347..ae71c5b34 100644 --- a/packages/ui/src/components/editor/utils/validate-content.ts +++ b/packages/ui/src/components/editor/utils/validate-content.ts @@ -38,6 +38,23 @@ export function validateAndFixTipTapContent(content: any): JSONContent { return createEmptyDocument(); } +/** + * Tries to parse a stringified JSON node into a proper object. + * Returns the parsed object or null if parsing fails. + */ +function tryParseStringNode(node: unknown): Record | null { + if (typeof node !== 'string') return null; + try { + const parsed = JSON.parse(node); + if (parsed && typeof parsed === 'object' && typeof parsed.type === 'string') { + return parsed as Record; + } + } catch { + // Not valid JSON + } + return null; +} + /** * Fixes an array of content nodes */ @@ -46,16 +63,55 @@ function fixContentArray(contentArray: any[]): JSONContent[] { return [createEmptyParagraph()]; } - const fixedContent = contentArray + // First pass: parse any stringified JSON nodes + const parsed = contentArray.map((node) => tryParseStringNode(node) ?? node); + + // Second pass: fix each node + const fixedNodes = parsed .map(fixNode) .filter((node): node is JSONContent => node !== null) as JSONContent[]; + // Third pass: merge orphaned listItems into bulletLists + const merged = mergeOrphanedListItems(fixedNodes); + // Ensure we have at least one paragraph - if (fixedContent.length === 0) { + if (merged.length === 0) { return [createEmptyParagraph()]; } - return fixedContent; + return merged; +} + +/** + * Merges consecutive orphaned listItem nodes into a bulletList. + */ +function mergeOrphanedListItems(nodes: JSONContent[]): JSONContent[] { + const result: JSONContent[] = []; + let i = 0; + + while (i < nodes.length) { + const node = nodes[i]!; + if (node.type === 'listItem') { + // Collect consecutive listItems + const items: JSONContent[] = []; + while (i < nodes.length && nodes[i]!.type === 'listItem') { + items.push(nodes[i]!); + i++; + } + // Check if the previous node is a bulletList or orderedList — append to it + const prev = result[result.length - 1]; + if (prev && (prev.type === 'bulletList' || prev.type === 'orderedList') && prev.content) { + prev.content.push(...items); + } else { + result.push({ type: 'bulletList', content: items }); + } + } else { + result.push(node); + i++; + } + } + + return result; } function ensureNonEmptyText(value: unknown): string { @@ -174,7 +230,23 @@ function fixList(node: any): JSONContent { }; } - const fixedContent = content.map(fixNode).filter(Boolean) as JSONContent[]; + // Parse stringified children and fix each node, wrapping non-listItem + // children (e.g. bare paragraphs) in a listItem so the list is valid. + const fixedContent: JSONContent[] = []; + for (const child of content) { + const resolved = tryParseStringNode(child) ?? child; + const fixed = fixNode(resolved); + if (!fixed) continue; + if (fixed.type === 'listItem') { + fixedContent.push(fixed); + } else { + // Wrap non-listItem nodes (paragraph, etc.) in a listItem + fixedContent.push({ + type: 'listItem', + content: [fixed], + }); + } + } if (fixedContent.length === 0) { fixedContent.push(createEmptyListItem()); @@ -317,6 +389,12 @@ function fixTableCell(node: any): JSONContent { return { type: 'tableCell', content: blocks, ...(attrs && { attrs }), ...rest }; } +/** + * Marks to strip during validation (they cause rendering issues or are + * artefacts of AI generation / paste-from-rich-text). + */ +const STRIPPED_MARK_TYPES = new Set(['textStyle']); + /** * Fixes marks array */ @@ -326,7 +404,10 @@ function fixMarks(marks: any[]): any[] { } return marks - .filter((mark) => mark && typeof mark === 'object' && mark.type) + .filter( + (mark) => + mark && typeof mark === 'object' && mark.type && !STRIPPED_MARK_TYPES.has(mark.type), + ) .map((mark) => ({ type: mark.type, ...(mark.attrs && typeof mark.attrs === 'object' && { attrs: mark.attrs }), From 4349f5069d6bb55a44eba5791336abcc26ccf002 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Mon, 30 Mar 2026 16:21:05 -0400 Subject: [PATCH 3/5] fix(integration-platform): move buildHeaders inside request lambda for token refresh buildHeaders was called once outside the request lambda, capturing the current auth token. On 401 retry after token refresh, executeRequest re-invokes the lambda but the stale token was reused. Moved buildHeaders inside the lambda for httpPost, httpPut, and httpPatch so each retry picks up the refreshed currentAccessToken. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/runtime/check-context.ts | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/packages/integration-platform/src/runtime/check-context.ts b/packages/integration-platform/src/runtime/check-context.ts index 64a7bac16..16a773f68 100644 --- a/packages/integration-platform/src/runtime/check-context.ts +++ b/packages/integration-platform/src/runtime/check-context.ts @@ -253,18 +253,18 @@ export function createCheckContext(options: CheckContextOptions): { opts?: { baseUrl?: string; headers?: Record }, ): Promise { const url = buildUrl(path, opts?.baseUrl); - const merged = buildHeaders(opts?.headers); - // Only set Content-Type to JSON if not already specified (allows form-encoded) - if (!merged['Content-Type'] && !merged['content-type']) { - merged['Content-Type'] = 'application/json'; - } - return executeRequest(() => - fetch(url.toString(), { + return executeRequest(() => { + // Build headers inside lambda so token refresh is picked up on 401 retry + const merged = buildHeaders(opts?.headers); + if (!merged['Content-Type'] && !merged['content-type']) { + merged['Content-Type'] = 'application/json'; + } + return fetch(url.toString(), { method: 'POST', headers: merged, body: body != null ? (typeof body === 'string' ? body : JSON.stringify(body)) : undefined, - }), - ); + }); + }); } async function httpPut( @@ -273,17 +273,17 @@ export function createCheckContext(options: CheckContextOptions): { opts?: { baseUrl?: string; headers?: Record }, ): Promise { const url = buildUrl(path, opts?.baseUrl); - const merged = buildHeaders(opts?.headers); - if (!merged['Content-Type'] && !merged['content-type']) { - merged['Content-Type'] = 'application/json'; - } - return executeRequest(() => - fetch(url.toString(), { + return executeRequest(() => { + const merged = buildHeaders(opts?.headers); + if (!merged['Content-Type'] && !merged['content-type']) { + merged['Content-Type'] = 'application/json'; + } + return fetch(url.toString(), { method: 'PUT', headers: merged, body: body != null ? (typeof body === 'string' ? body : JSON.stringify(body)) : undefined, - }), - ); + }); + }); } async function httpPatch( @@ -292,17 +292,17 @@ export function createCheckContext(options: CheckContextOptions): { opts?: { baseUrl?: string; headers?: Record }, ): Promise { const url = buildUrl(path, opts?.baseUrl); - const merged = buildHeaders(opts?.headers); - if (!merged['Content-Type'] && !merged['content-type']) { - merged['Content-Type'] = 'application/json'; - } - return executeRequest(() => - fetch(url.toString(), { + return executeRequest(() => { + const merged = buildHeaders(opts?.headers); + if (!merged['Content-Type'] && !merged['content-type']) { + merged['Content-Type'] = 'application/json'; + } + return fetch(url.toString(), { method: 'PATCH', headers: merged, body: body != null ? (typeof body === 'string' ? body : JSON.stringify(body)) : undefined, - }), - ); + }); + }); } async function httpDelete( From 089ceb715f59f9c04e3e206d8e587b6b408f1918 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Mon, 30 Mar 2026 16:34:05 -0400 Subject: [PATCH 4/5] fix(policy-editor): enhance permission handling during loading state Updated PolicyEditorWrapper to include isPending state from usePermissions, allowing the editor to remain editable while permissions are loading. This prevents premature locking of the editor when checking for update permissions. --- .../policies/[policyId]/editor/components/PolicyDetails.tsx | 5 +++-- apps/app/src/hooks/use-permissions.ts | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx index 6c93e1eaa..a5e8fc8cc 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx @@ -1111,7 +1111,7 @@ function PolicyEditorWrapper({ additionalExtensions?: import('@tiptap/core').Extension[]; suggestionsActive?: boolean; }) { - const { hasPermission } = usePermissions(); + const { hasPermission, isPending: isPermissionsPending } = usePermissions(); const canUpdatePolicy = hasPermission('policy', 'update'); const formattedContent = Array.isArray(policyContent) @@ -1132,7 +1132,8 @@ function PolicyEditorWrapper({ // Determine if editor should be read-only // isVersionReadOnly already covers the pending version case (isViewingPendingVersion) - const isReadOnly = isVersionReadOnly || !canUpdatePolicy; + // While permissions are loading, don't lock the editor — wait for the real state + const isReadOnly = isVersionReadOnly || (!isPermissionsPending && !canUpdatePolicy); // Get status message and styling for all states const getStatusInfo = (): { diff --git a/apps/app/src/hooks/use-permissions.ts b/apps/app/src/hooks/use-permissions.ts index 3a9110d9e..7de95d6ce 100644 --- a/apps/app/src/hooks/use-permissions.ts +++ b/apps/app/src/hooks/use-permissions.ts @@ -19,7 +19,7 @@ interface CustomRolePermissionsResponse { } export function usePermissions() { - const { data: activeMember } = useActiveMember(); + const { data: activeMember, isPending } = useActiveMember(); const roleString = activeMember?.role ?? null; // Resolve built-in roles synchronously @@ -78,6 +78,7 @@ export function usePermissions() { return { permissions, obligations, + isPending, hasPermission: (resource: string, action: string) => hasPermission(permissions, resource, action), }; From e4fb01a1be682e8f47ca9fafcf8c42594455af5c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 16:49:15 -0400 Subject: [PATCH 5/5] fix(policy-editor): simplify permission handling in PolicyEditorWrapper (#2400) Removed isPending state from usePermissions in PolicyEditorWrapper to streamline the logic for determining the editor's read-only state. This change ensures that the editor's lock state is based solely on the user's update permissions, enhancing clarity and maintainability. Co-authored-by: Mariano Fuentes --- .../[policyId]/editor/components/PolicyDetails.tsx | 5 ++--- apps/app/src/hooks/use-permissions.ts | 3 +-- packages/ui/src/components/editor/index.tsx | 8 ++++++++ 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx index a5e8fc8cc..6c93e1eaa 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx @@ -1111,7 +1111,7 @@ function PolicyEditorWrapper({ additionalExtensions?: import('@tiptap/core').Extension[]; suggestionsActive?: boolean; }) { - const { hasPermission, isPending: isPermissionsPending } = usePermissions(); + const { hasPermission } = usePermissions(); const canUpdatePolicy = hasPermission('policy', 'update'); const formattedContent = Array.isArray(policyContent) @@ -1132,8 +1132,7 @@ function PolicyEditorWrapper({ // Determine if editor should be read-only // isVersionReadOnly already covers the pending version case (isViewingPendingVersion) - // While permissions are loading, don't lock the editor — wait for the real state - const isReadOnly = isVersionReadOnly || (!isPermissionsPending && !canUpdatePolicy); + const isReadOnly = isVersionReadOnly || !canUpdatePolicy; // Get status message and styling for all states const getStatusInfo = (): { diff --git a/apps/app/src/hooks/use-permissions.ts b/apps/app/src/hooks/use-permissions.ts index 7de95d6ce..3a9110d9e 100644 --- a/apps/app/src/hooks/use-permissions.ts +++ b/apps/app/src/hooks/use-permissions.ts @@ -19,7 +19,7 @@ interface CustomRolePermissionsResponse { } export function usePermissions() { - const { data: activeMember, isPending } = useActiveMember(); + const { data: activeMember } = useActiveMember(); const roleString = activeMember?.role ?? null; // Resolve built-in roles synchronously @@ -78,7 +78,6 @@ export function usePermissions() { return { permissions, obligations, - isPending, hasPermission: (resource: string, action: string) => hasPermission(permissions, resource, action), }; diff --git a/packages/ui/src/components/editor/index.tsx b/packages/ui/src/components/editor/index.tsx index fc29773d5..c28622444 100644 --- a/packages/ui/src/components/editor/index.tsx +++ b/packages/ui/src/components/editor/index.tsx @@ -89,6 +89,14 @@ export const Editor = ({ }, }); + // Sync editable state with readOnly prop — TipTap v3's useEditor preserves + // the current editable state on option updates, so we must set it explicitly. + useEffect(() => { + if (editor && !editor.isDestroyed) { + editor.setEditable(!readOnly); + } + }, [editor, readOnly]); + useEffect(() => { setInitialLoadComplete(true); }, []);