sf-user-inactivator 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,147 @@
1
+ # Salesforce User Inactivation Tool
2
+
3
+ > A powerful Node.js CLI tool with a beautiful dark-themed web UI for managing user inactivation across multiple Salesforce orgs.
4
+
5
+ ## Features
6
+
7
+ - 🎨 **Modern Dark UI** - Beautiful Tailwind CSS interface with dark theme
8
+ - 📊 **Three-Panel Layout** - Users, Orgs, and Status panels with resizable splitters
9
+ - 🔍 **User Search** - Quick search functionality for filtering users
10
+ - ✅ **Selective Inactivation** - Choose specific user/org combinations
11
+ - 📈 **Real-time Status** - Live updates and detailed reports
12
+ - 🚀 **SF CLI Integration** - Uses official Salesforce CLI for operations
13
+
14
+ ## Prerequisites
15
+
16
+ - Node.js (v16 or higher)
17
+ - Salesforce CLI (`sf`) installed and configured
18
+ - Authenticated Salesforce orgs
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ npm install -g sf-user-inactivator
24
+
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ ### Prepare Your CSV File
30
+
31
+ Create a CSV file with user emails. The tool accepts various column names:
32
+
33
+ ```csv
34
+ email
35
+ user@example.com
36
+ admin@company.com
37
+ ```
38
+
39
+ Or:
40
+
41
+ ```csv
42
+ Email,Name
43
+ user@example.com,John Doe
44
+ admin@company.com,Jane Smith
45
+ ```
46
+
47
+ ### Run the Tool
48
+
49
+ ```bash
50
+
51
+ sf-user-inactive -o org1@example.com org2@example.com -u users.csv
52
+
53
+ # With custom port
54
+ sf-user-inactive -o org1@example.com -u users.csv -p 8080
55
+
56
+ ```
57
+
58
+ ### Command Options
59
+
60
+ - `-o, --orgs <usernames...>` - Salesforce org usernames (space-separated, required)
61
+ - `-u, --users <file>` - Path to CSV file with user emails (required)
62
+ - `-p, --port <number>` - Port for web UI (default: 3000)
63
+
64
+ ## How to Use the Web UI
65
+
66
+ 1. **Select a User** - Click on a user email in the left panel
67
+ 2. **Choose Orgs** - Check the boxes for orgs where you want to inactivate the user
68
+ 3. **Review Selections** - The button shows total selections count
69
+ 4. **Inactivate** - Click "Inactivate Selected" and confirm
70
+ 5. **View Report** - Check the right panel for detailed results
71
+
72
+ ### UI Features
73
+
74
+ - **Resizable Panels** - Drag the vertical splitters to adjust panel widths
75
+ - **User Search** - Type in the search box to filter users
76
+ - **Status Indicators** - Color-coded success/error/skipped status
77
+ - **Detailed Reports** - See exactly what happened with each operation
78
+
79
+ ## How It Works
80
+
81
+ 1. The tool queries each org for the specified user using SOQL
82
+ 2. If the user exists and is active, it updates the `IsActive` field to `false`
83
+ 3. Results are displayed with success/error/skipped status
84
+ 4. Users already inactive or not found are skipped
85
+
86
+ ## Example Workflow
87
+
88
+ ```bash
89
+ # 1. Create users CSV
90
+ echo "email\ntest1@example.com\ntest2@example.com" > users.csv
91
+
92
+ # 2. Run the tool
93
+ sf-inactive -o devhub@mycompany.com scratch1@mycompany.com -u users.csv
94
+
95
+ # 3. Browser opens automatically at http://localhost:3000
96
+ # 4. Use the UI to manage inactivations
97
+ ```
98
+
99
+ ## Report Format
100
+
101
+ The status panel shows:
102
+ - **Summary**: Count of successful, failed, and skipped operations
103
+ - **Detailed Report**: Each operation with:
104
+ - User email
105
+ - Target org
106
+ - Status (success/error/skipped)
107
+ - Detailed message
108
+
109
+ ## Error Handling
110
+
111
+ The tool handles:
112
+ - Users not found in orgs
113
+ - Users already inactive
114
+ - Network/connection errors
115
+ - Invalid org credentials
116
+ - CSV parsing errors
117
+
118
+ ## Tips
119
+
120
+ - Authenticate all orgs before running: `sf org login web`
121
+ - Use org aliases for easier management: `sf alias set myorg=user@example.com`
122
+ - Test with a small CSV first to verify connectivity
123
+ - Keep the browser window open while operations are running
124
+
125
+ ## Security Notes
126
+
127
+ - The tool only inactivates users, never deletes them
128
+ - Requires proper Salesforce CLI authentication
129
+ - All operations use official SF CLI commands
130
+ - Web UI is local-only (localhost)
131
+
132
+ ## Troubleshooting
133
+
134
+ **"User not found or already inactive"**
135
+ - The user doesn't exist in that org, or is already inactive
136
+
137
+ **"Command failed"**
138
+ - Check SF CLI is installed: `sf --version`
139
+ - Verify org authentication: `sf org list`
140
+
141
+ **CSV not loading**
142
+ - Ensure CSV has proper headers (email/Email/EMAIL)
143
+ - Check file path is correct
144
+
145
+ ## License
146
+
147
+ - MIT (c) Mohan Chinnappan
package/index.js ADDED
@@ -0,0 +1,501 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from 'commander';
4
+ import express from 'express';
5
+ import { readFile } from 'fs/promises';
6
+ import { parse } from 'csv-parse/sync';
7
+ import { exec } from 'child_process';
8
+ import { promisify } from 'util';
9
+ import {openResource} from 'open-resource';
10
+ import { fileURLToPath } from 'url';
11
+ import { dirname, join } from 'path';
12
+
13
+ const execAsync = promisify(exec);
14
+ const __filename = fileURLToPath(import.meta.url);
15
+ const __dirname = dirname(__filename);
16
+
17
+ const program = new Command();
18
+
19
+ program
20
+ .name('sf-inactive')
21
+ .description('Inactivate users across multiple Salesforce orgs')
22
+ .version('1.0.0')
23
+ .requiredOption('-o, --orgs <usernames...>', 'Salesforce org usernames')
24
+ .requiredOption('-u, --users <file>', 'CSV file with user emails')
25
+ .option('-p, --port <number>', 'Port for web UI', '3000')
26
+ .action(async (options) => {
27
+ try {
28
+ const csvContent = await readFile(options.users, 'utf-8');
29
+ const records = parse(csvContent, {
30
+ columns: true,
31
+ skip_empty_lines: true,
32
+ trim: true
33
+ });
34
+
35
+ const userEmails = records.map(record =>
36
+ record.email || record.Email || record.EMAIL || Object.values(record)[0]
37
+ ).filter(Boolean);
38
+
39
+ if (userEmails.length === 0) {
40
+ console.error('No user emails found in CSV file');
41
+ process.exit(1);
42
+ }
43
+
44
+ const app = express();
45
+ app.use(express.json());
46
+ app.use(express.static('public'));
47
+
48
+ app.get('/api/config', (req, res) => {
49
+ res.json({
50
+ orgs: options.orgs,
51
+ users: userEmails
52
+ });
53
+ });
54
+
55
+ app.post('/api/inactivate', async (req, res) => {
56
+ const { selections } = req.body;
57
+ const results = [];
58
+
59
+ for (const selection of selections) {
60
+ try {
61
+ const query = `SELECT Id FROM User WHERE Email='${selection.email}' AND IsActive=true LIMIT 1`;
62
+ const queryResult = await execAsync(
63
+ `sf data query --query "${query}" --target-org ${selection.org} --json`
64
+ );
65
+
66
+ const queryData = JSON.parse(queryResult.stdout);
67
+
68
+ if (queryData.result.records.length === 0) {
69
+ results.push({
70
+ org: selection.org,
71
+ email: selection.email,
72
+ status: 'skipped',
73
+ message: 'User not found or already inactive'
74
+ });
75
+ continue;
76
+ }
77
+
78
+ const userId = queryData.result.records[0].Id;
79
+
80
+ await execAsync(
81
+ `sf data update record --sobject User --record-id ${userId} --values "IsActive=false" --target-org ${selection.org} --json`
82
+ );
83
+
84
+ results.push({
85
+ org: selection.org,
86
+ email: selection.email,
87
+ status: 'success',
88
+ message: 'User inactivated successfully'
89
+ });
90
+ } catch (error) {
91
+ results.push({
92
+ org: selection.org,
93
+ email: selection.email,
94
+ status: 'error',
95
+ message: error.message
96
+ });
97
+ }
98
+ }
99
+
100
+ res.json({ results });
101
+ });
102
+
103
+ app.get('/', (req, res) => {
104
+ res.send(getHTML());
105
+ });
106
+
107
+ const port = parseInt(options.port);
108
+ app.listen(port, () => {
109
+ const url = `http://localhost:${port}`;
110
+ console.log(`\n🚀 Salesforce User Inactivation Tool`);
111
+ console.log(`📊 Dashboard: ${url}`);
112
+ console.log(`👥 Users loaded: ${userEmails.length}`);
113
+ console.log(`🏢 Orgs configured: ${options.orgs.length}\n`);
114
+ openResource(url);
115
+ });
116
+ } catch (error) {
117
+ console.error('Error:', error.message);
118
+ process.exit(1);
119
+ }
120
+ });
121
+
122
+ program.parse();
123
+
124
+ function getHTML() {
125
+ return `<!DOCTYPE html>
126
+ <html lang="en" class="dark">
127
+ <head>
128
+ <meta charset="UTF-8">
129
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
130
+ <title>SF User Inactivation Tool</title>
131
+ <script src="https://cdn.tailwindcss.com"></script>
132
+ <link rel="icon" type="image/x-icon" href="https://mohan-chinnappan-n5.github.io/dfv/img/mc_favIcon.ico" />
133
+ <script>
134
+ tailwind.config = {
135
+ darkMode: 'class',
136
+ theme: {
137
+ extend: {
138
+ colors: {
139
+ dark: {
140
+ bg: '#0f172a',
141
+ surface: '#1e293b',
142
+ border: '#334155',
143
+ hover: '#475569'
144
+ }
145
+ }
146
+ }
147
+ }
148
+ }
149
+ </script>
150
+ <style>
151
+ .splitter {
152
+ cursor: col-resize;
153
+ background: #334155;
154
+ transition: background 0.2s;
155
+ }
156
+ .splitter:hover {
157
+ background: #475569;
158
+ }
159
+ .panel {
160
+ overflow-y: auto;
161
+ scrollbar-width: thin;
162
+ scrollbar-color: #475569 #1e293b;
163
+ }
164
+ .panel::-webkit-scrollbar {
165
+ width: 8px;
166
+ }
167
+ .panel::-webkit-scrollbar-track {
168
+ background: #1e293b;
169
+ }
170
+ .panel::-webkit-scrollbar-thumb {
171
+ background: #475569;
172
+ border-radius: 4px;
173
+ }
174
+ .modal-backdrop {
175
+ background: rgba(0, 0, 0, 0.7);
176
+ backdrop-filter: blur(4px);
177
+ }
178
+ </style>
179
+ </head>
180
+ <body class="bg-dark-bg text-gray-100 h-screen overflow-hidden">
181
+ <div class="h-full flex flex-col">
182
+ <!-- Header -->
183
+ <header class="bg-dark-surface border-b border-dark-border px-6 py-4">
184
+ <div class="flex items-center justify-between">
185
+ <div>
186
+ <h1 class="text-2xl font-bold text-blue-400">Salesforce User Inactivation</h1>
187
+ <p class="text-sm text-gray-400 mt-1">Manage user status across multiple orgs</p>
188
+ </div>
189
+ <button
190
+ id="inactivateBtn"
191
+ class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2.5 rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
192
+ disabled
193
+ >
194
+ Inactivate Selected
195
+ </button>
196
+ </div>
197
+ </header>
198
+
199
+ <!-- Main Content -->
200
+ <div class="flex-1 flex overflow-hidden">
201
+ <!-- Left Panel - Users -->
202
+ <div id="leftPanel" class="panel bg-dark-surface border-r border-dark-border" style="width: 25%;">
203
+ <div class="p-4 border-b border-dark-border sticky top-0 bg-dark-surface z-10">
204
+ <h2 class="text-lg font-semibold text-gray-200 mb-3">Users</h2>
205
+ <input
206
+ type="text"
207
+ id="userSearch"
208
+ placeholder="Search users..."
209
+ class="w-full px-3 py-2 bg-dark-bg border border-dark-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
210
+ />
211
+ </div>
212
+ <div id="usersList" class="p-4 space-y-2"></div>
213
+ </div>
214
+
215
+ <div class="splitter w-1"></div>
216
+
217
+ <!-- Center Panel - Orgs -->
218
+ <div id="centerPanel" class="panel bg-dark-surface" style="width: 45%;">
219
+ <div class="p-4 border-b border-dark-border sticky top-0 bg-dark-surface z-10">
220
+ <h2 class="text-lg font-semibold text-gray-200 mb-3">Organizations</h2>
221
+ <div class="text-sm text-gray-400">Select which orgs to inactivate users in</div>
222
+ </div>
223
+ <div id="orgsList" class="p-4"></div>
224
+ </div>
225
+
226
+ <div class="splitter w-1"></div>
227
+
228
+ <!-- Right Panel - Status -->
229
+ <div id="rightPanel" class="panel bg-dark-surface border-l border-dark-border" style="width: 30%;">
230
+ <div class="p-4 border-b border-dark-border sticky top-0 bg-dark-surface z-10">
231
+ <h2 class="text-lg font-semibold text-gray-200">Status & Reports</h2>
232
+ </div>
233
+ <div id="statusArea" class="p-4">
234
+ <div class="text-center text-gray-500 mt-8">
235
+ <svg class="w-16 h-16 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
236
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
237
+ </svg>
238
+ <p>No operations performed yet</p>
239
+ </div>
240
+ </div>
241
+ </div>
242
+ </div>
243
+ </div>
244
+
245
+ <!-- Confirmation Modal -->
246
+ <div id="confirmModal" class="hidden fixed inset-0 z-50 flex items-center justify-center modal-backdrop">
247
+ <div class="bg-dark-surface rounded-xl shadow-2xl max-w-md w-full mx-4 border border-dark-border">
248
+ <div class="p-6">
249
+ <div class="flex items-center mb-4">
250
+ <div class="w-12 h-12 rounded-full bg-yellow-500/20 flex items-center justify-center mr-4">
251
+ <svg class="w-6 h-6 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
252
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
253
+ </svg>
254
+ </div>
255
+ <h3 class="text-xl font-semibold text-gray-100">Confirm Inactivation</h3>
256
+ </div>
257
+ <p class="text-gray-300 mb-4">You are about to inactivate the following users:</p>
258
+ <div id="confirmDetails" class="bg-dark-bg rounded-lg p-4 mb-6 max-h-48 overflow-y-auto text-sm"></div>
259
+ <div class="flex gap-3">
260
+ <button
261
+ id="cancelBtn"
262
+ class="flex-1 px-4 py-2.5 bg-dark-bg hover:bg-dark-hover border border-dark-border rounded-lg font-medium transition-colors"
263
+ >
264
+ Cancel
265
+ </button>
266
+ <button
267
+ id="confirmBtn"
268
+ class="flex-1 px-4 py-2.5 bg-red-600 hover:bg-red-700 text-white rounded-lg font-medium transition-colors"
269
+ >
270
+ Inactivate Users
271
+ </button>
272
+ </div>
273
+ </div>
274
+ </div>
275
+ </div>
276
+
277
+ <script>
278
+ let config = { orgs: [], users: [] };
279
+ let selectedUser = null;
280
+ let selections = new Map();
281
+
282
+ // Fetch configuration
283
+ fetch('/api/config')
284
+ .then(r => r.json())
285
+ .then(data => {
286
+ config = data;
287
+ renderUsers();
288
+ renderOrgs();
289
+ });
290
+
291
+ function renderUsers() {
292
+ const container = document.getElementById('usersList');
293
+ const search = document.getElementById('userSearch').value.toLowerCase();
294
+ const filtered = config.users.filter(u => u.toLowerCase().includes(search));
295
+
296
+ container.innerHTML = filtered.map(email => \`
297
+ <div
298
+ class="user-item p-3 rounded-lg cursor-pointer transition-colors \${selectedUser === email ? 'bg-blue-600' : 'bg-dark-bg hover:bg-dark-hover'}"
299
+ onclick="selectUser('\${email}')"
300
+ >
301
+ <div class="flex items-center">
302
+ <svg class="w-5 h-5 mr-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
303
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
304
+ </svg>
305
+ <span class="text-sm truncate">\${email}</span>
306
+ </div>
307
+ </div>
308
+ \`).join('');
309
+ }
310
+
311
+ function selectUser(email) {
312
+ selectedUser = email;
313
+ renderUsers();
314
+ renderOrgs();
315
+ }
316
+
317
+ function renderOrgs() {
318
+ const container = document.getElementById('orgsList');
319
+ if (!selectedUser) {
320
+ container.innerHTML = '<div class="text-center text-gray-500 mt-8">Select a user to configure orgs</div>';
321
+ return;
322
+ }
323
+
324
+ container.innerHTML = config.orgs.map(org => {
325
+ const key = \`\${selectedUser}::\${org}\`;
326
+ const checked = selections.has(key);
327
+ return \`
328
+ <div class="mb-3 p-4 bg-dark-bg rounded-lg border border-dark-border">
329
+ <label class="flex items-center cursor-pointer">
330
+ <input
331
+ type="checkbox"
332
+ class="w-5 h-5 rounded border-gray-600 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-0 bg-dark-surface"
333
+ onchange="toggleSelection('\${selectedUser}', '\${org}')"
334
+ \${checked ? 'checked' : ''}
335
+ />
336
+ <span class="ml-3 flex-1">
337
+ <div class="font-medium text-gray-200">\${org}</div>
338
+ <div class="text-xs text-gray-400 mt-1">Salesforce Org</div>
339
+ </span>
340
+ </label>
341
+ </div>
342
+ \`;
343
+ }).join('');
344
+
345
+ updateInactivateButton();
346
+ }
347
+
348
+ function toggleSelection(email, org) {
349
+ const key = \`\${email}::\${org}\`;
350
+ if (selections.has(key)) {
351
+ selections.delete(key);
352
+ } else {
353
+ selections.set(key, { email, org });
354
+ }
355
+ updateInactivateButton();
356
+ }
357
+
358
+ function updateInactivateButton() {
359
+ const btn = document.getElementById('inactivateBtn');
360
+ btn.disabled = selections.size === 0;
361
+ btn.textContent = \`Inactivate Selected (\${selections.size})\`;
362
+ }
363
+
364
+ document.getElementById('userSearch').addEventListener('input', renderUsers);
365
+
366
+ document.getElementById('inactivateBtn').addEventListener('click', () => {
367
+ const modal = document.getElementById('confirmModal');
368
+ const details = document.getElementById('confirmDetails');
369
+
370
+ details.innerHTML = Array.from(selections.values()).map(s => \`
371
+ <div class="mb-2 pb-2 border-b border-dark-border last:border-0">
372
+ <div class="font-medium text-gray-200">\${s.email}</div>
373
+ <div class="text-xs text-gray-400">→ \${s.org}</div>
374
+ </div>
375
+ \`).join('');
376
+
377
+ modal.classList.remove('hidden');
378
+ });
379
+
380
+ document.getElementById('cancelBtn').addEventListener('click', () => {
381
+ document.getElementById('confirmModal').classList.add('hidden');
382
+ });
383
+
384
+ document.getElementById('confirmBtn').addEventListener('click', async () => {
385
+ document.getElementById('confirmModal').classList.add('hidden');
386
+ const statusArea = document.getElementById('statusArea');
387
+
388
+ statusArea.innerHTML = \`
389
+ <div class="text-center py-8">
390
+ <div class="animate-spin w-12 h-12 border-4 border-blue-500 border-t-transparent rounded-full mx-auto mb-4"></div>
391
+ <p class="text-gray-400">Processing inactivations...</p>
392
+ </div>
393
+ \`;
394
+
395
+ try {
396
+ const response = await fetch('/api/inactivate', {
397
+ method: 'POST',
398
+ headers: { 'Content-Type': 'application/json' },
399
+ body: JSON.stringify({ selections: Array.from(selections.values()) })
400
+ });
401
+
402
+ const data = await response.json();
403
+ displayResults(data.results);
404
+ selections.clear();
405
+ updateInactivateButton();
406
+ renderOrgs();
407
+ } catch (error) {
408
+ statusArea.innerHTML = \`
409
+ <div class="bg-red-500/10 border border-red-500/30 rounded-lg p-4">
410
+ <p class="text-red-400 font-medium">Error occurred</p>
411
+ <p class="text-red-300 text-sm mt-1">\${error.message}</p>
412
+ </div>
413
+ \`;
414
+ }
415
+ });
416
+
417
+ function displayResults(results) {
418
+ const statusArea = document.getElementById('statusArea');
419
+ const success = results.filter(r => r.status === 'success').length;
420
+ const failed = results.filter(r => r.status === 'error').length;
421
+ const skipped = results.filter(r => r.status === 'skipped').length;
422
+
423
+ statusArea.innerHTML = \`
424
+ <div class="space-y-4">
425
+ <div class="bg-dark-bg rounded-lg p-4">
426
+ <h3 class="font-semibold text-gray-200 mb-3">Summary</h3>
427
+ <div class="grid grid-cols-3 gap-3 text-center">
428
+ <div>
429
+ <div class="text-2xl font-bold text-green-400">\${success}</div>
430
+ <div class="text-xs text-gray-400 mt-1">Success</div>
431
+ </div>
432
+ <div>
433
+ <div class="text-2xl font-bold text-red-400">\${failed}</div>
434
+ <div class="text-xs text-gray-400 mt-1">Failed</div>
435
+ </div>
436
+ <div>
437
+ <div class="text-2xl font-bold text-yellow-400">\${skipped}</div>
438
+ <div class="text-xs text-gray-400 mt-1">Skipped</div>
439
+ </div>
440
+ </div>
441
+ </div>
442
+
443
+ <div class="space-y-2">
444
+ <h3 class="font-semibold text-gray-200">Detailed Report</h3>
445
+ \${results.map(r => \`
446
+ <div class="bg-dark-bg rounded-lg p-3 border-l-4 \${
447
+ r.status === 'success' ? 'border-green-500' :
448
+ r.status === 'error' ? 'border-red-500' : 'border-yellow-500'
449
+ }">
450
+ <div class="flex items-start justify-between mb-1">
451
+ <span class="text-sm font-medium text-gray-200">\${r.email}</span>
452
+ <span class="text-xs px-2 py-1 rounded \${
453
+ r.status === 'success' ? 'bg-green-500/20 text-green-400' :
454
+ r.status === 'error' ? 'bg-red-500/20 text-red-400' :
455
+ 'bg-yellow-500/20 text-yellow-400'
456
+ }">\${r.status.toUpperCase()}</span>
457
+ </div>
458
+ <div class="text-xs text-gray-400">\${r.org}</div>
459
+ <div class="text-xs text-gray-500 mt-1">\${r.message}</div>
460
+ </div>
461
+ \`).join('')}
462
+ </div>
463
+ </div>
464
+ \`;
465
+ }
466
+
467
+ // Splitter functionality
468
+ const splitters = document.querySelectorAll('.splitter');
469
+ splitters.forEach((splitter, index) => {
470
+ let isResizing = false;
471
+ let startX, startWidth;
472
+ const leftPanel = splitter.previousElementSibling;
473
+ const rightPanel = splitter.nextElementSibling;
474
+
475
+ splitter.addEventListener('mousedown', (e) => {
476
+ isResizing = true;
477
+ startX = e.clientX;
478
+ startWidth = leftPanel.offsetWidth;
479
+ document.body.style.cursor = 'col-resize';
480
+ document.body.style.userSelect = 'none';
481
+ });
482
+
483
+ document.addEventListener('mousemove', (e) => {
484
+ if (!isResizing) return;
485
+ const diff = e.clientX - startX;
486
+ const newWidth = ((startWidth + diff) / window.innerWidth) * 100;
487
+ if (newWidth > 15 && newWidth < 60) {
488
+ leftPanel.style.width = newWidth + '%';
489
+ }
490
+ });
491
+
492
+ document.addEventListener('mouseup', () => {
493
+ isResizing = false;
494
+ document.body.style.cursor = '';
495
+ document.body.style.userSelect = '';
496
+ });
497
+ });
498
+ </script>
499
+ </body>
500
+ </html>`;
501
+ }
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "sf-user-inactivator",
3
+ "version": "1.0.0",
4
+ "description": "Salesforce User Inactivation Tool with Web UI",
5
+ "main": "index.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "sf-user-inactive": "./index.js"
9
+ },
10
+ "scripts": {
11
+ "start": "node index.js"
12
+ },
13
+ "keywords": [
14
+ "salesforce",
15
+ "cli",
16
+ "user-management"
17
+ ],
18
+ "author": "Mohan Chinnappan",
19
+ "license": "MIT",
20
+ "dependencies": {
21
+ "commander": "^11.1.0",
22
+ "csv-parse": "^5.5.3",
23
+ "express": "^4.18.2",
24
+ "open-resource": "^1.0.2"
25
+ }
26
+ }
@@ -0,0 +1,5 @@
1
+ email
2
+ bruce.wayne@wayneenterprises.com
3
+ diana.prince@themyscira.gov
4
+
5
+