diff --git a/apps/docs/docs/getting-started/chrome-extension/chrome-extension-page-menu-no-record.png b/apps/docs/docs/getting-started/chrome-extension/chrome-extension-page-menu-no-record.png index df56a69a..a93a6d39 100644 Binary files a/apps/docs/docs/getting-started/chrome-extension/chrome-extension-page-menu-no-record.png and b/apps/docs/docs/getting-started/chrome-extension/chrome-extension-page-menu-no-record.png differ diff --git a/apps/docs/docs/getting-started/chrome-extension/chrome-extension-page-menu-record.png b/apps/docs/docs/getting-started/chrome-extension/chrome-extension-page-menu-record.png index c0dd6784..0cadb62d 100644 Binary files a/apps/docs/docs/getting-started/chrome-extension/chrome-extension-page-menu-record.png and b/apps/docs/docs/getting-started/chrome-extension/chrome-extension-page-menu-record.png differ diff --git a/apps/docs/docs/getting-started/chrome-extension/chrome-extension-page-menu-user-search.png b/apps/docs/docs/getting-started/chrome-extension/chrome-extension-page-menu-user-search.png new file mode 100644 index 00000000..879546ee Binary files /dev/null and b/apps/docs/docs/getting-started/chrome-extension/chrome-extension-page-menu-user-search.png differ diff --git a/apps/docs/docs/getting-started/chrome-extension/chrome-extension.mdx b/apps/docs/docs/getting-started/chrome-extension/chrome-extension.mdx index ef0aca5d..cf7f55bd 100644 --- a/apps/docs/docs/getting-started/chrome-extension/chrome-extension.mdx +++ b/apps/docs/docs/getting-started/chrome-extension/chrome-extension.mdx @@ -22,9 +22,9 @@ export function PopupLogin() { export function PagePopupDisplay() { return (
- Chrome Extension page button Chrome Extension page popup Chrome Extension page record page popup + Chrome Extension user search
); } @@ -84,6 +84,18 @@ Once you are logged in, the chrome extension will indicate that you are logged i Visit any Salesforce page and you will see a Jetstream Icon conveniently located in the right side of the page. Click on the icon to open the Jetstream Chrome Extension. +Chrome Extension page button + +### Using the Page Popup + +Open the Jetstream Chrome Extension on any Salesforce page to quickly access the following features: + +- Quick access to common features, such as: Query, Load, Automation Control, and Permission Manager +- View or edit the currently open record _(appears when you visit a record page)_ +- Paste in a record id to view or edit any record in Jetstream +- Search for any user in your org with the option to open in Salesforce or Jetstream directly + - You can also login as the user if you have permission to do so + ### Options diff --git a/apps/jetstream-web-extension/src/components/SfdcPageButton.tsx b/apps/jetstream-web-extension/src/components/SfdcPageButton.tsx index 575d7bf9..8787c0a4 100644 --- a/apps/jetstream-web-extension/src/components/SfdcPageButton.tsx +++ b/apps/jetstream-web-extension/src/components/SfdcPageButton.tsx @@ -2,37 +2,19 @@ import { css } from '@emotion/react'; import { logger } from '@jetstream/shared/client-logger'; import { APP_ROUTES } from '@jetstream/shared/ui-router'; +import { useInterval } from '@jetstream/shared/ui-utils'; import type { Maybe } from '@jetstream/types'; -import { Grid, GridCol, OutsideClickHandler } from '@jetstream/ui'; +import { Grid, GridCol, OutsideClickHandler, Tabs } from '@jetstream/ui'; import { fromAppState } from '@jetstream/ui/app-state'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useRecoilValue, useSetRecoilState } from 'recoil'; import '../sfdc-styles-shim.scss'; import { chromeStorageOptions, chromeSyncStorage } from '../utils/extension.store'; import { getRecordPageRecordId, sendMessage } from '../utils/web-extension.utils'; import JetstreamIcon from './icons/JetstreamIcon'; import JetstreamLogo from './icons/JetstreamLogo'; - -function useInterval(callback: () => void, delay: number | null) { - const savedCallback = useRef<() => void>(); - - useEffect(() => { - savedCallback.current = callback; - }, [callback]); - - useEffect(() => { - function tick() { - if (savedCallback.current) { - savedCallback.current(); - } - } - - if (delay !== null && delay !== undefined) { - const id = setInterval(tick, delay); - return () => clearInterval(id); - } - }, [delay]); -} +import { SfdcPageButtonRecordSearch } from './SfdcPageButtonRecordSearch'; +import { SfdcPageButtonUserSearch } from './SfdcPageButtonUserSearch'; const PAGE_LINKS = [ { @@ -124,6 +106,33 @@ const ButtonLinkCss = css` cursor: pointer; `; +const ItemColStyles = css` + padding-right: var(--lwc-spacingSmall, 0.75rem); + padding-left: var(--lwc-spacingSmall, 0.75rem); + margin-bottom: var(--lwc-spacingXSmall, 0.5rem); + flex: 1 1 auto; +`; + +const HorizontalRule = () => { + return ( +
+ ); +}; + export function SfdcPageButton() { const options = useRecoilValue(chromeStorageOptions); const { authTokens, buttonPosition } = useRecoilValue(chromeSyncStorage); @@ -241,7 +250,7 @@ export function SfdcPageButton() { position: fixed; top: clamp(1px, ${buttonPosition.position - 50}px, calc(100vh - 500px)); ${buttonPosition.location}: 0; - width: 250px; + width: 300px; border-radius: var(--lwc-borderRadiusMedium, 0.25rem); min-height: 2rem; background-color: var(--slds-g-color-neutral-base-100, var(--lwc-colorBackgroundAlt, rgb(255, 255, 255))); @@ -277,114 +286,83 @@ export function SfdcPageButton() {
- - {PAGE_LINKS.map((item) => ( - - - {item.label} - - - ))} - {recordId && ( - <> -
- -

Record Actions

-
- - - View Current Record - - - - - Edit Current Record - - - - )} -
+ {PAGE_LINKS.map((item) => ( + + + {item.label} + + + ))} + + + + {recordId && ( + <> + + + View Current Record + + + + + Edit Current Record + + + + )} + + + + + ), + }, + { + id: 'user-search', + title: 'User Search', + content: , + }, + ]} + />
)} diff --git a/apps/jetstream-web-extension/src/components/SfdcPageButtonRecordSearch.tsx b/apps/jetstream-web-extension/src/components/SfdcPageButtonRecordSearch.tsx new file mode 100644 index 00000000..4858850e --- /dev/null +++ b/apps/jetstream-web-extension/src/components/SfdcPageButtonRecordSearch.tsx @@ -0,0 +1,57 @@ +import { Grid, Input } from '@jetstream/ui'; +import { useState } from 'react'; +import '../sfdc-styles-shim.scss'; + +interface SfdcPageButtonRecordSearchProps { + sfHost: string; +} + +export function SfdcPageButtonRecordSearch({ sfHost }: SfdcPageButtonRecordSearchProps) { + const [recordId, setRecordId] = useState(''); + + const isValidRecordId = recordId.length === 15 || recordId.length === 18; + + function handleSubmit(ev: React.FormEvent) { + ev.preventDefault(); + if (!isValidRecordId) { + return; + } + window.open(`${chrome.runtime.getURL('app.html')}?host=${sfHost}&action=VIEW_RECORD&actionValue=${recordId}`, '_blank'); + } + + function handleEditRecord() { + if (!isValidRecordId) { + return; + } + window.open(`${chrome.runtime.getURL('app.html')}?host=${sfHost}&action=EDIT_RECORD&actionValue=${recordId}`, '_blank'); + } + + return ( +
+ + setRecordId(event.target.value)} + /> + + + + + +
+ ); +} diff --git a/apps/jetstream-web-extension/src/components/SfdcPageButtonUserSearch.tsx b/apps/jetstream-web-extension/src/components/SfdcPageButtonUserSearch.tsx new file mode 100644 index 00000000..38ed1e32 --- /dev/null +++ b/apps/jetstream-web-extension/src/components/SfdcPageButtonUserSearch.tsx @@ -0,0 +1,237 @@ +import { css } from '@emotion/react'; +import { useDebounce, useNonInitialEffect } from '@jetstream/shared/ui-utils'; +import type { QueryResults } from '@jetstream/types'; +import { CopyToClipboard, Grid, GridCol, List, ScopedNotification, SearchInput } from '@jetstream/ui'; +import { useEffect, useRef, useState } from 'react'; +import '../sfdc-styles-shim.scss'; +import { OrgAndSessionInfo } from '../utils/extension.types'; +import { sendMessage } from '../utils/web-extension.utils'; + +interface SfdcPageButtonUserSearchProps { + sfHost: string; +} + +interface User { + Id: string; + Name: string; + Alias: string; + CreatedDate: string; + Email: string; + IsActive: boolean; + Profile: { + Id: string; + Name: string; + }; + Username: string; + UserRole?: { + Id: string; + Name: string; + }; + UserType: + | 'Standard' + | 'PowerPartner' + | 'PowerCustomerSuccess' + | 'CustomerSuccess' + | 'Guest' + | 'CspLitePortal' + | 'CsnOnly' + | 'SelfService'; +} + +function getUserSearchSoql(searchTerm: string) { + const isPossibleId = /^([0-9a-zA-Z]{16}|[0-9a-zA-Z]{18})$/.test(searchTerm); + return [ + `SELECT Id, Name, Alias, FORMAT(CreatedDate), Email, IsActive, Profile.Id, Profile.Name, Username, UserRole.Id, UserRole.Name, UserType`, + `FROM User`, + `WHERE Name LIKE '%${searchTerm}%' OR Email LIKE '%${searchTerm}%' OR Username LIKE '%${searchTerm}%'`, + isPossibleId ? `OR Id = '${searchTerm}'` : '', + `ORDER BY Name LIMIT 50`, + ].join(' '); +} + +export function SfdcPageButtonUserSearch({ sfHost }: SfdcPageButtonUserSearchProps) { + const currentSearchRef = useRef(0); + const [searchTerm, setSearchTerm] = useState(''); + const searchTermDebounced = useDebounce(searchTerm, 500); + + const [loading, setLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + const [usersResults, setUsersResults] = useState>(); + + const [org, setOrg] = useState(null); + // const [action, setAction] = useState('view' | 'edit'); + + useEffect(() => { + if (sfHost) { + sendMessage({ + message: 'GET_CURRENT_ORG', + data: { sfHost }, + }) + .then((data) => setOrg(data)) + .catch((err) => { + console.error(err); + setErrorMessage('There was an error initializing the search'); + }); + } + }, [sfHost]); + + useNonInitialEffect(() => { + if (sfHost && searchTermDebounced) { + const currentSearchValue = currentSearchRef.current; + setLoading(true); + setErrorMessage(null); + sendMessage({ + message: 'API_ACTION', + data: { + method: 'POST', + pathname: '/api/query', + sfHost, + body: { query: getUserSearchSoql(searchTermDebounced) }, + }, + }) + .then(({ data }) => { + if (currentSearchRef.current === currentSearchValue) { + setUsersResults(data as QueryResults); + setLoading(false); + } + }) + .catch((err) => { + console.error(err); + setErrorMessage(`There was an error searching for users. ${err.message}`); + }) + .finally(() => setLoading(false)); + } + return () => { + currentSearchRef.current = currentSearchRef.current + 1; + }; + }, [searchTermDebounced, sfHost]); + + return ( + <> + setSearchTerm(value.trim())} + /> + {errorMessage && ( + + {errorMessage} + + )} + {!!usersResults && !usersResults.queryResults.totalSize && ( +

No Results

+ )} + {!!usersResults?.queryResults?.totalSize && ( + item.Id === searchTerm} + // eslint-disable-next-line @typescript-eslint/no-empty-function + onSelected={(key: string) => {}} + getContent={(user: User) => ({ + key: user.Id, + id: user.Id, + heading: getListItemContent(sfHost, { user }), + children: ( + + + + Open in Jetstream + + + {org && ( + + + Attempt Login As User + + + )} + + ), + })} + searchTerm={searchTerm} + /> + )} + + ); +} + +// For some reason, links were not doing anything when clicked +function handleLinkClick(event: React.MouseEvent) { + event.preventDefault(); + window.open(event.currentTarget.href, '_blank'); +} + +function getOpenInJetstreamLink(sfHost: string, recordId: string) { + return `${chrome.runtime.getURL('app.html')}?host=${sfHost}&action=VIEW_RECORD&actionValue=${recordId}`; +} + +function getSalesforceUserLink(sfHost: string, recordId: string) { + return `https://${sfHost}/lightning/setup/ManageUsers/page?${new URLSearchParams({ + address: `/${recordId}?noredirect=1`, + }).toString()}`; +} + +function getSalesforceProfileLink(sfHost: string, recordId: string) { + return `https://${sfHost}/lightning/setup/EnhancedProfiles/page?${new URLSearchParams({ + address: `/${recordId}?noredirect=1`, + }).toString()}`; +} + +function getLoginAsLink(sfHost: string, { org }: OrgAndSessionInfo, recordId: string) { + return `https://${sfHost}/servlet/servlet.su?${new URLSearchParams({ + oid: org.organizationId, + suorgadminid: recordId, + retURL: window.location.pathname, + targetURL: window.location.pathname, + }).toString()}`; +} + +function getListItemContent(sfHost: string, { user }: { user: User }) { + const { Alias, Email, Id, IsActive, Name, Profile, Username, UserType, UserRole } = user; + return ( +
+ + {Name} ({Alias}) + +

+ Email: + {Email} +

+

+ Username: + + {Username} + +

+ {Profile?.Name && ( +

+ Profile: + + {Profile.Name} + +

+ )} + {UserRole?.Name && ( +

+ Role: + {UserRole.Name} +

+ )} + {!IsActive &&

Inactive

} +

+ + {Id} +

+ {UserType !== 'Standard' &&

{UserType}

} +
+ ); +} diff --git a/apps/jetstream-web-extension/src/serviceWorker.ts b/apps/jetstream-web-extension/src/serviceWorker.ts index 8f2ea0cb..0001f9b0 100644 --- a/apps/jetstream-web-extension/src/serviceWorker.ts +++ b/apps/jetstream-web-extension/src/serviceWorker.ts @@ -17,6 +17,7 @@ import { extensionRoutes } from './controllers/extension.routes'; import { environment } from './environments/environment'; import { initApiClient, initApiClientAndOrg } from './utils/api-client'; import { + ApiAction, AUTH_CHECK_INTERVAL_MIN, AuthTokensStorage, eventPayload, @@ -273,6 +274,19 @@ chrome.runtime.onMessage.addListener( .catch(handleError(sendResponse)); return true; // indicate that sendResponse will be called asynchronously } + case 'API_ACTION': { + // Used to call API from the Salesforce page button popover (e.g. user search) + handleApiRequestEvent(request.data, sender) + .then((data) => handleResponse(data, sendResponse)) + .catch(handleError(sendResponse)); + return true; // indicate that sendResponse will be called asynchronously + } + case 'GET_CURRENT_ORG': { + getCurrentOrg(request.data.sfHost, sender) + .then((data) => handleResponse(data, sendResponse)) + .catch(handleError(sendResponse)); + return true; // indicate that sendResponse will be called asynchronously + } default: logger.warn(`Unknown message`, request); return false; @@ -371,6 +385,29 @@ const doesAuthNeedToBeChecked = (authTokens: AuthTokensStorage['authTokens']): b return isAfter(new Date(), addMinutes(new Date(authTokens.lastChecked), AUTH_CHECK_INTERVAL_MIN)); }; +async function getCurrentOrg(sfHost: string, sender: chrome.runtime.MessageSender) { + // Because we offer loginAs, we need to find the connection based on the session id + // we cannot rely just on the host as the session id changes when using loginAs and we don't know the user id + // so we don't have a way to associate the session to a specific user unless we were to make API calls to see what user a session belongs to + const sessionCookie = await chrome.cookies.get({ + url: `https://${sfHost}`, + name: 'sid', + storeId: getCookieStoreId(sender), + }); + + if (!sessionCookie?.value) { + throw new Error(`Session cookie not found for host ${sfHost}`); + } + + const connection = Object.values(connections).find(({ sessionInfo }) => sessionInfo.key === sessionCookie.value); + + if (!connection) { + throw new Error(`Connection not found for host ${sfHost}`); + } + + return connection; +} + /** * HANDLERS */ @@ -511,3 +548,49 @@ async function handleInitOrg( await setConnection(response.org.uniqueId, { sessionInfo, org: response.org }); return response; } + +/** + * Used to make API requests outside of the extension context (e.g. on a Salesforce page) + */ +async function handleApiRequestEvent( + { method, sfHost, pathname, body, queryParams }: ApiAction['request']['data'], + sender: chrome.runtime.MessageSender +) { + const route = extensionRoutes.match(method as Method, pathname); + if (!route) { + throw new Error('Route not found'); + } + const connection = await getCurrentOrg(sfHost, sender); + + queryParams = queryParams || new URLSearchParams(); + const { sessionInfo, org } = connection; + const apiConnection = initApiClient(sessionInfo); + + const response = await route + .handler({ + event: { + request: { + url: `https://${sfHost}${pathname}?${queryParams.toString()}`, + headers: new Headers({ + 'content-type': 'application/json', + }), + json: async () => body, + text: async () => body, + body, + }, + } as FetchEvent, + params: route.params, + jetstreamConn: apiConnection, + org, + }) + .then((response) => { + if (!response.ok) { + return response.text().then((message) => { + throw new Error(message); + }); + } + return response.json(); + }); + + return response; +} diff --git a/apps/jetstream-web-extension/src/utils/extension.types.ts b/apps/jetstream-web-extension/src/utils/extension.types.ts index 8198a31e..14c9ca18 100644 --- a/apps/jetstream-web-extension/src/utils/extension.types.ts +++ b/apps/jetstream-web-extension/src/utils/extension.types.ts @@ -43,7 +43,7 @@ export interface ChromeStorageState { }; } -export type Message = Logout | VerifyAuth | ToggleExtension | GetSfHost | GetSession | GetPageUrl | InitOrg; +export type Message = Logout | VerifyAuth | ToggleExtension | GetSfHost | GetSession | GetPageUrl | InitOrg | ApiAction | GetCurrentOrg; export type MessageRequest = Message['request']; export interface ResponseError { @@ -122,6 +122,24 @@ export interface InitOrg { }; response: { org: SalesforceOrgUi }; } + +// Allows calling API routes from Salesforce pages +export interface ApiAction { + request: { + message: 'API_ACTION'; + data: { sfHost: string; method: string; pathname: string; queryParams?: URLSearchParams; body?: any }; + }; + response: { data: unknown }; +} + +export interface GetCurrentOrg { + request: { + message: 'GET_CURRENT_ORG'; + data: { sfHost: string }; + }; + response: OrgAndSessionInfo; +} + export interface OrgAndSessionInfo { org: SalesforceOrgUi; sessionInfo: SessionInfo; diff --git a/libs/shared/ui-core/src/data-sync/client-data.db.ts b/libs/shared/ui-core/src/data-sync/client-data.db.ts index 3e169b80..2f590e97 100644 --- a/libs/shared/ui-core/src/data-sync/client-data.db.ts +++ b/libs/shared/ui-core/src/data-sync/client-data.db.ts @@ -1,5 +1,6 @@ import { logger } from '@jetstream/shared/client-logger'; import { INDEXED_DB } from '@jetstream/shared/constants'; +import { delay } from '@jetstream/shared/utils'; import { LoadSavedMappingItem, QueryHistoryItem } from '@jetstream/types'; import { DEXIE_DB_SYNC_NAME, dexieDataSync, dexieDb, hashRecordKey, SyncableTables } from '@jetstream/ui/db'; import 'dexie-observable'; @@ -57,7 +58,8 @@ class DexieInitializer { initializeDexieSync(DEXIE_DB_SYNC_NAME); this.hasInitializedSync = true; } - // Connect to sync + // sometimes the connection does not initialize properly, delaying to ensure it does + await delay(1000); await dexieDataSync.connect(); }