diff --git a/app/render.js b/app/render.js
index c3a006f..4f576c0 100644
--- a/app/render.js
+++ b/app/render.js
@@ -45,12 +45,23 @@ $.when($.ready).then(() => {
}
});
- // Setup Object Select All
- $('#btn-select-all-objects').on('click', (event) => {
- event.preventDefault();
- $('#results-table input[type=checkbox]').prop('checked', true);
+ // Setup login radio behaviors.
+ $('#login-password-wrapper').hide();
+ $('#login-oauth-wrapper').show();
+ $('input[type=radio][name=sfconnect-radio-selectors]').on('change', (event) => {
+ $('#login-modal-message').addClass('d-none').text('');
+ if ($(event.target).val() === 'oauth') {
+ $('#login-password-wrapper').hide();
+ $('#login-oauth-wrapper').show();
+ } else {
+ $('#login-password-wrapper').show();
+ $('#login-oauth-wrapper').hide();
+ }
});
+ // Get the current application preferences.
+ window.api.send('get_preferences');
+
// Setup Object Select All
$('#btn-deselect-all-objects').on('click', (event) => {
event.preventDefault();
@@ -152,6 +163,15 @@ function logMessage(context, importance, message, data) {
* @returns User name for requested org
*/
function fetchOrgUser(orgId) {
+ const activeUser = document.getElementById('active-org-user');
+ const activeUserText = activeUser
+ ? (activeUser.innerText || activeUser.textContent || '')
+ : '';
+
+ if (activeUserText.trim() !== '') {
+ return activeUserText;
+ }
+
const orgRecord = document.getElementById(`sforg-${orgId}`);
if (orgRecord === null) {
return '';
@@ -387,16 +407,12 @@ document.getElementsByName('db-radio-selectors').forEach((el) => {
* @param {*} responseData The data sent from the main process.
*/
const handleLogin = (responseData) => {
- // Add the new connection to the list of options.
- const opt = document.createElement('option');
- opt.value = responseData.response.organizationId;
- opt.innerHTML = responseData.request.username;
- opt.id = `sforg-${opt.value}`;
- document.getElementById('active-org').appendChild(opt);
+ const activeUser = responseData.request?.username || responseData.response?.username || 'Authenticated User';
// Shuffle what's shown.
document.getElementById('org-status').style.display = 'block';
- replaceText('active-org-id', responseData.response.organizationId);
+ replaceText('active-org-user', activeUser);
+ replaceText('active-org-id', responseData.response.organizationId || 'Connected');
replaceText('login-response-message', responseData.message);
// Enable the button to fetch object list.
@@ -651,8 +667,11 @@ const updateSqlite3Path = (filePath) => {
// ========= Messages to the main process ===============
// Login
document.getElementById('login-trigger').addEventListener('click', () => {
+ const modeRadio = document.querySelector('input[type=radio][name="sfconnect-radio-selectors"]:checked');
+ $('#login-modal-message').addClass('d-none').text('');
showLoader('Attempting Login');
window.api.send('sf_login', {
+ mode: modeRadio ? modeRadio.value : 'oauth',
username: document.getElementById('login-username').value,
password: document.getElementById('login-password').value,
token: document.getElementById('login-token').value,
@@ -662,26 +681,19 @@ document.getElementById('login-trigger').addEventListener('click', () => {
// Logout
document.getElementById('logout-trigger').addEventListener('click', () => {
- const { value } = document.getElementById('active-org');
- window.api.send('sf_logout', {
- org: value,
- });
- // Remove from interface:
- const selectObject = document.getElementById('active-org');
- for (let i = 0; i < selectObject.length; i += 1) {
- if (selectObject.options[i].value === value) {
- selectObject.remove(i);
- }
- }
+ window.api.send('sf_logout', {});
document.getElementById('org-status').style.display = 'none';
+ replaceText('active-org-user', '');
+ replaceText('active-org-id', '');
+ replaceText('login-response-message', '');
+ $('#btn-fetch-objects').prop('disabled', true);
+ $('#btn-fetch-details').prop('disabled', true);
});
// Fetch Org Objects
document.getElementById('btn-fetch-objects').addEventListener('click', () => {
showLoader('Loading Object List');
- window.api.send('sf_describeGlobal', {
- org: document.getElementById('active-org').value,
- });
+ window.api.send('sf_describeGlobal', {});
});
// Fetch Object Field lists
@@ -693,7 +705,6 @@ document.getElementById('btn-fetch-details').addEventListener('click', () => {
}
showLoader('Loading Object Fields');
window.api.send('sf_getObjectFields', {
- org: document.getElementById('active-org').value,
objects: selectedObjects,
});
});
@@ -767,21 +778,32 @@ document.getElementById('btn-sqlite3-file').addEventListener('click', () => {
// Login response.
window.api.receive('response_login', (data) => {
hideLoader();
+ replaceText('login-response-message', data.message);
+
if (data.status) {
logMessage('Salesforce', 'Success', data.message, data.response);
updateMessage('Login Successful');
handleLogin(data, data.status);
+
+ if (window.bootstrap && window.bootstrap.Modal) {
+ const modalElement = document.getElementById('loginModal');
+ const modal = window.bootstrap.Modal.getOrCreateInstance(modalElement);
+ modal.hide();
+ }
} else {
logMessage('Salesforce', 'Error', data.message, data.response);
displayRawResponse(data);
updateMessage('Login Error');
+ $('#login-modal-message').removeClass('d-none').text(data.response || data.message);
}
});
// Logout Response.
window.api.receive('response_logout', (data) => {
+ hideLoader();
logMessage('Salesforce', 'Info', 'Log out complete', data);
updateMessage('Salesforce connection removed.');
+ document.getElementById('org-status').style.display = 'none';
});
// Generic Response.
@@ -833,6 +855,13 @@ window.api.receive('current_preferences', (data) => {
// Update the theme:
const cssPath = `../node_modules/bootswatch/dist/${data.theme.toLowerCase()}/bootstrap.min.css`;
document.getElementById('css-theme-link').href = cssPath;
+
+ const oauthStatus = document.getElementById('oauth-config-status');
+ if (oauthStatus) {
+ oauthStatus.innerText = data.oauth?.hasClientSecret
+ ? 'OAuth client credentials are configured and ready to use.'
+ : 'Set the OAuth client ID and secret in Preferences before connecting.';
+ }
});
// Start the find process by activating the controls and scrolling there.
diff --git a/app/tests/minIndex.html b/app/tests/minIndex.html
index 7a1f5dd..dd6dcb1 100644
--- a/app/tests/minIndex.html
+++ b/app/tests/minIndex.html
@@ -4,9 +4,7 @@
@@ -43,8 +41,7 @@
@@ -100,9 +115,7 @@
Enter Salesforce Login
-
+
\ No newline at end of file
diff --git a/app/tests/render.test.js b/app/tests/render.test.js
index d3a4e5f..967f6d0 100644
--- a/app/tests/render.test.js
+++ b/app/tests/render.test.js
@@ -18,10 +18,13 @@ beforeAll(() => {
});
beforeEach(() => {
+ jest.resetModules();
// Load index.html since render.js assumes it's structures.
const fs = require('fs'); // eslint-disable-line
const indexHtml = fs.readFileSync('app/tests/minIndex.html');
document.body.innerHTML = indexHtml.toString();
+ window.api.send.mockClear();
+ window.api.receive.mockClear();
global.render = require('../render'); // eslint-disable-line
});
@@ -145,20 +148,15 @@ test('fetchOrgUser returns empty string for an unknown org id', () => {
expect(fetchOrgUser('unknown-org')).toEqual('');
});
-test('fetchOrgUser returns the username text for a known org id', () => {
+test('fetchOrgUser returns the connected username when present', () => {
const fetchOrgUser = render.__get__('fetchOrgUser');
- const sel = document.getElementById('active-org');
- const opt = document.createElement('option');
- opt.value = 'abc123';
- opt.text = 'user@example.com';
- opt.id = 'sforg-abc123';
- sel.appendChild(opt);
+ document.getElementById('active-org-user').innerText = 'user@example.com';
expect(fetchOrgUser('abc123')).toEqual('user@example.com');
});
// ---- handleLogin ----
-test('handleLogin adds an option to the org dropdown, shows org-status, and enables fetch-objects', () => {
+test('handleLogin shows connection status and enables fetch-objects', () => {
const handleLogin = render.__get__('handleLogin');
const data = {
message: 'Welcome',
@@ -167,9 +165,8 @@ test('handleLogin adds an option to the org dropdown, shows org-status, and enab
};
handleLogin(data);
- const opt = document.getElementById('sforg-org001');
- expect(opt).not.toBeNull();
- expect(opt.value).toEqual('org001');
+ expect(document.getElementById('active-org-user').innerText).toEqual('admin@example.com');
+ expect(document.getElementById('active-org-id').innerText).toEqual('org001');
expect(document.getElementById('org-status').style.display).toEqual('block');
expect(document.getElementById('btn-fetch-objects').disabled).toBe(false);
});
@@ -258,6 +255,18 @@ test('response_login success path updates login message and enables fetch-object
expect(document.getElementById('btn-fetch-objects').disabled).toBe(false);
});
+test('login trigger sends the selected connection mode', () => {
+ document.getElementById('sfconnect-password').checked = true;
+ document.getElementById('login-trigger').click();
+
+ expect(window.api.send).toHaveBeenCalledWith(
+ 'sf_login',
+ expect.objectContaining({
+ mode: 'password',
+ }),
+ );
+});
+
test('response_login error path logs an error row and updates status message', () => {
const cb = getReceiveCallback('response_login');
const logTable = document.getElementById('consoleMessageTable');
diff --git a/main.js b/main.js
index d63e704..0f5d494 100644
--- a/main.js
+++ b/main.js
@@ -56,6 +56,7 @@ function createWindow() {
height: display.workArea.height,
frame: true,
webPreferences: {
+ partition: 'persist:secured-partition',
devTools: isDev,
nodeIntegration: false, // Disable nodeIntegration for security.
nodeIntegrationInWorker: false,
@@ -90,7 +91,7 @@ function createWindow() {
// https://www.electronjs.org/docs/tutorial/security#4-handle-session-permission-requests-from-remote-content
// https://github.com/doyensec/electronegativity/wiki/PERMISSION_REQUEST_HANDLER_GLOBAL_CHECK
session
- .fromPartition('secured-partition')
+ .fromPartition('persist:secured-partition')
.setPermissionRequestHandler((webContents, permission, callback) => {
callback(false);
});
@@ -167,5 +168,11 @@ ipcMain.on('find_text', (event, searchSettings) => {
// Add Preference listeners.
ipcMain.on('preferences_load', loadPreferences);
-ipcMain.on('preferences_save', savePreferences);
-ipcMain.on('preferences_close', closePreferences);
+ipcMain.on('preferences_save', (event, data) => {
+ savePreferences(event, data);
+ ipcFunctions.setPreferences(getCurrentPreferences());
+});
+ipcMain.on('preferences_close', () => {
+ ipcFunctions.setPreferences(getCurrentPreferences());
+ closePreferences();
+});
diff --git a/src/config.js b/src/config.js
new file mode 100644
index 0000000..be77416
--- /dev/null
+++ b/src/config.js
@@ -0,0 +1,13 @@
+const config = {
+ oauth: {
+ clientId: process.env.SALESFORCE_CLIENT_ID || '',
+ clientSecret: process.env.SALESFORCE_CLIENT_SECRET || '',
+ scopes: ['api', 'id', 'web', 'refresh_token'],
+ },
+ updateOAuthCredentials(clientId, clientSecret) {
+ this.oauth.clientId = clientId;
+ this.oauth.clientSecret = clientSecret;
+ },
+};
+
+module.exports = config;
diff --git a/src/preferences.js b/src/preferences.js
index 80078de..1c9d2ca 100644
--- a/src/preferences.js
+++ b/src/preferences.js
@@ -1,9 +1,17 @@
const path = require('path');
-const { app, BrowserWindow, Menu } = require('electron'); // eslint-disable-line
+const {
+ app,
+ BrowserWindow,
+ Menu,
+ safeStorage,
+} = require('electron'); // eslint-disable-line
const fs = require('fs-extra');
+const config = require('./config');
+
const appPath = app.getAppPath();
const settingsPath = path.join(app.getPath('userData'), 'preferences.json');
+const oauthSettingsPath = path.join(app.getPath('userData'), 'oauth-preferences.bin');
// A list of menu item IDs to disable when preference window is open.
const nonPrefWindowItems = [
@@ -17,33 +25,136 @@ const setMainWindow = (win) => {
mainWindow = win;
};
+const defaultPreferences = () => ({
+ theme: 'Cyborg',
+ indexes: {
+ externalIds: true,
+ lookups: true,
+ picklists: true,
+ },
+ picklists: {
+ type: 'enum',
+ unrestricted: true,
+ ensureBlanks: true,
+ },
+ lookups: {
+ type: 'char(18)',
+ },
+ defaults: {
+ attemptSFValues: false,
+ textEmptyString: false,
+ checkboxDefaultFalse: true,
+ suppressReadOnly: false,
+ suppressAudit: false,
+ },
+ oauth: {
+ clientId: '',
+ hasClientSecret: false,
+ },
+});
+
+const getStoredOAuthSettings = () => {
+ const envClientId = process.env.SALESFORCE_CLIENT_ID || '';
+ const envClientSecret = process.env.SALESFORCE_CLIENT_SECRET || '';
+
+ if (envClientId || envClientSecret) {
+ return {
+ clientId: envClientId,
+ clientSecret: envClientSecret,
+ hasClientSecret: Boolean(envClientSecret),
+ };
+ }
+
+ if (!safeStorage || !safeStorage.isEncryptionAvailable()) {
+ return {
+ clientId: '',
+ clientSecret: '',
+ hasClientSecret: false,
+ };
+ }
+
+ try {
+ if (!fs.existsSync(oauthSettingsPath)) {
+ return {
+ clientId: '',
+ clientSecret: '',
+ hasClientSecret: false,
+ };
+ }
+
+ const encryptedData = fs.readFileSync(oauthSettingsPath);
+ const rawData = safeStorage.decryptString(encryptedData);
+ const parsed = JSON.parse(rawData);
+
+ return {
+ clientId: parsed.clientId || '',
+ clientSecret: parsed.clientSecret || '',
+ hasClientSecret: Boolean(parsed.clientSecret),
+ };
+ } catch (err) {
+ return {
+ clientId: '',
+ clientSecret: '',
+ hasClientSecret: false,
+ };
+ }
+};
+
+const updateOAuthConfig = () => {
+ const oauthSettings = getStoredOAuthSettings();
+ config.updateOAuthCredentials(oauthSettings.clientId, oauthSettings.clientSecret);
+ return oauthSettings;
+};
+
+const saveSecureOAuthSettings = (oauthSettings = {}) => {
+ const existingSettings = getStoredOAuthSettings();
+ const clientId = (oauthSettings.clientId || '').trim();
+ let clientSecret = typeof oauthSettings.clientSecret === 'string'
+ ? oauthSettings.clientSecret.trim()
+ : '';
+
+ if (!clientSecret && existingSettings.hasClientSecret && clientId === existingSettings.clientId) {
+ clientSecret = existingSettings.clientSecret;
+ }
+
+ if (!clientId && !clientSecret) {
+ if (fs.existsSync(oauthSettingsPath)) {
+ fs.removeSync(oauthSettingsPath);
+ }
+ config.updateOAuthCredentials('', '');
+ return {
+ clientId: '',
+ hasClientSecret: false,
+ };
+ }
+
+ if (!safeStorage || !safeStorage.isEncryptionAvailable()) {
+ config.updateOAuthCredentials(clientId, clientSecret);
+ return {
+ clientId,
+ hasClientSecret: Boolean(clientSecret),
+ };
+ }
+
+ const encryptedData = safeStorage.encryptString(JSON.stringify({
+ clientId,
+ clientSecret,
+ }));
+
+ fs.writeFileSync(oauthSettingsPath, encryptedData);
+ config.updateOAuthCredentials(clientId, clientSecret);
+
+ return {
+ clientId,
+ hasClientSecret: Boolean(clientSecret),
+ };
+};
+
const getCurrentPreferences = () => {
// Ensure we have the settings file created.
fs.ensureFileSync(settingsPath);
- const preferences = {
- theme: 'Cyborg',
- indexes: {
- externalIds: true,
- lookups: true,
- picklists: true,
- },
- picklists: {
- type: 'enum',
- unrestricted: true,
- ensureBlanks: true,
- },
- lookups: {
- type: 'char(18)',
- },
- defaults: {
- attemptSFValues: false,
- textEmptyString: false,
- checkboxDefaultFalse: true,
- suppressReadOnly: false,
- suppressAudit: false,
- },
- };
+ const preferences = defaultPreferences();
// Load any existing values.
let settingsData = {};
@@ -53,13 +164,20 @@ const getCurrentPreferences = () => {
// Catch and release, we'll just use the defaults from there.
}
- // Merge in settings that in the file an we know how to use.
- const values = Object.getOwnPropertyNames(preferences);
+ // Merge in settings that in the file and we know how to use.
+ const values = Object.getOwnPropertyNames(preferences).filter((value) => value !== 'oauth');
for (let i = 0; i < values.length; i += 1) {
if (Object.prototype.hasOwnProperty.call(settingsData, values[i])) {
preferences[values[i]] = settingsData[values[i]];
}
}
+
+ const oauthSettings = updateOAuthConfig();
+ preferences.oauth = {
+ clientId: oauthSettings.clientId,
+ hasClientSecret: oauthSettings.hasClientSecret,
+ };
+
return preferences;
};
@@ -69,16 +187,18 @@ const loadPreferences = () => {
prefWindow.webContents.send('preferences_data', preferences);
};
-const savePreferences = (event, settingData) => {
+const savePreferences = (event, settingData = {}) => {
const preferences = getCurrentPreferences();
- // Merge in settings that in the file an we know how to use.
- const values = Object.getOwnPropertyNames(preferences);
+ // Merge in settings that in the file and we know how to use.
+ const values = Object.getOwnPropertyNames(preferences).filter((value) => value !== 'oauth');
for (let i = 0; i < values.length; i += 1) {
if (Object.prototype.hasOwnProperty.call(settingData, values[i])) {
preferences[values[i]] = settingData[values[i]];
}
}
+
+ preferences.oauth = saveSecureOAuthSettings(settingData.oauth);
fs.writeFileSync(settingsPath, JSON.stringify(preferences));
};
@@ -102,7 +222,7 @@ const openPreferences = () => {
if (!prefWindow || prefWindow.isDestroyed()) {
prefWindow = new BrowserWindow({
width: 550,
- height: 730,
+ height: 820,
resizable: false,
frame: false,
webPreferences: {
@@ -136,3 +256,4 @@ exports.openPreferences = openPreferences;
exports.loadPreferences = loadPreferences;
exports.savePreferences = savePreferences;
exports.closePreferences = closePreferences;
+exports.updateOAuthConfig = updateOAuthConfig;
diff --git a/src/sf_calls.js b/src/sf_calls.js
index a3634b0..b2c4d1f 100644
--- a/src/sf_calls.js
+++ b/src/sf_calls.js
@@ -4,12 +4,13 @@ const path = require('path');
const electron = require('electron');
const jsforce = require('jsforce');
const knex = require('knex');
+const oauth = require('./sf_oauth2');
const constants = require('./constants');
// Get the dialog library from Electron
const { dialog } = electron;
-const sfConnections = {};
+let activeConnection = null;
let mainWindow = null;
let proposedSchema = {};
let preferences = null;
@@ -39,6 +40,37 @@ const setPreferences = (prefs) => {
preferences = prefs;
};
+const buildMaskedRequest = (request = {}) => ({
+ mode: request.mode || 'password',
+ username: request.username || '',
+ password: '********',
+ token: '********',
+ url: request.url || '',
+});
+
+const setActiveSalesforceConnection = (conn, userInfo = {}) => {
+ activeConnection = {
+ instanceUrl: conn.instanceUrl,
+ accessToken: conn.accessToken,
+ refreshToken: conn.refreshToken,
+ version: '63.0',
+ userInfo,
+ };
+ return activeConnection;
+};
+
+const getActiveSalesforceConnection = () => {
+ if (!activeConnection) {
+ return null;
+ }
+
+ return new jsforce.Connection({
+ instanceUrl: activeConnection.instanceUrl,
+ accessToken: activeConnection.accessToken,
+ version: activeConnection.version,
+ });
+};
+
/**
* Determines to SQL data type to use for a given SF field type.
* @param {*} sfTypeName The SF field type.
@@ -650,61 +682,95 @@ const buildDatabase = (settings) => {
});
};
+const sendLoginResponse = (conn, status, message, response, request) => {
+ mainWindow.webContents.send('response_login', {
+ status,
+ message,
+ response,
+ limitInfo: conn?.limitInfo || {},
+ request: buildMaskedRequest(request),
+ });
+};
+
+const handleLoginSuccess = (conn, userInfo, request, context) => {
+ const response = {
+ ...userInfo,
+ organizationId: userInfo.organizationId || userInfo.organization_id || '',
+ username: request.username || userInfo.username || userInfo.preferred_username || userInfo.id || 'OAuth2',
+ };
+
+ logMessage(
+ context,
+ 'Info',
+ `Connection Org ${response.organizationId || 'Unknown'} for User ${response.username}`,
+ );
+ setActiveSalesforceConnection(conn, response);
+ sendLoginResponse(conn, true, 'Login Successful', response, request);
+};
+
+const handleLoginFailure = (err, conn, request) => {
+ sendLoginResponse(conn, false, 'Login Failed', err.message || `${err}`, request);
+};
+
+const sfPasswordLogin = (url, username, password) => {
+ const conn = new jsforce.Connection({
+ loginUrl: url,
+ });
+
+ return conn.login(username, password).then(
+ (userInfo) => {
+ handleLoginSuccess(conn, userInfo, {
+ mode: 'password',
+ username,
+ url,
+ }, 'Password Login Attempt');
+ },
+ (err) => {
+ handleLoginFailure(err, conn, {
+ mode: 'password',
+ username,
+ url,
+ });
+ },
+ );
+};
+
+const sfOAuthLogin = (url) => oauth.attemptLogin(url).then(
+ ({ conn, userInfo }) => {
+ handleLoginSuccess(conn, userInfo, {
+ mode: 'oauth',
+ url,
+ }, 'OAuth Login Attempt');
+ },
+ (err) => {
+ handleLoginFailure(err, null, {
+ mode: 'oauth',
+ url,
+ username: 'OAuth2',
+ });
+ },
+);
+
/**
* List of remote call handlers for using with IPC.
*/
const handlers = {
/**
- * Login to an org using password authentication.
+ * Login to an org.
* @param {*} event Standard message event.
* @param {*} args Login credentials from the interface.
*/
sf_login: (event, args) => {
- const conn = new jsforce.Connection({
- loginUrl: args.url,
- });
+ if (args.mode === 'oauth') {
+ return sfOAuthLogin(args.url);
+ }
let { password } = args;
- if (args.token !== '') {
- password = `${password}${args.token}`;
+ if (args.token && args.token.trim()) {
+ password = `${(password || '').trim()}${args.token.trim()}`;
}
-
- conn.login(args.username, password).then(
- (userInfo) => {
- // Since we send the args back to the interface, it's a good idea
- // to remove the security information.
- args.password = '';
- args.token = '';
-
- // Now you can get the access token and instance URL information.
- // Save them to establish connection next time.
- logMessage(event.sender.getTitle(), 'Info', `Connection Org ${userInfo.organizationId} for User ${userInfo.id}`);
-
- // Save the next connection in the global storage.
- sfConnections[userInfo.organizationId] = {
- instanceUrl: conn.instanceUrl,
- accessToken: conn.accessToken,
- version: '63.0',
- };
-
- mainWindow.webContents.send('response_login', {
- status: true,
- message: 'Login Successful',
- response: userInfo,
- limitInfo: conn.limitInfo,
- request: args,
- });
- },
- (err) => {
- mainWindow.webContents.send('response_login', {
- status: false,
- message: 'Login Failed',
- response: err,
- limitInfo: conn.limitInfo,
- request: args,
- });
- },
- );
+ logMessage(event.sender.getTitle(), 'Info', 'Attempting Login with Basic Credentials');
+ return sfPasswordLogin(args.url, args.username, password);
},
/**
* Logout of a specific Salesforce org.
@@ -712,7 +778,19 @@ const handlers = {
* @param {*} args The connection to disable.
*/
sf_logout: (event, args) => {
- const conn = new jsforce.Connection(sfConnections[args.org]);
+ const conn = getActiveSalesforceConnection();
+
+ if (!conn) {
+ mainWindow.webContents.send('response_logout', {
+ status: false,
+ message: 'Logout Failed',
+ response: 'No active Salesforce connection.',
+ limitInfo: {},
+ request: args,
+ });
+ return;
+ }
+
const fail = (err) => {
mainWindow.webContents.send('response_logout', {
status: false,
@@ -732,9 +810,10 @@ const handlers = {
limitInfo: conn.limitInfo,
request: args,
});
- sfConnections[args.org] = null;
+ activeConnection = null;
};
- conn.logout.then(success, fail);
+ const logoutAction = typeof conn.logout === 'function' ? conn.logout() : conn.logout;
+ Promise.resolve(logoutAction).then(success).catch(fail);
},
/**
* Run a global describe.
@@ -743,7 +822,19 @@ const handlers = {
* @returns True.
*/
sf_describeGlobal: (event, args) => {
- const conn = new jsforce.Connection(sfConnections[args.org]);
+ const conn = getActiveSalesforceConnection();
+
+ if (!conn) {
+ mainWindow.webContents.send('response_error', {
+ status: false,
+ message: 'Describe Global Failed',
+ response: 'No active Salesforce connection.',
+ limitInfo: {},
+ request: args,
+ });
+ return true;
+ }
+
const fail = (err) => {
mainWindow.webContents.send('response_error', {
status: false,
@@ -752,6 +843,7 @@ const handlers = {
limitInfo: conn.limitInfo,
request: args,
});
+ return false;
};
const success = (result) => {
// Send records back to the interface.
@@ -767,7 +859,7 @@ const handlers = {
return true;
};
- conn.describeGlobal().then(success, fail);
+ return conn.describeGlobal().then(success, fail);
},
/**
* Get a list of all fields on a provided list of objects.
@@ -776,10 +868,21 @@ const handlers = {
* @returns True.
*/
sf_getObjectFields: (event, args) => {
- const conn = new jsforce.Connection(sfConnections[args.org]);
+ const conn = getActiveSalesforceConnection();
let completedObjects = 0;
const allObjects = {};
+ if (!conn) {
+ mainWindow.webContents.send('response_error', {
+ status: false,
+ message: 'Field Fetch Failed',
+ response: 'No active Salesforce connection.',
+ limitInfo: {},
+ request: args,
+ });
+ return true;
+ }
+
// Reset the proposed schema back to baseline.
proposedSchema = {};
@@ -812,6 +915,7 @@ const handlers = {
});
}
});
+ return true;
},
/**
* Connect to a database and set the schema.
diff --git a/src/sf_oauth2.js b/src/sf_oauth2.js
new file mode 100644
index 0000000..049295e
--- /dev/null
+++ b/src/sf_oauth2.js
@@ -0,0 +1,104 @@
+const electron = require("electron"); // eslint-disable-line
+const { shell } = electron;
+
+// Additional Tooling.
+const jsforce = require('jsforce');
+const http = require('http');
+
+const config = require('./config');
+
+function createLocalServer(jsfOauth) {
+ return new Promise((resolve, reject) => {
+ const server = http.createServer((req, res) => {
+ // Remove any port information from the URL
+ if (req.url.startsWith('/completesetup')) {
+ const url = new URL(req.url, `http://localhost:${server.address().port}`);
+ const code = url.searchParams.get('code');
+
+ if (code) {
+ // Send success response to browser
+ res.writeHead(200, { 'Content-Type': 'text/html' });
+ res.end('
Authentication successful!
You can close this window.
');
+
+ // Close server and resolve promise with auth code
+ server.close();
+ resolve(code);
+ } else {
+ reject(new Error('No authorization code received'));
+ }
+ }
+ });
+
+ // Listen on a random available port
+ server.listen(0, 'localhost', () => {
+ const { port } = server.address();
+ // Update the OAuth config with the actual port
+ jsfOauth.redirectUri = `http://localhost:${port}/completesetup`;
+ });
+ });
+}
+
+function isValidSalesforceUrl(url) {
+ try {
+ const parsedUrl = new URL(url);
+
+ // Check for HTTPS protocol
+ if (parsedUrl.protocol !== 'https:') {
+ return false;
+ }
+
+ // List of valid Salesforce login domains
+ const validDomains = [
+ 'login.salesforce.com',
+ 'test.salesforce.com',
+ 'login.sandbox.salesforce.com',
+ 'login.cloudforce.com',
+ ];
+
+ return validDomains.some((domain) => parsedUrl.hostname === domain
+ || parsedUrl.hostname.endsWith('.my.salesforce.com')
+ || parsedUrl.hostname.endsWith('.cloudforce.com'));
+ } catch (err) {
+ return false;
+ }
+}
+
+async function attemptLogin(authDomain) {
+ if (!config.oauth.clientId || !config.oauth.clientSecret) {
+ throw new Error('Missing OAuth credentials. Both Client ID and Client Secret are required.');
+ }
+
+ // Create OAuth configuration
+ const jsfOauth = new jsforce.OAuth2({
+ loginUrl: authDomain,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ redirectUri: 'http://localhost/completesetup',
+ });
+
+ const codePromise = createLocalServer(jsfOauth);
+
+ const authUrl = jsfOauth.getAuthorizationUrl({
+ scope: config.oauth.scopes.join(' '),
+ });
+
+ if (!isValidSalesforceUrl(authUrl)) {
+ throw new Error('Invalid Salesforce authentication URL');
+ }
+
+ await shell.openExternal(authUrl);
+
+ // Wait for the authorization code
+ const code = await codePromise;
+
+ // Exchange code for access token
+ const conn = new jsforce.Connection({ oauth2: jsfOauth });
+ const userInfo = await conn.authorize(code);
+
+ return {
+ conn,
+ userInfo,
+ };
+}
+
+exports.attemptLogin = attemptLogin;
diff --git a/src/tests/__mocks__/electron.js b/src/tests/__mocks__/electron.js
index d8f4e67..2532ace 100644
--- a/src/tests/__mocks__/electron.js
+++ b/src/tests/__mocks__/electron.js
@@ -43,5 +43,13 @@ module.exports = {
handle: jest.fn(),
send: jest.fn(),
},
+ shell: {
+ openExternal: jest.fn().mockResolvedValue(true),
+ },
+ safeStorage: {
+ isEncryptionAvailable: jest.fn().mockReturnValue(true),
+ encryptString: jest.fn((value) => Buffer.from(value, 'utf8')),
+ decryptString: jest.fn((value) => value.toString('utf8')),
+ },
mainWindow: mockWindow,
};
diff --git a/src/tests/__mocks__/jsforce.js b/src/tests/__mocks__/jsforce.js
index 4280cea..a212de3 100644
--- a/src/tests/__mocks__/jsforce.js
+++ b/src/tests/__mocks__/jsforce.js
@@ -9,6 +9,10 @@ const jsforce = {
execute: jest.fn().mockResolvedValue([]),
}),
describeGlobal: jest.fn().mockResolvedValue({ sobjects: [] }),
+ authorize: jest.fn().mockResolvedValue({ id: 'test-user-id' }),
+ })),
+ OAuth2: jest.fn().mockImplementation(() => ({
+ getAuthorizationUrl: jest.fn().mockReturnValue('https://login.salesforce.com/auth'),
})),
};
diff --git a/src/tests/preferences.test.js b/src/tests/preferences.test.js
index ab346d4..8ecbeb2 100644
--- a/src/tests/preferences.test.js
+++ b/src/tests/preferences.test.js
@@ -38,6 +38,7 @@ test('Check SetPreferences', () => {
expect(testPrefs).toHaveProperty('picklists');
expect(testPrefs).toHaveProperty('lookups');
expect(testPrefs).toHaveProperty('defaults');
+ expect(testPrefs).toHaveProperty('oauth');
expect(testPrefs.indexes).toHaveProperty('externalIds');
expect(testPrefs.indexes).toHaveProperty('lookups');
expect(testPrefs.indexes).toHaveProperty('picklists');
@@ -49,4 +50,6 @@ test('Check SetPreferences', () => {
expect(testPrefs.defaults).toHaveProperty('textEmptyString');
expect(testPrefs.defaults).toHaveProperty('suppressReadOnly');
expect(testPrefs.defaults).toHaveProperty('suppressAudit');
+ expect(testPrefs.oauth).toHaveProperty('clientId');
+ expect(testPrefs.oauth).toHaveProperty('hasClientSecret');
});
diff --git a/src/tests/sf_calls.test.js b/src/tests/sf_calls.test.js
index 4a00d78..5089341 100644
--- a/src/tests/sf_calls.test.js
+++ b/src/tests/sf_calls.test.js
@@ -1,6 +1,7 @@
const fs = require('fs');
const electron = require('electron');
const jsforce = require('jsforce');
+const oauth = require('../sf_oauth2');
// The actual module we're testing.
const sfcalls = require('../sf_calls');
@@ -33,7 +34,7 @@ test('Validate exports', () => {
// Several are assumed and leveraged in later tests.
test('Validate existence of assumed internals', () => {
// Checking the existing of the four main variables.
- expect(sfcalls.__get__('sfConnections')).toStrictEqual({});
+ expect(sfcalls.__get__('activeConnection')).toBe(null);
expect(sfcalls.__get__('proposedSchema')).toStrictEqual({});
expect(sfcalls.__get__('mainWindow')).toBe(null);
expect(sfcalls.__get__('preferences')).toBe(null);
@@ -594,6 +595,7 @@ test('Test sf_login success path', async () => {
const mockEvent = { sender: { getTitle: jest.fn().mockReturnValue('Test Window') } };
const mockArgs = {
+ mode: 'password',
url: 'https://test.salesforce.com',
username: 'test@test.com',
password: 'testpassword',
@@ -610,9 +612,7 @@ test('Test sf_login success path', async () => {
message: 'Login Successful',
}),
);
- // Password and token must be cleared before sending back to the renderer.
- expect(mockArgs.password).toBe('');
- expect(mockArgs.token).toBe('');
+ expect(sfcalls.__get__('activeConnection')).not.toBeNull();
});
test('Test sf_login auth failure path', async () => {
@@ -625,6 +625,7 @@ test('Test sf_login auth failure path', async () => {
const mockEvent = { sender: { getTitle: jest.fn().mockReturnValue('Test Window') } };
const mockArgs = {
+ mode: 'password',
url: 'https://test.salesforce.com',
username: 'wrong@test.com',
password: 'wrongpassword',
@@ -643,19 +644,42 @@ test('Test sf_login auth failure path', async () => {
);
});
+test('Test sf_login oauth failure path returns renderer error instead of unhandled rejection', async () => {
+ jest.clearAllMocks();
+ const mockAttemptLogin = jest.spyOn(oauth, 'attemptLogin')
+ .mockRejectedValue(new Error('Missing OAuth credentials. Both Client ID and Client Secret are required.'));
+ sfcalls.setwindow(electron.mainWindow);
+
+ const mockEvent = { sender: { getTitle: jest.fn().mockReturnValue('Test Window') } };
+ const mockArgs = {
+ mode: 'oauth',
+ url: 'https://login.salesforce.com',
+ };
+
+ sfcalls.handlers.sf_login(mockEvent, mockArgs);
+ await new Promise((resolve) => { process.nextTick(resolve); });
+
+ expect(mockAttemptLogin).toHaveBeenCalledWith('https://login.salesforce.com');
+ expect(electron.mainWindow.webContents.send).toHaveBeenCalledWith(
+ 'response_login',
+ expect.objectContaining({
+ status: false,
+ message: 'Login Failed',
+ }),
+ );
+});
+
test('Test sf_logout success path', async () => {
jest.clearAllMocks();
- sfcalls.__set__('sfConnections', {
- testOrgId: {
- instanceUrl: 'https://test.salesforce.com',
- accessToken: 'testToken',
- version: '63.0',
- },
+ sfcalls.__set__('activeConnection', {
+ instanceUrl: 'https://test.salesforce.com',
+ accessToken: 'testToken',
+ version: '63.0',
});
sfcalls.setwindow(electron.mainWindow);
const mockEvent = { sender: { getTitle: jest.fn().mockReturnValue('Test Window') } };
- const mockArgs = { org: 'testOrgId' };
+ const mockArgs = {};
sfcalls.handlers.sf_logout(mockEvent, mockArgs);
await new Promise((resolve) => { process.nextTick(resolve); });
@@ -667,28 +691,27 @@ test('Test sf_logout success path', async () => {
message: 'Logout Successful',
}),
);
- expect(sfcalls.__get__('sfConnections').testOrgId).toBeNull();
+ expect(sfcalls.__get__('activeConnection')).toBeNull();
});
-test('Test sf_logout error path', () => {
+test('Test sf_logout error path', async () => {
jest.clearAllMocks();
jsforce.Connection.mockImplementationOnce(() => ({
logout: { then: (_onFulfilled, onRejected) => onRejected(new Error('Logout requested for unknown user')) },
limitInfo: {},
}));
- sfcalls.__set__('sfConnections', {
- errorOrgId: {
- instanceUrl: 'https://test.salesforce.com',
- accessToken: 'testToken',
- version: '63.0',
- },
+ sfcalls.__set__('activeConnection', {
+ instanceUrl: 'https://test.salesforce.com',
+ accessToken: 'testToken',
+ version: '63.0',
});
sfcalls.setwindow(electron.mainWindow);
const mockEvent = { sender: { getTitle: jest.fn().mockReturnValue('Test Window') } };
- const mockArgs = { org: 'errorOrgId' };
+ const mockArgs = {};
sfcalls.handlers.sf_logout(mockEvent, mockArgs);
+ await new Promise((resolve) => { process.nextTick(resolve); });
expect(electron.mainWindow.webContents.send).toHaveBeenCalledWith(
'response_logout',
@@ -701,18 +724,16 @@ test('Test sf_logout error path', () => {
test('Test sf_describeGlobal success path', async () => {
jest.clearAllMocks();
- sfcalls.__set__('sfConnections', {
- testOrgId: {
- instanceUrl: 'https://test.salesforce.com',
- accessToken: 'testToken',
- version: '63.0',
- },
+ sfcalls.__set__('activeConnection', {
+ instanceUrl: 'https://test.salesforce.com',
+ accessToken: 'testToken',
+ version: '63.0',
});
sfcalls.setwindow(electron.mainWindow);
sfcalls.setPreferences(samplePrefs);
const mockEvent = { sender: electron.mainWindow.webContents };
- const mockArgs = { org: 'testOrgId' };
+ const mockArgs = {};
sfcalls.handlers.sf_describeGlobal(mockEvent, mockArgs);
await new Promise((resolve) => { process.nextTick(resolve); });
@@ -736,17 +757,15 @@ test('Test sf_describeGlobal error path', async () => {
describeGlobal: jest.fn().mockRejectedValue(new Error('Connection timed out')),
limitInfo: {},
}));
- sfcalls.__set__('sfConnections', {
- errorOrgId: {
- instanceUrl: 'https://test.salesforce.com',
- accessToken: 'testToken',
- version: '63.0',
- },
+ sfcalls.__set__('activeConnection', {
+ instanceUrl: 'https://test.salesforce.com',
+ accessToken: 'testToken',
+ version: '63.0',
});
sfcalls.setwindow(electron.mainWindow);
const mockEvent = { sender: electron.mainWindow.webContents };
- const mockArgs = { org: 'errorOrgId' };
+ const mockArgs = {};
sfcalls.handlers.sf_describeGlobal(mockEvent, mockArgs);
await new Promise((resolve) => { process.nextTick(resolve); });
@@ -795,18 +814,16 @@ test('Test sf_getObjectFields success path', async () => {
}),
limitInfo: {},
}));
- sfcalls.__set__('sfConnections', {
- testOrgId: {
- instanceUrl: 'https://test.salesforce.com',
- accessToken: 'testToken',
- version: '63.0',
- },
+ sfcalls.__set__('activeConnection', {
+ instanceUrl: 'https://test.salesforce.com',
+ accessToken: 'testToken',
+ version: '63.0',
});
sfcalls.setwindow(electron.mainWindow);
sfcalls.setPreferences(samplePrefs);
const mockEvent = { sender: electron.mainWindow.webContents };
- const mockArgs = { org: 'testOrgId', objects: ['Account'] };
+ const mockArgs = { objects: ['Account'] };
sfcalls.handlers.sf_getObjectFields(mockEvent, mockArgs);
await new Promise((resolve) => { process.nextTick(resolve); });
@@ -838,18 +855,16 @@ test('Test sf_getObjectFields error path', async () => {
}),
limitInfo: {},
}));
- sfcalls.__set__('sfConnections', {
- testOrgId: {
- instanceUrl: 'https://test.salesforce.com',
- accessToken: 'testToken',
- version: '63.0',
- },
+ sfcalls.__set__('activeConnection', {
+ instanceUrl: 'https://test.salesforce.com',
+ accessToken: 'testToken',
+ version: '63.0',
});
sfcalls.setwindow(electron.mainWindow);
sfcalls.setPreferences(samplePrefs);
const mockEvent = { sender: electron.mainWindow.webContents };
- const mockArgs = { org: 'testOrgId', objects: ['NonExistentObject__c'] };
+ const mockArgs = { objects: ['NonExistentObject__c'] };
sfcalls.handlers.sf_getObjectFields(mockEvent, mockArgs);
await new Promise((resolve) => { process.nextTick(resolve); });
diff --git a/src/tests/sf_oauth2.test.js b/src/tests/sf_oauth2.test.js
new file mode 100644
index 0000000..ca49dfa
--- /dev/null
+++ b/src/tests/sf_oauth2.test.js
@@ -0,0 +1,156 @@
+const electron = require('electron');
+const jsforce = require('jsforce');
+const http = require('http');
+const oauth = require('../sf_oauth2');
+const config = require('../config');
+
+describe('Salesforce OAuth2 Tests', () => {
+ let serverHandler = null;
+
+ beforeEach(() => {
+ // Clear all mocks and reset handler
+ jest.clearAllMocks();
+ serverHandler = null;
+
+ // Single stable mock for http.createServer for all tests
+ jest.spyOn(http, 'createServer').mockImplementation((handler) => {
+ // Capture the handler for tests to invoke
+ serverHandler = handler;
+
+ const server = {
+ listen: (port, host, cb) => {
+ let cBack = cb;
+ if (typeof host === 'function') cBack = host;
+ // simulate async listen
+ process.nextTick(() => cBack && cBack());
+ },
+ address: () => ({ port: 51234 }),
+ close: (cBack) => cBack && cBack(),
+ on: jest.fn(),
+ };
+ return server;
+ });
+
+ // Ensure electron.shell.openExternal exists on the electron mock
+ if (electron && electron.shell && !electron.shell.openExternal) {
+ electron.shell.openExternal = jest.fn().mockResolvedValue(true);
+ }
+ });
+
+ describe('isValidSalesforceUrl', () => {
+ test('accepts valid Salesforce URLs', () => {
+ const validUrls = [
+ 'https://login.salesforce.com/auth',
+ 'https://test.salesforce.com/services/oauth2/authorize',
+ 'https://myorg.my.salesforce.com/setup',
+ 'https://company.cloudforce.com/oauth',
+ ];
+
+ validUrls.forEach((url) => {
+ expect(oauth.__get__('isValidSalesforceUrl')(url)).toBe(true);
+ });
+ });
+
+ test('rejects invalid URLs', () => {
+ const invalidUrls = [
+ 'http://login.salesforce.com',
+ 'https://fake-salesforce.com',
+ 'https://salesforce.com',
+ 'not-a-url',
+ ];
+
+ invalidUrls.forEach((url) => {
+ expect(oauth.__get__('isValidSalesforceUrl')(url)).toBe(false);
+ });
+ });
+ });
+
+ describe('createLocalServer', () => {
+ test('creates server and resolves with auth code', async () => {
+ const mockJsfOauth = { redirectUri: 'https://localhost/completesetup' };
+ const createLocalServer = oauth.__get__('createLocalServer');
+ const serverPromise = createLocalServer(mockJsfOauth);
+
+ // invoke captured handler to simulate incoming request
+ expect(typeof serverHandler).toBe('function');
+ serverHandler(
+ { url: '/completesetup?code=test-auth-code', method: 'GET' },
+ { writeHead: jest.fn(), end: jest.fn() },
+ );
+
+ const code = await serverPromise;
+ expect(code).toBe('test-auth-code');
+ });
+
+ test('rejects when no auth code provided', async () => {
+ const mockJsfOauth = { redirectUri: 'https://localhost/completesetup' };
+ const createLocalServer = oauth.__get__('createLocalServer');
+ const serverPromise = createLocalServer(mockJsfOauth);
+
+ expect(typeof serverHandler).toBe('function');
+ serverHandler(
+ { url: '/completesetup', method: 'GET' },
+ { writeHead: jest.fn(), end: jest.fn() },
+ );
+
+ await expect(serverPromise).rejects.toThrow('No authorization code received');
+ });
+ });
+
+ describe('attemptLogin', () => {
+ test('successful login flow', async () => {
+ // Set credentials directly in config
+ config.updateOAuthCredentials('test-client-id', 'test-client-secret');
+
+ // start attemptLogin, which will call createLocalServer (handler captured)
+ const promise = oauth.attemptLogin('https://login.salesforce.com');
+
+ // simulate callback arriving after server listen
+ process.nextTick(() => {
+ expect(typeof serverHandler).toBe('function');
+ serverHandler(
+ { url: '/completesetup?code=test-auth-code', method: 'GET' },
+ { writeHead: jest.fn(), end: jest.fn() },
+ );
+ });
+
+ const result = await promise;
+
+ expect(jsforce.OAuth2).toHaveBeenCalledWith({
+ loginUrl: 'https://login.salesforce.com',
+ clientId: 'test-client-id',
+ clientSecret: 'test-client-secret',
+ redirectUri: expect.stringContaining('localhost'),
+ });
+
+ expect(electron.shell.openExternal).toHaveBeenCalledWith(
+ expect.stringContaining('https://login.salesforce.com'),
+ );
+
+ expect(result.conn).toBeDefined();
+ expect(result.userInfo).toBeDefined();
+ });
+
+ test('fails with missing credentials', async () => {
+ // Clear credentials directly in config
+ config.updateOAuthCredentials('', '');
+
+ await expect(oauth.attemptLogin('https://login.salesforce.com'))
+ .rejects
+ .toThrow('Missing OAuth credentials');
+ });
+
+ test('fails with invalid Salesforce URL', async () => {
+ // Set credentials directly in config
+ config.updateOAuthCredentials('test-client-id', 'test-client-secret');
+
+ jsforce.OAuth2.mockImplementationOnce(() => ({
+ getAuthorizationUrl: jest.fn().mockReturnValue('https://not-salesforce.com/auth'),
+ }));
+
+ await expect(oauth.attemptLogin('https://junk.salesforce.com'))
+ .rejects
+ .toThrow('Invalid Salesforce authentication URL');
+ });
+ });
+});