diff --git a/build/plugins/react-css-inject.mts b/build/plugins/react-css-inject.mts new file mode 100644 index 000000000..7402a0195 --- /dev/null +++ b/build/plugins/react-css-inject.mts @@ -0,0 +1,49 @@ +import type { Plugin } from 'rolldown'; + +const INLINE_STYLE_RE = /(\/\/#region [^\n]+\.(?:css|less|sass|scss|styl|stylus)\?inline\nvar\s+([A-Za-z_$][\w$]*)\s*=\s*[\s\S]*?;\n)(\n\/\/#endregion)/g; + +function injectAfterImports(code: string, helper: string): string { + const lines = code.split('\n'); + let insertionIndex = 0; + + while (insertionIndex < lines.length && lines[insertionIndex].trimStart().startsWith('import ')) { + insertionIndex += 1; + } + + lines.splice(insertionIndex, 0, helper); + return lines.join('\n'); +} + +export default function reactCssInjectPlugin(): Plugin { + return { + name: 'vlocode:webview-css-inject', + renderChunk(code) { + const styleVariables: string[] = []; + const rewritten = code.replace(INLINE_STYLE_RE, (_match, block: string, variableName: string, suffix: string) => { + styleVariables.push(variableName); + return `${block}\n__vlocodeInjectStyle(${variableName});${suffix}`; + }); + + if (styleVariables.length === 0) { + return null; + } + + const helperBlock = [ + 'const __vlocodeInjectedStyles = new Set();', + 'function __vlocodeInjectStyle(css) {', + ' if (typeof document === "undefined" || !css || __vlocodeInjectedStyles.has(css)) {', + ' return;', + ' }', + ' const style = document.createElement("style");', + ' style.textContent = css;', + ' style.setAttribute("data-vlocode-webview-style", "");', + ' document.head.appendChild(style);', + ' __vlocodeInjectedStyles.add(css);', + '}', + '' + ].join('\n'); + + return injectAfterImports(rewritten, helperBlock); + } + }; +} \ No newline at end of file diff --git a/package.json b/package.json index 3b0ed78a8..08e967062 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,8 @@ "prettier": "^3.3.3", "rolldown": "1.0.0-rc.3", "ts-jest": "^29.3.4", - "tsdown": "0.20.3", + "tsdown": "0.21.4", + "@tsdown/css": "0.21.4", "typedoc": "^0.28.12", "typedoc-plugin-rename-defaults": "^0.7.0", "typescript": "catalog:", diff --git a/packages/salesforce/src/index.ts b/packages/salesforce/src/index.ts index 5c40981e4..7fc29dbd7 100644 --- a/packages/salesforce/src/index.ts +++ b/packages/salesforce/src/index.ts @@ -31,6 +31,7 @@ export * from './salesforceProfile'; export * from './salesforceUserPermissions'; export * from './salesforceProfileService'; + export * from './salesforceProfileValidator'; export * from './salesforceSchemaService'; export * from './salesforceService'; export * from './schemaValidator'; diff --git a/packages/salesforce/src/salesforceProfileValidator.ts b/packages/salesforce/src/salesforceProfileValidator.ts new file mode 100644 index 000000000..2349323a3 --- /dev/null +++ b/packages/salesforce/src/salesforceProfileValidator.ts @@ -0,0 +1,385 @@ +/** + * Profile/PermissionSet permission validator. + * + * Provides a config-table of rules where each rule stores an evaluator that accepts: + * - The `SalesforceUserPermissions` instance being scanned + * - The profile item being evaluated (e.g. a field-permission or object-permission entry) + * - The item type (e.g. `'fieldPermission'`, `'objectPermission'`) + * - The rule's own metadata + * + * Two scanning modes are available: + * - `validate(profile)` — structural checks that run without org access + * - `validateAgainstOrg(profile, schemaService)` — org-level checks (unknown fields/objects) + */ + +import { PermissionNameFields, SalesforceUserPermissions, type PermissableSubtype, type PermissionPropertyType, type UserPermissionMetadata } from './salesforceUserPermissions'; +import { SalesforceSchemaService } from './salesforceSchemaService'; +import type { ProfileObjectPermissions, PermissionSetObjectPermissions } from './types'; +import { forEachAsyncParallel, type ArrayElement } from '@vlocode/util'; + +type AnyObjectPermission = ProfileObjectPermissions | PermissionSetObjectPermissions; + +// ─── Problem model ──────────────────────────────────────────────────────────── + +export type ProfileValidationSeverity = 'error' | 'warning' | 'info'; +export type ProfileValidationCategory = 'structural' | 'org-validation' | 'deployment'; + +// ─── Rule infrastructure ────────────────────────────────────────────────────── + +export interface ProfileValidationRuleMetadata { + title: string; + description: string; + docsUrl?: string; + severity: ProfileValidationSeverity; +} + +/** + * Pre-computed indexes passed to each evaluator to avoid repeated linear scans. + */ +export interface ProfileValidationContext { + /** Object permissions indexed by object name for O(1) lookup */ + objectPermissionsByName: Map; +} + +export interface PermissionFixFunction< + TType extends PermissionPropertyType +> { + /** + * Apply a fix to the given item in the profile. The profile instance is mutable and changes will be reflected in the caller. + * @param profile - The full profile/permset being fixed. + * @param item - The individual permission entry to fix (field, object, class, …). + * @param itemType - The item type string (mirrors `this.type`). + */ + ( + profile: SalesforceUserPermissions, + item: PermissableSubtype, + itemType: TType + ): void | Promise; +} + +export interface PermissionValidationEvalFunction< + TType extends PermissionPropertyType +> { + /** + * Evaluate a single item against this rule. + * @param profile - The full profile/permset being validated. + * @param item - The individual permission entry (field, object, class, …). + * @param itemType - The item type string (mirrors `this.type`). + * @param meta - This rule's own metadata, for convenience. + * @param ctx - Pre-computed indexes for efficient lookups. + * @returns A `ProfileValidationProblem` if the rule is violated, or `null`. + */ + ( + ctx: + { + item: PermissableSubtype, + profile: SalesforceUserPermissions, + type: TType, + rule: ProfileValidationRuleMetadata + } + ): string | ProfileValidationProblem | null; +} + +export interface ProfileValidationRule< + TType extends PermissionPropertyType = PermissionPropertyType +> extends ProfileValidationRuleMetadata{ + id: string; + type: TType; + evaluate: PermissionValidationEvalFunction; + fixAction?: PermissionFixFunction; +} + +function defineProfileValidationRule< + const TType extends PermissionPropertyType +>(rule: ProfileValidationRule): ProfileValidationRule { + return rule; +} + +function getPermissionItemName< + TType extends PermissionPropertyType +>(type: TType, item: PermissableSubtype): string { + const nameField = PermissionNameFields[type] as unknown as keyof PermissableSubtype; + return String(item[nameField]); +} + +export interface ProfileValidationProblem { + /** Unique rule id + item name, e.g. `"field-editable-not-readable:Account.Name"` */ + id: string; + severity: ProfileValidationSeverity; + category: ProfileValidationCategory; + itemType: PermissionPropertyType; + /** API name of the affected item, e.g. `"Account"` or `"Account.Name"` */ + itemName: string; + message: string; + docsUrl?: string; + /** Whether a Fix action can auto-resolve this problem */ + fixAction?: PermissionFixFunction; +} + +/** + * Built-in structural rules, organised by item type. + * Add new rules here to extend the validator without touching any other code. + */ +const PROFILE_VALIDATION_RULES = { + + // ── Object permission rules ─────────────────────────────────────────────── + + 'obj-editable-not-readable': defineProfileValidationRule({ + id: 'obj-editable-not-readable', + type: 'objectPermissions', + severity: 'error', + title: 'Edit without Read', + description: 'An object with Create, Edit, or Delete access must also have Read access.', + docsUrl: 'https://developer.salesforce.com/docs/atlas.en-us.api_meta.meta/api_meta/meta_profile.htm#objectPermissions', + evaluate({ item }) { + if ((item.allowCreate || item.allowEdit || item.allowDelete) && !item.allowRead) { + return `Object "${item.object}" has Create/Edit/Delete but not Read access.`; + } + return null; + }, + fixAction(profile, item) { + profile.setObjectPermissions(item.object, { allowRead: true }); + } + }), + + 'obj-viewall-not-readable': defineProfileValidationRule({ + id: 'obj-viewall-not-readable', + type: 'objectPermissions', + severity: 'error', + title: 'View All without Read', + description: 'View All Records requires Read access on the object.', + docsUrl: 'https://developer.salesforce.com/docs/atlas.en-us.api_meta.meta/api_meta/meta_profile.htm#objectPermissions', + evaluate({ item }) { + if (item.viewAllRecords && !item.allowRead) { + return `Object "${item.object}" has View All Records but not Read access.`; + } + return null; + }, + fixAction(profile, item) { + profile.setObjectPermissions(item.object, { allowRead: true }); + } + }), + + 'obj-modifyall-incomplete': defineProfileValidationRule({ + id: 'obj-modifyall-incomplete', + type: 'objectPermissions', + severity: 'error', + title: 'Modify All requires all CRUD + View All', + description: 'Modify All Records requires Read, Create, Edit, Delete, and View All Records.', + docsUrl: 'https://developer.salesforce.com/docs/atlas.en-us.api_meta.meta/api_meta/meta_profile.htm#objectPermissions', + evaluate({ item }) { + if (item.modifyAllRecords && + !(item.allowRead && item.allowCreate && item.allowEdit && item.allowDelete && item.viewAllRecords)) { + return `Object "${item.object}" has Modify All Records but is missing required lower-level permissions.`; + } + return null; + }, + fixAction(profile, item) { + profile.setObjectPermissions(item.object, { modifyAllRecords: true }); + } + }), + + 'obj-no-permissions': defineProfileValidationRule({ + id: 'obj-no-permissions', + type: 'objectPermissions', + severity: 'info', + title: 'Empty object permission entry', + description: 'This object permission has no access flags set. Consider removing it.', + docsUrl: 'https://developer.salesforce.com/docs/atlas.en-us.api_meta.meta/api_meta/meta_profile.htm#objectPermissions', + evaluate({ item }) { + if (!item.allowRead && !item.allowCreate && !item.allowEdit && + !item.allowDelete && !item.viewAllRecords && !item.modifyAllRecords) { + return `Object "${item.object}" has no permissions set.`; + } + return null; + }, + fixAction(profile, item) { + profile.removeObjectPermissions(item.object); + } + }), + + // ── Field permission rules ──────────────────────────────────────────────── + + 'field-editable-not-readable': defineProfileValidationRule({ + id: 'field-editable-not-readable', + type: 'fieldPermissions', + severity: 'error', + title: 'Editable field is not Readable', + description: 'A field cannot be editable without also being readable.', + docsUrl: 'https://developer.salesforce.com/docs/atlas.en-us.api_meta.meta/api_meta/meta_profile.htm#fieldPermissions', + evaluate({ item }) { + if (item.editable && !item.readable) { + return `Field "${item.field}" is editable but not readable.`; + } + return null; + }, + fixAction(profile, item) { + profile.setFieldPermissions(item.field, true, item.editable); + } + }), + + 'field-no-object-read': defineProfileValidationRule({ + id: 'field-no-object-read', + type: 'fieldPermissions', + severity: 'warning', + title: 'Field permission without object Read access', + description: 'This field has permissions defined, but the parent object has no Read access.', + docsUrl: 'https://developer.salesforce.com/docs/atlas.en-us.api_meta.meta/api_meta/meta_profile.htm#fieldPermissions', + evaluate({ item, profile }) { + if (!item.readable && !item.editable) return null; + const objectName = item.field.split('.')[0]; + const objectPerm = profile.getObjectPermissions(objectName); + if (!objectPerm?.allowRead) { + return `Field "${item.field}" has FLS defined but object "${objectName}" has no Read access.`; + } + return null; + } + }), + 'field-no-permissions': defineProfileValidationRule({ + id: 'field-no-permissions', + type: 'fieldPermissions', + severity: 'info', + title: 'Empty field permission entry', + description: 'This field has neither Read nor Edit access. Consider removing the entry.', + docsUrl: 'https://developer.salesforce.com/docs/atlas.en-us.api_meta.meta/api_meta/meta_profile.htm#fieldPermissions', + evaluate({ item }) { + if (!item.readable && !item.editable) { + return `Field "${item.field}" has no access (neither readable nor editable).`; + } + return null; + }, + fixAction(profile, item) { + profile.removeField(item.field); + } + }), + + // ── Class access rules ──────────────────────────────────────────────────── + + 'class-disabled': defineProfileValidationRule({ + id: 'class-disabled', + type: 'classAccesses', + severity: 'info', + title: 'Disabled class access entry', + description: 'This Apex class access entry is explicitly disabled. Consider removing it.', + docsUrl: 'https://developer.salesforce.com/docs/atlas.en-us.api_meta.meta/api_meta/meta_profile.htm#classAccesses', + evaluate({ item }) { + if (!item.enabled) { + return `Apex class "${item.apexClass}" is explicitly set to disabled.`; + } + return null; + }, + fixAction(profile, item) { + profile.removeClass(item.apexClass); + } + }) +}; + +// ─── Public API ─────────────────────────────────────────────────────────────── + +/** + * Validates a Salesforce Profile or PermissionSet for structural permission inconsistencies. + * + * Rules are defined in `PROFILE_VALIDATION_RULES` (a config-table of evaluators). + * Each evaluator receives `(profile, item, itemType, ruleMeta)` and returns a problem or null. + * + * @example + * ```typescript + * const problems = SalesforceProfileValidator.validate(profile); + * // → [{ id: 'field-editable-not-readable:Account.Name', severity: 'error', … }] + * ``` + */ +export class SalesforceProfileValidator { + + /** + * The built-in rule config table. Consumers can add custom rules by mutating + * or spreading this object before calling `validate`. + */ + readonly rules = { ...PROFILE_VALIDATION_RULES } as Record; + + /** + * Run all structural rules (no org connection required). + */ + public validate(profile: SalesforceUserPermissions): ProfileValidationProblem[] { + const problems: ProfileValidationProblem[] = []; + + for (const rule of Object.values(this.rules)) { + for (const item of profile.getItemsForType(rule.type) ?? []) { + const itemName = item[PermissionNameFields[rule.type]]; + const problemOrMessage = rule.evaluate({ profile, item, rule, type: rule.type }); + + if (!problemOrMessage) { + continue; + } + + const problem = typeof problemOrMessage === 'string' + ? { + id: `${rule.id}:${itemName}`, + itemType: rule.type, + severity: rule.severity, + docsUrl: rule.docsUrl, + fixAction: rule.fixAction, + category: 'structural', + itemName: itemName, + message: problemOrMessage, + } + : problemOrMessage; + + problems.push(problem); + } + } + + return problems; + } + + /** + * Run org-level validation (requires a schema service connection). + * Checks for non-existing objects and fields in the connected org. + */ + public async validateAgainstOrg( + profile: SalesforceUserPermissions, + schemaService: SalesforceSchemaService + ): Promise { + const problems: ProfileValidationProblem[] = []; + + // Check object permissions against org object list + await Promise.all(profile.objects.map(async (op) => { + const isDefined = await schemaService.isSObjectDefined(op.object); + if (!isDefined) { + problems.push({ + id: `org-unknown-object:${op.object}`, + severity: 'warning', + category: 'org-validation', + itemType: 'objectPermissions', + itemName: op.object, + message: `Object "${op.object}" does not exist in this org.`, + docsUrl: 'https://developer.salesforce.com/docs/atlas.en-us.api_meta.meta/api_meta/meta_profile.htm#objectPermissions', + fixAction: async () => { + profile.removeObjectPermissions(op.object); + } + }); + } + })); + + // Check field permissions against org field list — run all describe calls in parallel + await Promise.all(profile.fields.map(async (field) => { + const [objectName, fieldName] = field.field.split('.'); + const isDefined = await schemaService.isSObjectFieldDefined(objectName, fieldName); + if (!isDefined) { + problems.push({ + id: `org-unknown-field:${field.field}`, + severity: 'warning', + category: 'org-validation', + itemType: 'fieldPermissions', + itemName: field.field, + message: `Field "${field.field}" does not exist in this org.`, + docsUrl: 'https://developer.salesforce.com/docs/atlas.en-us.api_meta.meta/api_meta/meta_profile.htm#fieldPermissions', + fixAction: async () => { + profile.removeField(field.field); + } + }); + } + })); + + return problems; + } +} \ No newline at end of file diff --git a/packages/salesforce/src/salesforceUserPermissions.ts b/packages/salesforce/src/salesforceUserPermissions.ts index 52f5fa625..2cb376e8b 100644 --- a/packages/salesforce/src/salesforceUserPermissions.ts +++ b/packages/salesforce/src/salesforceUserPermissions.ts @@ -25,7 +25,7 @@ type UserPermissionSortConfig = { keyof ArrayElement>; } -const PermissionNameFields = { +export const PermissionNameFields = { applicationVisibilities: 'application', classAccesses: 'apexClass', customMetadataTypeAccesses: 'name', @@ -40,6 +40,11 @@ const PermissionNameFields = { userPermissions: 'name', } as const; +export type PermissionPropertyType = keyof typeof PermissionNameFields; + +export type PermissableSubtype = ArrayElement; + + /** * Represents a Salesforce user permissions model, providing methods to manage and manipulate * metadata for profiles and permission sets. This class allows for loading, merging, updating, @@ -99,6 +104,21 @@ export class SalesforceUserPermissions { return this.metadata.fullName; } + /** + * The license associated with this profile or permission set. + * For profiles this is `userLicense`, for permission sets this is `license`. + */ + public get license() : string | undefined { + return (this.metadata as any).userLicense ?? (this.metadata as any).license; + } + + /** + * Optional human-readable description of this profile or permission set. + */ + public get description() : string | undefined { + return (this.metadata as any).description; + } + constructor( public readonly type: 'Profile' | 'PermissionSet', public readonly developerName: string, @@ -120,6 +140,19 @@ export class SalesforceUserPermissions { } } + /** + * Retrieves the items of a specific permission type from the metadata. + * @param type - The type of permission items to retrieve, corresponding to the keys of `UserPermissionMetadata`. + * @returns + */ + public getItemsForType(type: T) : UserPermissionMetadata[T] { + const value = this.metadata[type]; + if (!Array.isArray(value)) { + throw new Error(`Property '${type}' is not an array in the metadata.`); + } + return value as any as UserPermissionMetadata[T]; + } + /** * Loads and parses metadata XML content, merging the parsed data into the current instance. * @@ -249,6 +282,86 @@ export class SalesforceUserPermissions { this.removeItem('fieldPermissions', name); } + /** + * Removes all object-level permissions for the specified Salesforce object. + * + * @param objectName - The API name of the SObject to remove permissions for. + */ + public removeObjectPermissions(objectName: string) { + this.removeItem('objectPermissions', objectName); + } + + /** + * Adds or updates object-level permissions for the specified Salesforce object. + * + * @param objectName - The API name of the SObject. + * @param permissions - Partial permission flags to set. Salesforce access rules are automatically enforced: + * - modifyAllRecords implies viewAllRecords, allowRead, allowCreate, allowEdit, allowDelete. + * - viewAllRecords implies allowRead. + * - allowCreate / allowEdit / allowDelete imply allowRead. + */ + public setObjectPermissions(objectName: string, permissions: { + allowRead?: boolean; + allowCreate?: boolean; + allowEdit?: boolean; + allowDelete?: boolean; + viewAllRecords?: boolean; + modifyAllRecords?: boolean; + }) { + const existing = this.objects.find(o => o.object === objectName) ?? {}; + const merged = { ...existing, ...permissions, object: objectName }; + + // Enforce Salesforce access rules + if (merged.modifyAllRecords) { + merged.viewAllRecords = true; + merged.allowRead = true; + merged.allowCreate = true; + merged.allowEdit = true; + merged.allowDelete = true; + } + if (merged.viewAllRecords) { + merged.allowRead = true; + } + if (merged.allowCreate || merged.allowEdit || merged.allowDelete) { + merged.allowRead = true; + } + if (!merged.allowRead) { + merged.allowCreate = false; + merged.allowEdit = false; + merged.allowDelete = false; + merged.viewAllRecords = false; + merged.modifyAllRecords = false; + } + + this.update('objectPermissions', merged); + } + + /** + * Retrieves the object-level permissions for a specified Salesforce object. + * + * @param objectName - The API name of the SObject to retrieve permissions for. + * @returns An object containing the permissions for the specified SObject, or `undefined` if no permissions are found. + */ + getObjectPermissions(objectName: string) { + return this.objects.find(o => o.object === objectName); + } + + /** + * Sets field-level security permissions for the specified field. + * Enforces the Salesforce rule that editable fields must also be readable. + * + * @param fieldName - Qualified field name in the format "ObjectName.FieldName". + * @param readable - Whether the field should be readable. + * @param editable - Whether the field should be editable. If true, readable is also set to true. + */ + public setFieldPermissions(fieldName: string, readable: boolean, editable: boolean) { + this.update('fieldPermissions', { + field: fieldName, + readable: editable ? true : readable, + editable + }); + } + /** * Checks if the user has access to a specific Apex page by its name. * diff --git a/packages/vscode-extension/commands.yaml b/packages/vscode-extension/commands.yaml index b03a07061..8de61cdfb 100644 --- a/packages/vscode-extension/commands.yaml +++ b/packages/vscode-extension/commands.yaml @@ -245,6 +245,22 @@ vlocode.removeFromProfile: title: 'Salesforce: Remove from profiles' group: v_salesforce_profile menus: *profileCmdMenuConfig +vlocode.editProfilePermissions: + title: 'Salesforce: Edit Profile / Permission Set' + group: v_salesforce_profile + when: config.vlocity.salesforce.enabled + menus: + - menu: commandPalette + - menu: editor/context + when: resourceScheme == file && resourceFilename =~ /(\.profile-meta\.xml|\.profile|\.permissionset-meta\.xml|\.permissionset)$/ + - menu: explorer/context + when: resourceScheme == file && resourceFilename =~ /(\.profile-meta\.xml|\.profile|\.permissionset-meta\.xml|\.permissionset)$/ +vlocode.openProfileFromOrg: + title: 'Salesforce: Open Profile / Permission Set from Org' + group: v_salesforce_profile + when: config.vlocity.salesforce.enabled + menus: + - menu: commandPalette vlocode.deployRecentValidation: title: 'Salesforce: Deploy Recent Validation' group: v_salesforce diff --git a/packages/vscode-extension/package.json b/packages/vscode-extension/package.json index 8d84057f2..a3df7f7ad 100644 --- a/packages/vscode-extension/package.json +++ b/packages/vscode-extension/package.json @@ -282,6 +282,14 @@ "command": "vlocode.removeFromProfile", "title": "Salesforce: Remove from profiles" }, + { + "command": "vlocode.editProfilePermissions", + "title": "Salesforce: Edit Profile / Permission Set" + }, + { + "command": "vlocode.openProfileFromOrg", + "title": "Salesforce: Open Profile / Permission Set from Org" + }, { "command": "vlocode.deployRecentValidation", "title": "Salesforce: Deploy Recent Validation" @@ -541,6 +549,16 @@ "group": "v_salesforce", "when": "config.vlocity.salesforce.enabled" }, + { + "command": "vlocode.editProfilePermissions", + "group": "v_salesforce_profile", + "when": "config.vlocity.salesforce.enabled" + }, + { + "command": "vlocode.openProfileFromOrg", + "group": "v_salesforce_profile", + "when": "config.vlocity.salesforce.enabled" + }, { "command": "vlocode.deployRecentValidation", "group": "v_salesforce", @@ -782,6 +800,11 @@ "group": "v_salesforce_profile", "when": "config.vlocity.salesforce.profileActionsInContextMenu && config.vlocity.salesforce.enabled && explorerResourceIsFolder && resourceDirname =~ /(objects|classes)/" }, + { + "command": "vlocode.editProfilePermissions", + "group": "v_salesforce_profile", + "when": "config.vlocity.salesforce.enabled && resourceScheme == file && resourceFilename =~ /(\\.profile-meta\\.xml|\\.profile|\\.permissionset-meta\\.xml|\\.permissionset)$/" + }, { "command": "vlocode.omniScript.generateLwc", "group": "v_vlocity_omniscript", @@ -879,6 +902,11 @@ "group": "v_salesforce_profile", "when": "config.vlocity.salesforce.profileActionsInContextMenu && config.vlocity.salesforce.enabled && resourceScheme == file && resourceFilename =~ /(\\.object|\\.field-meta\\.xml|\\.cls|\\.cls-meta\\.xml)$/" }, + { + "command": "vlocode.editProfilePermissions", + "group": "v_salesforce_profile", + "when": "config.vlocity.salesforce.enabled && resourceScheme == file && resourceFilename =~ /(\\.profile-meta\\.xml|\\.profile|\\.permissionset-meta\\.xml|\\.permissionset)$/" + }, { "command": "vlocode.omniScript.generateLwc", "group": "v_vlocity_omniscript", @@ -1528,6 +1556,9 @@ "@types/js-yaml": "^4.0.2", "@types/jsforce": "^1.9.38", "@types/luxon": "^3.3.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@types/react-window": "^2.0.0", "@types/vscode": "^1.95.0", "@vlocode/core": "workspace:*", "@vlocode/omniscript": "workspace:*", @@ -1535,12 +1566,14 @@ "@vlocode/util": "workspace:*", "@vlocode/vlocity": "workspace:*", "@vlocode/vlocity-deploy": "workspace:*", + "@vscode/codicons": "^0.0.45", "@vscode/test-electron": "^2.3.8", "@vscode/vsce": "^3.4.1", "acorn": "^8.15.0", "acorn-walk": "^8.3.4", "acron": "^1.0.5", "chalk": "^4.1.2", + "esbuild": "^0.27.4", "escape-string-regexp": "^4.0.0", "fast-glob": "^3.3.3", "fs-extra": "^11.0", @@ -1552,7 +1585,11 @@ "mv": "^2.1.1", "open": "^8.2.1", "ovsx": "^0.10.7", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-window": "^2.2.7", "reflect-metadata": "^0.1.13", + "sass": "^1.97.3", "shx": "^0.3.4", "source-map-support": "^0.5.21", "ts-jest": "^29.3.4", diff --git a/packages/vscode-extension/src/commands/profiles/index.ts b/packages/vscode-extension/src/commands/profiles/index.ts index 4e664e27c..5158e4abe 100644 --- a/packages/vscode-extension/src/commands/profiles/index.ts +++ b/packages/vscode-extension/src/commands/profiles/index.ts @@ -1,3 +1,5 @@ // created from 'create-ts-index' export * from './updateProfileCommand'; +export * from './profileEditorCommand'; +export * from './openProfileFromOrgCommand'; diff --git a/packages/vscode-extension/src/commands/profiles/openProfileFromOrgCommand.ts b/packages/vscode-extension/src/commands/profiles/openProfileFromOrgCommand.ts new file mode 100644 index 000000000..a2c0bd3e4 --- /dev/null +++ b/packages/vscode-extension/src/commands/profiles/openProfileFromOrgCommand.ts @@ -0,0 +1,76 @@ +import * as vscode from 'vscode'; +import { container } from '@vlocode/core'; +import { + SalesforceConnectionProvider, + SalesforceProfileService, + SalesforceSchemaService, + SalesforceUserPermissions +} from '@vlocode/salesforce'; +import { vscodeCommand } from '../../lib/commandRouter'; +import { VlocodeCommand } from '../../constants'; +import { CommandBase } from '../../lib/commandBase'; +import { ProfileEditorWebview } from '../../lib/webview/profileEditorWebview'; +import { getContext } from '../../lib/vlocodeContext'; + +/** + * Command that opens the Profile/PermissionSet editor by fetching the profile + * directly from a connected Salesforce org without requiring a local file. + */ +@vscodeCommand(VlocodeCommand.openProfileFromOrg) +export default class OpenProfileFromOrgCommand extends CommandBase { + + private webview: ProfileEditorWebview | undefined; + + public async execute(): Promise { + const connectionProvider = container.get(SalesforceConnectionProvider); + const schemaService = container.get(SalesforceSchemaService); + + // Let user choose Profile or PermissionSet + const metadataType = await vscode.window.showQuickPick( + [ + { label: '$(account) Profile', description: 'Edit a Salesforce Profile', value: 'Profile' }, + { label: '$(shield) Permission Set', description: 'Edit a Salesforce Permission Set', value: 'PermissionSet' } + ], + { title: 'Open from Org — Select type', placeHolder: 'Select Profile or Permission Set' } + ); + if (!metadataType) return; + + // List available names from org + const connection = await connectionProvider.getJsForceConnection(); + const items = await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: `Loading ${metadataType.value} list from org…`, cancellable: false }, + async () => { + const records = await connection.metadata.list([{ type: metadataType.value as 'Profile' | 'PermissionSet' }]); + return Array.isArray(records) ? records : records ? [records] : []; + } + ); + + if (items.length === 0) { + void vscode.window.showWarningMessage(`No ${metadataType.value}s found in the connected org.`); + return; + } + + const picked = await vscode.window.showQuickPick( + items.map(item => ({ label: item.fullName, description: item.namespacePrefix ?? '' })), + { title: `Open ${metadataType.value} from Org`, placeHolder: `Search for a ${metadataType.value}…`, matchOnDescription: true } + ); + if (!picked) return; + + // Retrieve and open in editor + const profile = await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: `Loading ${picked.label}…`, cancellable: false }, + async () => { + const metadata = await connection.metadata.read(metadataType.value as 'Profile' | 'PermissionSet', picked.label); + return new SalesforceUserPermissions(metadataType.value as 'Profile' | 'PermissionSet', picked.label, metadata as any); + } + ); + + const extensionContext = getContext(); + if (!this.webview) { + this.webview = new ProfileEditorWebview(extensionContext, connectionProvider, schemaService); + } + + // No source file — org-only + await this.webview.openProfile(profile); + } +} diff --git a/packages/vscode-extension/src/commands/profiles/profileEditorCommand.ts b/packages/vscode-extension/src/commands/profiles/profileEditorCommand.ts new file mode 100644 index 000000000..ebcf25a33 --- /dev/null +++ b/packages/vscode-extension/src/commands/profiles/profileEditorCommand.ts @@ -0,0 +1,76 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import { container } from '@vlocode/core'; +import { SalesforceProfile, SalesforceConnectionProvider, SalesforceSchemaService, SalesforceUserPermissions } from '@vlocode/salesforce'; +import { getDocumentBodyAsString } from '@vlocode/util'; +import { vscodeCommand } from '../../lib/commandRouter'; +import { VlocodeCommand } from '../../constants'; +import { CommandBase } from '../../lib/commandBase'; +import { ProfileEditorWebview } from '../../lib/webview/profileEditorWebview'; +import { getContext } from '../../lib/vlocodeContext'; + +/** + * Command that opens the Profile/PermissionSet editor webview. + * The editor provides a visual table-based UI for managing FLS and object access. + */ +@vscodeCommand(VlocodeCommand.editProfilePermissions) +export default class ProfileEditorCommand extends CommandBase { + + private readonly webviews = new Map(); + + public async execute(...args: any[]): Promise { + const uri = args[0] instanceof vscode.Uri + ? args[0] + : this.currentOpenDocument; + + if (!uri) { + void vscode.window.showErrorMessage('No profile or permission set file selected. Open or select a .profile-meta.xml or .permissionset-meta.xml file.'); + return; + } + + const filePath = uri.fsPath; + const isProfile = filePath.endsWith('.profile-meta.xml') || filePath.endsWith('.profile'); + const isPermSet = filePath.endsWith('.permissionset-meta.xml') || filePath.endsWith('.permissionset'); + + if (!isProfile && !isPermSet) { + void vscode.window.showErrorMessage('Selected file is not a Salesforce Profile or PermissionSet metadata file.'); + return; + } + + const profileName = path.basename(filePath) + .replace('.profile-meta.xml', '') + .replace('.profile', '') + .replace('.permissionset-meta.xml', '') + .replace('.permissionset', ''); + + const xmlContent = await getDocumentBodyAsString(uri); + if (!xmlContent) { + void vscode.window.showErrorMessage('Could not read file content.'); + return; + } + + const profile = isProfile + ? SalesforceProfile.fromXml(profileName, xmlContent) + : this.loadPermissionSet(profileName, xmlContent); + + const extensionContext = getContext(); + const connectionProvider = container.get(SalesforceConnectionProvider); + const schemaService = container.get(SalesforceSchemaService); + const panelKey = uri.toString(); + + let webview = this.webviews.get(panelKey); + if (!webview) { + webview = new ProfileEditorWebview(extensionContext, connectionProvider, schemaService); + webview.onDidDispose(() => this.webviews.delete(panelKey)); + this.webviews.set(panelKey, webview); + } + + await webview.openProfile(profile, uri); + } + + private loadPermissionSet(name: string, xml: string): SalesforceUserPermissions { + const permSet = new SalesforceUserPermissions('PermissionSet', name); + permSet.load(xml); + return permSet; + } +} diff --git a/packages/vscode-extension/src/constants.ts b/packages/vscode-extension/src/constants.ts index c3450691c..858e1c91c 100644 --- a/packages/vscode-extension/src/constants.ts +++ b/packages/vscode-extension/src/constants.ts @@ -66,6 +66,8 @@ export enum VlocodeCommand { resumeDeploymentQueue = 'vlocode.resumeDeploymentQueue', addToProfile = 'vlocode.addToProfile', removeFromProfile = 'vlocode.removeFromProfile', + editProfilePermissions = 'vlocode.editProfilePermissions', + openProfileFromOrg = 'vlocode.openProfileFromOrg', openMetaXml = 'vlocode.openMetaXml', openSourceFile = 'vlocode.openSourceFile', omniScriptGenerateLwc = 'vlocode.omniScript.generateLwc', diff --git a/packages/vscode-extension/src/lib/webview/index.ts b/packages/vscode-extension/src/lib/webview/index.ts new file mode 100644 index 000000000..442204500 --- /dev/null +++ b/packages/vscode-extension/src/lib/webview/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './webviewPanel'; +export * from './profileEditorWebview'; diff --git a/packages/vscode-extension/src/lib/webview/profileEditorWebview.ts b/packages/vscode-extension/src/lib/webview/profileEditorWebview.ts new file mode 100644 index 000000000..f3b163b2d --- /dev/null +++ b/packages/vscode-extension/src/lib/webview/profileEditorWebview.ts @@ -0,0 +1,348 @@ +import * as vscode from 'vscode'; +import * as fs from 'fs/promises'; +import { + SalesforceConnectionProvider, + SalesforceProfileValidator, + SalesforceSchemaService, + SalesforceUserPermissions, + ProfileValidationProblem +} from '@vlocode/salesforce'; +import { WebviewPanel, type WebviewContext } from './webviewPanel'; +import { + ExtensionMessage, + FieldPermission, + ObjectPermission, + PermissionChanges, + PermissionProblem, + ProfileEditorData, + SaveTarget, + SObjectField, + WebviewMessage +} from './types'; + +/** + * VSCode webview panel that hosts the React-based Profile/PermissionSet editor UI. + * + * Responsibilities: + * - Load profile metadata and send to the React UI. + * - Handle save/reset/refresh/describe messages from the React UI. + * - Persist changes back to Salesforce org or to local source file. + * - Run permission validation and forward problems to the UI. + */ +export class ProfileEditorWebview extends WebviewPanel { + + private profile: SalesforceUserPermissions | undefined; + /** Absolute path to the local source file, when opened from disk. */ + private sourceFileUri: vscode.Uri | undefined; + private validator: SalesforceProfileValidator; + + constructor( + context: WebviewContext, + private readonly connectionProvider: SalesforceConnectionProvider, + private readonly schemaService: SalesforceSchemaService + ) { + super( + context, + 'vlocode.profileEditor', + 'Profile Editor', + 'webviews/profile-editor.mjs' + ); + this.validator = new SalesforceProfileValidator(); + } + + /** + * Open the editor for a given profile or permission set. + * @param profileOrPermSet - The loaded profile/permset model. + * @param sourceFile - Optional URI of the local source file to support "Save to file". + */ + public async openProfile( + profileOrPermSet: SalesforceUserPermissions, + sourceFile?: vscode.Uri + ): Promise { + const panelWasOpen = this.isOpen; + this.profile = profileOrPermSet; + this.sourceFileUri = sourceFile; + this.setTitle(`${profileOrPermSet.type}: ${profileOrPermSet.developerName}`); + this.open(); + + if (panelWasOpen) { + await this.sendProfileData(); + } + } + + protected async handleMessage(message: WebviewMessage): Promise { + switch (message.type) { + case 'ready': + await this.sendProfileData(); + break; + case 'save': + await this.saveChanges(message.changes, message.target); + break; + case 'reset': + await this.sendProfileData(); + break; + case 'refresh': + await this.refreshFromOrg(); + break; + case 'loadObjects': + await this.sendAvailableObjects(); + break; + case 'loadFields': + await this.sendObjectFields(message.objectName); + break; + case 'validatePermissions': + await this.validatePermissions(); + break; + } + } + + private async sendProfileData(): Promise { + if (!this.profile) { + return; + } + + this.post({ type: 'loading', message: 'Loading profile data...' }); + + try { + const data = this.buildProfileEditorData(this.profile); + this.post({ type: 'init', data }); + // Run structural validation automatically after loading + const structuralProblems = this.validator.validate(this.profile) + .map(p => this.mapValidationProblem(p)); + if (structuralProblems.length > 0) { + this.post({ type: 'problems', problems: structuralProblems }); + } + } catch (err) { + this.logger.error(`Failed to load profile data: ${err}`); + this.post({ type: 'error', message: `Failed to load profile data: ${err}` }); + } + } + + private buildProfileEditorData(profile: SalesforceUserPermissions): ProfileEditorData { + const objectPermissions: ObjectPermission[] = profile.objects.map(op => ({ + objectName: op.object, + allowRead: op.allowRead ?? false, + allowCreate: op.allowCreate ?? false, + allowEdit: op.allowEdit ?? false, + allowDelete: op.allowDelete ?? false, + viewAllRecords: op.viewAllRecords ?? false, + modifyAllRecords: op.modifyAllRecords ?? false + })); + + const fieldPermissions: FieldPermission[] = profile.fields.map(fp => ({ + fieldName: fp.field, + objectName: fp.field.split('.')[0], + readable: fp.readable ?? false, + editable: fp.editable ?? false + })); + + return { + profileName: profile.developerName, + profileLabel: profile.name, + profileType: profile.type, + userLicense: profile.license, + description: profile.description, + objectPermissions, + fieldPermissions, + filePath: this.sourceFileUri?.fsPath + }; + } + + private applyChangesToProfile(changes: PermissionChanges): void { + if (!this.profile) return; + + // Apply removals first + for (const name of changes.removedObjectNames ?? []) { + this.profile.removeObjectPermissions(name); + } + for (const name of changes.removedFieldNames ?? []) { + this.profile.removeField(name); + } + + // Then apply updates + for (const op of changes.objectPermissions) { + this.profile.setObjectPermissions(op.objectName, { + allowRead: op.allowRead, + allowCreate: op.allowCreate, + allowEdit: op.allowEdit, + allowDelete: op.allowDelete, + viewAllRecords: op.viewAllRecords, + modifyAllRecords: op.modifyAllRecords + }); + } + for (const fp of changes.fieldPermissions) { + this.profile.setFieldPermissions(fp.fieldName, fp.readable, fp.editable); + } + } + + private async saveChanges(changes: PermissionChanges, target: SaveTarget): Promise { + if (!this.profile) { + return; + } + + const targetLabel = target === 'file' ? 'file' : 'org'; + this.post({ type: 'loading', message: `Saving changes to ${targetLabel}...` }); + + try { + this.applyChangesToProfile(changes); + + if (target === 'file') { + await this.saveToFile(); + this.post({ type: 'saved', target: 'file' }); + } else { + await this.saveToOrg(); + this.post({ type: 'saved', target: 'org' }); + } + } catch (err) { + this.logger.error(`Failed to save profile: ${err}`); + // If it's a deployment error, forward it to the problems tab + const msg = String(err instanceof Error ? err.message : err); + const deploymentProblems = this.parseDeploymentErrors(msg); + if (deploymentProblems.length > 0) { + this.post({ type: 'problems', problems: deploymentProblems }); + } + this.post({ type: 'error', message: `Failed to save: ${msg}` }); + } + } + + private async saveToOrg(): Promise { + const connection = await this.connectionProvider.getJsForceConnection(); + await this.profile!.save(connection); + } + + private async saveToFile(): Promise { + if (!this.sourceFileUri) { + throw new Error('No source file path is set. Open the profile from a file to use "Save to File".'); + } + const xml = this.profile!.toXml({ sort: true }); + await fs.writeFile(this.sourceFileUri.fsPath, xml, 'utf-8'); + // Reset change tracking so future saves only include new changes + this.profile!.resetChanges(); + } + + private async refreshFromOrg(): Promise { + if (!this.profile) { + return; + } + + this.post({ type: 'loading', message: 'Refreshing from org...' }); + + try { + const connection = await this.connectionProvider.getJsForceConnection(); + const metadata = await connection.metadata.read(this.profile.type, this.profile.developerName); + if (!metadata) { + throw new Error(`${this.profile.type} '${this.profile.developerName}' not found in the org.`); + } + // Re-create with fresh metadata + this.profile = new SalesforceUserPermissions(this.profile.type, this.profile.developerName, metadata as any); + const data = this.buildProfileEditorData(this.profile); + this.post({ type: 'reset', data }); + } catch (err) { + this.logger.error(`Failed to refresh from org: ${err}`); + this.post({ type: 'error', message: `Failed to refresh: ${err}` }); + } + } + + /** + * Validates profile permissions using {@link SalesforceProfileValidator}. + * Runs structural checks immediately, then org-level checks asynchronously. + * Both sets of results are sent to the webview as `problems` messages. + */ + private async validatePermissions(): Promise { + if (!this.profile) { + return; + } + + this.post({ type: 'loading', message: 'Validating permissions…' }); + + try { + // 1. Structural rules (synchronous, no org access needed) + const structural = this.validator.validate(this.profile) + .map(p => this.mapValidationProblem(p)); + this.post({ type: 'problems', problems: structural }); + + // 2. Org-level rules (async, requires schema service) + const orgProblems = await this.validator.validateAgainstOrg( + this.profile, + this.schemaService + ); + const allProblems = [ + ...structural, + ...orgProblems.map(p => this.mapValidationProblem(p)) + ]; + this.post({ type: 'problems', problems: allProblems }); + this.post({ type: 'loading', message: undefined }); + } catch (err) { + this.logger.error(`Validation error: ${err}`); + this.post({ type: 'error', message: `Validation failed: ${err}` }); + } + } + + /** + * Maps a {@link ProfileValidationProblem} from the salesforce package to the + * webview-facing {@link PermissionProblem} DTO. + */ + private mapValidationProblem(p: ProfileValidationProblem): PermissionProblem { + return { + id: p.id, + severity: p.severity, + category: p.category === 'structural' ? 'validation' : p.category as 'deployment' | 'validation', + itemType: p.itemType as PermissionProblem['itemType'], + itemName: p.itemName, + message: p.message, + docsUrl: p.docsUrl, + fixable: p.fixAction !== undefined + }; + } + + /** + * Parses Salesforce deployment error messages into structured PermissionProblem objects. + */ + private parseDeploymentErrors(errorMessage: string): PermissionProblem[] { + const problems: PermissionProblem[] = []; + const lines = errorMessage.split(/[,;]/).map(l => l.trim()).filter(Boolean); + for (const line of lines) { + problems.push({ + id: `deploy-error:${line.slice(0, 40)}`, + severity: 'error', + category: 'deployment', + itemType: 'general', + itemName: '', + message: line, + fixable: false + }); + } + return problems; + } + + private async sendAvailableObjects(): Promise { + try { + const sobjects = await this.schemaService.describeSObjects(); + const names = sobjects + .filter(o => o.queryable) + .map(o => o.name) + .sort((a, b) => a.localeCompare(b)); + this.post({ type: 'objectsLoaded', objects: names }); + } catch (err) { + this.logger.error(`Failed to describe objects: ${err}`); + this.post({ type: 'error', message: `Failed to describe objects: ${err}` }); + } + } + + private async sendObjectFields(objectName: string): Promise { + try { + const describe = await this.schemaService.describeSObject(objectName, false); + if (!describe) { + this.post({ type: 'fieldsLoaded', objectName, fields: [] }); + return; + } + const fields: SObjectField[] = describe.fields + .filter(f => f.type?.toLowerCase() !== 'id') + .map(f => ({ name: f.name, label: f.label, type: f.type })); + this.post({ type: 'fieldsLoaded', objectName, fields }); + } catch (err) { + this.logger.error(`Failed to describe object ${objectName}: ${err}`); + this.post({ type: 'error', message: `Failed to describe object ${objectName}: ${err}` }); + } + } +} diff --git a/packages/vscode-extension/src/lib/webview/types.ts b/packages/vscode-extension/src/lib/webview/types.ts new file mode 100644 index 000000000..8bcbe3bc5 --- /dev/null +++ b/packages/vscode-extension/src/lib/webview/types.ts @@ -0,0 +1,102 @@ +/** + * Shared message types for communication between the VSCode extension and webview panels. + * These types are used by both the extension backend and the React frontend. + */ + +// ─── Profile/PermissionSet Data Types ─────────────────────────────────────── + +export interface ObjectPermission { + objectName: string; + allowRead: boolean; + allowCreate: boolean; + allowEdit: boolean; + allowDelete: boolean; + viewAllRecords: boolean; + modifyAllRecords: boolean; +} + +export interface FieldPermission { + /** Qualified field name in the format "ObjectName.FieldName" */ + fieldName: string; + objectName: string; + readable: boolean; + editable: boolean; +} + +export interface ProfileEditorData { + profileName: string; + profileLabel: string; + profileType: 'Profile' | 'PermissionSet'; + userLicense?: string; + description?: string; + objectPermissions: ObjectPermission[]; + fieldPermissions: FieldPermission[]; + availableObjects?: string[]; + /** Absolute path to source file, if opened from disk */ + filePath?: string; +} + +export interface PermissionChanges { + objectPermissions: ObjectPermission[]; + fieldPermissions: FieldPermission[]; + removedObjectNames: string[]; + removedFieldNames: string[]; +} + +export interface SObjectField { + name: string; + label: string; + type: string; +} + +export interface SObjectDescribe { + name: string; + label: string; + fields: SObjectField[]; +} + +// ─── Permission Problem Types ──────────────────────────────────────────────── + +export type PermissionProblemSeverity = 'error' | 'warning' | 'info'; +export type PermissionProblemCategory = 'validation' | 'deployment'; +export type PermissionProblemItemType = 'objectPermission' | 'fieldPermission' | 'classAccess' | 'general'; + +export interface PermissionProblem { + id: string; + severity: PermissionProblemSeverity; + category: PermissionProblemCategory; + itemType: PermissionProblemItemType; + /** The name of the item that has the problem (e.g., "Account" or "Account.Name") */ + itemName: string; + message: string; + docsUrl?: string; + /** If true, a Fix button can be shown to auto-resolve */ + fixable: boolean; + /** Hint for the frontend on how to fix (e.g., 'remove', 'grant-read') */ + fixAction?: string; +} + +// ─── Message Types (Extension → Webview) ──────────────────────────────────── + +export type ExtensionMessage = + | { type: 'init'; data: ProfileEditorData } + | { type: 'loading'; message?: string } + | { type: 'saved'; target: SaveTarget } + | { type: 'reset'; data: ProfileEditorData } + | { type: 'error'; message: string } + | { type: 'objectsLoaded'; objects: string[] } + | { type: 'fieldsLoaded'; objectName: string; fields: SObjectField[] } + | { type: 'problems'; problems: PermissionProblem[] }; + +// ─── Message Types (Webview → Extension) ──────────────────────────────────── + +export type SaveTarget = 'org' | 'file'; + +export type WebviewMessage = + | { type: 'ready' } + | { type: 'save'; changes: PermissionChanges; target: SaveTarget } + | { type: 'reset' } + | { type: 'refresh' } + | { type: 'loadObjects' } + | { type: 'loadFields'; objectName: string } + | { type: 'validatePermissions' }; diff --git a/packages/vscode-extension/src/lib/webview/webviewPanel.ts b/packages/vscode-extension/src/lib/webview/webviewPanel.ts new file mode 100644 index 000000000..9003c7c6a --- /dev/null +++ b/packages/vscode-extension/src/lib/webview/webviewPanel.ts @@ -0,0 +1,195 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; + +export interface WebviewContext { + extensionPath: string; +} + +/** + * Reusable base class for VSCode webview panels with React UI support. + * Manages the lifecycle of a webview panel and provides infrastructure + * for bidirectional message passing between the extension and the React UI. + * + * @template TIncoming Message type that the webview sends to the extension. + * @template TOutgoing Message type that the extension sends to the webview. + */ +export abstract class WebviewPanel { + #panel: vscode.WebviewPanel | undefined; + #disposables: vscode.Disposable[] = []; + #disposeListeners = new Set<() => void>(); + #currentTitle: string; + + protected readonly logger = console; /*vscode.window.createOutputChannel(this.viewType);*/ + + constructor( + protected readonly context: WebviewContext, + protected readonly viewType: string, + protected readonly title: string, + protected readonly scriptPath: string, + protected readonly viewColumn: vscode.ViewColumn = vscode.ViewColumn.One + ) { + this.#currentTitle = title; + } + + /** + * Opens or reveals the webview panel. + */ + public open(): void { + if (this.#panel) { + this.#panel.reveal(this.viewColumn); + return; + } + + this.#panel = vscode.window.createWebviewPanel( + this.viewType, + this.#currentTitle, + this.viewColumn, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [ + vscode.Uri.file(path.join(this.context.extensionPath, 'dist')) + ] + } + ); + + this.#panel.webview.html = this.buildHtml(this.#panel.webview); + + this.#panel.webview.onDidReceiveMessage( + (message: TIncoming) => this.handleMessage(message), + undefined, + this.#disposables + ); + + this.#panel.onDidDispose( + () => this.onDispose(), + undefined, + this.#disposables + ); + + //this.#disposables.push(this.logger); + } + + /** + * Sends a message to the webview. + */ + protected post(message: TOutgoing): void { + if (this.#panel) { + void this.#panel.webview.postMessage(message); + } + } + + /** + * Updates the panel title. + */ + protected setTitle(title: string): void { + this.#currentTitle = title; + if (this.#panel) { + this.#panel.title = title; + } + } + + /** + * Returns true if the panel is currently visible. + */ + public get isVisible(): boolean { + return this.#panel?.visible ?? false; + } + + /** + * Returns true if the panel has been created and not yet disposed. + */ + public get isOpen(): boolean { + return this.#panel !== undefined; + } + + /** + * Register a callback that fires when the panel is disposed. + */ + public onDidDispose(listener: () => void): vscode.Disposable { + this.#disposeListeners.add(listener); + return new vscode.Disposable(() => this.#disposeListeners.delete(listener)); + } + + /** + * Disposes the webview panel and cleans up resources. + */ + public dispose(): void { + this.#panel?.dispose(); + } + + /** + * Called when the panel is disposed. Subclasses may override to add cleanup logic. + */ + protected onDispose(): void { + this.#panel = undefined; + for (const listener of this.#disposeListeners) { + listener(); + } + for (const d of this.#disposables) { + d.dispose(); + } + this.#disposables = []; + } + + /** + * Handle a message received from the webview. Implemented by subclasses. + */ + protected abstract handleMessage(message: TIncoming): void | Promise; + + /** + * Generates the HTML content for the webview, injecting the bundled React script. + */ + private buildHtml(webview: vscode.Webview): string { + const scriptUri = webview.asWebviewUri( + vscode.Uri.file(path.join(this.context.extensionPath, 'dist', this.scriptPath)) + ); + const nonce = this.generateNonce(); + + return /* html */ ` + + + + + + ${this.escapeHtml(this.#currentTitle)} + + + +
+ + +`; + } + + private generateNonce(): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + return Array.from({ length: 32 }, () => chars[Math.floor(Math.random() * chars.length)]).join(''); + } + + private escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } +} diff --git a/packages/vscode-extension/src/webviews/declarations.d.ts b/packages/vscode-extension/src/webviews/declarations.d.ts new file mode 100644 index 000000000..caaba6a2b --- /dev/null +++ b/packages/vscode-extension/src/webviews/declarations.d.ts @@ -0,0 +1 @@ +declare module '*.scss'; \ No newline at end of file diff --git a/packages/vscode-extension/src/webviews/profileEditor/App.tsx b/packages/vscode-extension/src/webviews/profileEditor/App.tsx new file mode 100644 index 000000000..cf2e8b6e6 --- /dev/null +++ b/packages/vscode-extension/src/webviews/profileEditor/App.tsx @@ -0,0 +1,549 @@ +import React, { useCallback, useEffect, useMemo, useReducer, useState } from 'react'; +import type { + ExtensionMessage, + FieldPermission, + ObjectPermission, + PermissionProblem, + ProfileEditorData, + SaveTarget, + SObjectField, + WebviewMessage +} from './types'; +import type { AppAction } from './actions'; +export type { AppAction } from './actions'; +import { ProfileHeader } from './components/ProfileHeader'; +import { FilterBar } from './components/FilterBar'; +import { ObjectPermissionsTable } from './components/ObjectPermissionsTable'; +import { FieldPermissionsTable } from './components/FieldPermissionsTable'; +import { ActionBar } from './components/ActionBar'; +import { AddPermissionDialog } from './components/AddPermissionDialog'; +import { ProblemsTable } from './components/ProblemsTable'; + +type Tab = 'objects' | 'fields' | 'problems'; + +export interface AppState { + data: ProfileEditorData | null; + objectChanges: Map; + fieldChanges: Map; + removedObjectNames: Set; + removedFieldNames: Set; + status: 'idle' | 'loading' | 'saving' | 'refreshing' | 'validating' | 'error'; + statusMessage: string; + availableObjects: string[]; + loadedFields: Map; + /** Problems from the backend validator ({@link SalesforceProfileValidator}) */ + orgProblems: PermissionProblem[]; +} + +function reducer(state: AppState, action: AppAction): AppState { + switch (action.type) { + case 'init': + return { + ...state, + data: action.data, + objectChanges: new Map(), + fieldChanges: new Map(), + removedObjectNames: new Set(), + removedFieldNames: new Set(), + status: 'idle', + statusMessage: '', + orgProblems: [] + }; + case 'loading': + return { ...state, status: 'loading', statusMessage: action.message ?? 'Loading…' }; + case 'saving': + return { ...state, status: 'saving', statusMessage: 'Saving…' }; + case 'refreshing': + return { ...state, status: 'refreshing', statusMessage: 'Refreshing…' }; + case 'validating': + return { ...state, status: 'validating', statusMessage: 'Validating…' }; + case 'saved': + return { + ...state, + objectChanges: new Map(), + fieldChanges: new Map(), + removedObjectNames: new Set(), + removedFieldNames: new Set(), + status: 'idle', + statusMessage: '' + }; + case 'reset': + return { + ...state, + data: action.data, + objectChanges: new Map(), + fieldChanges: new Map(), + removedObjectNames: new Set(), + removedFieldNames: new Set(), + orgProblems: [], + status: 'idle', + statusMessage: '' + }; + case 'error': + return { ...state, status: 'error', statusMessage: action.message }; + case 'objectsLoaded': + return { ...state, availableObjects: action.objects }; + case 'fieldsLoaded': { + const loadedFields = new Map(state.loadedFields); + loadedFields.set(action.objectName, action.fields); + return { ...state, loadedFields }; + } + case 'orgProblems': + return { ...state, orgProblems: action.problems, status: 'idle', statusMessage: '' }; + case 'changeObject': { + const objectChanges = new Map(state.objectChanges); + objectChanges.set(action.permission.objectName, action.permission); + if (state.data) { + const objectPermissions = state.data.objectPermissions.map(op => + op.objectName === action.permission.objectName ? action.permission : op + ); + return { ...state, objectChanges, data: { ...state.data, objectPermissions } }; + } + return { ...state, objectChanges }; + } + case 'changeField': { + const fieldChanges = new Map(state.fieldChanges); + fieldChanges.set(action.permission.fieldName, action.permission); + if (state.data) { + const fieldPermissions = state.data.fieldPermissions.map(fp => + fp.fieldName === action.permission.fieldName ? action.permission : fp + ); + return { ...state, fieldChanges, data: { ...state.data, fieldPermissions } }; + } + return { ...state, fieldChanges }; + } + case 'removeObject': { + const removedObjectNames = new Set(state.removedObjectNames); + removedObjectNames.add(action.objectName); + const objectChanges = new Map(state.objectChanges); + objectChanges.delete(action.objectName); + if (state.data) { + const objectPermissions = state.data.objectPermissions.filter( + op => op.objectName !== action.objectName + ); + return { ...state, objectChanges, removedObjectNames, data: { ...state.data, objectPermissions } }; + } + return { ...state, objectChanges, removedObjectNames }; + } + case 'removeField': { + const removedFieldNames = new Set(state.removedFieldNames); + removedFieldNames.add(action.fieldName); + const fieldChanges = new Map(state.fieldChanges); + fieldChanges.delete(action.fieldName); + if (state.data) { + const fieldPermissions = state.data.fieldPermissions.filter( + fp => fp.fieldName !== action.fieldName + ); + return { ...state, fieldChanges, removedFieldNames, data: { ...state.data, fieldPermissions } }; + } + return { ...state, fieldChanges, removedFieldNames }; + } + case 'addObject': { + const objectChanges = new Map(state.objectChanges); + objectChanges.set(action.permission.objectName, action.permission); + if (state.data) { + const existing = state.data.objectPermissions.find(op => op.objectName === action.permission.objectName); + const objectPermissions = existing + ? state.data.objectPermissions + : [...state.data.objectPermissions, action.permission]; + return { ...state, objectChanges, data: { ...state.data, objectPermissions } }; + } + return { ...state, objectChanges }; + } + case 'addField': { + const fieldChanges = new Map(state.fieldChanges); + fieldChanges.set(action.permission.fieldName, action.permission); + if (state.data) { + const existing = state.data.fieldPermissions.find(fp => fp.fieldName === action.permission.fieldName); + const fieldPermissions = existing + ? state.data.fieldPermissions + : [...state.data.fieldPermissions, action.permission]; + return { ...state, fieldChanges, data: { ...state.data, fieldPermissions } }; + } + return { ...state, fieldChanges }; + } + default: + return state; + } +} + +const initialState: AppState = { + data: null, + objectChanges: new Map(), + fieldChanges: new Map(), + removedObjectNames: new Set(), + removedFieldNames: new Set(), + status: 'loading', + statusMessage: 'Loading profile…', + availableObjects: [], + loadedFields: new Map(), + orgProblems: [] +}; + +interface AppProps { + postMessage: (msg: WebviewMessage) => void; +} + +/** + * Main React application for the Salesforce Profile/PermissionSet editor. + */ +export const App: React.FC = ({ postMessage }) => { + const [state, dispatch] = useReducer(reducer, initialState); + const [activeTab, setActiveTab] = useState('objects'); + const [filter, setFilter] = useState(''); + const [objectFilter, setObjectFilter] = useState(''); + const [dialogMode, setDialogMode] = useState<'object' | 'field' | null>(null); + + // Per-tab selection state + const [objectSelection, setObjectSelection] = useState>(new Set()); + const [fieldSelection, setFieldSelection] = useState>(new Set()); + + // Listen for messages from the extension + useEffect(() => { + const handler = (event: MessageEvent) => { + const msg = event.data; + switch (msg.type) { + case 'init': + dispatch({ type: 'init', data: msg.data }); + break; + case 'loading': + dispatch({ type: 'loading', message: msg.message }); + break; + case 'saved': + dispatch({ type: 'saved' }); + break; + case 'reset': + dispatch({ type: 'reset', data: msg.data }); + setObjectSelection(new Set()); + setFieldSelection(new Set()); + break; + case 'error': + dispatch({ type: 'error', message: msg.message }); + break; + case 'objectsLoaded': + dispatch({ type: 'objectsLoaded', objects: msg.objects }); + break; + case 'fieldsLoaded': + dispatch({ type: 'fieldsLoaded', objectName: msg.objectName, fields: msg.fields }); + break; + case 'problems': + dispatch({ type: 'orgProblems', problems: msg.problems }); + // Auto-switch to problems tab if org errors found + if (msg.problems.some(p => p.category === 'deployment')) { + setActiveTab('problems'); + } + break; + } + }; + window.addEventListener('message', handler); + return () => window.removeEventListener('message', handler); + }, []); + + // Tell the extension we're ready + useEffect(() => { + postMessage({ type: 'ready' }); + }, [postMessage]); + + // Problems come exclusively from the backend (structural + org-level validation) + const allProblems = state.orgProblems; + const problemCount = allProblems.length; + const problemErrorCount = allProblems.filter(p => p.severity === 'error').length; + + const handleObjectChange = useCallback( + (perm: ObjectPermission) => dispatch({ type: 'changeObject', permission: perm }), + [] + ); + + const handleFieldChange = useCallback( + (perm: FieldPermission) => dispatch({ type: 'changeField', permission: perm }), + [] + ); + + const handleRemoveObject = useCallback( + (objectName: string) => { + dispatch({ type: 'removeObject', objectName }); + setObjectSelection(prev => { const n = new Set(prev); n.delete(objectName); return n; }); + }, + [] + ); + + const handleRemoveField = useCallback( + (fieldName: string) => { + dispatch({ type: 'removeField', fieldName }); + setFieldSelection(prev => { const n = new Set(prev); n.delete(fieldName); return n; }); + }, + [] + ); + + const handleRemoveSelectedObjects = useCallback(() => { + for (const name of objectSelection) { + dispatch({ type: 'removeObject', objectName: name }); + } + setObjectSelection(new Set()); + }, [objectSelection]); + + const handleRemoveSelectedFields = useCallback(() => { + for (const name of fieldSelection) { + dispatch({ type: 'removeField', fieldName: name }); + } + setFieldSelection(new Set()); + }, [fieldSelection]); + + const handleSave = useCallback((target: SaveTarget) => { + dispatch({ type: 'saving' }); + postMessage({ + type: 'save', + target, + changes: { + objectPermissions: Array.from(state.objectChanges.values()), + fieldPermissions: Array.from(state.fieldChanges.values()), + removedObjectNames: Array.from(state.removedObjectNames), + removedFieldNames: Array.from(state.removedFieldNames) + } + }); + }, [state.objectChanges, state.fieldChanges, state.removedObjectNames, state.removedFieldNames, postMessage]); + + const handleReset = useCallback(() => { + postMessage({ type: 'reset' }); + }, [postMessage]); + + const handleRefresh = useCallback(() => { + dispatch({ type: 'refreshing' }); + postMessage({ type: 'refresh' }); + }, [postMessage]); + + const handleValidate = useCallback(() => { + dispatch({ type: 'validating' }); + postMessage({ type: 'validatePermissions' }); + }, [postMessage]); + + const handleOpenAddDialog = useCallback( + (mode: 'object' | 'field') => { + if (state.availableObjects.length === 0) { + postMessage({ type: 'loadObjects' }); + } + setDialogMode(mode); + }, + [state.availableObjects, postMessage] + ); + + const handleAddObject = useCallback( + (objectName: string) => { + dispatch({ + type: 'addObject', + permission: { + objectName, + allowRead: true, + allowCreate: false, + allowEdit: false, + allowDelete: false, + viewAllRecords: false, + modifyAllRecords: false + } + }); + }, + [] + ); + + const handleAddField = useCallback( + (objectName: string, fieldName: string) => { + dispatch({ + type: 'addField', + permission: { + fieldName: `${objectName}.${fieldName}`, + objectName, + readable: true, + editable: false + } + }); + }, + [] + ); + + const totalChanges = + state.objectChanges.size + + state.fieldChanges.size + + state.removedObjectNames.size + + state.removedFieldNames.size; + const hasChanges = totalChanges > 0; + + const filteredObjectCount = useMemo(() => { + if (!state.data) return 0; + if (!filter) return state.data.objectPermissions.length; + return state.data.objectPermissions.filter(p => + p.objectName.toLowerCase().includes(filter.toLowerCase()) + ).length; + }, [state.data, filter]); + + const filteredFieldCount = useMemo(() => { + if (!state.data) return 0; + const term = filter.toLowerCase(); + return state.data.fieldPermissions.filter(p => { + const matchesObject = !objectFilter || p.objectName.toLowerCase().includes(objectFilter.toLowerCase()); + const matchesField = !term || p.objectName.toLowerCase().includes(term) || p.fieldName.toLowerCase().includes(term); + return matchesObject && matchesField; + }).length; + }, [state.data, filter, objectFilter]); + + if (state.status === 'loading' && !state.data) { + return ( +
+
+ {state.statusMessage} +
+ ); + } + + if (state.status === 'error' && !state.data) { + return ( +
+