thevoidforge 21.0.0 → 21.0.1
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/dist/wizard/danger-room.config.json +5 -0
- package/dist/wizard/ui/app.js +1231 -0
- package/dist/wizard/ui/danger-room-prophecy.js +217 -0
- package/dist/wizard/ui/danger-room.html +626 -0
- package/dist/wizard/ui/danger-room.js +880 -0
- package/dist/wizard/ui/deploy.html +177 -0
- package/dist/wizard/ui/deploy.js +582 -0
- package/dist/wizard/ui/favicon.svg +11 -0
- package/dist/wizard/ui/index.html +394 -0
- package/dist/wizard/ui/lobby.html +228 -0
- package/dist/wizard/ui/lobby.js +783 -0
- package/dist/wizard/ui/login.html +110 -0
- package/dist/wizard/ui/login.js +184 -0
- package/dist/wizard/ui/rollback.js +107 -0
- package/dist/wizard/ui/styles.css +1029 -0
- package/dist/wizard/ui/tower.html +171 -0
- package/dist/wizard/ui/tower.js +444 -0
- package/dist/wizard/ui/war-room-prophecy.js +217 -0
- package/dist/wizard/ui/war-room.html +219 -0
- package/dist/wizard/ui/war-room.js +285 -0
- package/package.json +2 -2
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Lobby — Multi-project dashboard for VoidForge Avengers Tower.
|
|
3
|
+
* Fetches project list, renders cards, handles navigation and import.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
(function () {
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
// ── State ──────────────────────────────────────────
|
|
10
|
+
let projects = [];
|
|
11
|
+
let pollTimer = null;
|
|
12
|
+
let previousFocusEl = null; // For modal focus restoration
|
|
13
|
+
let currentUser = { username: '', role: '' };
|
|
14
|
+
const REFRESH_INTERVAL_MS = 30_000; // 30 seconds
|
|
15
|
+
|
|
16
|
+
// ── DOM refs ───────────────────────────────────────
|
|
17
|
+
const grid = document.getElementById('project-grid');
|
|
18
|
+
const emptyState = document.getElementById('empty-state');
|
|
19
|
+
const importModal = document.getElementById('import-modal');
|
|
20
|
+
const importDir = document.getElementById('import-dir');
|
|
21
|
+
const importStatus = document.getElementById('import-status');
|
|
22
|
+
const importConfirm = document.getElementById('import-confirm');
|
|
23
|
+
const importCancel = document.getElementById('import-cancel');
|
|
24
|
+
const statProjects = document.getElementById('stat-projects');
|
|
25
|
+
const statCost = document.getElementById('stat-cost');
|
|
26
|
+
|
|
27
|
+
// ── Build status helper ───────────────────────────
|
|
28
|
+
function getBuildStatus(project) {
|
|
29
|
+
// deployUrl alone isn't proof of deployment — it's set during wizard setup as the intended domain.
|
|
30
|
+
// lastDeployAt confirms an actual deploy happened.
|
|
31
|
+
if (project.deployUrl && project.lastDeployAt) return { label: 'Live', action: 'Open Room', badge: 'success', auto: '' };
|
|
32
|
+
if (project.lastBuildPhase >= 13) return { label: 'Built', action: 'Open Room', badge: 'info', auto: '' };
|
|
33
|
+
if (project.lastBuildPhase > 0) return { label: 'Phase ' + project.lastBuildPhase + '/13', action: 'Return to the Shire', badge: 'warning', auto: 'campaign --blitz --resume' };
|
|
34
|
+
return { label: 'Ready', action: 'Engage', badge: 'accent', auto: 'campaign --blitz' };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── API helpers ────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
async function fetchProjects() {
|
|
40
|
+
try {
|
|
41
|
+
const res = await fetch('/api/projects');
|
|
42
|
+
if (!res.ok) return [];
|
|
43
|
+
const body = await res.json();
|
|
44
|
+
return body.data || [];
|
|
45
|
+
} catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function importProject(directory) {
|
|
51
|
+
const res = await fetch('/api/projects/import', {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: { 'Content-Type': 'application/json', 'X-VoidForge-Request': '1' },
|
|
54
|
+
body: JSON.stringify({ directory }),
|
|
55
|
+
});
|
|
56
|
+
const body = await res.json();
|
|
57
|
+
if (!res.ok) throw new Error(body.error || 'Import failed');
|
|
58
|
+
return body.data;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function deleteProject(id) {
|
|
62
|
+
const res = await fetch('/api/projects/delete', {
|
|
63
|
+
method: 'POST',
|
|
64
|
+
headers: { 'Content-Type': 'application/json', 'X-VoidForge-Request': '1' },
|
|
65
|
+
body: JSON.stringify({ id }),
|
|
66
|
+
});
|
|
67
|
+
if (!res.ok) {
|
|
68
|
+
const body = await res.json();
|
|
69
|
+
throw new Error(body.error || 'Delete failed');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Rendering ──────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
function escapeHtml(str) {
|
|
76
|
+
if (str == null) return '';
|
|
77
|
+
const div = document.createElement('div');
|
|
78
|
+
div.appendChild(document.createTextNode(String(str)));
|
|
79
|
+
return div.innerHTML;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function timeAgo(dateStr) {
|
|
83
|
+
if (!dateStr) return 'Never';
|
|
84
|
+
const diff = Date.now() - new Date(dateStr).getTime();
|
|
85
|
+
const mins = Math.floor(diff / 60000);
|
|
86
|
+
if (mins < 1) return 'Just now';
|
|
87
|
+
if (mins < 60) return mins + 'm ago';
|
|
88
|
+
const hours = Math.floor(mins / 60);
|
|
89
|
+
if (hours < 24) return hours + 'h ago';
|
|
90
|
+
const days = Math.floor(hours / 24);
|
|
91
|
+
return days + 'd ago';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function renderCard(project) {
|
|
95
|
+
const card = document.createElement('div');
|
|
96
|
+
card.className = 'project-card';
|
|
97
|
+
card.setAttribute('role', 'listitem');
|
|
98
|
+
card.setAttribute('tabindex', '0');
|
|
99
|
+
card.dataset.projectId = project.id;
|
|
100
|
+
|
|
101
|
+
const healthStatus = project.healthStatus || 'unchecked';
|
|
102
|
+
const healthLabels = { healthy: 'Up', degraded: 'Warn', down: 'Down', unchecked: '—' };
|
|
103
|
+
const healthTitle = healthStatus === 'unchecked'
|
|
104
|
+
? 'No health check configured'
|
|
105
|
+
: healthStatus.charAt(0).toUpperCase() + healthStatus.slice(1);
|
|
106
|
+
|
|
107
|
+
const urlHtml = project.deployUrl
|
|
108
|
+
? `<a href="${escapeHtml(project.deployUrl)}" class="project-url" target="_blank" rel="noopener" onclick="event.stopPropagation()">${escapeHtml(project.deployUrl)}</a>`
|
|
109
|
+
: '<span class="project-url" style="color: var(--text-muted)">Not deployed</span>';
|
|
110
|
+
|
|
111
|
+
// Role badge for this project
|
|
112
|
+
const userRole = project.userRole || 'viewer';
|
|
113
|
+
|
|
114
|
+
const sshBtn = project.sshHost && userRole !== 'viewer'
|
|
115
|
+
? `<button class="btn" data-action="ssh" data-id="${escapeHtml(project.id)}" title="SSH to production">SSH</button>`
|
|
116
|
+
: '';
|
|
117
|
+
const roleLabels = { owner: 'Owner', admin: 'Admin', deployer: 'Deployer', viewer: 'Viewer' };
|
|
118
|
+
const roleBadgeClass = userRole === 'owner' ? 'role-owner' : 'role-' + userRole;
|
|
119
|
+
|
|
120
|
+
// Build status drives button label and auto-command
|
|
121
|
+
const buildStatus = getBuildStatus(project);
|
|
122
|
+
|
|
123
|
+
// Conditional action buttons based on role
|
|
124
|
+
const canOpenRoom = userRole !== 'viewer';
|
|
125
|
+
const canRemove = userRole === 'owner' || userRole === 'admin';
|
|
126
|
+
const canManageAccess = userRole === 'owner' || userRole === 'admin';
|
|
127
|
+
|
|
128
|
+
// Contextual tooltips for build-state buttons
|
|
129
|
+
const tooltips = {
|
|
130
|
+
'Engage': 'Begin building this project — "Make it so."',
|
|
131
|
+
'Return to the Shire': 'Resume the campaign with fresh context — pick up where you left off',
|
|
132
|
+
'Open Room': 'Open the terminal workspace',
|
|
133
|
+
};
|
|
134
|
+
const tooltip = tooltips[buildStatus.action] || 'Open terminal workspace';
|
|
135
|
+
|
|
136
|
+
const openBtn = canOpenRoom
|
|
137
|
+
? `<button class="btn btn-primary" data-action="open" data-id="${escapeHtml(project.id)}" data-auto="${escapeHtml(buildStatus.auto)}" title="${escapeHtml(tooltip)}">${escapeHtml(buildStatus.action)}</button>`
|
|
138
|
+
: '';
|
|
139
|
+
const removeBtn = canRemove
|
|
140
|
+
? `<button class="btn btn-danger-ghost" data-action="remove" data-id="${escapeHtml(project.id)}" title="Remove from registry (does not delete files)">Remove</button>`
|
|
141
|
+
: '';
|
|
142
|
+
const accessBtn = canManageAccess
|
|
143
|
+
? `<button class="btn" data-action="access" data-id="${escapeHtml(project.id)}" title="Manage project access">Access</button>`
|
|
144
|
+
: '';
|
|
145
|
+
const linkBtn = canManageAccess
|
|
146
|
+
? `<button class="btn" data-action="link" data-id="${escapeHtml(project.id)}" title="Link to another project">Link</button>`
|
|
147
|
+
: '';
|
|
148
|
+
|
|
149
|
+
card.innerHTML = `
|
|
150
|
+
<div class="project-card-header">
|
|
151
|
+
<div class="project-card-name">${escapeHtml(project.name)}</div>
|
|
152
|
+
<div style="display: flex; align-items: center; gap: 6px;">
|
|
153
|
+
<span class="badge ${escapeHtml(roleBadgeClass)} user-role-badge">${escapeHtml(roleLabels[userRole] || 'Viewer')}</span>
|
|
154
|
+
<div class="health-indicator" title="${escapeHtml(healthTitle)}" role="img" aria-label="Health: ${escapeHtml(healthTitle)}">
|
|
155
|
+
<span class="health-label">${escapeHtml(healthLabels[healthStatus] || '—')}</span>
|
|
156
|
+
<div class="health-dot ${escapeHtml(healthStatus)}"></div>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
${urlHtml}
|
|
161
|
+
<div class="badge-row">
|
|
162
|
+
<span class="badge">${escapeHtml(project.framework || 'unknown')}</span>
|
|
163
|
+
<span class="badge deploy">${escapeHtml(project.deployTarget || 'unknown')}</span>
|
|
164
|
+
${project.database && project.database !== 'none' ? `<span class="badge">${escapeHtml(project.database)}</span>` : ''}
|
|
165
|
+
${project.linkedProjects && project.linkedProjects.length > 0 ? `<span class="badge linked">Linked: ${escapeHtml(String(project.linkedProjects.length))}</span>` : ''}
|
|
166
|
+
</div>
|
|
167
|
+
<div class="project-actions">
|
|
168
|
+
${openBtn}
|
|
169
|
+
${sshBtn}
|
|
170
|
+
${linkBtn}
|
|
171
|
+
${accessBtn}
|
|
172
|
+
${removeBtn}
|
|
173
|
+
</div>
|
|
174
|
+
<div class="project-footer">
|
|
175
|
+
<span>${project.monthlyCost ? '$' + escapeHtml(String(project.monthlyCost)) + '/mo' : ''}</span>
|
|
176
|
+
<span class="badge build-${escapeHtml(buildStatus.badge)}">${escapeHtml(buildStatus.label)}</span>
|
|
177
|
+
<span>${project.lastDeployAt ? 'Deployed ' + escapeHtml(timeAgo(project.lastDeployAt)) : ''}</span>
|
|
178
|
+
</div>
|
|
179
|
+
`;
|
|
180
|
+
|
|
181
|
+
// Card click → open room (unless clicking a button or link)
|
|
182
|
+
card.addEventListener('click', (e) => {
|
|
183
|
+
if (e.target.closest('button') || e.target.closest('a')) return;
|
|
184
|
+
openRoom(project);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Keyboard: Enter/Space on card opens room
|
|
188
|
+
card.addEventListener('keydown', (e) => {
|
|
189
|
+
if (e.target.closest('button') || e.target.closest('a')) return;
|
|
190
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
191
|
+
e.preventDefault();
|
|
192
|
+
openRoom(project);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Button handlers
|
|
197
|
+
card.querySelectorAll('button[data-action]').forEach((btn) => {
|
|
198
|
+
btn.addEventListener('click', (e) => {
|
|
199
|
+
e.stopPropagation();
|
|
200
|
+
const action = btn.dataset.action;
|
|
201
|
+
const id = btn.dataset.id;
|
|
202
|
+
const p = projects.find((proj) => proj.id === id);
|
|
203
|
+
if (!p) return;
|
|
204
|
+
|
|
205
|
+
if (action === 'open') openRoom(p);
|
|
206
|
+
else if (action === 'ssh') openRoom(p, 'ssh');
|
|
207
|
+
else if (action === 'remove') handleRemove(p);
|
|
208
|
+
else if (action === 'access') openAccessModal(p);
|
|
209
|
+
else if (action === 'link') openLinkModal(p);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
return card;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function render() {
|
|
217
|
+
// Clear existing cards (keep empty state for reference)
|
|
218
|
+
const existing = grid.querySelectorAll('.project-card');
|
|
219
|
+
existing.forEach((el) => el.remove());
|
|
220
|
+
|
|
221
|
+
if (projects.length === 0) {
|
|
222
|
+
emptyState.style.display = '';
|
|
223
|
+
} else {
|
|
224
|
+
emptyState.style.display = 'none';
|
|
225
|
+
for (const project of projects) {
|
|
226
|
+
grid.appendChild(renderCard(project));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Update stats from API for accurate cost aggregation
|
|
231
|
+
statProjects.textContent = projects.length + ' project' + (projects.length !== 1 ? 's' : '');
|
|
232
|
+
fetchCosts();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function fetchCosts() {
|
|
236
|
+
try {
|
|
237
|
+
const res = await fetch('/api/projects/costs');
|
|
238
|
+
if (!res.ok) return;
|
|
239
|
+
const body = await res.json();
|
|
240
|
+
const data = body.data || {};
|
|
241
|
+
statCost.textContent = data.totalMonthlyCost > 0 ? '$' + data.totalMonthlyCost + '/mo' : '$0/mo';
|
|
242
|
+
} catch {
|
|
243
|
+
const fallback = projects.reduce(function (sum, p) { return sum + (p.monthlyCost || 0); }, 0);
|
|
244
|
+
statCost.textContent = fallback > 0 ? '$' + fallback + '/mo' : '$0/mo';
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ── Navigation ─────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
function openRoom(project, mode) {
|
|
251
|
+
const params = new URLSearchParams({
|
|
252
|
+
project: project.id,
|
|
253
|
+
name: project.name,
|
|
254
|
+
dir: project.directory,
|
|
255
|
+
});
|
|
256
|
+
if (mode === 'ssh' && project.sshHost) {
|
|
257
|
+
params.set('ssh', project.sshHost);
|
|
258
|
+
}
|
|
259
|
+
// Pass auto-command for build status context (start/resume)
|
|
260
|
+
const buildStatus = getBuildStatus(project);
|
|
261
|
+
if (buildStatus.auto && mode !== 'ssh') {
|
|
262
|
+
params.set('auto', buildStatus.auto);
|
|
263
|
+
}
|
|
264
|
+
window.location.href = '/tower.html?' + params.toString();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── Import Modal (with focus trap) ─────────────────
|
|
268
|
+
|
|
269
|
+
function getFocusableElements() {
|
|
270
|
+
const modal = importModal.querySelector('.modal');
|
|
271
|
+
if (!modal) return [];
|
|
272
|
+
return Array.from(modal.querySelectorAll(
|
|
273
|
+
'input, button, [tabindex]:not([tabindex="-1"])'
|
|
274
|
+
)).filter((el) => !el.disabled);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function trapFocus(e) {
|
|
278
|
+
if (!importModal.classList.contains('active')) return;
|
|
279
|
+
if (e.key !== 'Tab') return;
|
|
280
|
+
|
|
281
|
+
const focusable = getFocusableElements();
|
|
282
|
+
if (focusable.length === 0) return;
|
|
283
|
+
|
|
284
|
+
const first = focusable[0];
|
|
285
|
+
const last = focusable[focusable.length - 1];
|
|
286
|
+
|
|
287
|
+
if (e.shiftKey) {
|
|
288
|
+
if (document.activeElement === first) {
|
|
289
|
+
e.preventDefault();
|
|
290
|
+
last.focus();
|
|
291
|
+
}
|
|
292
|
+
} else {
|
|
293
|
+
if (document.activeElement === last) {
|
|
294
|
+
e.preventDefault();
|
|
295
|
+
first.focus();
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function openImportModal() {
|
|
301
|
+
previousFocusEl = document.activeElement;
|
|
302
|
+
importDir.value = '';
|
|
303
|
+
importStatus.textContent = '';
|
|
304
|
+
importStatus.className = 'status-row';
|
|
305
|
+
importModal.classList.add('active');
|
|
306
|
+
importDir.focus();
|
|
307
|
+
document.addEventListener('keydown', trapFocus);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function closeImportModal() {
|
|
311
|
+
importModal.classList.remove('active');
|
|
312
|
+
document.removeEventListener('keydown', trapFocus);
|
|
313
|
+
// Restore focus to trigger element
|
|
314
|
+
if (previousFocusEl && previousFocusEl.focus) {
|
|
315
|
+
previousFocusEl.focus();
|
|
316
|
+
previousFocusEl = null;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function handleImport() {
|
|
321
|
+
const dir = importDir.value.trim();
|
|
322
|
+
if (!dir) {
|
|
323
|
+
importStatus.textContent = 'Please enter a directory path';
|
|
324
|
+
importStatus.className = 'status-row error';
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (!dir.startsWith('/')) {
|
|
329
|
+
importStatus.textContent = 'Path must be absolute (start with /)';
|
|
330
|
+
importStatus.className = 'status-row error';
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
importConfirm.disabled = true;
|
|
335
|
+
importStatus.textContent = 'Scanning project...';
|
|
336
|
+
importStatus.className = 'status-row loading';
|
|
337
|
+
|
|
338
|
+
try {
|
|
339
|
+
const project = await importProject(dir);
|
|
340
|
+
projects.push(project);
|
|
341
|
+
render();
|
|
342
|
+
closeImportModal();
|
|
343
|
+
} catch (err) {
|
|
344
|
+
importStatus.textContent = err.message;
|
|
345
|
+
importStatus.className = 'status-row error';
|
|
346
|
+
} finally {
|
|
347
|
+
importConfirm.disabled = false;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function handleRemove(project) {
|
|
352
|
+
if (!confirm('Remove "' + project.name + '" from The Lobby?\n\nThis only removes it from the registry — project files are not deleted.')) {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
try {
|
|
357
|
+
await deleteProject(project.id);
|
|
358
|
+
projects = projects.filter((p) => p.id !== project.id);
|
|
359
|
+
render();
|
|
360
|
+
} catch (err) {
|
|
361
|
+
alert('Failed to remove: ' + err.message);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ── Access Modal ───────────────────────────────────
|
|
366
|
+
|
|
367
|
+
const accessModal = document.getElementById('access-modal');
|
|
368
|
+
const accessOwner = document.getElementById('access-owner');
|
|
369
|
+
const accessList = document.getElementById('access-list');
|
|
370
|
+
const accessUsername = document.getElementById('access-username');
|
|
371
|
+
const accessRole = document.getElementById('access-role');
|
|
372
|
+
const accessStatus = document.getElementById('access-status');
|
|
373
|
+
const accessGrant = document.getElementById('access-grant');
|
|
374
|
+
const accessCancel = document.getElementById('access-cancel');
|
|
375
|
+
let currentAccessProjectId = '';
|
|
376
|
+
|
|
377
|
+
async function openAccessModal(project) {
|
|
378
|
+
currentAccessProjectId = project.id;
|
|
379
|
+
accessUsername.value = '';
|
|
380
|
+
accessStatus.textContent = '';
|
|
381
|
+
accessStatus.className = 'status-row';
|
|
382
|
+
|
|
383
|
+
try {
|
|
384
|
+
const res = await fetch('/api/projects/access?id=' + encodeURIComponent(project.id));
|
|
385
|
+
const body = await res.json();
|
|
386
|
+
if (!res.ok) throw new Error(body.error || 'Failed to load access');
|
|
387
|
+
const data = body.data;
|
|
388
|
+
|
|
389
|
+
accessOwner.textContent = 'Owner: ' + (data.owner || 'unassigned');
|
|
390
|
+
|
|
391
|
+
if (data.access.length === 0) {
|
|
392
|
+
accessList.innerHTML = '<div style="color: var(--text-dim); font-size: 12px;">No shared access</div>';
|
|
393
|
+
} else {
|
|
394
|
+
accessList.innerHTML = '';
|
|
395
|
+
data.access.forEach(function (entry) {
|
|
396
|
+
const row = document.createElement('div');
|
|
397
|
+
row.style.cssText = 'display: flex; justify-content: space-between; align-items: center; padding: 4px 0;';
|
|
398
|
+
const label = document.createElement('span');
|
|
399
|
+
label.textContent = entry.username + ' ';
|
|
400
|
+
const badge = document.createElement('span');
|
|
401
|
+
badge.className = 'badge role-' + (entry.role || 'viewer') + ' user-role-badge';
|
|
402
|
+
badge.textContent = entry.role;
|
|
403
|
+
label.appendChild(badge);
|
|
404
|
+
const revokeBtn = document.createElement('button');
|
|
405
|
+
revokeBtn.className = 'btn btn-danger-ghost';
|
|
406
|
+
revokeBtn.style.cssText = 'padding: 2px 6px; font-size: 10px;';
|
|
407
|
+
revokeBtn.textContent = 'Revoke';
|
|
408
|
+
revokeBtn.addEventListener('click', function () {
|
|
409
|
+
revokeProjectAccess(entry.username);
|
|
410
|
+
});
|
|
411
|
+
row.appendChild(label);
|
|
412
|
+
row.appendChild(revokeBtn);
|
|
413
|
+
accessList.appendChild(row);
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
} catch (err) {
|
|
417
|
+
accessOwner.textContent = '';
|
|
418
|
+
accessList.innerHTML = '<div class="status-row error">' + escapeHtml(err.message) + '</div>';
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
previousFocusEl = document.activeElement;
|
|
422
|
+
accessModal.classList.add('active');
|
|
423
|
+
accessUsername.focus();
|
|
424
|
+
document.addEventListener('keydown', trapAccessFocus);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function trapAccessFocus(e) {
|
|
428
|
+
if (!accessModal.classList.contains('active')) return;
|
|
429
|
+
if (e.key === 'Escape') {
|
|
430
|
+
closeAccessModal();
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (e.key !== 'Tab') return;
|
|
434
|
+
const modal = accessModal.querySelector('.modal');
|
|
435
|
+
if (!modal) return;
|
|
436
|
+
const focusable = Array.from(modal.querySelectorAll(
|
|
437
|
+
'input, select, button, [tabindex]:not([tabindex="-1"])'
|
|
438
|
+
)).filter(function (el) { return !el.disabled; });
|
|
439
|
+
if (focusable.length === 0) return;
|
|
440
|
+
const first = focusable[0];
|
|
441
|
+
const last = focusable[focusable.length - 1];
|
|
442
|
+
if (e.shiftKey) {
|
|
443
|
+
if (document.activeElement === first) { e.preventDefault(); last.focus(); }
|
|
444
|
+
} else {
|
|
445
|
+
if (document.activeElement === last) { e.preventDefault(); first.focus(); }
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function closeAccessModal() {
|
|
450
|
+
accessModal.classList.remove('active');
|
|
451
|
+
document.removeEventListener('keydown', trapAccessFocus);
|
|
452
|
+
if (previousFocusEl && previousFocusEl.focus) {
|
|
453
|
+
previousFocusEl.focus();
|
|
454
|
+
previousFocusEl = null;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function handleGrantAccess() {
|
|
459
|
+
const username = accessUsername.value.trim();
|
|
460
|
+
if (!username) {
|
|
461
|
+
accessStatus.textContent = 'Username is required';
|
|
462
|
+
accessStatus.className = 'status-row error';
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
accessGrant.disabled = true;
|
|
467
|
+
try {
|
|
468
|
+
const res = await fetch('/api/projects/access/grant', {
|
|
469
|
+
method: 'POST',
|
|
470
|
+
headers: { 'Content-Type': 'application/json', 'X-VoidForge-Request': '1' },
|
|
471
|
+
body: JSON.stringify({ projectId: currentAccessProjectId, username: username, role: accessRole.value }),
|
|
472
|
+
});
|
|
473
|
+
const body = await res.json();
|
|
474
|
+
if (!res.ok) throw new Error(body.error || 'Failed to grant access');
|
|
475
|
+
// Refresh the access list
|
|
476
|
+
const project = projects.find(function (p) { return p.id === currentAccessProjectId; });
|
|
477
|
+
if (project) await openAccessModal(project);
|
|
478
|
+
} catch (err) {
|
|
479
|
+
accessStatus.textContent = err.message;
|
|
480
|
+
accessStatus.className = 'status-row error';
|
|
481
|
+
} finally {
|
|
482
|
+
accessGrant.disabled = false;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async function revokeProjectAccess(username) {
|
|
487
|
+
try {
|
|
488
|
+
const res = await fetch('/api/projects/access/revoke', {
|
|
489
|
+
method: 'POST',
|
|
490
|
+
headers: { 'Content-Type': 'application/json', 'X-VoidForge-Request': '1' },
|
|
491
|
+
body: JSON.stringify({ projectId: currentAccessProjectId, username: username }),
|
|
492
|
+
});
|
|
493
|
+
const body = await res.json();
|
|
494
|
+
if (!res.ok) throw new Error(body.error || 'Failed to revoke');
|
|
495
|
+
// Refresh
|
|
496
|
+
const project = projects.find(function (p) { return p.id === currentAccessProjectId; });
|
|
497
|
+
if (project) await openAccessModal(project);
|
|
498
|
+
} catch (err) {
|
|
499
|
+
alert('Failed to revoke: ' + err.message);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (accessCancel) accessCancel.addEventListener('click', closeAccessModal);
|
|
504
|
+
if (accessGrant) accessGrant.addEventListener('click', handleGrantAccess);
|
|
505
|
+
if (accessModal) {
|
|
506
|
+
accessModal.addEventListener('click', function (e) {
|
|
507
|
+
if (e.target === accessModal) closeAccessModal();
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
if (accessUsername) {
|
|
511
|
+
accessUsername.addEventListener('keydown', function (e) {
|
|
512
|
+
if (e.key === 'Enter') handleGrantAccess();
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ── Link Modal ─────────────────────────────────────
|
|
517
|
+
|
|
518
|
+
const linkModal = document.getElementById('link-modal');
|
|
519
|
+
const linkCurrent = document.getElementById('link-current');
|
|
520
|
+
const linkExisting = document.getElementById('link-existing');
|
|
521
|
+
const linkSelect = document.getElementById('link-select');
|
|
522
|
+
const linkStatus = document.getElementById('link-status');
|
|
523
|
+
const linkConfirm = document.getElementById('link-confirm');
|
|
524
|
+
const linkCancel = document.getElementById('link-cancel');
|
|
525
|
+
let currentLinkProjectId = '';
|
|
526
|
+
|
|
527
|
+
async function openLinkModal(project) {
|
|
528
|
+
currentLinkProjectId = project.id;
|
|
529
|
+
linkStatus.textContent = '';
|
|
530
|
+
linkStatus.className = 'status-row';
|
|
531
|
+
linkCurrent.textContent = 'Project: ' + project.name;
|
|
532
|
+
|
|
533
|
+
// Show existing links
|
|
534
|
+
if (project.linkedProjects && project.linkedProjects.length > 0) {
|
|
535
|
+
const linkedNames = project.linkedProjects.map(function (lid) {
|
|
536
|
+
const lp = projects.find(function (p) { return p.id === lid; });
|
|
537
|
+
return lp ? lp.name : lid;
|
|
538
|
+
});
|
|
539
|
+
linkExisting.innerHTML = '';
|
|
540
|
+
linkedNames.forEach(function (name, i) {
|
|
541
|
+
const row = document.createElement('div');
|
|
542
|
+
row.style.cssText = 'display: flex; justify-content: space-between; align-items: center; padding: 4px 0;';
|
|
543
|
+
const label = document.createElement('span');
|
|
544
|
+
label.textContent = name;
|
|
545
|
+
const unlinkBtn = document.createElement('button');
|
|
546
|
+
unlinkBtn.className = 'btn btn-danger-ghost';
|
|
547
|
+
unlinkBtn.style.cssText = 'padding: 2px 6px; font-size: 10px;';
|
|
548
|
+
unlinkBtn.textContent = 'Unlink';
|
|
549
|
+
unlinkBtn.addEventListener('click', function () {
|
|
550
|
+
handleUnlink(project.linkedProjects[i]);
|
|
551
|
+
});
|
|
552
|
+
row.appendChild(label);
|
|
553
|
+
row.appendChild(unlinkBtn);
|
|
554
|
+
linkExisting.appendChild(row);
|
|
555
|
+
});
|
|
556
|
+
} else {
|
|
557
|
+
linkExisting.innerHTML = '<div style="color: var(--text-dim); font-size: 12px;">No linked projects</div>';
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Populate dropdown with other projects the user can link to
|
|
561
|
+
linkSelect.innerHTML = '';
|
|
562
|
+
const linkable = projects.filter(function (p) {
|
|
563
|
+
return p.id !== project.id &&
|
|
564
|
+
(p.userRole === 'owner' || p.userRole === 'admin') &&
|
|
565
|
+
(!project.linkedProjects || !project.linkedProjects.includes(p.id));
|
|
566
|
+
});
|
|
567
|
+
if (linkable.length === 0) {
|
|
568
|
+
const opt = document.createElement('option');
|
|
569
|
+
opt.value = '';
|
|
570
|
+
opt.textContent = 'No projects available to link';
|
|
571
|
+
linkSelect.appendChild(opt);
|
|
572
|
+
linkConfirm.disabled = true;
|
|
573
|
+
} else {
|
|
574
|
+
linkConfirm.disabled = false;
|
|
575
|
+
linkable.forEach(function (p) {
|
|
576
|
+
const opt = document.createElement('option');
|
|
577
|
+
opt.value = p.id;
|
|
578
|
+
opt.textContent = p.name;
|
|
579
|
+
linkSelect.appendChild(opt);
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
previousFocusEl = document.activeElement;
|
|
584
|
+
linkModal.classList.add('active');
|
|
585
|
+
linkSelect.focus();
|
|
586
|
+
document.addEventListener('keydown', trapLinkFocus);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function trapLinkFocus(e) {
|
|
590
|
+
if (!linkModal.classList.contains('active')) return;
|
|
591
|
+
if (e.key === 'Escape') {
|
|
592
|
+
closeLinkModal();
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
if (e.key !== 'Tab') return;
|
|
596
|
+
var modal = linkModal.querySelector('.modal');
|
|
597
|
+
if (!modal) return;
|
|
598
|
+
var focusable = Array.from(modal.querySelectorAll(
|
|
599
|
+
'select, button, [tabindex]:not([tabindex="-1"])'
|
|
600
|
+
)).filter(function (el) { return !el.disabled; });
|
|
601
|
+
if (focusable.length === 0) return;
|
|
602
|
+
var first = focusable[0];
|
|
603
|
+
var last = focusable[focusable.length - 1];
|
|
604
|
+
if (e.shiftKey) {
|
|
605
|
+
if (document.activeElement === first) { e.preventDefault(); last.focus(); }
|
|
606
|
+
} else {
|
|
607
|
+
if (document.activeElement === last) { e.preventDefault(); first.focus(); }
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function closeLinkModal() {
|
|
612
|
+
linkModal.classList.remove('active');
|
|
613
|
+
document.removeEventListener('keydown', trapLinkFocus);
|
|
614
|
+
if (previousFocusEl && previousFocusEl.focus) {
|
|
615
|
+
previousFocusEl.focus();
|
|
616
|
+
previousFocusEl = null;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
async function handleLink() {
|
|
621
|
+
const targetId = linkSelect.value;
|
|
622
|
+
if (!targetId) return;
|
|
623
|
+
|
|
624
|
+
linkConfirm.disabled = true;
|
|
625
|
+
try {
|
|
626
|
+
const res = await fetch('/api/projects/link', {
|
|
627
|
+
method: 'POST',
|
|
628
|
+
headers: { 'Content-Type': 'application/json', 'X-VoidForge-Request': '1' },
|
|
629
|
+
body: JSON.stringify({ projectIdA: currentLinkProjectId, projectIdB: targetId }),
|
|
630
|
+
});
|
|
631
|
+
const body = await res.json();
|
|
632
|
+
if (!res.ok) throw new Error(body.error || 'Failed to link');
|
|
633
|
+
// Refresh projects and reopen modal
|
|
634
|
+
projects = await fetchProjects();
|
|
635
|
+
render();
|
|
636
|
+
const updated = projects.find(function (p) { return p.id === currentLinkProjectId; });
|
|
637
|
+
if (updated) openLinkModal(updated);
|
|
638
|
+
} catch (err) {
|
|
639
|
+
linkStatus.textContent = err.message;
|
|
640
|
+
linkStatus.className = 'status-row error';
|
|
641
|
+
} finally {
|
|
642
|
+
linkConfirm.disabled = false;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
async function handleUnlink(targetId) {
|
|
647
|
+
try {
|
|
648
|
+
const res = await fetch('/api/projects/unlink', {
|
|
649
|
+
method: 'POST',
|
|
650
|
+
headers: { 'Content-Type': 'application/json', 'X-VoidForge-Request': '1' },
|
|
651
|
+
body: JSON.stringify({ projectIdA: currentLinkProjectId, projectIdB: targetId }),
|
|
652
|
+
});
|
|
653
|
+
const body = await res.json();
|
|
654
|
+
if (!res.ok) throw new Error(body.error || 'Failed to unlink');
|
|
655
|
+
projects = await fetchProjects();
|
|
656
|
+
render();
|
|
657
|
+
const updated = projects.find(function (p) { return p.id === currentLinkProjectId; });
|
|
658
|
+
if (updated) openLinkModal(updated);
|
|
659
|
+
} catch (err) {
|
|
660
|
+
alert('Failed to unlink: ' + err.message);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (linkCancel) linkCancel.addEventListener('click', closeLinkModal);
|
|
665
|
+
if (linkConfirm) linkConfirm.addEventListener('click', handleLink);
|
|
666
|
+
if (linkModal) {
|
|
667
|
+
linkModal.addEventListener('click', function (e) {
|
|
668
|
+
if (e.target === linkModal) closeLinkModal();
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// ── Event Listeners ────────────────────────────────
|
|
673
|
+
|
|
674
|
+
document.getElementById('btn-new').addEventListener('click', () => {
|
|
675
|
+
window.location.href = '/index.html';
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
document.getElementById('btn-new-empty').addEventListener('click', () => {
|
|
679
|
+
window.location.href = '/index.html';
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
document.getElementById('btn-import').addEventListener('click', openImportModal);
|
|
683
|
+
importCancel.addEventListener('click', closeImportModal);
|
|
684
|
+
importConfirm.addEventListener('click', handleImport);
|
|
685
|
+
|
|
686
|
+
// Close modal on backdrop click
|
|
687
|
+
importModal.addEventListener('click', (e) => {
|
|
688
|
+
if (e.target === importModal) closeImportModal();
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
// Close modal on Escape
|
|
692
|
+
document.addEventListener('keydown', (e) => {
|
|
693
|
+
if (e.key === 'Escape' && importModal.classList.contains('active')) {
|
|
694
|
+
closeImportModal();
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
// Import on Enter
|
|
699
|
+
importDir.addEventListener('keydown', (e) => {
|
|
700
|
+
if (e.key === 'Enter') handleImport();
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
// ── Auth UI ─────────────────────────────────────────
|
|
704
|
+
|
|
705
|
+
const authUser = document.getElementById('auth-user');
|
|
706
|
+
const btnLogout = document.getElementById('btn-logout');
|
|
707
|
+
|
|
708
|
+
async function checkAuth() {
|
|
709
|
+
try {
|
|
710
|
+
const res = await fetch('/api/auth/session');
|
|
711
|
+
const body = await res.json();
|
|
712
|
+
const data = body.data || {};
|
|
713
|
+
if (data.remoteMode && data.authenticated) {
|
|
714
|
+
currentUser = { username: data.username || '', role: data.role || 'viewer' };
|
|
715
|
+
const roleLabel = { admin: 'Admin', deployer: 'Deployer', viewer: 'Viewer' }[data.role] || '';
|
|
716
|
+
authUser.textContent = data.username + (roleLabel ? ' (' + roleLabel + ')' : '');
|
|
717
|
+
authUser.style.display = '';
|
|
718
|
+
btnLogout.style.display = '';
|
|
719
|
+
}
|
|
720
|
+
if (data.remoteMode && !data.authenticated) {
|
|
721
|
+
window.location.href = '/login.html';
|
|
722
|
+
}
|
|
723
|
+
} catch { /* local mode — no auth needed */ }
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if (btnLogout) {
|
|
727
|
+
btnLogout.addEventListener('click', async () => {
|
|
728
|
+
await fetch('/api/auth/logout', {
|
|
729
|
+
method: 'POST',
|
|
730
|
+
headers: { 'X-VoidForge-Request': '1' },
|
|
731
|
+
});
|
|
732
|
+
window.location.href = '/login.html';
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// ── Init ───────────────────────────────────────────
|
|
737
|
+
|
|
738
|
+
// ── Restart detection ────────────────────────────────
|
|
739
|
+
async function checkServerRestart() {
|
|
740
|
+
try {
|
|
741
|
+
const res = await fetch('/api/server/status', { headers: { 'X-VoidForge-Request': '1' } });
|
|
742
|
+
const data = await res.json();
|
|
743
|
+
if (data.needsRestart) {
|
|
744
|
+
showRestartBanner();
|
|
745
|
+
}
|
|
746
|
+
} catch { /* non-fatal */ }
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function showRestartBanner() {
|
|
750
|
+
if (document.getElementById('restart-banner')) return;
|
|
751
|
+
const banner = document.createElement('div');
|
|
752
|
+
banner.id = 'restart-banner';
|
|
753
|
+
banner.setAttribute('role', 'alert');
|
|
754
|
+
banner.style.cssText = 'background:#2d1b00;border:1px solid #f59e0b;color:#fbbf24;padding:12px 16px;margin:0 0 16px;border-radius:8px;';
|
|
755
|
+
|
|
756
|
+
const msg = document.createElement('span');
|
|
757
|
+
msg.textContent = 'VoidForge updated \u2014 native modules changed on disk. Restart the server (Ctrl+C, then re-run) for changes to take effect.';
|
|
758
|
+
banner.appendChild(msg);
|
|
759
|
+
|
|
760
|
+
const header = document.querySelector('.lobby-header');
|
|
761
|
+
if (header) header.after(banner);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
async function init() {
|
|
765
|
+
await checkAuth();
|
|
766
|
+
projects = await fetchProjects();
|
|
767
|
+
render();
|
|
768
|
+
await checkServerRestart();
|
|
769
|
+
|
|
770
|
+
// Start polling for health updates
|
|
771
|
+
pollTimer = setInterval(async () => {
|
|
772
|
+
projects = await fetchProjects();
|
|
773
|
+
render();
|
|
774
|
+
}, REFRESH_INTERVAL_MS);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Clean up on page leave
|
|
778
|
+
window.addEventListener('beforeunload', () => {
|
|
779
|
+
if (pollTimer) clearInterval(pollTimer);
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
init();
|
|
783
|
+
})();
|