super_auth 0.1.4 → 0.2.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.
- checksums.yaml +4 -4
- data/.ruby-version +1 -0
- data/Gemfile +7 -10
- data/Gemfile.lock +53 -5
- data/LICENSE.txt +125 -21
- data/README.md +32 -1
- data/Rakefile +0 -2
- data/USAGE.md +619 -0
- data/VISUALIZATION.md +58 -0
- data/app/controllers/super_auth/graph_controller.rb +661 -0
- data/app/views/super_auth/graph/index.html.erb +1408 -0
- data/config/routes.rb +73 -0
- data/db/migrate/1_users.rb +7 -4
- data/db/migrate/2_groups.rb +14 -3
- data/db/migrate/3_permissions.rb +6 -2
- data/db/migrate/4_roles.rb +14 -3
- data/db/migrate/5_resources.rb +7 -4
- data/db/migrate/6_edge.rb +6 -4
- data/db/migrate/7_authorization.rb +41 -0
- data/db/migrate/8_add_indexes_to_edges.rb +17 -0
- data/db/migrate_activerecord/20250101000001_create_super_auth_users.rb +10 -0
- data/db/migrate_activerecord/20250101000002_create_super_auth_groups.rb +11 -0
- data/db/migrate_activerecord/20250101000003_create_super_auth_permissions.rb +8 -0
- data/db/migrate_activerecord/20250101000004_create_super_auth_roles.rb +11 -0
- data/db/migrate_activerecord/20250101000005_create_super_auth_resources.rb +10 -0
- data/db/migrate_activerecord/20250101000006_create_super_auth_edges.rb +12 -0
- data/db/migrate_activerecord/20250101000007_create_super_auth_authorizations.rb +41 -0
- data/db/seeds/sample_data.rb +193 -0
- data/lib/basic_loader.rb +10 -2
- data/lib/generators/super_auth/install/install_generator.rb +19 -0
- data/lib/generators/super_auth/install/templates/README +29 -0
- data/lib/generators/super_auth/install/templates/super_auth.rb +7 -0
- data/lib/super_auth/active_record/authorization.rb +3 -0
- data/lib/super_auth/active_record/by_current_user.rb +39 -0
- data/lib/super_auth/active_record/edge.rb +48 -0
- data/lib/super_auth/active_record/group.rb +10 -0
- data/lib/super_auth/active_record/permission.rb +7 -0
- data/lib/super_auth/active_record/resource.rb +4 -0
- data/lib/super_auth/active_record/role.rb +10 -0
- data/lib/super_auth/active_record/user.rb +14 -0
- data/lib/super_auth/active_record.rb +20 -0
- data/lib/super_auth/authorization.rb +2 -0
- data/lib/super_auth/edge.rb +205 -92
- data/lib/super_auth/group.rb +1 -0
- data/lib/super_auth/nestable.rb +17 -10
- data/lib/super_auth/permission.rb +1 -1
- data/lib/super_auth/railtie.rb +30 -0
- data/lib/super_auth/role.rb +2 -1
- data/lib/super_auth/user.rb +14 -14
- data/lib/super_auth/version.rb +1 -3
- data/lib/super_auth.rb +103 -29
- data/lib/tasks/super_auth_tasks.rake +9 -8
- data/visualization.html +747 -0
- metadata +33 -6
|
@@ -0,0 +1,1408 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>Interactive SuperAuth Graph</title>
|
|
5
|
+
<script src="https://d3js.org/d3.v7.min.js"></script>
|
|
6
|
+
<style>
|
|
7
|
+
body {
|
|
8
|
+
margin: 0;
|
|
9
|
+
padding: 20px;
|
|
10
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
11
|
+
background: #1a1a1a;
|
|
12
|
+
color: #fff;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.container {
|
|
16
|
+
display: flex;
|
|
17
|
+
gap: 20px;
|
|
18
|
+
height: 95vh;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.sidebar {
|
|
22
|
+
width: 350px;
|
|
23
|
+
background: #2a2a2a;
|
|
24
|
+
border-radius: 8px;
|
|
25
|
+
padding: 20px;
|
|
26
|
+
overflow-y: auto;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.graph-container {
|
|
30
|
+
flex: 1;
|
|
31
|
+
background: #2a2a2a;
|
|
32
|
+
border-radius: 8px;
|
|
33
|
+
position: relative;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
h1, h2, h3 {
|
|
37
|
+
margin-top: 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.section {
|
|
41
|
+
margin-bottom: 30px;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.form-group {
|
|
45
|
+
margin-bottom: 15px;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
label {
|
|
49
|
+
display: block;
|
|
50
|
+
margin-bottom: 5px;
|
|
51
|
+
font-size: 14px;
|
|
52
|
+
color: #aaa;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
input, select {
|
|
56
|
+
width: 100%;
|
|
57
|
+
padding: 8px 12px;
|
|
58
|
+
background: #1a1a1a;
|
|
59
|
+
border: 1px solid #444;
|
|
60
|
+
border-radius: 4px;
|
|
61
|
+
color: #fff;
|
|
62
|
+
font-size: 14px;
|
|
63
|
+
box-sizing: border-box;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
button {
|
|
67
|
+
padding: 10px 20px;
|
|
68
|
+
background: #0066cc;
|
|
69
|
+
color: white;
|
|
70
|
+
border: none;
|
|
71
|
+
border-radius: 4px;
|
|
72
|
+
cursor: pointer;
|
|
73
|
+
font-size: 14px;
|
|
74
|
+
width: 100%;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
button:hover {
|
|
78
|
+
background: #0052a3;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
button.delete {
|
|
82
|
+
background: #cc0000;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
button.delete:hover {
|
|
86
|
+
background: #a30000;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.entity-list {
|
|
90
|
+
max-height: 200px;
|
|
91
|
+
overflow-y: auto;
|
|
92
|
+
background: #1a1a1a;
|
|
93
|
+
border-radius: 4px;
|
|
94
|
+
padding: 10px;
|
|
95
|
+
margin-top: 10px;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.entity-item {
|
|
99
|
+
display: flex;
|
|
100
|
+
justify-content: space-between;
|
|
101
|
+
align-items: center;
|
|
102
|
+
padding: 8px;
|
|
103
|
+
margin-bottom: 5px;
|
|
104
|
+
background: #2a2a2a;
|
|
105
|
+
border-radius: 4px;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.entity-item button {
|
|
109
|
+
width: auto;
|
|
110
|
+
padding: 4px 12px;
|
|
111
|
+
font-size: 12px;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.entity-item span[onclick]:hover,
|
|
115
|
+
.entity-item span[onclick^="highlightEdgeFromList"]:hover {
|
|
116
|
+
color: #ffd700;
|
|
117
|
+
text-decoration: underline;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.stats {
|
|
121
|
+
display: grid;
|
|
122
|
+
grid-template-columns: repeat(2, 1fr);
|
|
123
|
+
gap: 10px;
|
|
124
|
+
margin-bottom: 20px;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.stat-box {
|
|
128
|
+
background: #1a1a1a;
|
|
129
|
+
padding: 15px;
|
|
130
|
+
border-radius: 4px;
|
|
131
|
+
text-align: center;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.stat-number {
|
|
135
|
+
font-size: 24px;
|
|
136
|
+
font-weight: bold;
|
|
137
|
+
color: #0066cc;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.stat-label {
|
|
141
|
+
font-size: 12px;
|
|
142
|
+
color: #aaa;
|
|
143
|
+
margin-top: 5px;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
svg {
|
|
147
|
+
width: 100%;
|
|
148
|
+
height: 100%;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.node {
|
|
152
|
+
cursor: move;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.node circle {
|
|
156
|
+
stroke: #fff;
|
|
157
|
+
stroke-width: 2px;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.node.pinned circle {
|
|
161
|
+
stroke-dasharray: 3,3;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.node text {
|
|
165
|
+
font-size: 12px;
|
|
166
|
+
fill: #fff;
|
|
167
|
+
text-anchor: middle;
|
|
168
|
+
pointer-events: none;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.link {
|
|
172
|
+
stroke: #666;
|
|
173
|
+
stroke-width: 2px;
|
|
174
|
+
stroke-opacity: 0.6;
|
|
175
|
+
fill: none;
|
|
176
|
+
cursor: pointer;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.link.highlighted {
|
|
180
|
+
stroke: #ffd700;
|
|
181
|
+
stroke-width: 3px;
|
|
182
|
+
stroke-opacity: 1;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.node.highlighted circle {
|
|
186
|
+
stroke: #ffd700;
|
|
187
|
+
stroke-width: 4px;
|
|
188
|
+
filter: drop-shadow(0 0 8px #ffd700);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.node.dimmed {
|
|
192
|
+
opacity: 0.2;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.link.dimmed {
|
|
196
|
+
opacity: 0.1;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.message {
|
|
200
|
+
position: fixed;
|
|
201
|
+
top: 20px;
|
|
202
|
+
right: 20px;
|
|
203
|
+
padding: 15px 20px;
|
|
204
|
+
border-radius: 4px;
|
|
205
|
+
background: #0066cc;
|
|
206
|
+
color: white;
|
|
207
|
+
z-index: 1000;
|
|
208
|
+
animation: slideIn 0.3s ease;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.message.error {
|
|
212
|
+
background: #cc0000;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
@keyframes slideIn {
|
|
216
|
+
from {
|
|
217
|
+
transform: translateX(400px);
|
|
218
|
+
opacity: 0;
|
|
219
|
+
}
|
|
220
|
+
to {
|
|
221
|
+
transform: translateX(0);
|
|
222
|
+
opacity: 1;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
.tabs {
|
|
227
|
+
display: flex;
|
|
228
|
+
gap: 10px;
|
|
229
|
+
margin-bottom: 20px;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.tab {
|
|
233
|
+
padding: 8px 16px;
|
|
234
|
+
background: #1a1a1a;
|
|
235
|
+
border-radius: 4px;
|
|
236
|
+
cursor: pointer;
|
|
237
|
+
font-size: 14px;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.tab.active {
|
|
241
|
+
background: #0066cc;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.tab-content {
|
|
245
|
+
display: none;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.tab-content.active {
|
|
249
|
+
display: block;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
.orphan-item {
|
|
253
|
+
padding: 10px;
|
|
254
|
+
background: #2a2a2a;
|
|
255
|
+
border-radius: 4px;
|
|
256
|
+
margin-bottom: 8px;
|
|
257
|
+
border-left: 3px solid #ff9800;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.orphan-item .name {
|
|
261
|
+
font-weight: bold;
|
|
262
|
+
margin-bottom: 4px;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.orphan-item .reason {
|
|
266
|
+
font-size: 12px;
|
|
267
|
+
color: #aaa;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
.legend {
|
|
271
|
+
position: absolute;
|
|
272
|
+
top: 20px;
|
|
273
|
+
right: 20px;
|
|
274
|
+
background: rgba(42, 42, 42, 0.95);
|
|
275
|
+
border: 1px solid #444;
|
|
276
|
+
border-radius: 8px;
|
|
277
|
+
padding: 15px;
|
|
278
|
+
z-index: 100;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.legend h4 {
|
|
282
|
+
margin: 0 0 10px 0;
|
|
283
|
+
font-size: 14px;
|
|
284
|
+
font-weight: 600;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
.legend-item {
|
|
288
|
+
display: flex;
|
|
289
|
+
align-items: center;
|
|
290
|
+
gap: 10px;
|
|
291
|
+
margin-bottom: 8px;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.legend-item:last-child {
|
|
295
|
+
margin-bottom: 0;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
.legend-color {
|
|
299
|
+
width: 16px;
|
|
300
|
+
height: 16px;
|
|
301
|
+
border-radius: 50%;
|
|
302
|
+
flex-shrink: 0;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
.legend-label {
|
|
306
|
+
font-size: 13px;
|
|
307
|
+
}
|
|
308
|
+
</style>
|
|
309
|
+
</head>
|
|
310
|
+
<body>
|
|
311
|
+
<div class="container">
|
|
312
|
+
<div class="sidebar">
|
|
313
|
+
<h1>SuperAuth Graph</h1>
|
|
314
|
+
|
|
315
|
+
<!-- Filters Section -->
|
|
316
|
+
<div class="section">
|
|
317
|
+
<h3>Filters</h3>
|
|
318
|
+
<div class="form-group">
|
|
319
|
+
<label for="filter-user">User</label>
|
|
320
|
+
<select id="filter-user" onchange="applyFilters()">
|
|
321
|
+
<option value="">All Users</option>
|
|
322
|
+
</select>
|
|
323
|
+
</div>
|
|
324
|
+
<div class="form-group">
|
|
325
|
+
<label for="filter-resource">Resource</label>
|
|
326
|
+
<select id="filter-resource" onchange="applyFilters()">
|
|
327
|
+
<option value="">All Resources</option>
|
|
328
|
+
</select>
|
|
329
|
+
</div>
|
|
330
|
+
<button onclick="clearFilters()" style="width: 100%; padding: 8px; background: #444; border: none; color: white; border-radius: 4px; cursor: pointer;">Clear Filters</button>
|
|
331
|
+
</div>
|
|
332
|
+
|
|
333
|
+
<div class="stats">
|
|
334
|
+
<div class="stat-box">
|
|
335
|
+
<div class="stat-number" id="user-count">0</div>
|
|
336
|
+
<div class="stat-label">Users</div>
|
|
337
|
+
</div>
|
|
338
|
+
<div class="stat-box">
|
|
339
|
+
<div class="stat-number" id="group-count">0</div>
|
|
340
|
+
<div class="stat-label">Groups</div>
|
|
341
|
+
</div>
|
|
342
|
+
<div class="stat-box">
|
|
343
|
+
<div class="stat-number" id="role-count">0</div>
|
|
344
|
+
<div class="stat-label">Roles</div>
|
|
345
|
+
</div>
|
|
346
|
+
<div class="stat-box">
|
|
347
|
+
<div class="stat-number" id="permission-count">0</div>
|
|
348
|
+
<div class="stat-label">Permissions</div>
|
|
349
|
+
</div>
|
|
350
|
+
<div class="stat-box">
|
|
351
|
+
<div class="stat-number" id="resource-count">0</div>
|
|
352
|
+
<div class="stat-label">Resources</div>
|
|
353
|
+
</div>
|
|
354
|
+
<div class="stat-box">
|
|
355
|
+
<div class="stat-number" id="edge-count">0</div>
|
|
356
|
+
<div class="stat-label">Edges</div>
|
|
357
|
+
</div>
|
|
358
|
+
</div>
|
|
359
|
+
|
|
360
|
+
<div class="section">
|
|
361
|
+
<button onclick="compileAuthorizations()" style="background: #9C27B0;">
|
|
362
|
+
Compile Authorizations
|
|
363
|
+
</button>
|
|
364
|
+
<p style="font-size: 12px; color: #aaa; margin-top: 10px;">
|
|
365
|
+
Analyzes all edges and populates the authorizations table with complete user-to-resource paths for auditing.
|
|
366
|
+
</p>
|
|
367
|
+
</div>
|
|
368
|
+
|
|
369
|
+
<div class="tabs">
|
|
370
|
+
<div class="tab active" data-tab="add">Add</div>
|
|
371
|
+
<div class="tab" data-tab="manage">Manage</div>
|
|
372
|
+
<div class="tab" data-tab="orphans">Orphans</div>
|
|
373
|
+
</div>
|
|
374
|
+
|
|
375
|
+
<div class="tab-content active" id="add-tab">
|
|
376
|
+
<div class="section">
|
|
377
|
+
<h3>Add User</h3>
|
|
378
|
+
<form id="add-user-form">
|
|
379
|
+
<div class="form-group">
|
|
380
|
+
<label>Name</label>
|
|
381
|
+
<input type="text" name="name" required>
|
|
382
|
+
</div>
|
|
383
|
+
<button type="submit">Add User</button>
|
|
384
|
+
</form>
|
|
385
|
+
</div>
|
|
386
|
+
|
|
387
|
+
<div class="section">
|
|
388
|
+
<h3>Add Group</h3>
|
|
389
|
+
<form id="add-group-form">
|
|
390
|
+
<div class="form-group">
|
|
391
|
+
<label>Name</label>
|
|
392
|
+
<input type="text" name="name" required>
|
|
393
|
+
</div>
|
|
394
|
+
<div class="form-group">
|
|
395
|
+
<label>Parent Group (optional)</label>
|
|
396
|
+
<select name="parent_id" id="parent-group-select">
|
|
397
|
+
<option value="">None</option>
|
|
398
|
+
</select>
|
|
399
|
+
</div>
|
|
400
|
+
<button type="submit">Add Group</button>
|
|
401
|
+
</form>
|
|
402
|
+
</div>
|
|
403
|
+
|
|
404
|
+
<div class="section">
|
|
405
|
+
<h3>Add Role</h3>
|
|
406
|
+
<form id="add-role-form">
|
|
407
|
+
<div class="form-group">
|
|
408
|
+
<label>Name</label>
|
|
409
|
+
<input type="text" name="name" required>
|
|
410
|
+
</div>
|
|
411
|
+
<div class="form-group">
|
|
412
|
+
<label>Parent Role (optional)</label>
|
|
413
|
+
<select name="parent_id" id="parent-role-select">
|
|
414
|
+
<option value="">None</option>
|
|
415
|
+
</select>
|
|
416
|
+
</div>
|
|
417
|
+
<button type="submit">Add Role</button>
|
|
418
|
+
</form>
|
|
419
|
+
</div>
|
|
420
|
+
|
|
421
|
+
<div class="section">
|
|
422
|
+
<h3>Add Permission</h3>
|
|
423
|
+
<form id="add-permission-form">
|
|
424
|
+
<div class="form-group">
|
|
425
|
+
<label>Name</label>
|
|
426
|
+
<input type="text" name="name" required>
|
|
427
|
+
</div>
|
|
428
|
+
<button type="submit">Add Permission</button>
|
|
429
|
+
</form>
|
|
430
|
+
</div>
|
|
431
|
+
|
|
432
|
+
<div class="section">
|
|
433
|
+
<h3>Add Resource</h3>
|
|
434
|
+
<form id="add-resource-form">
|
|
435
|
+
<div class="form-group">
|
|
436
|
+
<label>Name</label>
|
|
437
|
+
<input type="text" name="name" required>
|
|
438
|
+
</div>
|
|
439
|
+
<button type="submit">Add Resource</button>
|
|
440
|
+
</form>
|
|
441
|
+
</div>
|
|
442
|
+
|
|
443
|
+
<div class="section">
|
|
444
|
+
<h3>Add Edge (Connection)</h3>
|
|
445
|
+
<form id="add-edge-form">
|
|
446
|
+
<div class="form-group">
|
|
447
|
+
<label>User</label>
|
|
448
|
+
<select name="user_id" id="edge-user-select">
|
|
449
|
+
<option value="">None</option>
|
|
450
|
+
</select>
|
|
451
|
+
</div>
|
|
452
|
+
<div class="form-group">
|
|
453
|
+
<label>Group</label>
|
|
454
|
+
<select name="group_id" id="edge-group-select">
|
|
455
|
+
<option value="">None</option>
|
|
456
|
+
</select>
|
|
457
|
+
</div>
|
|
458
|
+
<div class="form-group">
|
|
459
|
+
<label>Role</label>
|
|
460
|
+
<select name="role_id" id="edge-role-select">
|
|
461
|
+
<option value="">None</option>
|
|
462
|
+
</select>
|
|
463
|
+
</div>
|
|
464
|
+
<div class="form-group">
|
|
465
|
+
<label>Permission</label>
|
|
466
|
+
<select name="permission_id" id="edge-permission-select">
|
|
467
|
+
<option value="">None</option>
|
|
468
|
+
</select>
|
|
469
|
+
</div>
|
|
470
|
+
<div class="form-group">
|
|
471
|
+
<label>Resource</label>
|
|
472
|
+
<select name="resource_id" id="edge-resource-select">
|
|
473
|
+
<option value="">None</option>
|
|
474
|
+
</select>
|
|
475
|
+
</div>
|
|
476
|
+
<button type="submit">Add Edge</button>
|
|
477
|
+
</form>
|
|
478
|
+
</div>
|
|
479
|
+
</div>
|
|
480
|
+
|
|
481
|
+
<div class="tab-content" id="manage-tab">
|
|
482
|
+
<div class="section">
|
|
483
|
+
<h3>Users</h3>
|
|
484
|
+
<div class="entity-list" id="users-list"></div>
|
|
485
|
+
</div>
|
|
486
|
+
|
|
487
|
+
<div class="section">
|
|
488
|
+
<h3>Groups</h3>
|
|
489
|
+
<div class="entity-list" id="groups-list"></div>
|
|
490
|
+
</div>
|
|
491
|
+
|
|
492
|
+
<div class="section">
|
|
493
|
+
<h3>Roles</h3>
|
|
494
|
+
<div class="entity-list" id="roles-list"></div>
|
|
495
|
+
</div>
|
|
496
|
+
|
|
497
|
+
<div class="section">
|
|
498
|
+
<h3>Permissions</h3>
|
|
499
|
+
<div class="entity-list" id="permissions-list"></div>
|
|
500
|
+
</div>
|
|
501
|
+
|
|
502
|
+
<div class="section">
|
|
503
|
+
<h3>Resources</h3>
|
|
504
|
+
<div class="entity-list" id="resources-list"></div>
|
|
505
|
+
</div>
|
|
506
|
+
|
|
507
|
+
<div class="section">
|
|
508
|
+
<h3>Edges</h3>
|
|
509
|
+
<div class="entity-list" id="edges-list"></div>
|
|
510
|
+
</div>
|
|
511
|
+
</div>
|
|
512
|
+
|
|
513
|
+
<div class="tab-content" id="orphans-tab">
|
|
514
|
+
<p style="color: #aaa; margin-bottom: 20px;">
|
|
515
|
+
Orphaned records are entities not part of any complete authorization path between a user and a resource.
|
|
516
|
+
</p>
|
|
517
|
+
|
|
518
|
+
<button onclick="loadOrphans()" style="margin-bottom: 20px;">
|
|
519
|
+
Refresh Orphans
|
|
520
|
+
</button>
|
|
521
|
+
|
|
522
|
+
<div class="section">
|
|
523
|
+
<h3>Orphaned Users (<span id="orphan-users-count">0</span>)</h3>
|
|
524
|
+
<div class="entity-list" id="orphan-users-list"></div>
|
|
525
|
+
</div>
|
|
526
|
+
|
|
527
|
+
<div class="section">
|
|
528
|
+
<h3>Orphaned Groups (<span id="orphan-groups-count">0</span>)</h3>
|
|
529
|
+
<div class="entity-list" id="orphan-groups-list"></div>
|
|
530
|
+
</div>
|
|
531
|
+
|
|
532
|
+
<div class="section">
|
|
533
|
+
<h3>Orphaned Roles (<span id="orphan-roles-count">0</span>)</h3>
|
|
534
|
+
<div class="entity-list" id="orphan-roles-list"></div>
|
|
535
|
+
</div>
|
|
536
|
+
|
|
537
|
+
<div class="section">
|
|
538
|
+
<h3>Orphaned Permissions (<span id="orphan-permissions-count">0</span>)</h3>
|
|
539
|
+
<div class="entity-list" id="orphan-permissions-list"></div>
|
|
540
|
+
</div>
|
|
541
|
+
|
|
542
|
+
<div class="section">
|
|
543
|
+
<h3>Orphaned Resources (<span id="orphan-resources-count">0</span>)</h3>
|
|
544
|
+
<div class="entity-list" id="orphan-resources-list"></div>
|
|
545
|
+
</div>
|
|
546
|
+
</div>
|
|
547
|
+
</div>
|
|
548
|
+
|
|
549
|
+
<div class="graph-container">
|
|
550
|
+
<svg id="graph-svg"></svg>
|
|
551
|
+
|
|
552
|
+
<!-- Legend -->
|
|
553
|
+
<div class="legend">
|
|
554
|
+
<h4>Node Types</h4>
|
|
555
|
+
<div class="legend-item">
|
|
556
|
+
<div class="legend-color" style="background: #4CAF50;"></div>
|
|
557
|
+
<div class="legend-label">User</div>
|
|
558
|
+
</div>
|
|
559
|
+
<div class="legend-item">
|
|
560
|
+
<div class="legend-color" style="background: #2196F3;"></div>
|
|
561
|
+
<div class="legend-label">Group</div>
|
|
562
|
+
</div>
|
|
563
|
+
<div class="legend-item">
|
|
564
|
+
<div class="legend-color" style="background: #FF9800;"></div>
|
|
565
|
+
<div class="legend-label">Role</div>
|
|
566
|
+
</div>
|
|
567
|
+
<div class="legend-item">
|
|
568
|
+
<div class="legend-color" style="background: #9C27B0;"></div>
|
|
569
|
+
<div class="legend-label">Permission</div>
|
|
570
|
+
</div>
|
|
571
|
+
<div class="legend-item">
|
|
572
|
+
<div class="legend-color" style="background: #F44336;"></div>
|
|
573
|
+
<div class="legend-label">Resource</div>
|
|
574
|
+
</div>
|
|
575
|
+
</div>
|
|
576
|
+
</div>
|
|
577
|
+
</div>
|
|
578
|
+
|
|
579
|
+
<script>
|
|
580
|
+
// Get the engine mount path - request.script_name contains the mount point
|
|
581
|
+
const basePath = '<%= request.script_name %>';
|
|
582
|
+
|
|
583
|
+
let graphData = { users: [], groups: [], roles: [], permissions: [], resources: [], edges: [] };
|
|
584
|
+
let highlightNodeById = null; // Will be set by renderGraph
|
|
585
|
+
let highlightEdgeByData = null; // Will be set by renderGraph
|
|
586
|
+
|
|
587
|
+
// Colors for different node types
|
|
588
|
+
const colors = {
|
|
589
|
+
user: '#4CAF50',
|
|
590
|
+
group: '#2196F3',
|
|
591
|
+
role: '#FF9800',
|
|
592
|
+
permission: '#9C27B0',
|
|
593
|
+
resource: '#F44336'
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
// Tab switching
|
|
597
|
+
document.querySelectorAll('.tab').forEach(tab => {
|
|
598
|
+
tab.addEventListener('click', () => {
|
|
599
|
+
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
600
|
+
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
|
601
|
+
tab.classList.add('active');
|
|
602
|
+
document.getElementById(tab.dataset.tab + '-tab').classList.add('active');
|
|
603
|
+
});
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
// Show message
|
|
607
|
+
function showMessage(text, isError = false) {
|
|
608
|
+
const msg = document.createElement('div');
|
|
609
|
+
msg.className = 'message' + (isError ? ' error' : '');
|
|
610
|
+
msg.textContent = text;
|
|
611
|
+
document.body.appendChild(msg);
|
|
612
|
+
setTimeout(() => msg.remove(), 3000);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Load graph data
|
|
616
|
+
async function loadGraph() {
|
|
617
|
+
try {
|
|
618
|
+
const response = await fetch(basePath + '/graph/data');
|
|
619
|
+
graphData = await response.json();
|
|
620
|
+
updateStats();
|
|
621
|
+
updateSelects();
|
|
622
|
+
updateLists();
|
|
623
|
+
renderGraph();
|
|
624
|
+
} catch (error) {
|
|
625
|
+
showMessage('Error loading graph: ' + error.message, true);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Apply filters (client-side)
|
|
630
|
+
function applyFilters() {
|
|
631
|
+
renderGraph();
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Clear filters
|
|
635
|
+
function clearFilters() {
|
|
636
|
+
document.getElementById('filter-user').value = '';
|
|
637
|
+
document.getElementById('filter-resource').value = '';
|
|
638
|
+
renderGraph();
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Update statistics
|
|
642
|
+
function updateStats() {
|
|
643
|
+
document.getElementById('user-count').textContent = graphData.users.length;
|
|
644
|
+
document.getElementById('group-count').textContent = graphData.groups.length;
|
|
645
|
+
document.getElementById('role-count').textContent = graphData.roles.length;
|
|
646
|
+
document.getElementById('permission-count').textContent = graphData.permissions.length;
|
|
647
|
+
document.getElementById('resource-count').textContent = graphData.resources.length;
|
|
648
|
+
document.getElementById('edge-count').textContent = graphData.edges.length;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Update select dropdowns
|
|
652
|
+
function updateSelects() {
|
|
653
|
+
// Filter dropdowns - preserve current selection
|
|
654
|
+
const filterUser = document.getElementById('filter-user');
|
|
655
|
+
const currentUserFilter = filterUser.value;
|
|
656
|
+
filterUser.innerHTML = '<option value="">All Users</option>';
|
|
657
|
+
graphData.users.forEach(u => {
|
|
658
|
+
filterUser.innerHTML += `<option value="${u.id}"${currentUserFilter == u.id ? ' selected' : ''}>${u.name}</option>`;
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
const filterResource = document.getElementById('filter-resource');
|
|
662
|
+
const currentResourceFilter = filterResource.value;
|
|
663
|
+
filterResource.innerHTML = '<option value="">All Resources</option>';
|
|
664
|
+
graphData.resources.forEach(r => {
|
|
665
|
+
filterResource.innerHTML += `<option value="${r.id}"${currentResourceFilter == r.id ? ' selected' : ''}>${r.name}</option>`;
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
// Parent group select
|
|
669
|
+
const parentGroupSelect = document.getElementById('parent-group-select');
|
|
670
|
+
parentGroupSelect.innerHTML = '<option value="">None</option>';
|
|
671
|
+
graphData.groups.forEach(g => {
|
|
672
|
+
parentGroupSelect.innerHTML += `<option value="${g.id}">${g.name}</option>`;
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
// Parent role select
|
|
676
|
+
const parentRoleSelect = document.getElementById('parent-role-select');
|
|
677
|
+
parentRoleSelect.innerHTML = '<option value="">None</option>';
|
|
678
|
+
graphData.roles.forEach(r => {
|
|
679
|
+
parentRoleSelect.innerHTML += `<option value="${r.id}">${r.name}</option>`;
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
// Edge selects
|
|
683
|
+
const edgeUserSelect = document.getElementById('edge-user-select');
|
|
684
|
+
edgeUserSelect.innerHTML = '<option value="">None</option>';
|
|
685
|
+
graphData.users.forEach(u => {
|
|
686
|
+
edgeUserSelect.innerHTML += `<option value="${u.id}">${u.name}</option>`;
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
const edgeGroupSelect = document.getElementById('edge-group-select');
|
|
690
|
+
edgeGroupSelect.innerHTML = '<option value="">None</option>';
|
|
691
|
+
graphData.groups.forEach(g => {
|
|
692
|
+
edgeGroupSelect.innerHTML += `<option value="${g.id}">${g.name}</option>`;
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
const edgeRoleSelect = document.getElementById('edge-role-select');
|
|
696
|
+
edgeRoleSelect.innerHTML = '<option value="">None</option>';
|
|
697
|
+
graphData.roles.forEach(r => {
|
|
698
|
+
edgeRoleSelect.innerHTML += `<option value="${r.id}">${r.name}</option>`;
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
const edgePermissionSelect = document.getElementById('edge-permission-select');
|
|
702
|
+
edgePermissionSelect.innerHTML = '<option value="">None</option>';
|
|
703
|
+
graphData.permissions.forEach(p => {
|
|
704
|
+
edgePermissionSelect.innerHTML += `<option value="${p.id}">${p.name}</option>`;
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
const edgeResourceSelect = document.getElementById('edge-resource-select');
|
|
708
|
+
edgeResourceSelect.innerHTML = '<option value="">None</option>';
|
|
709
|
+
graphData.resources.forEach(r => {
|
|
710
|
+
edgeResourceSelect.innerHTML += `<option value="${r.id}">${r.name}</option>`;
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Update entity lists
|
|
715
|
+
function updateLists() {
|
|
716
|
+
// Users list
|
|
717
|
+
const usersList = document.getElementById('users-list');
|
|
718
|
+
usersList.innerHTML = '';
|
|
719
|
+
graphData.users.forEach(u => {
|
|
720
|
+
usersList.innerHTML += `
|
|
721
|
+
<div class="entity-item">
|
|
722
|
+
<span onclick="highlightEntityFromList('user', ${u.id})" style="cursor: pointer; flex: 1;">${u.name}</span>
|
|
723
|
+
<button class="delete" onclick="deleteEntity('users', ${u.id})">Delete</button>
|
|
724
|
+
</div>
|
|
725
|
+
`;
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
// Groups list
|
|
729
|
+
const groupsList = document.getElementById('groups-list');
|
|
730
|
+
groupsList.innerHTML = '';
|
|
731
|
+
graphData.groups.forEach(g => {
|
|
732
|
+
const parentInfo = g.parent_id ? ` (parent: ${graphData.groups.find(p => p.id === g.parent_id)?.name || 'unknown'})` : '';
|
|
733
|
+
groupsList.innerHTML += `
|
|
734
|
+
<div class="entity-item">
|
|
735
|
+
<span onclick="highlightEntityFromList('group', ${g.id})" style="cursor: pointer; flex: 1;">${g.name}${parentInfo}</span>
|
|
736
|
+
<button class="delete" onclick="deleteEntity('groups', ${g.id})">Delete</button>
|
|
737
|
+
</div>
|
|
738
|
+
`;
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
// Roles list
|
|
742
|
+
const rolesList = document.getElementById('roles-list');
|
|
743
|
+
rolesList.innerHTML = '';
|
|
744
|
+
graphData.roles.forEach(r => {
|
|
745
|
+
const parentInfo = r.parent_id ? ` (parent: ${graphData.roles.find(p => p.id === r.parent_id)?.name || 'unknown'})` : '';
|
|
746
|
+
rolesList.innerHTML += `
|
|
747
|
+
<div class="entity-item">
|
|
748
|
+
<span onclick="highlightEntityFromList('role', ${r.id})" style="cursor: pointer; flex: 1;">${r.name}${parentInfo}</span>
|
|
749
|
+
<button class="delete" onclick="deleteEntity('roles', ${r.id})">Delete</button>
|
|
750
|
+
</div>
|
|
751
|
+
`;
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
// Permissions list
|
|
755
|
+
const permissionsList = document.getElementById('permissions-list');
|
|
756
|
+
permissionsList.innerHTML = '';
|
|
757
|
+
graphData.permissions.forEach(p => {
|
|
758
|
+
permissionsList.innerHTML += `
|
|
759
|
+
<div class="entity-item">
|
|
760
|
+
<span onclick="highlightEntityFromList('permission', ${p.id})" style="cursor: pointer; flex: 1;">${p.name}</span>
|
|
761
|
+
<button class="delete" onclick="deleteEntity('permissions', ${p.id})">Delete</button>
|
|
762
|
+
</div>
|
|
763
|
+
`;
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
// Resources list
|
|
767
|
+
const resourcesList = document.getElementById('resources-list');
|
|
768
|
+
resourcesList.innerHTML = '';
|
|
769
|
+
graphData.resources.forEach(r => {
|
|
770
|
+
resourcesList.innerHTML += `
|
|
771
|
+
<div class="entity-item">
|
|
772
|
+
<span onclick="highlightEntityFromList('resource', ${r.id})" style="cursor: pointer; flex: 1;">${r.name}</span>
|
|
773
|
+
<button class="delete" onclick="deleteEntity('resources', ${r.id})">Delete</button>
|
|
774
|
+
</div>
|
|
775
|
+
`;
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
// Edges list
|
|
779
|
+
const edgesList = document.getElementById('edges-list');
|
|
780
|
+
edgesList.innerHTML = '';
|
|
781
|
+
graphData.edges.forEach(e => {
|
|
782
|
+
const parts = [];
|
|
783
|
+
if (e.user_id) parts.push(`U:${graphData.users.find(u => u.id === e.user_id)?.name || e.user_id}`);
|
|
784
|
+
if (e.group_id) parts.push(`G:${graphData.groups.find(g => g.id === e.group_id)?.name || e.group_id}`);
|
|
785
|
+
if (e.role_id) parts.push(`R:${graphData.roles.find(r => r.id === e.role_id)?.name || e.role_id}`);
|
|
786
|
+
if (e.permission_id) parts.push(`P:${graphData.permissions.find(p => p.id === e.permission_id)?.name || e.permission_id}`);
|
|
787
|
+
if (e.resource_id) parts.push(`Res:${graphData.resources.find(r => r.id === e.resource_id)?.name || e.resource_id}`);
|
|
788
|
+
|
|
789
|
+
const edgeDataJson = JSON.stringify(e).replace(/"/g, '"');
|
|
790
|
+
edgesList.innerHTML += `
|
|
791
|
+
<div class="entity-item">
|
|
792
|
+
<span onclick='highlightEdgeFromList(${edgeDataJson})' style="font-size: 11px; flex: 1; cursor: pointer;">${parts.join(' - ')}</span>
|
|
793
|
+
<button class="delete" onclick="deleteEntity('edges', ${e.id})">Delete</button>
|
|
794
|
+
</div>
|
|
795
|
+
`;
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Highlight entity from list click
|
|
800
|
+
function highlightEntityFromList(type, id) {
|
|
801
|
+
if (highlightNodeById) {
|
|
802
|
+
highlightNodeById(type, id);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// Highlight edge from list click
|
|
807
|
+
function highlightEdgeFromList(edgeData) {
|
|
808
|
+
if (highlightEdgeByData) {
|
|
809
|
+
highlightEdgeByData(edgeData);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Render graph with D3
|
|
814
|
+
function renderGraph() {
|
|
815
|
+
const svg = d3.select('#graph-svg');
|
|
816
|
+
svg.selectAll('*').remove();
|
|
817
|
+
|
|
818
|
+
const width = document.querySelector('.graph-container').clientWidth;
|
|
819
|
+
const height = document.querySelector('.graph-container').clientHeight;
|
|
820
|
+
|
|
821
|
+
// Create a container group for zoom/pan
|
|
822
|
+
const g = svg.append('g');
|
|
823
|
+
|
|
824
|
+
// Add zoom behavior
|
|
825
|
+
const zoom = d3.zoom()
|
|
826
|
+
.scaleExtent([0.1, 4])
|
|
827
|
+
.on('zoom', (event) => {
|
|
828
|
+
g.attr('transform', event.transform);
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
svg.call(zoom);
|
|
832
|
+
|
|
833
|
+
// Apply client-side filters
|
|
834
|
+
// Create nodes array - backend already filtered the data
|
|
835
|
+
const nodes = [
|
|
836
|
+
...graphData.users.map(u => ({ ...u, type: 'user' })),
|
|
837
|
+
...graphData.groups.map(g => ({ ...g, type: 'group' })),
|
|
838
|
+
...graphData.roles.map(r => ({ ...r, type: 'role' })),
|
|
839
|
+
...graphData.permissions.map(p => ({ ...p, type: 'permission' })),
|
|
840
|
+
...graphData.resources.map(r => ({ ...r, type: 'resource' }))
|
|
841
|
+
];
|
|
842
|
+
|
|
843
|
+
// Create links array from edges
|
|
844
|
+
const links = [];
|
|
845
|
+
graphData.edges.forEach(edge => {
|
|
846
|
+
const connectedNodes = [];
|
|
847
|
+
if (edge.user_id) connectedNodes.push({ id: edge.user_id, type: 'user' });
|
|
848
|
+
if (edge.group_id) connectedNodes.push({ id: edge.group_id, type: 'group' });
|
|
849
|
+
if (edge.role_id) connectedNodes.push({ id: edge.role_id, type: 'role' });
|
|
850
|
+
if (edge.permission_id) connectedNodes.push({ id: edge.permission_id, type: 'permission' });
|
|
851
|
+
if (edge.resource_id) connectedNodes.push({ id: edge.resource_id, type: 'resource' });
|
|
852
|
+
|
|
853
|
+
for (let i = 0; i < connectedNodes.length - 1; i++) {
|
|
854
|
+
links.push({
|
|
855
|
+
source: `${connectedNodes[i].type}-${connectedNodes[i].id}`,
|
|
856
|
+
target: `${connectedNodes[i + 1].type}-${connectedNodes[i + 1].id}`,
|
|
857
|
+
edgeId: edge.id
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
// Add parent-child links for groups and roles
|
|
863
|
+
graphData.groups.forEach(g => {
|
|
864
|
+
if (g.parent_id) {
|
|
865
|
+
links.push({
|
|
866
|
+
source: `group-${g.parent_id}`,
|
|
867
|
+
target: `group-${g.id}`,
|
|
868
|
+
isHierarchy: true
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
graphData.roles.forEach(r => {
|
|
874
|
+
if (r.parent_id) {
|
|
875
|
+
links.push({
|
|
876
|
+
source: `role-${r.parent_id}`,
|
|
877
|
+
target: `role-${r.id}`,
|
|
878
|
+
isHierarchy: true
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
// Create simulation
|
|
884
|
+
const simulation = d3.forceSimulation(nodes)
|
|
885
|
+
.force('link', d3.forceLink(links).id(d => `${d.type}-${d.id}`).distance(100))
|
|
886
|
+
.force('charge', d3.forceManyBody().strength(-300))
|
|
887
|
+
.force('center', d3.forceCenter(width / 2, height / 2))
|
|
888
|
+
.force('collision', d3.forceCollide().radius(40));
|
|
889
|
+
|
|
890
|
+
// Create links
|
|
891
|
+
const link = g.append('g')
|
|
892
|
+
.selectAll('line')
|
|
893
|
+
.data(links)
|
|
894
|
+
.join('line')
|
|
895
|
+
.attr('class', 'link')
|
|
896
|
+
.style('stroke-dasharray', d => d.isHierarchy ? '5,5' : 'none')
|
|
897
|
+
.on('click', function(event, d) {
|
|
898
|
+
event.stopPropagation();
|
|
899
|
+
highlightEdge(d);
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
// Create nodes
|
|
903
|
+
const node = g.append('g')
|
|
904
|
+
.selectAll('g')
|
|
905
|
+
.data(nodes)
|
|
906
|
+
.join('g')
|
|
907
|
+
.attr('class', 'node')
|
|
908
|
+
.on('click', function(event, d) {
|
|
909
|
+
event.stopPropagation();
|
|
910
|
+
highlightNode(d, this);
|
|
911
|
+
})
|
|
912
|
+
.on('dblclick', function(event, d) {
|
|
913
|
+
event.stopPropagation();
|
|
914
|
+
// Double-click to unpin node
|
|
915
|
+
d.fx = null;
|
|
916
|
+
d.fy = null;
|
|
917
|
+
d3.select(this).classed('pinned', false);
|
|
918
|
+
simulation.alpha(0.3).restart();
|
|
919
|
+
})
|
|
920
|
+
.call(d3.drag()
|
|
921
|
+
.on('start', dragstarted)
|
|
922
|
+
.on('drag', dragged)
|
|
923
|
+
.on('end', dragended));
|
|
924
|
+
|
|
925
|
+
node.append('circle')
|
|
926
|
+
.attr('r', 20)
|
|
927
|
+
.attr('fill', d => colors[d.type]);
|
|
928
|
+
|
|
929
|
+
node.append('text')
|
|
930
|
+
.attr('dy', 30)
|
|
931
|
+
.text(d => d.name);
|
|
932
|
+
|
|
933
|
+
// Click on background to clear highlights
|
|
934
|
+
svg.on('click', clearHighlights);
|
|
935
|
+
|
|
936
|
+
// Highlight functionality
|
|
937
|
+
let selectedNode = null;
|
|
938
|
+
let selectedEdge = null;
|
|
939
|
+
|
|
940
|
+
function highlightNode(d, nodeElement) {
|
|
941
|
+
// If clicking the same node, clear highlights
|
|
942
|
+
if (selectedNode && selectedNode.id === d.id && selectedNode.type === d.type) {
|
|
943
|
+
clearHighlights();
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
selectedNode = d;
|
|
948
|
+
selectedEdge = null;
|
|
949
|
+
const nodeId = `${d.type}-${d.id}`;
|
|
950
|
+
|
|
951
|
+
// Find all connected nodes
|
|
952
|
+
const connectedNodeIds = new Set([nodeId]);
|
|
953
|
+
const connectedLinks = [];
|
|
954
|
+
|
|
955
|
+
links.forEach(link => {
|
|
956
|
+
const sourceId = typeof link.source === 'object' ? `${link.source.type}-${link.source.id}` : link.source;
|
|
957
|
+
const targetId = typeof link.target === 'object' ? `${link.target.type}-${link.target.id}` : link.target;
|
|
958
|
+
|
|
959
|
+
if (sourceId === nodeId || targetId === nodeId) {
|
|
960
|
+
connectedNodeIds.add(sourceId);
|
|
961
|
+
connectedNodeIds.add(targetId);
|
|
962
|
+
connectedLinks.push(link);
|
|
963
|
+
}
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
// Dim all nodes and links
|
|
967
|
+
node.classed('dimmed', true).classed('highlighted', false);
|
|
968
|
+
link.classed('dimmed', true).classed('highlighted', false);
|
|
969
|
+
|
|
970
|
+
// Highlight selected node and connected nodes
|
|
971
|
+
node.classed('highlighted', function(n) {
|
|
972
|
+
return connectedNodeIds.has(`${n.type}-${n.id}`);
|
|
973
|
+
}).classed('dimmed', function(n) {
|
|
974
|
+
return !connectedNodeIds.has(`${n.type}-${n.id}`);
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
// Highlight connected links
|
|
978
|
+
link.classed('highlighted', function(l) {
|
|
979
|
+
return connectedLinks.includes(l);
|
|
980
|
+
}).classed('dimmed', function(l) {
|
|
981
|
+
return !connectedLinks.includes(l);
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function highlightEdge(d) {
|
|
986
|
+
// If clicking the same edge, clear highlights
|
|
987
|
+
if (selectedEdge === d) {
|
|
988
|
+
clearHighlights();
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
selectedEdge = d;
|
|
993
|
+
selectedNode = null;
|
|
994
|
+
|
|
995
|
+
const sourceId = typeof d.source === 'object' ? `${d.source.type}-${d.source.id}` : d.source;
|
|
996
|
+
const targetId = typeof d.target === 'object' ? `${d.target.type}-${d.target.id}` : d.target;
|
|
997
|
+
|
|
998
|
+
// Highlight only the two nodes connected by this edge
|
|
999
|
+
const connectedNodeIds = new Set([sourceId, targetId]);
|
|
1000
|
+
|
|
1001
|
+
// Dim all nodes and links
|
|
1002
|
+
node.classed('dimmed', true).classed('highlighted', false);
|
|
1003
|
+
link.classed('dimmed', true).classed('highlighted', false);
|
|
1004
|
+
|
|
1005
|
+
// Highlight only the two connected nodes
|
|
1006
|
+
node.classed('highlighted', function(n) {
|
|
1007
|
+
return connectedNodeIds.has(`${n.type}-${n.id}`);
|
|
1008
|
+
}).classed('dimmed', function(n) {
|
|
1009
|
+
return !connectedNodeIds.has(`${n.type}-${n.id}`);
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
// Highlight only this edge
|
|
1013
|
+
link.classed('highlighted', function(l) {
|
|
1014
|
+
return l === d;
|
|
1015
|
+
}).classed('dimmed', function(l) {
|
|
1016
|
+
return l !== d;
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function clearHighlights() {
|
|
1021
|
+
selectedNode = null;
|
|
1022
|
+
selectedEdge = null;
|
|
1023
|
+
node.classed('highlighted', false).classed('dimmed', false);
|
|
1024
|
+
link.classed('highlighted', false).classed('dimmed', false);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// Expose highlight function for external use
|
|
1028
|
+
highlightNodeById = function(type, id) {
|
|
1029
|
+
const nodeData = nodes.find(n => n.type === type && n.id === id);
|
|
1030
|
+
if (nodeData) {
|
|
1031
|
+
highlightNode(nodeData);
|
|
1032
|
+
}
|
|
1033
|
+
};
|
|
1034
|
+
|
|
1035
|
+
// Expose edge highlight function for external use
|
|
1036
|
+
highlightEdgeByData = function(edgeData) {
|
|
1037
|
+
// Find all links that are part of this edge
|
|
1038
|
+
const matchingLinks = links.filter(l => l.edgeId === edgeData.id);
|
|
1039
|
+
|
|
1040
|
+
if (matchingLinks.length === 0) return;
|
|
1041
|
+
|
|
1042
|
+
// Clear previous selection
|
|
1043
|
+
selectedEdge = null;
|
|
1044
|
+
selectedNode = null;
|
|
1045
|
+
|
|
1046
|
+
// Find all nodes that are part of this edge
|
|
1047
|
+
const connectedNodeIds = new Set();
|
|
1048
|
+
matchingLinks.forEach(link => {
|
|
1049
|
+
const sourceId = typeof link.source === 'object' ? `${link.source.type}-${link.source.id}` : link.source;
|
|
1050
|
+
const targetId = typeof link.target === 'object' ? `${link.target.type}-${link.target.id}` : link.target;
|
|
1051
|
+
connectedNodeIds.add(sourceId);
|
|
1052
|
+
connectedNodeIds.add(targetId);
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
// Dim all nodes and links
|
|
1056
|
+
node.classed('dimmed', true).classed('highlighted', false);
|
|
1057
|
+
link.classed('dimmed', true).classed('highlighted', false);
|
|
1058
|
+
|
|
1059
|
+
// Highlight connected nodes
|
|
1060
|
+
node.classed('highlighted', function(n) {
|
|
1061
|
+
return connectedNodeIds.has(`${n.type}-${n.id}`);
|
|
1062
|
+
}).classed('dimmed', function(n) {
|
|
1063
|
+
return !connectedNodeIds.has(`${n.type}-${n.id}`);
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
// Highlight all links that are part of this edge
|
|
1067
|
+
link.classed('highlighted', function(l) {
|
|
1068
|
+
return matchingLinks.includes(l);
|
|
1069
|
+
}).classed('dimmed', function(l) {
|
|
1070
|
+
return !matchingLinks.includes(l);
|
|
1071
|
+
});
|
|
1072
|
+
};
|
|
1073
|
+
|
|
1074
|
+
// Update positions on simulation tick
|
|
1075
|
+
simulation.on('tick', () => {
|
|
1076
|
+
link
|
|
1077
|
+
.attr('x1', d => d.source.x)
|
|
1078
|
+
.attr('y1', d => d.source.y)
|
|
1079
|
+
.attr('x2', d => d.target.x)
|
|
1080
|
+
.attr('y2', d => d.target.y);
|
|
1081
|
+
|
|
1082
|
+
node.attr('transform', d => `translate(${d.x},${d.y})`);
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
// Drag functions
|
|
1086
|
+
function dragstarted(event, d) {
|
|
1087
|
+
if (!event.active) simulation.alphaTarget(0.3).restart();
|
|
1088
|
+
d.fx = d.x;
|
|
1089
|
+
d.fy = d.y;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function dragged(event, d) {
|
|
1093
|
+
d.fx = event.x;
|
|
1094
|
+
d.fy = event.y;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
function dragended(event, d) {
|
|
1098
|
+
if (!event.active) simulation.alphaTarget(0);
|
|
1099
|
+
// Keep fx and fy set so the node stays where it was placed
|
|
1100
|
+
// Mark node as pinned with visual indicator
|
|
1101
|
+
node.filter(n => n === d).classed('pinned', true);
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// Delete entity
|
|
1106
|
+
async function deleteEntity(type, id) {
|
|
1107
|
+
if (!confirm(`Are you sure you want to delete this ${type.slice(0, -1)}?`)) return;
|
|
1108
|
+
|
|
1109
|
+
try {
|
|
1110
|
+
const response = await fetch(basePath + `/graph/${type}/${id}`, {
|
|
1111
|
+
method: 'DELETE',
|
|
1112
|
+
headers: { 'Content-Type': 'application/json' }
|
|
1113
|
+
});
|
|
1114
|
+
const result = await response.json();
|
|
1115
|
+
|
|
1116
|
+
if (result.success) {
|
|
1117
|
+
showMessage(`${type.slice(0, -1)} deleted successfully`);
|
|
1118
|
+
loadGraph();
|
|
1119
|
+
} else {
|
|
1120
|
+
showMessage(result.error, true);
|
|
1121
|
+
}
|
|
1122
|
+
} catch (error) {
|
|
1123
|
+
showMessage('Error: ' + error.message, true);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// Form submissions
|
|
1128
|
+
document.getElementById('add-user-form').addEventListener('submit', async (e) => {
|
|
1129
|
+
e.preventDefault();
|
|
1130
|
+
const formData = new FormData(e.target);
|
|
1131
|
+
|
|
1132
|
+
try {
|
|
1133
|
+
const response = await fetch(basePath + '/graph/users', {
|
|
1134
|
+
method: 'POST',
|
|
1135
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1136
|
+
body: JSON.stringify({ user: Object.fromEntries(formData) })
|
|
1137
|
+
});
|
|
1138
|
+
const result = await response.json();
|
|
1139
|
+
|
|
1140
|
+
if (result.success) {
|
|
1141
|
+
showMessage('User added successfully');
|
|
1142
|
+
e.target.reset();
|
|
1143
|
+
loadGraph();
|
|
1144
|
+
} else {
|
|
1145
|
+
showMessage(result.error, true);
|
|
1146
|
+
}
|
|
1147
|
+
} catch (error) {
|
|
1148
|
+
showMessage('Error: ' + error.message, true);
|
|
1149
|
+
}
|
|
1150
|
+
});
|
|
1151
|
+
|
|
1152
|
+
document.getElementById('add-group-form').addEventListener('submit', async (e) => {
|
|
1153
|
+
e.preventDefault();
|
|
1154
|
+
const formData = new FormData(e.target);
|
|
1155
|
+
const data = Object.fromEntries(formData);
|
|
1156
|
+
if (!data.parent_id) delete data.parent_id;
|
|
1157
|
+
|
|
1158
|
+
try {
|
|
1159
|
+
const response = await fetch(basePath + '/graph/groups', {
|
|
1160
|
+
method: 'POST',
|
|
1161
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1162
|
+
body: JSON.stringify({ group: data })
|
|
1163
|
+
});
|
|
1164
|
+
const result = await response.json();
|
|
1165
|
+
|
|
1166
|
+
if (result.success) {
|
|
1167
|
+
showMessage('Group added successfully');
|
|
1168
|
+
e.target.reset();
|
|
1169
|
+
loadGraph();
|
|
1170
|
+
} else {
|
|
1171
|
+
showMessage(result.error, true);
|
|
1172
|
+
}
|
|
1173
|
+
} catch (error) {
|
|
1174
|
+
showMessage('Error: ' + error.message, true);
|
|
1175
|
+
}
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
document.getElementById('add-role-form').addEventListener('submit', async (e) => {
|
|
1179
|
+
e.preventDefault();
|
|
1180
|
+
const formData = new FormData(e.target);
|
|
1181
|
+
const data = Object.fromEntries(formData);
|
|
1182
|
+
if (!data.parent_id) delete data.parent_id;
|
|
1183
|
+
|
|
1184
|
+
try {
|
|
1185
|
+
const response = await fetch(basePath + '/graph/roles', {
|
|
1186
|
+
method: 'POST',
|
|
1187
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1188
|
+
body: JSON.stringify({ role: data })
|
|
1189
|
+
});
|
|
1190
|
+
const result = await response.json();
|
|
1191
|
+
|
|
1192
|
+
if (result.success) {
|
|
1193
|
+
showMessage('Role added successfully');
|
|
1194
|
+
e.target.reset();
|
|
1195
|
+
loadGraph();
|
|
1196
|
+
} else {
|
|
1197
|
+
showMessage(result.error, true);
|
|
1198
|
+
}
|
|
1199
|
+
} catch (error) {
|
|
1200
|
+
showMessage('Error: ' + error.message, true);
|
|
1201
|
+
}
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
document.getElementById('add-permission-form').addEventListener('submit', async (e) => {
|
|
1205
|
+
e.preventDefault();
|
|
1206
|
+
const formData = new FormData(e.target);
|
|
1207
|
+
|
|
1208
|
+
try {
|
|
1209
|
+
const response = await fetch(basePath + '/graph/permissions', {
|
|
1210
|
+
method: 'POST',
|
|
1211
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1212
|
+
body: JSON.stringify({ permission: Object.fromEntries(formData) })
|
|
1213
|
+
});
|
|
1214
|
+
const result = await response.json();
|
|
1215
|
+
|
|
1216
|
+
if (result.success) {
|
|
1217
|
+
showMessage('Permission added successfully');
|
|
1218
|
+
e.target.reset();
|
|
1219
|
+
loadGraph();
|
|
1220
|
+
} else {
|
|
1221
|
+
showMessage(result.error, true);
|
|
1222
|
+
}
|
|
1223
|
+
} catch (error) {
|
|
1224
|
+
showMessage('Error: ' + error.message, true);
|
|
1225
|
+
}
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
document.getElementById('add-resource-form').addEventListener('submit', async (e) => {
|
|
1229
|
+
e.preventDefault();
|
|
1230
|
+
const formData = new FormData(e.target);
|
|
1231
|
+
|
|
1232
|
+
try {
|
|
1233
|
+
const response = await fetch(basePath + '/graph/resources', {
|
|
1234
|
+
method: 'POST',
|
|
1235
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1236
|
+
body: JSON.stringify({ resource: Object.fromEntries(formData) })
|
|
1237
|
+
});
|
|
1238
|
+
const result = await response.json();
|
|
1239
|
+
|
|
1240
|
+
if (result.success) {
|
|
1241
|
+
showMessage('Resource added successfully');
|
|
1242
|
+
e.target.reset();
|
|
1243
|
+
loadGraph();
|
|
1244
|
+
} else {
|
|
1245
|
+
showMessage(result.error, true);
|
|
1246
|
+
}
|
|
1247
|
+
} catch (error) {
|
|
1248
|
+
showMessage('Error: ' + error.message, true);
|
|
1249
|
+
}
|
|
1250
|
+
});
|
|
1251
|
+
|
|
1252
|
+
document.getElementById('add-edge-form').addEventListener('submit', async (e) => {
|
|
1253
|
+
e.preventDefault();
|
|
1254
|
+
const formData = new FormData(e.target);
|
|
1255
|
+
const data = {};
|
|
1256
|
+
|
|
1257
|
+
// Only include non-empty values
|
|
1258
|
+
for (const [key, value] of formData.entries()) {
|
|
1259
|
+
if (value) data[key] = value;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
if (Object.keys(data).length < 2) {
|
|
1263
|
+
showMessage('Please select at least 2 entities to connect', true);
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
try {
|
|
1268
|
+
const response = await fetch(basePath + '/graph/edges', {
|
|
1269
|
+
method: 'POST',
|
|
1270
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1271
|
+
body: JSON.stringify({ edge: data })
|
|
1272
|
+
});
|
|
1273
|
+
const result = await response.json();
|
|
1274
|
+
|
|
1275
|
+
if (result.success) {
|
|
1276
|
+
showMessage('Edge added successfully');
|
|
1277
|
+
e.target.reset();
|
|
1278
|
+
loadGraph();
|
|
1279
|
+
} else {
|
|
1280
|
+
showMessage(result.error, true);
|
|
1281
|
+
}
|
|
1282
|
+
} catch (error) {
|
|
1283
|
+
showMessage('Error: ' + error.message, true);
|
|
1284
|
+
}
|
|
1285
|
+
});
|
|
1286
|
+
|
|
1287
|
+
// Compile authorizations
|
|
1288
|
+
async function compileAuthorizations() {
|
|
1289
|
+
if (!confirm('This will recompile all authorization paths. Continue?')) return;
|
|
1290
|
+
|
|
1291
|
+
try {
|
|
1292
|
+
const response = await fetch(basePath + '/graph/compile_authorizations', {
|
|
1293
|
+
method: 'POST',
|
|
1294
|
+
headers: { 'Content-Type': 'application/json' }
|
|
1295
|
+
});
|
|
1296
|
+
const result = await response.json();
|
|
1297
|
+
|
|
1298
|
+
if (result.success) {
|
|
1299
|
+
showMessage(result.message);
|
|
1300
|
+
} else {
|
|
1301
|
+
showMessage(result.error, true);
|
|
1302
|
+
}
|
|
1303
|
+
} catch (error) {
|
|
1304
|
+
showMessage('Error: ' + error.message, true);
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
// Load orphans
|
|
1309
|
+
async function loadOrphans() {
|
|
1310
|
+
try {
|
|
1311
|
+
const response = await fetch(basePath + '/graph/orphaned');
|
|
1312
|
+
const data = await response.json();
|
|
1313
|
+
const orphans = data.orphans;
|
|
1314
|
+
|
|
1315
|
+
// Update counts
|
|
1316
|
+
document.getElementById('orphan-users-count').textContent = orphans.users.length;
|
|
1317
|
+
document.getElementById('orphan-groups-count').textContent = orphans.groups.length;
|
|
1318
|
+
document.getElementById('orphan-roles-count').textContent = orphans.roles.length;
|
|
1319
|
+
document.getElementById('orphan-permissions-count').textContent = orphans.permissions.length;
|
|
1320
|
+
document.getElementById('orphan-resources-count').textContent = orphans.resources.length;
|
|
1321
|
+
|
|
1322
|
+
// Update lists
|
|
1323
|
+
const orphanUsersList = document.getElementById('orphan-users-list');
|
|
1324
|
+
orphanUsersList.innerHTML = '';
|
|
1325
|
+
if (orphans.users.length === 0) {
|
|
1326
|
+
orphanUsersList.innerHTML = '<div style="color: #666; padding: 10px;">No orphaned users</div>';
|
|
1327
|
+
} else {
|
|
1328
|
+
orphans.users.forEach(u => {
|
|
1329
|
+
orphanUsersList.innerHTML += `
|
|
1330
|
+
<div class="orphan-item">
|
|
1331
|
+
<div class="name">${u.name}</div>
|
|
1332
|
+
<div class="reason">${u.reason}</div>
|
|
1333
|
+
</div>
|
|
1334
|
+
`;
|
|
1335
|
+
});
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
const orphanGroupsList = document.getElementById('orphan-groups-list');
|
|
1339
|
+
orphanGroupsList.innerHTML = '';
|
|
1340
|
+
if (orphans.groups.length === 0) {
|
|
1341
|
+
orphanGroupsList.innerHTML = '<div style="color: #666; padding: 10px;">No orphaned groups</div>';
|
|
1342
|
+
} else {
|
|
1343
|
+
orphans.groups.forEach(g => {
|
|
1344
|
+
orphanGroupsList.innerHTML += `
|
|
1345
|
+
<div class="orphan-item">
|
|
1346
|
+
<div class="name">${g.name}</div>
|
|
1347
|
+
<div class="reason">${g.reason}</div>
|
|
1348
|
+
</div>
|
|
1349
|
+
`;
|
|
1350
|
+
});
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
const orphanRolesList = document.getElementById('orphan-roles-list');
|
|
1354
|
+
orphanRolesList.innerHTML = '';
|
|
1355
|
+
if (orphans.roles.length === 0) {
|
|
1356
|
+
orphanRolesList.innerHTML = '<div style="color: #666; padding: 10px;">No orphaned roles</div>';
|
|
1357
|
+
} else {
|
|
1358
|
+
orphans.roles.forEach(r => {
|
|
1359
|
+
orphanRolesList.innerHTML += `
|
|
1360
|
+
<div class="orphan-item">
|
|
1361
|
+
<div class="name">${r.name}</div>
|
|
1362
|
+
<div class="reason">${r.reason}</div>
|
|
1363
|
+
</div>
|
|
1364
|
+
`;
|
|
1365
|
+
});
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
const orphanPermissionsList = document.getElementById('orphan-permissions-list');
|
|
1369
|
+
orphanPermissionsList.innerHTML = '';
|
|
1370
|
+
if (orphans.permissions.length === 0) {
|
|
1371
|
+
orphanPermissionsList.innerHTML = '<div style="color: #666; padding: 10px;">No orphaned permissions</div>';
|
|
1372
|
+
} else {
|
|
1373
|
+
orphans.permissions.forEach(p => {
|
|
1374
|
+
orphanPermissionsList.innerHTML += `
|
|
1375
|
+
<div class="orphan-item">
|
|
1376
|
+
<div class="name">${p.name}</div>
|
|
1377
|
+
<div class="reason">${p.reason}</div>
|
|
1378
|
+
</div>
|
|
1379
|
+
`;
|
|
1380
|
+
});
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
const orphanResourcesList = document.getElementById('orphan-resources-list');
|
|
1384
|
+
orphanResourcesList.innerHTML = '';
|
|
1385
|
+
if (orphans.resources.length === 0) {
|
|
1386
|
+
orphanResourcesList.innerHTML = '<div style="color: #666; padding: 10px;">No orphaned resources</div>';
|
|
1387
|
+
} else {
|
|
1388
|
+
orphans.resources.forEach(r => {
|
|
1389
|
+
orphanResourcesList.innerHTML += `
|
|
1390
|
+
<div class="orphan-item">
|
|
1391
|
+
<div class="name">${r.name}</div>
|
|
1392
|
+
<div class="reason">${r.reason}</div>
|
|
1393
|
+
</div>
|
|
1394
|
+
`;
|
|
1395
|
+
});
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
showMessage('Orphans loaded successfully');
|
|
1399
|
+
} catch (error) {
|
|
1400
|
+
showMessage('Error loading orphans: ' + error.message, true);
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
// Initial load
|
|
1405
|
+
loadGraph();
|
|
1406
|
+
</script>
|
|
1407
|
+
</body>
|
|
1408
|
+
</html>
|