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 +147 -0
- package/index.js +501 -0
- package/package.json +26 -0
- package/users/users.csv +5 -0
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
|
+
}
|