diff --git a/.github/workflows/sfdx-release.yml b/.github/workflows/sfdx-release.yml new file mode 100644 index 000000000..c63e7d171 --- /dev/null +++ b/.github/workflows/sfdx-release.yml @@ -0,0 +1,110 @@ +name: Salesforce Release + +# Creates a new Salesforce managed package version, optionally promotes it to +# released, tags `salesforce-vX.Y.Z`, and cuts a GitHub release. +# Triggered locally via `pnpm release:salesforce`. +on: + workflow_dispatch: + inputs: + bump: + description: "Version bump type" + required: true + default: "patch" + type: choice + options: [patch, minor, major] + + promote: + description: "Promote to released (installable in production orgs). False = beta only." + required: true + type: boolean + +concurrency: + group: sfdx-release + cancel-in-progress: false + +permissions: + contents: write + +jobs: + release-salesforce: + runs-on: ubuntu-latest + timeout-minutes: 120 + environment: production + + steps: + - name: Validate branch + env: + REF_NAME: ${{ github.ref_name }} + run: | + if [[ "$REF_NAME" != "main" && ! "$REF_NAME" =~ ^hotfix/ ]]; then + echo "Error: This workflow can only run on 'main' or a 'hotfix/*' branch. Current branch: $REF_NAME" + exit 1 + fi + + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + # Overridden later with a GitHub App installation token so the bump + # commit + tag push are made as the App (on the ruleset bypass list). + persist-credentials: false + + - uses: actions/setup-node@v6 + with: + node-version: "24" + + - name: Install Salesforce CLI + run: npm install -g @salesforce/cli + + - name: Decode JWT key + run: echo "${{ secrets.SFDX_JWT_KEY_BASE64 }}" | base64 --decode > server.key + + - name: Authenticate to Dev Hub + run: | + sf org login jwt \ + --client-id ${{ secrets.SFDX_CONSUMER_KEY }} \ + --jwt-key-file server.key \ + --username ${{ secrets.SFDX_DEVHUB_USERNAME }} \ + --set-default-dev-hub \ + --alias devhub + + # Generate a GitHub App installation token for git pushes. + # The App's bot account is on the main-branch ruleset bypass list, which a PAT is not. + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ secrets.CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Get GitHub App user ID + id: app-user + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + APP_SLUG: ${{ steps.app-token.outputs.app-slug }} + run: | + USER_ID=$(gh api "/users/${APP_SLUG}[bot]" --jq .id) + echo "user-id=${USER_ID}" >> "$GITHUB_OUTPUT" + + - name: Configure git for release + env: + APP_SLUG: ${{ steps.app-token.outputs.app-slug }} + USER_ID: ${{ steps.app-user.outputs.user-id }} + APP_TOKEN: ${{ steps.app-token.outputs.token }} + REPO: ${{ github.repository }} + run: | + git config user.name "${APP_SLUG}[bot]" + git config user.email "${USER_ID}+${APP_SLUG}[bot]@users.noreply.github.com" + git remote set-url origin "https://x-access-token:${APP_TOKEN}@github.com/${REPO}.git" + + - name: Create package version and release + env: + BUMP: ${{ inputs.bump }} + PROMOTE: ${{ inputs.promote }} + SFDX_INSTALLATION_PASSWORD: ${{ secrets.SFDX_INSTALLATION_PASSWORD }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} + REPO: ${{ github.repository }} + run: node apps-sfdx/scripts/sfdx/release.mjs + + - name: Remove key file + if: always() + run: rm -f server.key diff --git a/apps-sfdx/README.md b/apps-sfdx/README.md index 89174a61c..4413ba148 100644 --- a/apps-sfdx/README.md +++ b/apps-sfdx/README.md @@ -1,5 +1,90 @@ # Jetstream for Salesforce -Navigating to the full screen version of Jetstream: +A managed package that embeds [Jetstream](https://getjetstream.app) directly inside Salesforce as a +[canvas app](https://developer.salesforce.com/docs/platform/canvas-developer-guide/overview), giving users a powerful workspace to +query, load, update, and manage their data and metadata without leaving their org. -`/jetstream/JetstreamApp.app` +- **Package name:** `Jetstream for Salesforce` +- **Namespace:** `jetstream` +- **Type:** Managed (2GP) + +## What's included + +| Component | API Name | Purpose | +| ------------------- | ------------------------- | ------------------------------------------------------------------------------------------ | +| External Client App | `Jetstream_Canvas` | The canvas app definition, OAuth settings, and policies that embed Jetstream | +| Custom Application | `Jetstream` | Lightning app that hosts the Jetstream tab | +| Custom Tab | `JetstreamAppTab` | Tab that renders the Visualforce wrapper page | +| Visualforce Page | `JetstreamPage` | Full-screen wrapper that hosts the canvas iframe | +| Apex Controller | `JetstreamPageController` | Supplies canvas parameters (e.g. full-screen detection) to the page | +| Custom Setting | `UserPreferences__c` | Hierarchy custom setting storing per-user preferences (SOQL formatting, record sync, etc.) | +| Permission Set | `Jetstream` | Grants access to the app, tab, page, and user-preference fields | + +## Accessing the app + +After the package is installed and the **Jetstream** permission set is assigned (see [Permissions](#permissions)): + +- Open the **App Launcher** and search for **Jetstream**, or +- Navigate directly to the Lightning app: `/lightning/app/jetstream__Jetstream` + +## Requirements + +For local development and packaging you'll need: + +- [Salesforce CLI](https://developer.salesforce.com/tools/salesforcecli) (`sf`) +- A Dev Hub org with the `jetstream` namespace linked +- [Node.js](https://nodejs.org) (for the helper scripts) + +All commands below are run from the `apps-sfdx/` directory. + +## Local development + +Spin up a fully configured scratch org (creates the org, deploys source, assigns the `Jetstream` permission set, sets a password, and +opens the org): + +```bash +npm run org:scratch:create +``` + +### Environment variables + +The canvas URL in the package points at production (`https://getjetstream.app/canvas/app`). To point a scratch org at a local or +staging Jetstream instance, create a `.env.local` file and the `replacements` block in `sfdx-project.json` will swap the URL on deploy: + +```bash +# apps-sfdx/.env.local +SFDX_ENV=DEVELOPMENT +JETSTREAM_CANVAS_BASE_URL=http://localhost:3333 +``` + +The override only applies when `SFDX_ENV=DEVELOPMENT`; production package builds always use the `getjetstream.app` URL. + +## Project structure + +``` +apps-sfdx/ +├── canvas-app/ +│ ├── jetstream/ # Packaged source (everything that ships in the managed package) +│ │ └── main/default/ +│ │ ├── applications/ # Jetstream Lightning app +│ │ ├── classes/ # Apex controller + tests +│ │ ├── contentassets/ # App logo +│ │ ├── externalClientApps/ # Canvas app (ECA) + OAuth/canvas settings & policies +│ │ ├── objects/ # UserPreferences__c custom setting + fields +│ │ ├── pages/ # JetstreamPage Visualforce wrapper +│ │ ├── permissionsets/ # Jetstream permission set +│ │ └── tabs/ # JetstreamAppTab +│ └── unpackaged/ # Source deployed to dev orgs but NOT included in the package +├── config/ # Scratch org definition files +└── scripts/ # Helper scripts (scratch org creation, etc.) +``` + +## Permissions + +Access to the canvas app is gated by the **Jetstream** permission set. + +```bash +sf org assign permset --name Jetstream --target-org +``` + +Subscribers must assign this permission set to any user who needs the app. diff --git a/apps-sfdx/canvas-app/jetstream/main/default/classes/JetstreamPageController.cls-meta.xml b/apps-sfdx/canvas-app/jetstream/main/default/classes/JetstreamPageController.cls-meta.xml index cad713d0a..7d3a8b2f1 100644 --- a/apps-sfdx/canvas-app/jetstream/main/default/classes/JetstreamPageController.cls-meta.xml +++ b/apps-sfdx/canvas-app/jetstream/main/default/classes/JetstreamPageController.cls-meta.xml @@ -1,5 +1,5 @@ - + - 66.0 + 67.0 Active diff --git a/apps-sfdx/canvas-app/jetstream/main/default/classes/JetstreamPageControllerTest.cls-meta.xml b/apps-sfdx/canvas-app/jetstream/main/default/classes/JetstreamPageControllerTest.cls-meta.xml index cad713d0a..7d3a8b2f1 100644 --- a/apps-sfdx/canvas-app/jetstream/main/default/classes/JetstreamPageControllerTest.cls-meta.xml +++ b/apps-sfdx/canvas-app/jetstream/main/default/classes/JetstreamPageControllerTest.cls-meta.xml @@ -1,5 +1,5 @@ - + - 66.0 + 67.0 Active diff --git a/apps-sfdx/canvas-app/jetstream/main/default/classes/UserPreferencesTest.cls-meta.xml b/apps-sfdx/canvas-app/jetstream/main/default/classes/UserPreferencesTest.cls-meta.xml index cad713d0a..7d3a8b2f1 100644 --- a/apps-sfdx/canvas-app/jetstream/main/default/classes/UserPreferencesTest.cls-meta.xml +++ b/apps-sfdx/canvas-app/jetstream/main/default/classes/UserPreferencesTest.cls-meta.xml @@ -1,5 +1,5 @@ - + - 66.0 + 67.0 Active diff --git a/apps-sfdx/canvas-app/jetstream/main/default/externalClientApps/Jetstream_Canvas.eca-meta.xml b/apps-sfdx/canvas-app/jetstream/main/default/externalClientApps/Jetstream_Canvas.eca-meta.xml index f0f47fb3b..b433be80d 100644 --- a/apps-sfdx/canvas-app/jetstream/main/default/externalClientApps/Jetstream_Canvas.eca-meta.xml +++ b/apps-sfdx/canvas-app/jetstream/main/default/externalClientApps/Jetstream_Canvas.eca-meta.xml @@ -4,7 +4,7 @@ Packaged https://getjetstream.app/assets/images/jetstream-icon-pro-128.png false - + https://getjetstream.app/assets/images/jetstream-icon-pro-128.png - 00D4S000000pHDFUA2:Jetstream_Canvas + 00Dam00001cYDdx:Jetstream_Canvas diff --git a/apps-sfdx/canvas-app/jetstream/main/default/extlClntAppOauthSettings/Jetstream_Canvas_oauth.ecaOauth-meta.xml b/apps-sfdx/canvas-app/jetstream/main/default/extlClntAppOauthSettings/Jetstream_Canvas_oauth.ecaOauth-meta.xml index 8b9d81d3c..b49ec62b6 100644 --- a/apps-sfdx/canvas-app/jetstream/main/default/extlClntAppOauthSettings/Jetstream_Canvas_oauth.ecaOauth-meta.xml +++ b/apps-sfdx/canvas-app/jetstream/main/default/extlClntAppOauthSettings/Jetstream_Canvas_oauth.ecaOauth-meta.xml @@ -4,5 +4,5 @@ Jetstream_Canvas false - + 00Dam00001cYDdx:888am000001yNkL diff --git a/apps-sfdx/canvas-app/jetstream/main/default/objects/UserPreferences__c/UserPreferences__c.object-meta.xml b/apps-sfdx/canvas-app/jetstream/main/default/objects/UserPreferences__c/UserPreferences__c.object-meta.xml index 8b4d5caf9..46af0bd38 100644 --- a/apps-sfdx/canvas-app/jetstream/main/default/objects/UserPreferences__c/UserPreferences__c.object-meta.xml +++ b/apps-sfdx/canvas-app/jetstream/main/default/objects/UserPreferences__c/UserPreferences__c.object-meta.xml @@ -1,7 +1,7 @@ - + Hierarchy - Stores per-user preferences for the Jetstream Canvas App. + Stores per-user preferences for the Jetstream for Salesforce App. false Public diff --git a/apps-sfdx/canvas-app/jetstream/main/default/objects/UserPreferences__c/fields/ColorScheme__c.field-meta.xml b/apps-sfdx/canvas-app/jetstream/main/default/objects/UserPreferences__c/fields/ColorScheme__c.field-meta.xml new file mode 100644 index 000000000..2087bcc1a --- /dev/null +++ b/apps-sfdx/canvas-app/jetstream/main/default/objects/UserPreferences__c/fields/ColorScheme__c.field-meta.xml @@ -0,0 +1,11 @@ + + + ColorScheme__c + The user's preferred color scheme: light, dark, or system. + false + + 10 + false + Text + false + diff --git a/apps-sfdx/canvas-app/jetstream/main/default/pages/JetstreamPage.page-meta.xml b/apps-sfdx/canvas-app/jetstream/main/default/pages/JetstreamPage.page-meta.xml index a62b59eef..92ad140f8 100644 --- a/apps-sfdx/canvas-app/jetstream/main/default/pages/JetstreamPage.page-meta.xml +++ b/apps-sfdx/canvas-app/jetstream/main/default/pages/JetstreamPage.page-meta.xml @@ -1,6 +1,6 @@ - 66.0 + 67.0 false false diff --git a/apps-sfdx/canvas-app/jetstream/main/default/appMenus/AppSwitcher.appMenu-meta.xml b/apps-sfdx/canvas-app/unpackaged/main/default/appMenus/AppSwitcher.appMenu-meta.xml similarity index 100% rename from apps-sfdx/canvas-app/jetstream/main/default/appMenus/AppSwitcher.appMenu-meta.xml rename to apps-sfdx/canvas-app/unpackaged/main/default/appMenus/AppSwitcher.appMenu-meta.xml diff --git a/apps-sfdx/canvas-app/jetstream/main/default/extlClntAppOauthPolicies/Jetstream_Canvas_oauthPlcy.ecaOauthPlcy-meta.xml b/apps-sfdx/canvas-app/unpackaged/main/default/extlClntAppOauthPolicies/Jetstream_Canvas_oauthPlcy.ecaOauthPlcy-meta.xml similarity index 100% rename from apps-sfdx/canvas-app/jetstream/main/default/extlClntAppOauthPolicies/Jetstream_Canvas_oauthPlcy.ecaOauthPlcy-meta.xml rename to apps-sfdx/canvas-app/unpackaged/main/default/extlClntAppOauthPolicies/Jetstream_Canvas_oauthPlcy.ecaOauthPlcy-meta.xml diff --git a/apps-sfdx/canvas-app/jetstream/main/default/extlClntAppPolicies/Jetstream_Canvas_plcy.ecaPlcy-meta.xml b/apps-sfdx/canvas-app/unpackaged/main/default/extlClntAppPolicies/Jetstream_Canvas_plcy.ecaPlcy-meta.xml similarity index 100% rename from apps-sfdx/canvas-app/jetstream/main/default/extlClntAppPolicies/Jetstream_Canvas_plcy.ecaPlcy-meta.xml rename to apps-sfdx/canvas-app/unpackaged/main/default/extlClntAppPolicies/Jetstream_Canvas_plcy.ecaPlcy-meta.xml diff --git a/apps-sfdx/canvas-app/unpackaged/main/default/profiles/Admin.profile-meta.xml b/apps-sfdx/canvas-app/unpackaged/main/default/profiles/Admin.profile-meta.xml deleted file mode 100644 index d96ddf1b2..000000000 --- a/apps-sfdx/canvas-app/unpackaged/main/default/profiles/Admin.profile-meta.xml +++ /dev/null @@ -1,842 +0,0 @@ - - - - Jetstream - false - true - - false - - JetstreamPage - true - - - JetstreamAppTab - DefaultOn - - Salesforce - - true - AIViewInsightObjects - - - true - AccessOrchestrationObjects - - - true - ActivateContract - - - true - ActivateOrder - - - true - ActivitiesAccess - - - true - AddDirectMessageMembers - - - true - AllowUniversalSearch - - - true - AllowViewKnowledge - - - true - ApexRestServices - - - true - ApiEnabled - - - true - AppFrameworkManageApp - - - true - AppFrameworkManageTemplate - - - true - ApprovalAdmin - - - true - ApprovalDesigner - - - true - AssignPermissionSets - - - true - AssignTopics - - - true - AuthorApex - - - true - BulkMacrosAllowed - - - true - CanAccessCE - - - true - CanApproveUninstalledApps - - - true - CanInsertFeedSystemFields - - - true - CanUseNewDashboardBuilder - - - true - CanVerifyComment - - - true - ChangeDashboardColors - - - true - ChatterEditOwnPost - - - true - ChatterEditOwnRecordPost - - - true - ChatterFileLink - - - true - ChatterInternalUser - - - true - ChatterInviteExternalUsers - - - true - ChatterOwnGroups - - - true - ClientSecretRotation - - - true - ConnectOrgToEnvironmentHub - - - true - ConsentApiUpdate - - - true - ContentAdministrator - - - true - ContentWorkspaces - - - true - ConvertLeads - - - true - CreateCustomizeDashboards - - - true - CreateCustomizeFilters - - - true - CreateCustomizeReports - - - true - CreateDashboardFolders - - - true - CreateLtngTempFolder - - - true - CreateReportFolders - - - true - CreateTopics - - - true - CreateWorkBadgeDefinition - - - true - CreateWorkspaces - - - true - CustomizeApplication - - - true - DataExport - - - true - DelegatedTwoFactor - - - true - DeleteActivatedContract - - - true - DeleteTopics - - - true - DistributeFromPersWksp - - - true - EditActivatedOrders - - - true - EditBillingInfo - - - true - EditBrandTemplates - - - true - EditCaseComments - - - true - EditEvent - - - true - EditHtmlTemplates - - - true - EditKnowledge - - - true - EditMyDashboards - - - true - EditMyReports - - - true - EditOppLineItemUnitPrice - - - true - EditPublicDocuments - - - true - EditPublicFilters - - - true - EditPublicTemplates - - - true - EditReadonlyFields - - - true - EditTask - - - true - EditTopics - - - true - EmailMass - - - true - EmailSingle - - - true - EnableCommunityAppLauncher - - - true - EnableNotifications - - - true - ExportReport - - - true - ExternalClientAppAdmin - - - true - ExternalClientAppDeveloper - - - true - ExternalClientAppViewer - - - true - FieldServiceAccess - - - true - FreezeUsers - - - true - GiveRecognitionBadge - - - true - ImportCustomObjects - - - true - ImportLeads - - - true - ImportPersonal - - - true - InstallPackaging - - - true - LightningConsoleAllowedForUser - - - true - LightningExperienceUser - - - true - ListEmailSend - - - true - ManageAnalyticSnapshots - - - true - ManageApiNamedQueries - - - true - ManageAuthProviders - - - true - ManageBusinessHourHolidays - - - true - ManageC360AConnections - - - true - ManageCMS - - - true - ManageCallCenters - - - true - ManageCases - - - true - ManageCategories - - - true - ManageCertificates - - - true - ManageContentPermissions - - - true - ManageContentProperties - - - true - ManageContentTaxonomy - - - true - ManageContentTypes - - - true - ManageCustomDomains - - - true - ManageCustomPermissions - - - true - ManageCustomReportTypes - - - true - ManageDashbdsInPubFolders - - - true - ManageDataCategories - - - true - ManageDataIntegrations - - - true - ManageDataMaskPolicies - - - true - ManageDynamicDashboards - - - true - ManageEmailClientConfig - - - true - ManageEntitlements - - - true - ManageExchangeConfig - - - true - ManageFilesAndAttachments - - - true - ManageHealthCheck - - - true - ManageHerokuAppLink - - - true - ManageHubConnections - - - true - ManageInteraction - - - true - ManageInternalUsers - - - true - ManageIpAddresses - - - true - ManageKnowledge - - - true - ManageKnowledgeImportExport - - - true - ManageLeads - - - true - ManageLoginAccessPolicies - - - true - ManageMobile - - - true - ManageNetworks - - - true - ManageOrchInstsAndWorkItems - - - true - ManagePackageLicenses - - - true - ManagePasswordPolicies - - - true - ManageProfilesPermissionsets - - - true - ManagePropositions - - - true - ManagePvtRptsAndDashbds - - - true - ManageRecommendationStrategies - - - true - ManageReleaseUpdates - - - true - ManageRemoteAccess - - - true - ManageReportsInPubFolders - - - true - ManageRoles - - - true - ManageSearchPromotionRules - - - true - ManageSharing - - - true - ManageSolutions - - - true - ManageSubscriptions - - - true - ManageSynonyms - - - true - ManageUnlistedGroups - - - true - ManageUsers - - - true - MassInlineEdit - - - true - MergeTopics - - - true - ModerateChatter - - - true - ModifyAllData - - - true - ModifyDataClassification - - - true - ModifyMetadata - - - true - MonitorLoginHistory - - - true - NewReportBuilder - - - true - OmnichannelInventorySync - - - true - Packaging2 - - - true - Packaging2Delete - - - true - PrivacyDataAccess - - - true - QrCodeGeneratorMobilePublisherPlayground - - - true - RemoveDirectMessageMembers - - - true - ResetPasswords - - - true - RunReports - - - true - ScheduleReports - - - true - SelectFilesFromSalesforce - - - true - SendCustomNotifications - - - true - SendExternalEmailAvailable - - - true - SendSitRequests - - - true - ShareInternalArticles - - - true - ShowCompanyNameAsUserBadge - - - true - SolutionImport - - - true - SubmitMacrosAllowed - - - true - SubscribeDashboardRolesGrps - - - true - SubscribeDashboardToOtherUsers - - - true - SubscribeReportRolesGrps - - - true - SubscribeReportToOtherUsers - - - true - SubscribeReportsRunAsUser - - - true - SubscribeToLightningDashboards - - - true - SubscribeToLightningReports - - - true - TerritoryOperations - - - true - TransactionalEmailSend - - - true - TransferAnyCase - - - true - TransferAnyEntity - - - true - TransferAnyLead - - - true - UseOmnichannelInventoryAPIs - - - true - UseTeamReassignWizards - - - true - UseWebLink - - - true - ViewAllData - - - true - ViewAllProfiles - - - true - ViewAllUsers - - - true - ViewApiNamedQueries - - - true - ViewClientSecret - - - true - ViewContentTaxonomy - - - true - ViewDataAssessment - - - true - ViewDataCategories - - - true - ViewDataLeakageEvents - - - true - ViewDeveloperName - - - true - ViewEventLogFiles - - - true - ViewFlowUsageAndFlowEventData - - - true - ViewHealthCheck - - - true - ViewHelpLink - - - true - ViewMLModels - - - true - ViewMyTeamsDashboards - - - true - ViewPlatformEvents - - - true - ViewPublicCapstoneFolders - - - true - ViewPublicDashboards - - - true - ViewPublicReports - - - true - ViewRoles - - - true - ViewSALifecycle - - - true - ViewSetup - - - true - ViewUserPII - - - true - WorkCalibrationUser - - diff --git a/apps-sfdx/scripts/sfdx/release.mjs b/apps-sfdx/scripts/sfdx/release.mjs new file mode 100644 index 000000000..c58f503d4 --- /dev/null +++ b/apps-sfdx/scripts/sfdx/release.mjs @@ -0,0 +1,207 @@ +#!/usr/bin/env node + +/** + * In-workflow Salesforce managed package release worker. + * + * Runs inside the `sfdx-release.yml` GitHub Action after the Dev Hub auth and + * git identity have been configured. It: + * 1. Bumps `versionName` / `versionNumber` in sfdx-project.json + * 2. Creates a new package version (`sf package version create`) + * 3. Optionally promotes it to "released" + * 4. Commits the bump, tags `salesforce-vX.Y.Z`, and cuts a GitHub release + * 5. Writes a rich job summary including the install URL + * + * Uses only Node built-ins so CI does not need to install apps-sfdx dependencies. + * + * Required env: BUMP (patch|minor|major), PROMOTE (true|false), SFDX_INSTALLATION_PASSWORD. + * Optional env: REPO, GITHUB_STEP_SUMMARY, DRY_RUN (true skips all sf/git/gh side effects). + */ + +import { execFileSync } from 'node:child_process'; +import { appendFileSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +// apps-sfdx root – sf commands must run here so they can locate sfdx-project.json +const sfdxProjectDir = path.resolve(scriptDir, '../..'); +const sfdxProjectFile = path.join(sfdxProjectDir, 'sfdx-project.json'); + +const PACKAGE_NAME = 'Jetstream for Salesforce'; +const TAG_PREFIX = 'salesforce-v'; + +const bump = requireEnv('BUMP'); +const promote = process.env.PROMOTE === 'true'; +const isDryRun = process.env.DRY_RUN === 'true'; +const installationKey = isDryRun ? 'dry-run-key' : requireEnv('SFDX_INSTALLATION_PASSWORD'); +const repo = process.env.REPO ?? 'jetstreamapp/jetstream'; + +if (!['patch', 'minor', 'major'].includes(bump)) { + fail(`Invalid BUMP "${bump}". Expected one of: patch, minor, major.`); +} + +// ── 1. Bump version in sfdx-project.json ──────────────────────────────────── +const projectJson = JSON.parse(readFileSync(sfdxProjectFile, 'utf8')); +const defaultPackage = projectJson.packageDirectories?.find(({ package: pkg }) => pkg === PACKAGE_NAME); +if (!defaultPackage) { + fail(`Could not find package "${PACKAGE_NAME}" in sfdx-project.json.`); +} + +const version = nextVersion(defaultPackage.versionNumber, bump); +const versionString = `${version.major}.${version.minor}.${version.patch}`; +const tag = `${TAG_PREFIX}${versionString}`; + +console.log(`Releasing ${PACKAGE_NAME} ${versionString} (${bump} bump from ${defaultPackage.versionNumber})`); +console.log(`Promote to released: ${promote ? 'yes' : 'no (beta)'}`); + +defaultPackage.versionName = `v${versionString}`; +defaultPackage.versionNumber = `${versionString}.NEXT`; +writeFileSync(sfdxProjectFile, `${JSON.stringify(projectJson, null, 2)}\n`); + +// ── 2. Create the package version ─────────────────────────────────────────── +// `--json` keeps output parseable; `--code-coverage` is required to later promote. +console.log('\nCreating package version (this can take several minutes)...'); +const createResult = isDryRun + ? { result: { SubscriberPackageVersionId: '04tXXXXXXXXXXXXXXX' } } + : JSON.parse( + captureSf([ + 'package', + 'version', + 'create', + '--package', + PACKAGE_NAME, + '--installation-key', + installationKey, + '--code-coverage', + '--wait', + '90', + '--json' + ]) + ); + +const subscriberPackageVersionId = createResult?.result?.SubscriberPackageVersionId; +if (!subscriberPackageVersionId) { + fail(`Package version create did not return a SubscriberPackageVersionId.\n${JSON.stringify(createResult, null, 2)}`); +} +console.log(`Created package version: ${subscriberPackageVersionId}`); + +// ── 3. Promote (optional) ─────────────────────────────────────────────────── +if (promote && !isDryRun) { + console.log('\nPromoting package version to released...'); + captureSf(['package', 'version', 'promote', '--package', subscriberPackageVersionId, '--no-prompt', '--json']); + console.log('Promoted.'); +} + +// ── 4. Commit, tag, push, GitHub release ──────────────────────────────────── +if (!isDryRun) { + console.log('\nCommitting version bump and creating tag...'); + git(['add', 'sfdx-project.json']); + git(['commit', '-m', `chore: release salesforce ${tag.replace(TAG_PREFIX, 'v')}`]); + git(['tag', tag]); + git(['push', 'origin', 'HEAD']); + git(['push', 'origin', tag]); + + console.log('Creating GitHub release...'); + exec('gh', ['release', 'create', tag, '--title', `Jetstream for Salesforce ${versionString}`, '--notes', releaseNotes()], { + cwd: sfdxProjectDir + }); +} + +// ── 5. Job summary ────────────────────────────────────────────────────────── +writeSummary(); +console.log('\nDone.'); + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function installUrl() { + return `https://login.salesforce.com/packaging/installPackage.apexp?p0=${subscriberPackageVersionId}`; +} + +function releaseNotes() { + return [ + `**Version:** ${versionString}`, + `**Status:** ${promote ? 'Released (installable in production orgs)' : 'Beta (scratch/sandbox only)'}`, + `**Package version id:** \`${subscriberPackageVersionId}\``, + '', + `**Install:** ${installUrl()}`, + '', + 'An installation key is required to install this package (see the `SFDX_INSTALLATION_PASSWORD` secret).' + ].join('\n'); +} + +function writeSummary() { + const lines = [ + `## 🚀 Salesforce Managed Package Release`, + '', + `| Field | Value |`, + `|-------|-------|`, + `| Version | \`${versionString}\` |`, + `| Bump | \`${bump}\` |`, + `| Status | ${promote ? '✅ Released' : '🧪 Beta'} |`, + `| Tag | [${tag}](https://github.com/${repo}/releases/tag/${tag}) |`, + `| Package version id | \`${subscriberPackageVersionId}\` |`, + '', + `**Install URL:** ${installUrl()}`, + '', + '> An installation key is required to install (the `SFDX_INSTALLATION_PASSWORD` value).', + '' + ]; + const summary = lines.join('\n'); + console.log(`\n${summary}`); + if (process.env.GITHUB_STEP_SUMMARY) { + appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`); + } +} + +/** Parse `X.Y.Z.NEXT` (or `X.Y.Z`) and return the bumped components. */ +function nextVersion(versionNumber, bumpType) { + const [major, minor, patch] = versionNumber.split('.').map((part) => Number.parseInt(part, 10)); + if ([major, minor, patch].some((part) => Number.isNaN(part))) { + fail(`Could not parse versionNumber "${versionNumber}". Expected "X.Y.Z.NEXT".`); + } + switch (bumpType) { + case 'major': + return { major: major + 1, minor: 0, patch: 0 }; + case 'minor': + return { major, minor: minor + 1, patch: 0 }; + default: + return { major, minor, patch: patch + 1 }; + } +} + +function git(args) { + exec('git', args, { cwd: sfdxProjectDir }); +} + +/** Run an `sf` command and return its stdout (used for `--json` output). */ +function captureSf(args) { + try { + return execFileSync('sf', args, { + cwd: sfdxProjectDir, + encoding: 'utf8', + stdio: ['inherit', 'pipe', 'inherit'], + maxBuffer: 64 * 1024 * 1024 + }); + } catch (error) { + // sf emits the JSON error payload on stdout even on failure + const details = error.stdout || error.message; + fail(`sf ${args[0]} ${args[1]} failed:\n${details}`); + } +} + +function exec(command, args, options = {}) { + execFileSync(command, args, { stdio: 'inherit', maxBuffer: 64 * 1024 * 1024, ...options }); +} + +function requireEnv(name) { + const value = process.env[name]; + if (!value) { + fail(`Missing required environment variable: ${name}`); + } + return value; +} + +function fail(message) { + console.error(`\nError: ${message}`); + process.exit(1); +} diff --git a/apps-sfdx/sfdx-project.json b/apps-sfdx/sfdx-project.json index 3ebe8ec40..37f3c3137 100644 --- a/apps-sfdx/sfdx-project.json +++ b/apps-sfdx/sfdx-project.json @@ -1,20 +1,21 @@ { "packageDirectories": [ { - "package": "Jetstream Canvas", + "package": "Jetstream for Salesforce", + "versionName": "v0.1", + "versionNumber": "0.1.0.NEXT", "path": "canvas-app/jetstream", "default": true, - "versionNumber": "1.0.0", - "versionName": "v1.0.0" + "versionDescription": "Jetstream brings its powerful Salesforce workspace directly into your org. Query, load, update, automate, and manage your data and metadata — all without leaving Salesforce. Built for admins, developers, and power users who want to work faster and with more confidence." }, { "path": "canvas-app/unpackaged" } ], - "name": "jetstream-canvas", + "name": "jetstream-for-salesforce", "namespace": "jetstream", "sfdcLoginUrl": "https://login.salesforce.com", - "sourceApiVersion": "66.0", + "sourceApiVersion": "67.0", "replacements": [ { "filename": "main/default/extlClntAppCanvasSettings/Jetstream_Canvas_canvasSettings.ecaCanvas-meta.xml", @@ -27,5 +28,9 @@ } ] } - ] -} + ], + "packageAliases": { + "Jetstream for Salesforce": "0HoWj0000000B8LKAU", + "Jetstream for Salesforce@0.1.0-1": "04tWj000000v9BRIAY" + } +} \ No newline at end of file diff --git a/apps/api/src/app/controllers/canvas.controller.ts b/apps/api/src/app/controllers/canvas.controller.ts index 507a10df9..568df2f69 100644 --- a/apps/api/src/app/controllers/canvas.controller.ts +++ b/apps/api/src/app/controllers/canvas.controller.ts @@ -3,7 +3,8 @@ import { salesforceCanvasOauthCallback, SalesforceCanvasOauthInit } from '@jetst import { getErrorMessage, urlSearchParamsToJson } from '@jetstream/shared/utils'; import { Canvas } from '@jetstream/types'; import z from 'zod'; -import { escapeJsonForScript, getCanvasIndexFile, verifyAndDecodeAsJson } from '../utils/canvas.utils'; +import { isCanvasOrgEntitled } from '../db/canvas-entitlement.db'; +import { escapeJsonForScript, getCanvasAccessDeniedHtml, getCanvasIndexFile, verifyAndDecodeAsJson } from '../utils/canvas.utils'; import { createRoute } from '../utils/route.utils'; const INVALID_ACCESS = 'invalid'; @@ -147,6 +148,14 @@ const appHandler = createRoute(routeDefinition.appHandler.validators, async ({ b }, '[CANVAS][SIGNED_REQUEST_VERIFIED]', ); + + // The signed request is cryptographically trusted at this point, so gate access by org entitlement + const organizationId = envelope?.context?.organization?.organizationId; + if (!organizationId || !(await isCanvasOrgEntitled(organizationId, envelope?.client?.instanceUrl))) { + res.log.info({ organizationId, instanceUrl: envelope?.client?.instanceUrl }, '[CANVAS][ACCESS_DENIED]'); + res.status(403).send(getCanvasAccessDeniedHtml()); + return; + } } else { res.status(400).send({ status: 'error', diff --git a/apps/api/src/app/db/__tests__/canvas-entitlement.db.spec.ts b/apps/api/src/app/db/__tests__/canvas-entitlement.db.spec.ts new file mode 100644 index 000000000..cbbaa2942 --- /dev/null +++ b/apps/api/src/app/db/__tests__/canvas-entitlement.db.spec.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { isCanvasOrgEntitled, parseMyDomainBase } from '../canvas-entitlement.db'; + +const prismaMock = vi.hoisted(() => ({ + salesforceCanvasOrg: { + findMany: vi.fn(), + }, + teamEntitlement: { + count: vi.fn(), + }, + entitlement: { + count: vi.fn(), + }, +})); + +vi.mock('@jetstream/api-config', () => ({ + prisma: prismaMock, +})); + +const PROD_ORG_ID = '00D000000000001AAA'; +const SANDBOX_ORG_ID = '00D000000000999AAA'; + +describe('parseMyDomainBase', () => { + it('returns the base for a production My Domain URL', () => { + expect(parseMyDomainBase('https://acme.my.salesforce.com')).toBe('acme'); + }); + + it('strips the sandbox suffix to share the production base', () => { + expect(parseMyDomainBase('https://acme--uat.sandbox.my.salesforce.com')).toBe('acme'); + }); + + it('returns null for an unparseable value', () => { + expect(parseMyDomainBase('not-a-url')).toBeNull(); + }); +}); + +describe('isCanvasOrgEntitled', () => { + beforeEach(() => { + vi.clearAllMocks(); + prismaMock.teamEntitlement.count.mockResolvedValue(0); + prismaMock.entitlement.count.mockResolvedValue(0); + }); + + it('grants access when an ACTIVE authorization exists and the user owner holds salesforceCanvas', async () => { + prismaMock.salesforceCanvasOrg.findMany.mockResolvedValue([{ jetstreamUserId: 'user-1', teamId: null }]); + prismaMock.entitlement.count.mockResolvedValue(1); + + const result = await isCanvasOrgEntitled(PROD_ORG_ID, 'https://acme.my.salesforce.com'); + + expect(result).toBe(true); + // Only ACTIVE records, matched on the 15-char org id prefix (tolerates 15/18-char ids) + expect(prismaMock.salesforceCanvasOrg.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: 'ACTIVE', + OR: expect.arrayContaining([{ organizationId: { startsWith: PROD_ORG_ID.slice(0, 15) } }]), + }), + }), + ); + expect(prismaMock.entitlement.count).toHaveBeenCalledWith({ where: { userId: 'user-1', salesforceCanvas: true } }); + }); + + it('grants access for a team-owned authorization via the team entitlement', async () => { + prismaMock.salesforceCanvasOrg.findMany.mockResolvedValue([{ jetstreamUserId: 'user-1', teamId: 'team-1' }]); + prismaMock.teamEntitlement.count.mockResolvedValue(1); + + expect(await isCanvasOrgEntitled(PROD_ORG_ID, 'https://acme.my.salesforce.com')).toBe(true); + expect(prismaMock.teamEntitlement.count).toHaveBeenCalledWith({ where: { teamId: 'team-1', salesforceCanvas: true } }); + // Team takes precedence — the user entitlement is not consulted + expect(prismaMock.entitlement.count).not.toHaveBeenCalled(); + }); + + it('grants a sandbox access by matching the My Domain base of an entitled production authorization', async () => { + prismaMock.salesforceCanvasOrg.findMany.mockResolvedValue([{ jetstreamUserId: 'user-1', teamId: null }]); + prismaMock.entitlement.count.mockResolvedValue(1); + + const result = await isCanvasOrgEntitled(SANDBOX_ORG_ID, 'https://acme--uat.sandbox.my.salesforce.com'); + + expect(result).toBe(true); + expect(prismaMock.salesforceCanvasOrg.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ OR: expect.arrayContaining([{ myDomainBase: 'acme' }]) }) }), + ); + }); + + it('denies access when the authorization exists but the owner is not entitled', async () => { + prismaMock.salesforceCanvasOrg.findMany.mockResolvedValue([{ jetstreamUserId: 'user-1', teamId: null }]); + + expect(await isCanvasOrgEntitled(PROD_ORG_ID, 'https://acme.my.salesforce.com')).toBe(false); + }); + + it('denies access when no authorization record matches', async () => { + prismaMock.salesforceCanvasOrg.findMany.mockResolvedValue([]); + + expect(await isCanvasOrgEntitled(PROD_ORG_ID, 'https://acme.my.salesforce.com')).toBe(false); + expect(prismaMock.entitlement.count).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/app/db/canvas-entitlement.db.ts b/apps/api/src/app/db/canvas-entitlement.db.ts new file mode 100644 index 000000000..5ba3356de --- /dev/null +++ b/apps/api/src/app/db/canvas-entitlement.db.ts @@ -0,0 +1,82 @@ +import { prisma } from '@jetstream/api-config'; + +/** + * Default number of Salesforce orgs an entitled subscription may authorize for the Canvas app + * ("Canvas deployment to one org"). Enforced only in the web-app registration flow — the access + * gate never checks this, so manually-inserted DB rows intentionally bypass the cap for one-off + * edge cases (e.g. the occasional consultant working across several orgs). + */ +export const CANVAS_ORG_LIMIT_DEFAULT = 1; + +/** + * Resolves the authorized-org cap for a scope. Kept as a function so it can vary per plan later + * without touching the registration controller. + */ +export function getCanvasOrgLimit(): number { + return CANVAS_ORG_LIMIT_DEFAULT; +} + +/** + * Parses the "My Domain" base from a Salesforce instance URL so an entitled production org can + * extend access to its sandboxes (which share the My Domain base but get a brand-new org id). + * + * prod: https://acme.my.salesforce.com -> "acme" + * sandbox: https://acme--uat.sandbox.my.salesforce.com -> "acme" + * + * This is a usability convenience, not a security boundary (admins can rename My Domain). + */ +export function parseMyDomainBase(instanceUrl: string): string | null { + try { + const { hostname } = new URL(instanceUrl); + const [subdomain] = hostname.split('.'); + // Strip any sandbox suffix ("acme--uat" -> "acme") + return subdomain?.split('--')[0] || null; + } catch { + return null; + } +} + +/** + * Confirms the owning scope of an authorized-org record still holds the `salesforceCanvas` + * entitlement, so a cancelled subscription immediately revokes all of that owner's orgs. + */ +async function ownerHasCanvasEntitlement(owner: { jetstreamUserId: string | null; teamId: string | null }): Promise { + if (owner.teamId) { + return prisma.teamEntitlement.count({ where: { teamId: owner.teamId, salesforceCanvas: true } }).then((result) => result > 0); + } + if (owner.jetstreamUserId) { + return prisma.entitlement.count({ where: { userId: owner.jetstreamUserId, salesforceCanvas: true } }).then((result) => result > 0); + } + return false; +} + +/** + * Determines whether a Salesforce org may use the Jetstream Canvas app. + * + * Access requires BOTH: + * 1. an ACTIVE `SalesforceCanvasOrg` authorization record (matched by org id, or by My Domain base + * so an entitled production org also covers its sandboxes), AND + * 2. the owning user/team still holding the `salesforceCanvas` entitlement. + * + * Authorization records are created via the web-app management UI (cap-enforced) or inserted + * manually for edge cases. This gate deliberately does not check the org cap. + */ +export async function isCanvasOrgEntitled(organizationId: string, instanceUrl?: string | null): Promise { + const orgIdPrefix = organizationId.slice(0, 15); + const myDomainBase = instanceUrl ? parseMyDomainBase(instanceUrl) : null; + + const authorizedOrgs = await prisma.salesforceCanvasOrg.findMany({ + where: { + status: 'ACTIVE', + OR: [{ organizationId: { startsWith: orgIdPrefix } }, ...(myDomainBase ? [{ myDomainBase }] : [])], + }, + select: { jetstreamUserId: true, teamId: true }, + }); + + for (const owner of authorizedOrgs) { + if (await ownerHasCanvasEntitlement(owner)) { + return true; + } + } + return false; +} diff --git a/apps/api/src/app/db/team.db.ts b/apps/api/src/app/db/team.db.ts index 32f9101fe..4aa351c83 100644 --- a/apps/api/src/app/db/team.db.ts +++ b/apps/api/src/app/db/team.db.ts @@ -396,6 +396,7 @@ export const createTeam = async ({ googleDrive: false, desktop: false, recordSync: false, + salesforceCanvas: false, }, }, }, diff --git a/apps/api/src/app/services/stripe.service.ts b/apps/api/src/app/services/stripe.service.ts index e4d1310bb..3b4eace77 100644 --- a/apps/api/src/app/services/stripe.service.ts +++ b/apps/api/src/app/services/stripe.service.ts @@ -295,6 +295,7 @@ export async function updateEntitlements(customerId: string, entitlements: Strip chromeExtension: false, recordSync: false, desktop: false, + salesforceCanvas: false, }, ); diff --git a/apps/api/src/app/utils/canvas.utils.ts b/apps/api/src/app/utils/canvas.utils.ts index 7cb411d89..5805d253f 100644 --- a/apps/api/src/app/utils/canvas.utils.ts +++ b/apps/api/src/app/utils/canvas.utils.ts @@ -90,3 +90,66 @@ export async function getCanvasIndexFile(): Promise { const filePath = join(__dirname, '../jetstream-canvas/index.html'); return await readFile(filePath, 'utf-8'); } + +/** + * Branded screen rendered (inside the Salesforce Canvas iframe) when the org is not entitled + * to the Canvas app. Returned as static HTML so it has no build/asset dependencies. + */ +export function getCanvasAccessDeniedHtml(): string { + return ` + + + + + Jetstream + + + +
+

This org isn't authorized for Canvas

+

+ The Jetstream Canvas app requires an active Jetstream subscription with Canvas access, and this Salesforce org + must be authorized for Canvas in Jetstream. +

+

+ An admin on your team can authorize this org from Jetstream settings. If your team doesn't have Canvas yet, + start a plan to get going. +

+ Open Jetstream +
+ +`; +} diff --git a/apps/api/src/app/utils/server-process.utils.ts b/apps/api/src/app/utils/server-process.utils.ts index 55b101167..410d8e67d 100644 --- a/apps/api/src/app/utils/server-process.utils.ts +++ b/apps/api/src/app/utils/server-process.utils.ts @@ -192,13 +192,15 @@ async function initExampleUserIfRequired() { lastLoggedIn: new Date(), preferences: { create: { skipFrontdoorLogin: false } }, authFactors: { create: { type: '2fa-email', enabled: false } }, - entitlements: { create: { chromeExtension: false, recordSync: false, googleDrive: false, desktop: false } }, + entitlements: { + create: { chromeExtension: false, recordSync: false, googleDrive: false, desktop: false, salesforceCanvas: false }, + }, }, update: { entitlements: { upsert: { - create: { chromeExtension: false, recordSync: false, googleDrive: false, desktop: false }, - update: { chromeExtension: false, recordSync: false, googleDrive: false, desktop: false }, + create: { chromeExtension: false, recordSync: false, googleDrive: false, desktop: false, salesforceCanvas: false }, + update: { chromeExtension: false, recordSync: false, googleDrive: false, desktop: false, salesforceCanvas: false }, }, }, }, diff --git a/apps/docs/docs/getting-started/managed-package/managed-package.mdx b/apps/docs/docs/getting-started/managed-package/managed-package.mdx index 4e1507b03..2caaff4d0 100644 --- a/apps/docs/docs/getting-started/managed-package/managed-package.mdx +++ b/apps/docs/docs/getting-started/managed-package/managed-package.mdx @@ -22,30 +22,30 @@ import { JetstreamProLogo } from '@site/src/shared-components/JetstreamProLogo'; :::tip -The Canvas App is currently in early access. If you're interested in using the Canvas App or want to learn more, +**Jetstream for Salesforce** is currently in early access. If you're interested in using it or want to learn more, reach out to our support team or your account manager for more information and to request access. ::: -**The Salesforce Managed Package requires a subscription and is available on a per-user basis or on an org-wide basis.** +**Jetstream for Salesforce requires a paid subscription.** -## What is the Salesforce Managed Package? +## What is Jetstream for Salesforce? -The Salesforce Managed Package is a Salesforce Canvas application that allows you to access Jetstream features directly within the Salesforce interface. +Jetstream for Salesforce is a Managed Package that allows you to access Jetstream features directly within the Salesforce interface. With the Managed Package, you can run queries, view results, and manage your Jetstream data without leaving Salesforce. -When using the connected app, none of your Salesforce record data is processed via Jetstream's servers. +When using the Managed Package, none of your Salesforce record data is processed via Jetstream's servers. Instead, the app uses Salesforce's Canvas SDK to make API calls directly from your browser to Salesforce, ensuring that your data remains secure and private. -Jetstream never stores any Salesforce access tokens or data from the connected app, and all interactions with Salesforce are handled securely through the Canvas SDK. +Jetstream never stores any Salesforce access tokens or data from the Managed Package, and all interactions with Salesforce are handled securely through the Canvas SDK. ## Known Limitations While the Managed Package provides convenient access to Jetstream features within Salesforce, there are some limitations to be aware of: -- Working with multiple orgs at the same time is not currently supported within the Canvas app. +- Working with multiple orgs at the same time is not currently supported within the Managed Package. - For deployment features that work with multiple orgs, you will need to download the metadata and then re-upload in the target org. - Ability to sync query history between different orgs and platforms (we plan on delivering this as an opt-in feature in the future) @@ -53,28 +53,68 @@ While the Managed Package provides convenient access to Jetstream features withi Reach out to our [support team](mailto:sales@getjetstream.app) for installation instructions. -After installing the package, an administrator will need to configure access by pre-authorizing users or allowing users to self-authorize. +After the package is installed, an administrator needs to complete two steps before users can access Jetstream for Salesforce: -We recommend pre-authorizing users to provide a smoother experience, but the self-authorization flow is also available if needed. +1. [Assign the **Jetstream** permission set](#assign-the-jetstream-permission-set) to each user who needs access. +2. [Configure the connected app](#configure-the-connected-app) to pre-authorize those users. -The Self-authorization flow allows users to authorize themselves to access the Jetstream Canvas App without requiring administrator intervention. +### Assign the Jetstream permission set -### Configuring Access +Assign the **Jetstream** permission set to every user who needs access to the app. This grants access to the Jetstream app, tab, page, and +related user-preference fields. + +You can assign it from **Setup > Permission Sets > Jetstream > Manage Assignments**. + +### Configure the connected app + +Pre-authorize users to provide the smoothest experience. To do this: 1. Navigate to **Setup > External Client App Manager** in Salesforce -2. Find **Jetstream Canvas App** in the list and click on the name -3. Click **Edit** on the **Policies** tab -4. Add the **Jetstream** Permission Set to the list of **Selected Permission Sets** -5. Expand the **Oauth Policies** accordion and change the **Permission Users** to **Admin approved users are pre-authorized** +2. Find **Jetstream for Salesforce** in the list and click the name +3. Open the **Policies** tab and click **Edit** +4. Expand the **OAuth Policies** section and change **Permitted Users** to **Admin approved users are pre-authorized** +5. Choose a **profile** or an **unmanaged permission set** to pre-authorize the users who should have access + +:::caution + +When pre-authorizing users, you must choose a **profile** or an **unmanaged permission set**. Attempting to use the **Jetstream** +permission set — or any other managed permission set — will result in a save error. + +::: External Client Application Configuration ## Accessing the App -Jetstream is available as a tab within Salesforce, but can also be accessed in full-screen mode by navigating directly to the application URL. +Open the **App Launcher** in Salesforce and search for **Jetstream**, then select it to open the app. + +For the best experience, click the **Open Fullscreen** button to open Jetstream in full-screen mode. + +You can also navigate directly to the full-screen page or bookmark it for quick access: `https://{your-domain}.my.salesforce.com/apex/jetstream__JetstreamPage` -Bookmark this page or create a link or button to easily navigate here from within Salesforce. +## Troubleshooting + +### "Your browsing session has ended or is invalid" + +You may see an error similar to the following when opening the app: + +> Oops, there was an error rendering Canvas application [Jetstream_Canvas]. +> Your browsing session has ended or is invalid. Please re-login to Salesforce.com again. + +This usually means your current Salesforce session is not compatible with the Managed Package. To resolve it: + +1. **Confirm REST API access is enabled.** Make sure the user's profile or permission set has **API Enabled** (REST API access). +2. **Make sure you logged in directly to Salesforce.** If you opened Salesforce by logging in through another app — for example, by + launching it from the Jetstream web app or desktop app — your session may not be valid for the Managed Package. + +If the error persists, clear the site data for your Salesforce domain and log in to Salesforce directly: + +- **Chrome / Edge:** While on your Salesforce page, click the icon to the left of the address bar (a lock or tune icon) → **Site settings** + (or **Cookies and site data**) → **Delete data** / **Clear data**, then reload the page and log in again. +- **Firefox:** Click the lock icon to the left of the address bar → **Clear cookies and site data**, then reload and log in again. +- **Safari:** Go to **Safari > Settings > Privacy > Manage Website Data**, search for your Salesforce domain, remove its data, then reload + and log in again. -You can access Jetstream from the App Launcher in Salesforce. Simply search for "Jetstream". +Alternatively, open a private / incognito window and log in to Salesforce directly — this also resolves the issue. diff --git a/apps/jetstream-canvas/index.html b/apps/jetstream-canvas/index.html index 12edd19a9..84734cc52 100644 --- a/apps/jetstream-canvas/index.html +++ b/apps/jetstream-canvas/index.html @@ -2,7 +2,7 @@ - Jetstream Canvas + Jetstream for Salesforce diff --git a/apps/jetstream-canvas/src/app/app.tsx b/apps/jetstream-canvas/src/app/app.tsx index 8566a6ddc..24cc2230c 100644 --- a/apps/jetstream-canvas/src/app/app.tsx +++ b/apps/jetstream-canvas/src/app/app.tsx @@ -14,6 +14,7 @@ import z from 'zod'; import { AppRoutes } from './AppRoutes'; import AppInitializer from './core/AppInitializer'; import './core/monaco-loader'; +import { useCanvasColorScheme } from './core/useCanvasColorScheme'; const Sfdc = window.Sfdc; @@ -23,12 +24,14 @@ const isFullscreen = z .parse(sr.context?.environment?.parameters?.isFullScreen); export const App = () => { + const [colorScheme, setColorScheme] = useCanvasColorScheme(); + return ( }> - + @@ -39,6 +42,8 @@ export const App = () => { isEmbeddedApp isFullscreen={isFullscreen} isBillingEnabled={false} + colorScheme={colorScheme} + onColorSchemeChange={setColorScheme} onLogoutHandlerFn={() => Sfdc.canvas.oauth.logout()} /> diff --git a/apps/jetstream-canvas/src/app/core/AppInitializer.tsx b/apps/jetstream-canvas/src/app/core/AppInitializer.tsx index e9785c88a..d807c0a8b 100644 --- a/apps/jetstream-canvas/src/app/core/AppInitializer.tsx +++ b/apps/jetstream-canvas/src/app/core/AppInitializer.tsx @@ -11,6 +11,7 @@ import localforage from 'localforage'; import React, { FunctionComponent, useEffect, useState } from 'react'; import { Observable } from 'rxjs'; import { getCanvasOrg } from '../../utils/canvas.utils'; +import { canvasColorSchemeState } from './useCanvasColorScheme'; // Configure IndexedDB database localforage.config({ @@ -26,6 +27,7 @@ export const AppInitializer: FunctionComponent = ({ allowWi const setSelectedOrgId = useSetAtom(fromAppState.selectedOrgIdState); const setSalesforceOrgs = useSetAtom(fromAppState.salesforceOrgsState); const setUserProfile = useSetAtom(fromAppState.userProfileState); + const setCanvasColorScheme = useSetAtom(canvasColorSchemeState); const selectedOrg = useAtomValue(fromAppState.selectedOrgState); const onSaveSoqlQueryFormatOptions = useObservable( @@ -59,6 +61,9 @@ export const AppInitializer: FunctionComponent = ({ allowWi const org = getCanvasOrg(); const preferences = await getCanvasPreferences(org); if (preferences && Object.keys(preferences).length > 0) { + if (preferences.colorScheme) { + setCanvasColorScheme(preferences.colorScheme); + } setUserProfile((prev) => { if (prev instanceof Promise) { return prev.then((resolved) => ({ @@ -76,7 +81,7 @@ export const AppInitializer: FunctionComponent = ({ allowWi logger.error('Error loading canvas preferences', ex); } })(); - }, [setUserProfile]); + }, [setUserProfile, setCanvasColorScheme]); // Save SOQL query format options to Salesforce custom setting when changed useEffect(() => { diff --git a/apps/jetstream-canvas/src/app/core/useCanvasColorScheme.ts b/apps/jetstream-canvas/src/app/core/useCanvasColorScheme.ts new file mode 100644 index 000000000..00ea702b2 --- /dev/null +++ b/apps/jetstream-canvas/src/app/core/useCanvasColorScheme.ts @@ -0,0 +1,35 @@ +import { logger } from '@jetstream/shared/client-logger'; +import { updateCanvasPreferences } from '@jetstream/shared/data'; +import { ColorScheme } from '@jetstream/types'; +import { atom, useAtom } from 'jotai'; +import { useCallback } from 'react'; +import { getCanvasOrg } from '../../utils/canvas.utils'; + +/** + * Current color scheme for the canvas app. The canonical value is persisted to + * the Salesforce `UserPreferences__c` custom setting (so it follows the user + * across browsers); this atom mirrors it for the React tree. `AppInitializer` + * seeds it from the loaded preferences and `ThemeApplier`/`HeaderNavbar` read it. + */ +export const canvasColorSchemeState = atom('light'); + +export function useCanvasColorScheme(): [ColorScheme, (colorScheme: ColorScheme) => void] { + const [colorScheme, setColorSchemeState] = useAtom(canvasColorSchemeState); + + const setColorScheme = useCallback( + (nextColorScheme: ColorScheme) => { + setColorSchemeState(nextColorScheme); + try { + const org = getCanvasOrg(); + updateCanvasPreferences(org, { colorScheme: nextColorScheme }).catch((ex) => { + logger.error('Error saving canvas color scheme', ex); + }); + } catch (ex) { + logger.error('Error saving canvas color scheme', ex); + } + }, + [setColorSchemeState], + ); + + return [colorScheme, setColorScheme]; +} diff --git a/apps/jetstream-canvas/src/controllers/preferences.canvas.controller.ts b/apps/jetstream-canvas/src/controllers/preferences.canvas.controller.ts index 37cefff48..f6d3eb420 100644 --- a/apps/jetstream-canvas/src/controllers/preferences.canvas.controller.ts +++ b/apps/jetstream-canvas/src/controllers/preferences.canvas.controller.ts @@ -1,28 +1,30 @@ import { ApiConnection } from '@jetstream/salesforce-api'; import { logger } from '@jetstream/shared/client-logger'; -import { SoqlQueryFormatOptionsSchema } from '@jetstream/types'; +import { ColorScheme, SoqlQueryFormatOptionsSchema } from '@jetstream/types'; import { z } from 'zod'; import { createRoute, handleErrorResponse, handleJsonResponse, RouteValidator } from './route.utils'; -// Namespace-qualified API names for the managed package custom setting fields -const NS = 'jetstream__'; -const SOBJECT = `${NS}UserPreferences__c`; +const SOBJECT = `jetstream__UserPreferences__c`; const FIELD = { - skipFrontdoorLogin: `${NS}SkipFrontdoorLogin__c`, - recordSyncEnabled: `${NS}RecordSyncEnabled__c`, - numIndent: `${NS}NumIndent__c`, - fieldMaxLineLength: `${NS}FieldMaxLineLength__c`, - subqueryParensOnOwnLine: `${NS}SubqueryParensOnOwnLine__c`, - whereClauseOpsIndented: `${NS}WhereClauseOpsIndented__c`, - newLineAfterKeywords: `${NS}NewLineAfterKeywords__c`, + skipFrontdoorLogin: `jetstream__SkipFrontdoorLogin__c`, + recordSyncEnabled: `jetstream__RecordSyncEnabled__c`, + colorScheme: `jetstream__ColorScheme__c`, + numIndent: `jetstream__NumIndent__c`, + fieldMaxLineLength: `jetstream__FieldMaxLineLength__c`, + subqueryParensOnOwnLine: `jetstream__SubqueryParensOnOwnLine__c`, + whereClauseOpsIndented: `jetstream__WhereClauseOpsIndented__c`, + newLineAfterKeywords: `jetstream__NewLineAfterKeywords__c`, } as const; const ALL_FIELDS = Object.values(FIELD).join(', '); +const COLOR_SCHEMES = ['light', 'dark', 'system'] as const; + const PreferencesBodySchema = z.object({ preferences: z.object({ skipFrontdoorLogin: z.boolean().optional(), recordSyncEnabled: z.boolean().optional(), + colorScheme: z.enum(COLOR_SCHEMES).optional(), soqlQueryFormatOptions: SoqlQueryFormatOptionsSchema.optional(), }), }); @@ -48,11 +50,17 @@ interface CustomSettingRecord { [key: string]: unknown; } +/** The custom setting field is free text, so guard against unexpected values (manual edits, legacy data) */ +function toColorScheme(value: unknown): ColorScheme | undefined { + return COLOR_SCHEMES.includes(value as ColorScheme) ? (value as ColorScheme) : undefined; +} + /** Maps a Salesforce custom setting record to our app preferences shape */ function recordToPreferences(record: CustomSettingRecord) { return { skipFrontdoorLogin: record[FIELD.skipFrontdoorLogin] as boolean | undefined, recordSyncEnabled: record[FIELD.recordSyncEnabled] as boolean | undefined, + colorScheme: toColorScheme(record[FIELD.colorScheme]), soqlQueryFormatOptions: { numIndent: record[FIELD.numIndent] as number | undefined, fieldMaxLineLength: record[FIELD.fieldMaxLineLength] as number | undefined, @@ -72,6 +80,9 @@ function preferencesToFieldValues(preferences: z.infer[0] = (url, options) => { return new Promise((resolve, reject) => { @@ -42,54 +40,3 @@ export function initApiClient(): ApiConnection { enableLogging: false, }); } - -export async function initApiClientAndOrg(): Promise { - const apiConnection = initApiClient(); - const apiVersion = window.sr.context.environment.version.api; - - // https://login.salesforce.com/id/00D6g000008KX1jEAG/0056g000004tCpaAAE - const [userId, organizationId] = (await apiConnection.org.discovery()).identity.split('/').reverse().slice(0, 2); - - apiConnection.updateSessionInfo({ apiVersion, userId, organizationId }); - - const identity = await apiConnection.org.identity(); - - let companyInfoRecord: SObjectOrganization | undefined; - - try { - const { queryResults } = await apiConnection.query.query( - `SELECT Id, Name, Country, OrganizationType, InstanceName, IsSandbox, LanguageLocaleKey, NamespacePrefix, TrialExpirationDate FROM Organization`, - ); - if (queryResults.totalSize > 0) { - companyInfoRecord = queryResults.records[0]; - } - } catch (ex) { - logger.warn('Error getting org info %o', ex); - } - - const org: SalesforceOrgUi = { - uniqueId: `${identity.organization_id}-${identity.user_id}`, - label: identity.username, - filterText: identity.username, - accessToken: window.sr.client.oauthToken, - instanceUrl: window.sr.client.instanceUrl, - loginUrl: window.sr.client.instanceUrl, - userId: identity.user_id, - email: identity.email, - organizationId: identity.organization_id, - username: identity.username, - displayName: identity.display_name, - thumbnail: identity.photos?.thumbnail, - orgName: companyInfoRecord?.Name || 'Unknown Organization', - orgCountry: companyInfoRecord?.Country, - orgOrganizationType: companyInfoRecord?.OrganizationType, - orgInstanceName: companyInfoRecord?.InstanceName, - orgIsSandbox: companyInfoRecord?.IsSandbox, - orgLanguageLocaleKey: companyInfoRecord?.LanguageLocaleKey, - orgNamespacePrefix: companyInfoRecord?.NamespacePrefix, - orgTrialExpirationDate: companyInfoRecord?.TrialExpirationDate, - }; - - // return new ApiClient(sessionInfo, apiVersion, org); - return { org, apiConnection }; -} diff --git a/apps/jetstream-desktop/src/services/persistence.service.ts b/apps/jetstream-desktop/src/services/persistence.service.ts index fc46c0571..2f40787f2 100644 --- a/apps/jetstream-desktop/src/services/persistence.service.ts +++ b/apps/jetstream-desktop/src/services/persistence.service.ts @@ -285,6 +285,7 @@ export function getFullUserProfile() { desktop: false, chromeExtension: false, recordSync: true, + salesforceCanvas: false, }, teamMembership: appData.userProfile.teamMembership, subscriptions: [], diff --git a/apps/jetstream-e2e/src/utils/auth-fixtures.ts b/apps/jetstream-e2e/src/utils/auth-fixtures.ts index 8fbcaa141..7012d5eed 100644 --- a/apps/jetstream-e2e/src/utils/auth-fixtures.ts +++ b/apps/jetstream-e2e/src/utils/auth-fixtures.ts @@ -55,7 +55,9 @@ export async function createSsoFixture(options: SsoFixtureOptions = {}): Promise tosAcceptedVersion: CURRENT_TOS_VERSION, name: 'Test User', preferences: { create: { skipFrontdoorLogin: false } }, - entitlements: { create: { chromeExtension: false, recordSync: false, googleDrive: false, desktop: false } }, + entitlements: { + create: { chromeExtension: false, recordSync: false, googleDrive: false, desktop: false, salesforceCanvas: false }, + }, identities: { create: { type: 'credentials', diff --git a/apps/jetstream-web-extension/src/hooks/useExtensionColorScheme.ts b/apps/jetstream-web-extension/src/hooks/useExtensionColorScheme.ts new file mode 100644 index 000000000..76f9697cf --- /dev/null +++ b/apps/jetstream-web-extension/src/hooks/useExtensionColorScheme.ts @@ -0,0 +1,31 @@ +import { ColorScheme } from '@jetstream/types'; +import { useAtomValue } from 'jotai'; +import { useCallback } from 'react'; +import browser from 'webextension-polyfill'; +import { chromeStorageOptions } from '../utils/extension.store'; + +/** + * Reads/writes the extension color scheme from `browser.storage.local.options`, + * the same store `ExtensionThemeApplier` reads. Lets the in-app HeaderNavbar theme + * picker drive the extension theme without going through the main app's IndexedDB + * preference (which the extension never reads). Intentionally lighter than + * `useExtensionSettings`, which also re-verifies auth on mount. + */ +export function useExtensionColorScheme(): [ColorScheme, (colorScheme: ColorScheme) => void] { + const options = useAtomValue(chromeStorageOptions); + const colorScheme = options?.colorScheme ?? 'light'; + + const setColorScheme = useCallback( + (nextColorScheme: ColorScheme) => { + // browser.storage replaces the whole `options` value, so preserve the siblings. + browser.storage.local + .set({ options: { enabled: options.enabled, recordSyncEnabled: options.recordSyncEnabled, colorScheme: nextColorScheme } }) + .catch(() => { + // best-effort; the storage onChanged listener reconciles atom state + }); + }, + [options.enabled, options.recordSyncEnabled], + ); + + return [colorScheme, setColorScheme]; +} diff --git a/apps/jetstream-web-extension/src/pages/app/App.tsx b/apps/jetstream-web-extension/src/pages/app/App.tsx index f1c4a2f00..2ddf9f7b9 100644 --- a/apps/jetstream-web-extension/src/pages/app/App.tsx +++ b/apps/jetstream-web-extension/src/pages/app/App.tsx @@ -23,6 +23,7 @@ import { ErrorBoundary } from 'react-error-boundary'; import { Navigate, Route, Routes, useNavigate } from 'react-router'; import { AppWrapper } from '../../core/AppWrapper'; import { applyExtensionThemeBeforeMount } from '../../core/ExtensionThemeApplier'; +import { useExtensionColorScheme } from '../../hooks/useExtensionColorScheme'; import { initAndRenderReact } from '../../utils/web-extension.utils'; applyExtensionThemeBeforeMount().finally(() => { @@ -46,6 +47,7 @@ window.history.replaceState({}, '', pageUrl); export function App() { const navigate = useNavigate(); + const [colorScheme, setColorScheme] = useExtensionColorScheme(); useEffect(() => { if (url) { @@ -65,7 +67,7 @@ export function App() { - +
}> diff --git a/libs/auth/server/src/lib/auth.db.service.ts b/libs/auth/server/src/lib/auth.db.service.ts index a1a5a4a37..31e151e75 100644 --- a/libs/auth/server/src/lib/auth.db.service.ts +++ b/libs/auth/server/src/lib/auth.db.service.ts @@ -1288,7 +1288,9 @@ async function createUserFromProvider( // picture: providerUser.picture, lastLoggedIn: new Date(), preferences: { create: { skipFrontdoorLogin: false } }, - entitlements: { create: { chromeExtension: false, recordSync: false, googleDrive: false, desktop: false } }, + entitlements: { + create: { chromeExtension: false, recordSync: false, googleDrive: false, desktop: false, salesforceCanvas: false }, + }, identities: { create: { type: 'oauth', @@ -1450,7 +1452,9 @@ async function createUserFromUserInfo( tosAcceptedVersion: tosVersion, tosAcceptedAt: new Date(), preferences: { create: { skipFrontdoorLogin: false } }, - entitlements: { create: { chromeExtension: false, recordSync: false, googleDrive: false, desktop: false } }, + entitlements: { + create: { chromeExtension: false, recordSync: false, googleDrive: false, desktop: false, salesforceCanvas: false }, + }, authFactors: { create: { type: '2fa-email', diff --git a/libs/auth/server/src/lib/sso-auth.service.ts b/libs/auth/server/src/lib/sso-auth.service.ts index 476dd4ee8..3f00b22ad 100644 --- a/libs/auth/server/src/lib/sso-auth.service.ts +++ b/libs/auth/server/src/lib/sso-auth.service.ts @@ -373,7 +373,9 @@ export async function handleSsoLogin( emailVerified: true, lastLoggedIn: new Date(), preferences: { create: { skipFrontdoorLogin: false } }, - entitlements: { create: { chromeExtension: false, recordSync: false, googleDrive: false, desktop: false } }, + entitlements: { + create: { chromeExtension: false, recordSync: false, googleDrive: false, desktop: false, salesforceCanvas: false }, + }, identities: { create: { type: 'sso', diff --git a/libs/shared/data/src/lib/client-data.ts b/libs/shared/data/src/lib/client-data.ts index 8afd147cd..fa3ac6496 100644 --- a/libs/shared/data/src/lib/client-data.ts +++ b/libs/shared/data/src/lib/client-data.ts @@ -31,6 +31,7 @@ import { ChildRelationship, CloudinarySignature, CloudinaryUploadResponse, + ColorScheme, DeployOptions, DeployResult, DescribeGlobalResult, @@ -302,13 +303,23 @@ export async function updateUserProfile(userPro */ export async function getCanvasPreferences( org: SalesforceOrgUi, -): Promise<{ skipFrontdoorLogin?: boolean; recordSyncEnabled?: boolean; soqlQueryFormatOptions?: SoqlQueryFormatOptions }> { +): Promise<{ + skipFrontdoorLogin?: boolean; + recordSyncEnabled?: boolean; + colorScheme?: ColorScheme; + soqlQueryFormatOptions?: SoqlQueryFormatOptions; +}> { return handleRequest({ method: 'GET', url: '/api/me/preferences' }, { org }).then(unwrapResponseIgnoreCache); } export async function updateCanvasPreferences( org: SalesforceOrgUi, - preferences: { skipFrontdoorLogin?: boolean; recordSyncEnabled?: boolean; soqlQueryFormatOptions?: SoqlQueryFormatOptions }, + preferences: { + skipFrontdoorLogin?: boolean; + recordSyncEnabled?: boolean; + colorScheme?: ColorScheme; + soqlQueryFormatOptions?: SoqlQueryFormatOptions; + }, ): Promise { return handleRequest({ method: 'PATCH', url: '/api/me/preferences', data: { preferences } }, { org }).then(unwrapResponseIgnoreCache); } diff --git a/libs/shared/ui-app-state/src/lib/ui-app-state.ts b/libs/shared/ui-app-state/src/lib/ui-app-state.ts index 6ae409746..0c0fa2024 100644 --- a/libs/shared/ui-app-state/src/lib/ui-app-state.ts +++ b/libs/shared/ui-app-state/src/lib/ui-app-state.ts @@ -55,6 +55,7 @@ export const DEFAULT_PROFILE: UserProfileUi = { chromeExtension: false, desktop: false, recordSync: false, + salesforceCanvas: false, }, subscriptions: [], featureFlags: DEFAULT_FEATURE_FLAGS, diff --git a/libs/shared/ui-core/src/app/HeaderNavbar.tsx b/libs/shared/ui-core/src/app/HeaderNavbar.tsx index 5aa5afc44..7e7ca0519 100644 --- a/libs/shared/ui-core/src/app/HeaderNavbar.tsx +++ b/libs/shared/ui-core/src/app/HeaderNavbar.tsx @@ -42,6 +42,8 @@ export interface HeaderNavbarProps { logoCss?: SerializedStyles; onAddOrgHandlerFn?: AddOrgHandlerFn; onLogoutHandlerFn?: () => void; + colorScheme?: ColorScheme; + onColorSchemeChange?: (colorScheme: ColorScheme) => void; } function logout(serverUrl: string) { @@ -56,12 +58,14 @@ function getMenuItems({ deniedNotifications, isBillingEnabled, colorScheme, + showThemePicker, }: { ability: AppAbility; userProfile: UserProfileUi; deniedNotifications?: boolean; isBillingEnabled: boolean; colorScheme: ColorScheme; + showThemePicker: boolean; }) { const menu: DropDownItem[] = []; @@ -90,7 +94,7 @@ function getMenuItems({ }); } - if (!isCanvasApp()) { + if (showThemePicker) { menu.push( { id: 'theme-light', @@ -135,6 +139,8 @@ export const HeaderNavbar = ({ logoCss, onAddOrgHandlerFn, onLogoutHandlerFn, + colorScheme: colorSchemeOverride, + onColorSchemeChange, }: HeaderNavbarProps) => { const navigate = useNavigate(); const { trackEvent } = useAmplitude(); @@ -146,10 +152,22 @@ export const HeaderNavbar = ({ const applicationState = useAtomValue(applicationCookieState); const [userPreferences, setUserPreferences] = useUserPreferenceState(); const deniedNotifications = userPreferences?.deniedNotifications; - const colorScheme: ColorScheme = userPreferences?.colorScheme ?? 'light'; + // Embedded hosts inject the value + setter; web/desktop fall back to the IndexedDB-backed preference. + const colorScheme: ColorScheme = colorSchemeOverride ?? userPreferences?.colorScheme ?? 'light'; + // Canvas normally hides the picker; show it when a host wires up its own persistence. + const showThemePicker = !isCanvasApp() || !!onColorSchemeChange; const [enableNotifications, setEnableNotifications] = useState(false); const [userMenuItems, setUserMenuItems] = useState([]); + function applyColorScheme(nextColorScheme: ColorScheme) { + if (onColorSchemeChange) { + onColorSchemeChange(nextColorScheme); + } else { + setUserPreferences({ ...userPreferences, colorScheme: nextColorScheme }); + } + trackEvent(ANALYTICS_KEYS.settings_color_scheme_changed, { colorScheme: nextColorScheme }); + } + function handleUserMenuSelection(id: string) { switch (id) { case 'profile': @@ -176,16 +194,13 @@ export const HeaderNavbar = ({ setEnableNotifications(true); break; case 'theme-light': - setUserPreferences({ ...userPreferences, colorScheme: 'light' }); - trackEvent(ANALYTICS_KEYS.settings_color_scheme_changed, { colorScheme: 'light' }); + applyColorScheme('light'); break; case 'theme-dark': - setUserPreferences({ ...userPreferences, colorScheme: 'dark' }); - trackEvent(ANALYTICS_KEYS.settings_color_scheme_changed, { colorScheme: 'dark' }); + applyColorScheme('dark'); break; case 'theme-system': - setUserPreferences({ ...userPreferences, colorScheme: 'system' }); - trackEvent(ANALYTICS_KEYS.settings_color_scheme_changed, { colorScheme: 'system' }); + applyColorScheme('system'); break; default: break; @@ -194,12 +209,14 @@ export const HeaderNavbar = ({ function handleNotificationMenuClosed(isEnabled: boolean) { setEnableNotifications(false); - setUserMenuItems(getMenuItems({ ability, userProfile, deniedNotifications: !isEnabled, isBillingEnabled, colorScheme })); + setUserMenuItems( + getMenuItems({ ability, userProfile, deniedNotifications: !isEnabled, isBillingEnabled, colorScheme, showThemePicker }), + ); } useEffect(() => { - setUserMenuItems(getMenuItems({ ability, userProfile, deniedNotifications, isBillingEnabled, colorScheme })); - }, [ability, userProfile, deniedNotifications, isBillingEnabled, colorScheme]); + setUserMenuItems(getMenuItems({ ability, userProfile, deniedNotifications, isBillingEnabled, colorScheme, showThemePicker })); + }, [ability, userProfile, deniedNotifications, isBillingEnabled, colorScheme, showThemePicker]); const handleCheckForUpdates = () => { if (window.electronAPI) { diff --git a/libs/test/e2e-utils/src/lib/TeamCreationUtils.ts b/libs/test/e2e-utils/src/lib/TeamCreationUtils.ts index d4d6f1655..2621052fc 100644 --- a/libs/test/e2e-utils/src/lib/TeamCreationUtils.ts +++ b/libs/test/e2e-utils/src/lib/TeamCreationUtils.ts @@ -3,8 +3,8 @@ import { LoginConfiguration, SessionData } from '@jetstream/auth/types'; import { TeamBillingStatusSchema, TeamMemberRole, TeamMemberRoleSchema, TeamMemberStatus, TeamMemberStatusSchema } from '@jetstream/types'; import { encodeHexLowerCase, encodeHexUpperCase } from '@oslojs/encoding'; import { Browser, BrowserContext, Page } from '@playwright/test'; -import { AuthenticationPage } from './pageObjectModels/AuthenticationPage.model'; import chalk from 'chalk'; +import { AuthenticationPage } from './pageObjectModels/AuthenticationPage.model'; type Team = Awaited>; type TeamMember = Awaited>['members'][number]; @@ -305,6 +305,7 @@ export class TeamCreationUtils { chromeExtension: true, desktop: true, recordSync: true, + salesforceCanvas: true, }, }, billingAccount: { diff --git a/libs/types/src/lib/billing.types.ts b/libs/types/src/lib/billing.types.ts index 5e17b27c9..a24141cfd 100644 --- a/libs/types/src/lib/billing.types.ts +++ b/libs/types/src/lib/billing.types.ts @@ -6,6 +6,7 @@ export const EntitlementsAccessSchema = z.object({ desktop: z.boolean().optional().default(false), recordSync: z.boolean().optional().default(false), chromeExtension: z.boolean().optional().default(false), + salesforceCanvas: z.boolean().optional().default(false), }); export type EntitlementsAccess = z.infer; export type Entitlements = keyof EntitlementsAccess; diff --git a/libs/types/src/lib/team.types.ts b/libs/types/src/lib/team.types.ts index d88cf7835..be7faff52 100644 --- a/libs/types/src/lib/team.types.ts +++ b/libs/types/src/lib/team.types.ts @@ -113,6 +113,7 @@ export const TeamEntitlementSchema = z.object({ googleDrive: z.boolean().optional().default(false), desktop: z.boolean().optional().default(false), recordSync: z.boolean().optional().default(false), + salesforceCanvas: z.boolean().optional().default(false), }); export const TeamLoginConfigSchema = z.object({ diff --git a/libs/types/src/lib/types.ts b/libs/types/src/lib/types.ts index c21f352e5..330d16e03 100644 --- a/libs/types/src/lib/types.ts +++ b/libs/types/src/lib/types.ts @@ -173,6 +173,7 @@ const EntitlementsSchema = z.object({ chromeExtension: z.boolean().default(false), desktop: z.boolean().default(false), recordSync: z.boolean().default(false), + salesforceCanvas: z.boolean().default(false), }); export const UserProfileUiSchema = z.object({ diff --git a/package.json b/package.json index 3f0fc994f..94fade9ff 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "start:desktop": "pnpm nx run-many --target=build --parallel=2 --projects=jetstream-desktop --configuration=production && pnpm electron --inspect=9229 \"dist/apps/jetstream-desktop/main.js\"", "start:desktop:prod": "cross-env ENV=production pnpm electron dist/desktop-build/main.js", "build": "cross-env NODE_ENV=production npm-run-all db:generate generate:version build:core build:landing", - "build:core": "cross-env NODE_OPTIONS=--max_old_space_size=8192 nx run-many --output-style=dynamic --target=build --parallel=3 --projects=jetstream,api --configuration=production", + "build:core": "cross-env NODE_OPTIONS=--max_old_space_size=8192 nx run-many --output-style=dynamic --target=build --parallel=3 --projects=jetstream,api,jetstream-canvas --configuration=production", "build:ci": "pnpm nx run-many --output-style=static --target=build --parallel=3 --projects=jetstream,api,landing,jetstream-canvas,jetstream-web-extension,jetstream-desktop,jetstream-desktop-client,geo-ip-api,cron-tasks --configuration=production", "build:affected": "nx affected --target=build --exclude jetstream-e2e,jetstream-desktop-client-e2e,jetstream-web-extension-e2e --parallel=3 --configuration=production", "build:docker": "cross-env NODE_ENV=production npm-run-all db:generate generate:version build:client:docker build:api:docker build:landing", @@ -91,6 +91,7 @@ "scripts:copy-monaco-assets:desktop": "node scripts/copy-monaco-assets.mjs --target desktop", "scripts:copy-monaco-assets:web-extension": "node scripts/copy-monaco-assets.mjs --target web-extension", "release": "node scripts/release.mjs", + "release:salesforce": "node scripts/release-salesforce.mjs", "release:preview": "node scripts/release-preview.mjs", "hotfix": "node scripts/create-hotfix.mjs", "docs:index": "docker run -it -e APPLICATION_ID=21D7I5RB7N -e API_KEY=$ALGOLIA_API_KEY -e \"CONFIG=$(cat ./apps/docs/algolia-config.json | jq -r tostring)\" algolia/docsearch-scraper", diff --git a/prisma/migrations/20260703155125_add_salesforce_canvas_org/migration.sql b/prisma/migrations/20260703155125_add_salesforce_canvas_org/migration.sql new file mode 100644 index 000000000..aa2ee7982 --- /dev/null +++ b/prisma/migrations/20260703155125_add_salesforce_canvas_org/migration.sql @@ -0,0 +1,48 @@ +-- CreateTable +CREATE TABLE "salesforce_canvas_org" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "organizationId" VARCHAR(18) NOT NULL, + "instanceUrl" VARCHAR NOT NULL, + "myDomainBase" VARCHAR, + "orgName" VARCHAR, + "isSandbox" BOOLEAN, + "status" VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + "authorizedByUserId" UUID, + "jetstreamUserId" UUID, + "teamId" UUID, + "createdAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "salesforce_canvas_org_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "salesforce_canvas_org_organizationId_key" ON "salesforce_canvas_org"("organizationId"); + +-- CreateIndex +CREATE INDEX "salesforce_canvas_org_organizationId_idx" ON "salesforce_canvas_org"("organizationId"); + +-- CreateIndex +CREATE INDEX "salesforce_canvas_org_myDomainBase_idx" ON "salesforce_canvas_org"("myDomainBase"); + +-- CreateIndex +CREATE INDEX "salesforce_canvas_org_jetstreamUserId_idx" ON "salesforce_canvas_org"("jetstreamUserId"); + +-- CreateIndex +CREATE INDEX "salesforce_canvas_org_teamId_idx" ON "salesforce_canvas_org"("teamId"); + +-- AddForeignKey +ALTER TABLE "salesforce_canvas_org" ADD CONSTRAINT "salesforce_canvas_org_jetstreamUserId_fkey" FOREIGN KEY ("jetstreamUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "salesforce_canvas_org" ADD CONSTRAINT "salesforce_canvas_org_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AlterTable +ALTER TABLE "entitlement" ADD COLUMN "salesforceCanvas" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "team_entitlement" ADD COLUMN "salesforceCanvas" BOOLEAN NOT NULL DEFAULT false; + +-- Backfill: existing paid users (identified by the chromeExtension entitlement) also get the Salesforce Canvas entitlement. +UPDATE "entitlement" SET "salesforceCanvas" = true WHERE "chromeExtension" = true; +UPDATE "team_entitlement" SET "salesforceCanvas" = true WHERE "chromeExtension" = true; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 49007be6a..81b591391 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -50,6 +50,7 @@ model User { preferences UserPreference? rememberedDevices RememberedDevice[] salesforceOrgs SalesforceOrg[] + salesforceCanvasOrgs SalesforceCanvasOrg[] subscriptions Subscription[] syncData UserSyncData[] teamMembership TeamMember? @@ -93,6 +94,7 @@ model Team { members TeamMember[] sharedOrgs SalesforceOrg[] @relation("TeamSharedOrgs") + salesforceCanvasOrgs SalesforceCanvasOrg[] subscriptions TeamSubscription[] billingAccount TeamBillingAccount? invitations TeamMemberInvitation[] @@ -244,12 +246,13 @@ model PasswordHistory { } model Entitlement { - id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid - userId String @unique @db.Uuid - chromeExtension Boolean @default(false) - googleDrive Boolean @default(false) - recordSync Boolean @default(false) - desktop Boolean @default(false) + id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + userId String @unique @db.Uuid + chromeExtension Boolean @default(false) + googleDrive Boolean @default(false) + recordSync Boolean @default(false) + desktop Boolean @default(false) + salesforceCanvas Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -260,12 +263,13 @@ model Entitlement { } model TeamEntitlement { - id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid - teamId String @unique @db.Uuid - chromeExtension Boolean @default(false) - googleDrive Boolean @default(false) - recordSync Boolean @default(false) - desktop Boolean @default(false) + id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + teamId String @unique @db.Uuid + chromeExtension Boolean @default(false) + googleDrive Boolean @default(false) + recordSync Boolean @default(false) + desktop Boolean @default(false) + salesforceCanvas Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -602,6 +606,35 @@ model SalesforceOrg { @@map("salesforce_org") } +/// Salesforce orgs explicitly authorized to use the Canvas app. Unlike SalesforceOrg, this is a +/// lightweight authorization record (no OAuth token / web-app connection) — the Canvas signed request +/// already proves identity. The owning user/team must hold the `salesforceCanvas` entitlement for +/// access to be granted; cancelling the subscription revokes all of that owner's authorized orgs. +model SalesforceCanvasOrg { + id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + organizationId String @unique @db.VarChar(18) + instanceUrl String @db.VarChar + myDomainBase String? @db.VarChar + orgName String? @db.VarChar + isSandbox Boolean? + status String @default("ACTIVE") @db.VarChar(20) // 'ACTIVE' | 'REVOKED' + authorizedByUserId String? @db.Uuid + + jetstreamUserId String? @db.Uuid + jetstreamUser User? @relation(fields: [jetstreamUserId], references: [id], onDelete: Cascade) + teamId String? @db.Uuid + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) @db.Timestamp(6) + updatedAt DateTime @updatedAt + + @@index([organizationId]) + @@index([myDomainBase]) + @@index([jetstreamUserId]) + @@index([teamId]) + @@map("salesforce_canvas_org") +} + model sessions { sid String @id(map: "session_pkey") @db.VarChar sess Json @db.JsonB diff --git a/scripts/release-salesforce.mjs b/scripts/release-salesforce.mjs new file mode 100644 index 000000000..6a6a9ea7d --- /dev/null +++ b/scripts/release-salesforce.mjs @@ -0,0 +1,164 @@ +#!/usr/bin/env node + +/** + * Interactive Salesforce managed package release script. + * Triggers the `sfdx-release.yml` GitHub Actions workflow and auto-approves the + * deployment review, then watches the run. + * + * Usage: node scripts/release-salesforce.mjs (or `pnpm release:salesforce`) + */ + +import { confirm, select } from '@inquirer/prompts'; +import { $, chalk } from 'zx'; + +const REPO = 'jetstreamapp/jetstream'; +const WORKFLOW = 'sfdx-release.yml'; + +// ── Header ───────────────────────────────────────────────────────────────── +console.log('\n' + chalk.bold.cyan(' Jetstream — Salesforce Managed Package Release') + '\n'); + +// ── Detect branch ────────────────────────────────────────────────────────── +const currentBranch = (await $`git rev-parse --abbrev-ref HEAD`.quiet()).stdout.trim(); +const isHotfix = currentBranch.startsWith('hotfix/'); +const releaseRef = isHotfix ? currentBranch : 'main'; + +// ── Fetch tags + compare link ────────────────────────────────────────────── +process.stdout.write(chalk.dim(' Fetching tags... ')); +try { + await $`git fetch origin --tags --prune --quiet`.quiet(); + console.log(chalk.green('done')); +} catch { + console.log(chalk.yellow('skipped (offline?)')); +} + +const latestTag = ( + (await $`git tag --list salesforce-v* --sort=-v:refname`.quiet()).stdout.split('\n').find((line) => line.trim().length > 0) ?? '' +).trim(); + +const compareUrl = latestTag ? `https://github.com/${REPO}/compare/${latestTag}...${releaseRef}` : chalk.dim('(no prior tag found)'); +console.log(` ${chalk.dim('compare')} ${chalk.underline.blue(compareUrl)}`); +console.log(''); + +// ── Prompts ──────────────────────────────────────────────────────────────── +const bump = await select({ + message: 'Version bump type', + choices: [ + { value: 'patch', name: 'patch – bug fixes' }, + { value: 'minor', name: 'minor – new features' }, + { value: 'major', name: 'major – breaking changes' }, + ], + default: 'patch', +}); + +const promote = await confirm({ + message: 'Promote to released (installable in production orgs)? No = beta only (scratch/sandbox)', + default: true, +}); + +// ── Confirm ──────────────────────────────────────────────────────────────── +console.log(''); +console.log(chalk.bold(' Release summary')); +console.log(chalk.dim(' ─────────────────────────')); +console.log(` ${chalk.dim('branch')} ${chalk.cyan(releaseRef)}${isHotfix ? chalk.dim(' (hotfix)') : ''}`); +console.log(` ${chalk.dim('bump')} ${chalk.cyan(bump)}`); +console.log(` ${chalk.dim('promote')} ${promote ? chalk.green('yes (released)') : chalk.yellow('no (beta)')}`); +console.log(''); + +const ok = await confirm({ message: 'Trigger Salesforce release workflow?', default: true }); +if (!ok) { + console.log(chalk.yellow('\nAborted.')); + process.exit(0); +} + +// ── Trigger workflow ─────────────────────────────────────────────────────── +console.log('\n' + chalk.bold('Triggering workflow...')); + +await $`gh workflow run ${WORKFLOW} \ + --repo ${REPO} \ + --ref ${releaseRef} \ + -f bump=${bump} \ + -f promote=${String(promote)}`; + +// Give GitHub a moment to register the run before we query for it +await new Promise((resolve) => setTimeout(resolve, 4000)); + +// ── Find the run ID ──────────────────────────────────────────────────────── +process.stdout.write(chalk.dim('Fetching run ID... ')); + +const runId = ( + await $`gh run list \ + --repo ${REPO} \ + --workflow=${WORKFLOW} \ + --branch ${releaseRef} \ + --limit 1 \ + --json databaseId \ + --jq '.[0].databaseId'` +).stdout.trim(); + +// `gh run list ... --jq '.[0].databaseId'` prints the literal string "null" when no runs match yet, which is truthy +if (!/^\d+$/.test(runId)) { + console.log(chalk.red('failed')); + console.error(chalk.red('Could not determine run ID. Approve manually via the GitHub UI.')); + process.exit(1); +} + +console.log(chalk.green(runId)); + +// ── Wait for the deployment review to appear ─────────────────────────────── +process.stdout.write(chalk.dim('Waiting for deployment review')); + +let environmentId = null; +for (let attempt = 0; attempt < 20; attempt++) { + await new Promise((resolve) => setTimeout(resolve, 3000)); + process.stdout.write(chalk.dim('.')); + + const pending = JSON.parse((await $`gh api repos/${REPO}/actions/runs/${runId}/pending_deployments`.quiet()).stdout); + if (pending.length > 0) { + environmentId = pending[0].environment.id; + break; + } +} + +console.log(''); + +if (!environmentId) { + console.error(chalk.red('\nNo pending deployment review found. Approve manually:')); + console.log(' ' + chalk.cyan(`gh run review ${runId} --approve --repo ${REPO}`)); + process.exit(1); +} + +// ── Approve ──────────────────────────────────────────────────────────────── +process.stdout.write(chalk.dim(`Approving deployment... `)); + +await $`gh api repos/${REPO}/actions/runs/${runId}/pending_deployments \ + --method POST \ + -f state=approved \ + -f comment="Approved via Salesforce release script" \ + -F environment_ids[]=${environmentId}`; + +console.log(chalk.green('approved')); + +// ── Print recovery info early (survives ctrl+c on the watch below) ───────── +const runUrl = `https://github.com/${REPO}/actions/runs/${runId}`; +console.log(''); +console.log(chalk.dim(' To check again:')); +console.log(' ' + chalk.cyan(`gh run view ${runId} --repo ${REPO}`)); +console.log(''); +console.log(` ${chalk.dim('url')} ${chalk.underline.blue(runUrl)}`); +console.log(''); + +// ── Watch the run ───────────────────────────────────────────────────────── +console.log(chalk.bold('Watching workflow run...\n')); +try { + await $({ stdio: 'inherit' })`gh run watch ${runId} --repo ${REPO} --exit-status`; +} catch { + console.log(chalk.red('\nWorkflow run did not complete successfully.')); + process.exit(1); +} + +// ── Done ─────────────────────────────────────────────────────────────────── +console.log(''); +console.log(chalk.green.bold(' Salesforce package released.')); +console.log(chalk.dim(' The install URL and package version id are in the run summary:')); +console.log(` ${chalk.underline.blue(runUrl)}`); +console.log('');