mirror of
https://github.com/sol1/rustguac.git
synced 2026-09-10 09:35:45 +00:00
842aeec8f1
Dismissing the "N credentials to set up" banner now persists in localStorage instead of a page-lifetime flag, so it stays gone across refreshes. It records exactly which credentials were dismissed and re-appears only if a genuinely new unset credential shows up — so a partial save that shrinks the set won't re-nag, but a new requirement still surfaces.
4259 lines
236 KiB
HTML
4259 lines
236 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>rustguac - Connections</title>
|
|
<link rel="stylesheet" href="/rustguac.css">
|
|
<style>
|
|
/* ============================================================
|
|
Connections — page-specific styles only. Shared design
|
|
system lives in /rustguac.css.
|
|
============================================================ */
|
|
|
|
/* Two-column connections layout */
|
|
.layout { display: flex; gap: var(--s-5); align-items: flex-start; }
|
|
.sidebar {
|
|
min-width: 280px; max-width: 340px; flex-shrink: 0;
|
|
border-right: 1px solid var(--border);
|
|
padding-right: var(--s-4);
|
|
}
|
|
.main { flex: 1; min-width: 0; }
|
|
|
|
/* Compact "open in new window" companion to the Connect button — a small
|
|
icon, not a full-size button */
|
|
.btn-connect-win {
|
|
margin-left: var(--s-1);
|
|
min-width: 0; width: auto; flex: none;
|
|
padding: 0 var(--s-1);
|
|
font-size: var(--fz-md); line-height: 1;
|
|
vertical-align: middle;
|
|
}
|
|
/* Keep Connect + its icon sibling on one line, same centerline */
|
|
.connect-cell { white-space: nowrap; }
|
|
.connect-cell .btn-connect { vertical-align: middle; }
|
|
|
|
.sidebar-header {
|
|
display: flex; align-items: center; gap: var(--s-2);
|
|
margin-bottom: var(--s-3); padding-bottom: var(--s-2);
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.sidebar-header strong {
|
|
flex: 1; text-transform: uppercase; letter-spacing: 0.14em;
|
|
font-size: var(--fz-sm); color: var(--text-muted); font-weight: normal;
|
|
}
|
|
|
|
/* Folder tree */
|
|
.folder-list { list-style: none; padding: 0; margin: 0; }
|
|
.folder-list ul { list-style: none; padding: 0; margin: 0; }
|
|
.folder-list li { cursor: pointer; margin-bottom: 1px; }
|
|
.folder-list li:hover > .folder-row { background: var(--surface); color: var(--text); }
|
|
.folder-list li.selected > .folder-row { background: var(--input); color: var(--text); border-left-color: var(--accent); }
|
|
.folder-row {
|
|
display: flex; align-items: center; gap: var(--s-2);
|
|
padding: var(--s-2) var(--s-2) var(--s-2) var(--s-1);
|
|
border-left: 3px solid transparent;
|
|
min-height: var(--ctl-md);
|
|
font-size: var(--fz-md);
|
|
transition: background .1s;
|
|
}
|
|
.tree-chevron {
|
|
display: inline-flex; align-items: center; justify-content: center;
|
|
width: 32px; height: 32px; flex-shrink: 0;
|
|
color: var(--accent); user-select: none;
|
|
font-size: 20px; font-weight: bold;
|
|
border-radius: var(--radius); cursor: pointer;
|
|
transition: background .1s, color .1s;
|
|
}
|
|
.tree-chevron:hover { color: var(--primary); background: var(--input); }
|
|
.tree-chevron.expanded { color: var(--primary); }
|
|
.tree-chevron.leaf { visibility: hidden; cursor: default; }
|
|
.tree-children {
|
|
padding-left: var(--s-4) !important;
|
|
margin-left: 15px !important;
|
|
border-left: 1px dashed var(--border);
|
|
}
|
|
|
|
.folder-row-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; line-height: 1.3; }
|
|
.folder-name-row { display: flex; align-items: center; gap: var(--s-2); min-width: 0; }
|
|
.folder-name-row > span:first-child {
|
|
flex: 1 1 auto; min-width: 0;
|
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
font-size: var(--fz-md);
|
|
}
|
|
.folder-scope {
|
|
flex-shrink: 0;
|
|
display: inline-flex; align-items: center; justify-content: center;
|
|
width: 24px; height: 24px;
|
|
font-size: 20px; color: var(--accent);
|
|
line-height: 1; cursor: help;
|
|
}
|
|
.folder-scope::before { display: block; }
|
|
.folder-scope[data-scope="shared"]::before { content: "\2295"; }
|
|
.folder-scope[data-scope="instance"]::before { content: "\25A3"; }
|
|
.folder-list li.selected > .folder-row .folder-scope { color: var(--accent); }
|
|
.folder-count { font-size: var(--fz-xs); color: var(--text-dim); letter-spacing: 0.02em; }
|
|
.folder-count::before { content: "\203A "; color: var(--border); }
|
|
.folder-desc {
|
|
font-size: var(--fz-xs); color: var(--text-dim);
|
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
}
|
|
|
|
/* Entries table */
|
|
.entries-table { border-collapse: collapse; width: 100%; margin-top: var(--s-3); border: 1px solid var(--border); }
|
|
.entries-table th, .entries-table td {
|
|
text-align: left;
|
|
padding: var(--s-3) var(--s-4);
|
|
border-bottom: 1px solid var(--border);
|
|
font-size: var(--fz-md);
|
|
vertical-align: middle;
|
|
}
|
|
.entries-table th {
|
|
color: var(--text-muted); font-size: var(--fz-sm); font-weight: normal;
|
|
text-transform: uppercase; letter-spacing: 0.12em;
|
|
background: var(--surface); line-height: 1.6;
|
|
}
|
|
.entries-table tbody tr:nth-child(even) { background: rgba(255,255,255,0.02); }
|
|
.entries-table tbody tr:hover { background: var(--surface); }
|
|
.entries-table tbody tr:last-child td { border-bottom: none; }
|
|
.entries-table td:last-child { text-align: right; white-space: nowrap; }
|
|
|
|
/* Folder action bar */
|
|
.folder-actions {
|
|
display: flex; align-items: center; gap: var(--s-2);
|
|
margin-bottom: var(--s-4); padding-bottom: var(--s-3);
|
|
border-bottom: 1px solid var(--border);
|
|
flex-wrap: wrap;
|
|
}
|
|
.folder-actions strong {
|
|
font-size: var(--fz-xl); color: var(--text);
|
|
font-weight: bold; letter-spacing: 0.02em; line-height: 1.3;
|
|
}
|
|
.folder-actions .desc {
|
|
color: var(--text-muted); font-size: var(--fz-md); font-style: italic;
|
|
}
|
|
|
|
/* Active sessions strip */
|
|
.active-sessions { margin-bottom: var(--s-5); }
|
|
.session-grid { display: flex; gap: var(--s-3); overflow-x: auto; padding-bottom: var(--s-2); }
|
|
.session-card {
|
|
flex: 0 0 auto; width: 280px;
|
|
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
|
|
overflow: hidden; cursor: pointer; transition: border-color .15s;
|
|
}
|
|
.session-card:hover { border-color: var(--accent); }
|
|
.session-card-thumb { width: 100%; height: 158px; object-fit: cover; background: #111; display: block; }
|
|
.session-card-info { padding: var(--s-2) var(--s-3); font-size: var(--fz-sm); }
|
|
.session-card-info .card-name {
|
|
font-weight: bold; color: var(--text);
|
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
}
|
|
.session-card-info .card-meta {
|
|
color: var(--text-muted); font-size: var(--fz-xs);
|
|
margin-top: var(--s-1);
|
|
display: flex; gap: var(--s-1); align-items: center;
|
|
}
|
|
.session-card-info .card-meta .type-badge { font-size: 10px; min-width: 36px; }
|
|
/* Proxmox VE brand orange badge */
|
|
.type-badge.type-proxmox { background: #e57000; color: #000; padding: 1px 6px; border-radius: 3px; font-weight: 600; }
|
|
.session-card.dormant { opacity: 0.75; }
|
|
.session-card.dormant .session-card-thumb { filter: brightness(0.7); }
|
|
.card-actions {
|
|
display: flex;
|
|
gap: var(--s-1);
|
|
padding: 0 var(--s-3) var(--s-2);
|
|
margin-top: var(--s-1);
|
|
}
|
|
.card-actions button {
|
|
height: 28px;
|
|
padding: 0 var(--s-2);
|
|
font-size: var(--fz-xs);
|
|
font-weight: normal;
|
|
}
|
|
.card-actions .btn-danger {
|
|
color: var(--primary);
|
|
border-color: var(--border);
|
|
}
|
|
.card-actions .btn-danger:hover {
|
|
color: var(--text-on-primary);
|
|
background: var(--primary);
|
|
border-color: var(--primary);
|
|
}
|
|
|
|
/* Share URL display in the share modal */
|
|
.share-url-box {
|
|
display: flex;
|
|
align-items: stretch;
|
|
gap: var(--s-2);
|
|
margin-top: var(--s-1);
|
|
}
|
|
.share-url-box code {
|
|
flex: 1;
|
|
min-width: 0;
|
|
padding: var(--s-2) var(--s-3);
|
|
background: var(--input);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
font-size: var(--fz-sm);
|
|
word-break: break-all;
|
|
user-select: all;
|
|
overflow-x: auto;
|
|
white-space: nowrap;
|
|
}
|
|
.share-url-box button {
|
|
flex-shrink: 0;
|
|
}
|
|
.share-warning {
|
|
margin-top: var(--s-3);
|
|
padding: var(--s-2) var(--s-3);
|
|
border: 1px solid var(--status-pending);
|
|
border-left-width: 3px;
|
|
border-radius: var(--radius);
|
|
color: var(--status-pending);
|
|
font-size: var(--fz-sm);
|
|
line-height: 1.4;
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: var(--s-2);
|
|
}
|
|
.share-warning-icon {
|
|
font-size: var(--fz-md);
|
|
flex-shrink: 0;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
/* Chip-based group picker */
|
|
.chip-picker {
|
|
margin-top: var(--s-1);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
background: var(--input);
|
|
padding: var(--s-2);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--s-2);
|
|
}
|
|
.chip-list { display: flex; flex-wrap: wrap; gap: var(--s-1); min-height: 0; }
|
|
.chip-list:empty { display: none; }
|
|
.chip {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: var(--s-1);
|
|
padding: 2px var(--s-2);
|
|
background: var(--surface);
|
|
border: 1px solid var(--accent);
|
|
border-radius: var(--radius);
|
|
color: var(--accent);
|
|
font-size: var(--fz-sm);
|
|
letter-spacing: 0.04em;
|
|
}
|
|
.chip-remove {
|
|
background: none; border: none; padding: 0 0 0 var(--s-1);
|
|
color: var(--text-muted); cursor: pointer;
|
|
font-family: monospace; font-size: var(--fz-md);
|
|
font-weight: normal; line-height: 1; height: auto;
|
|
}
|
|
.chip-remove:hover { color: var(--primary); background: none; border-color: transparent; }
|
|
.chip-input-wrap {
|
|
display: flex;
|
|
gap: var(--s-2);
|
|
align-items: center;
|
|
}
|
|
.chip-input-wrap .combo { flex: 1; position: relative; }
|
|
.chip-input-wrap .combo input {
|
|
width: 100%;
|
|
margin-top: 0;
|
|
height: var(--ctl-sm);
|
|
background: var(--bg);
|
|
text-transform: none;
|
|
letter-spacing: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
.combo-menu {
|
|
display: none;
|
|
position: absolute;
|
|
left: 0;
|
|
right: 0;
|
|
top: calc(var(--ctl-sm) + 2px);
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
max-height: 240px;
|
|
overflow-y: auto;
|
|
z-index: 120;
|
|
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
|
}
|
|
.combo.open .combo-menu { display: block; }
|
|
.combo-item {
|
|
padding: var(--s-2) var(--s-3);
|
|
font-size: var(--fz-sm);
|
|
color: var(--text);
|
|
cursor: pointer;
|
|
line-height: 1.3;
|
|
}
|
|
.combo-item:hover,
|
|
.combo-item.active {
|
|
background: var(--input);
|
|
color: var(--accent);
|
|
}
|
|
.combo-item.empty {
|
|
color: var(--text-dim);
|
|
cursor: default;
|
|
font-style: italic;
|
|
}
|
|
.combo-item.empty:hover { background: transparent; color: var(--text-dim); }
|
|
.combo-item .combo-hint {
|
|
color: var(--text-dim);
|
|
font-size: var(--fz-xs);
|
|
margin-left: var(--s-2);
|
|
}
|
|
|
|
/* Onboarding */
|
|
#onboard-body code { background: var(--input); padding: 1px var(--s-1); border-radius: var(--radius); font-size: 0.9em; }
|
|
.onboard-key {
|
|
display: inline-block; background: var(--surface);
|
|
border: 1px solid var(--border); border-radius: var(--radius);
|
|
padding: 1px var(--s-1); font-size: 0.85em; color: var(--text);
|
|
}
|
|
.onboard-hl { color: var(--accent); font-weight: bold; }
|
|
</style>
|
|
<script>(function(){var c=localStorage.getItem('rustguac_theme_colors');if(c){try{var o=JSON.parse(c),r=document.documentElement.style;for(var k in o)r.setProperty('--'+k.replace(/_/g,'-'),o[k]);if(o.bg_pattern&&o.bg_pattern!=='none'){var s=document.createElement('style');s.id='bg-pattern-style';s.textContent='body{background-image:'+o.bg_pattern+';background-attachment:fixed}';document.head.appendChild(s)}}catch(e){}}})();</script>
|
|
</head>
|
|
<body>
|
|
<div class="brand-row">
|
|
<img id="site-logo" src="/logo.svg" alt="">
|
|
<h1>rustguac</h1>
|
|
</div>
|
|
<nav>
|
|
<a href="/connections.html" class="active">Connections</a>
|
|
<a href="/sessions.html" id="sessions-link">Sessions</a>
|
|
<a href="/recordings.html">Recordings</a>
|
|
<a href="/reports.html" id="reports-link" style="display:none">Reports</a>
|
|
<a href="/docs.html">Docs</a>
|
|
<a href="/tokens.html" id="tokens-link" style="display:none">Tokens</a>
|
|
<a href="/admin.html" id="admin-link" style="display:none">Admin</a>
|
|
<a href="#" id="my-creds-nav" title="Manage your saved credential variables">🔑 Credentials</a>
|
|
<span id="user-menu-wrapper">
|
|
<a href="#" id="logout-item">Logout</a>
|
|
<span id="user-menu-btn" title="Preferences">⚙ Settings</span>
|
|
<div id="user-menu">
|
|
<div class="um-section-label">Theme</div>
|
|
<div id="um-theme-list"></div>
|
|
<div class="um-divider"></div>
|
|
<div class="um-item" id="my-creds-item">🔑 My Credentials</div>
|
|
<div class="um-item" id="show-tour-item">ⓘ Welcome Tour</div>
|
|
</div>
|
|
</span>
|
|
</nav>
|
|
|
|
<div id="global-error"></div>
|
|
|
|
<div id="no-vault" class="no-vault" style="display:none">
|
|
Connections is not configured. A Vault/OpenBao backend is required.<br>
|
|
See <a href="/docs.html">Docs</a> for setup instructions.
|
|
</div>
|
|
|
|
<div id="vault-unavailable" class="no-vault" style="display:none">
|
|
<strong>Connections is temporarily unavailable.</strong><br>
|
|
Cannot reach the Vault server. The service will retry automatically every 30 seconds.<br>
|
|
Check the rustguac logs for details: <code>journalctl -u rustguac -f</code>
|
|
</div>
|
|
|
|
<div id="empty-state" class="empty-state" style="display:none">
|
|
<h3>No folders yet</h3>
|
|
<p>Create a folder to start organising connection entries.</p>
|
|
<button class="btn-connect" id="btn-empty-create-folder">Create First Folder</button>
|
|
</div>
|
|
|
|
<div id="main-content" style="display:none">
|
|
<div id="creds-banner" style="display:none;background:var(--input);border:1px solid var(--border);border-left:3px solid var(--status-pending);border-radius:var(--radius);padding:0.6em 0.9em;margin:0 0 0.8em 0;font-size:0.9em;display:none;align-items:center;gap:0.8em">
|
|
<span>🔑</span>
|
|
<span id="creds-banner-text" style="flex:1"></span>
|
|
<button class="btn-small" id="creds-banner-setup">Set up now</button>
|
|
<span id="creds-banner-dismiss" title="Dismiss" style="cursor:pointer;color:var(--text-dim);padding:0 0.3em">×</span>
|
|
</div>
|
|
<div id="active-sessions" class="active-sessions" style="display:none">
|
|
<div class="section-head">
|
|
<strong>Active Sessions</strong>
|
|
<span id="active-sessions-count" class="section-count"></span>
|
|
</div>
|
|
<div id="active-sessions-grid" class="session-grid"></div>
|
|
</div>
|
|
<div class="layout">
|
|
<div class="sidebar">
|
|
<div class="sidebar-header">
|
|
<strong>Folders</strong>
|
|
<button class="btn-add admin-only" id="btn-new-folder" style="display:none" title="Create new folder">+ folder</button>
|
|
</div>
|
|
<ul class="folder-list" id="folder-list"></ul>
|
|
</div>
|
|
<div class="main">
|
|
<div id="entries-header" style="display:none">
|
|
<div class="folder-actions">
|
|
<strong id="entries-title"></strong>
|
|
<span id="entries-desc" class="desc"></span>
|
|
<input type="search" id="connections-search" class="connections-search" placeholder="Search connections..." autocomplete="off" disabled />
|
|
<button class="btn-add admin-only" id="btn-new-entry" style="display:none">+ add entry</button>
|
|
<button class="btn-add admin-only" id="btn-new-subfolder" style="display:none" title="Create subfolder under this folder">+ subfolder</button>
|
|
<button class="btn-small admin-only" id="btn-edit-folder" style="display:none" title="Edit folder settings">edit folder</button>
|
|
<button class="btn-small admin-only" id="btn-delete-folder" style="display:none" title="Delete folder and all entries">delete folder</button>
|
|
</div>
|
|
</div>
|
|
<div id="entries-content">
|
|
<p class="empty">Select a folder to view entries.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Folder create/edit modal -->
|
|
<div class="modal-overlay" id="folder-modal">
|
|
<div class="modal">
|
|
<h3 id="folder-modal-title">New Folder</h3>
|
|
<label>Name
|
|
<input type="text" id="fm-name" placeholder="e.g. infra-servers" pattern="[a-zA-Z0-9_.-]+" maxlength="64">
|
|
</label>
|
|
<div class="field-hint">Alphanumeric, hyphens, underscores, dots only.</div>
|
|
<label>Description
|
|
<input type="text" id="fm-desc" placeholder="e.g. Infrastructure servers">
|
|
</label>
|
|
<label>Allowed groups</label>
|
|
<div class="chip-picker" id="fm-groups-picker">
|
|
<div class="chip-list" id="fm-groups-chips"></div>
|
|
<div class="chip-input-wrap">
|
|
<div class="combo" id="fm-groups-combo">
|
|
<input type="text" id="fm-groups-input" placeholder="Type or pick a group..." autocomplete="off" spellcheck="false">
|
|
<div class="combo-menu" id="fm-groups-menu" role="listbox"></div>
|
|
</div>
|
|
<button type="button" class="btn-add" id="fm-groups-add">Add</button>
|
|
</div>
|
|
</div>
|
|
<div class="field-hint">OIDC groups that can see this folder. Leave empty for admin-only access. Type to filter known groups or enter a custom one; Enter or Add to attach; × on a chip to remove.</div>
|
|
<label style="display:flex;align-items:center;gap:0.5em;cursor:pointer">
|
|
<input type="checkbox" id="fm-inherit" style="width:auto">
|
|
Inherit permissions from parent folder
|
|
</label>
|
|
<div class="field-hint">When ticked, if this folder's own groups list doesn't grant a user access, the check walks up to the parent (and further ancestors if they also inherit). Uncheck to enforce this folder's groups list on its own. Top-level folders have no parent so this has no effect.</div>
|
|
<label>Scope
|
|
<select id="fm-scope">
|
|
<option value="shared">shared (all instances)</option>
|
|
<option value="instance">instance (this server only)</option>
|
|
</select>
|
|
</label>
|
|
<div id="fm-error" style="color:var(--primary);margin-top:0.5em"></div>
|
|
<div class="modal-actions">
|
|
<button id="fm-save">Save</button>
|
|
<button class="btn-cancel" id="fm-cancel">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Entry create/edit modal -->
|
|
<div class="modal-overlay" id="entry-modal">
|
|
<div class="modal">
|
|
<h3 id="entry-modal-title">New Entry</h3>
|
|
<label>Name
|
|
<input type="text" id="em-name" placeholder="e.g. web-server-01" pattern="[a-zA-Z0-9_.-]+" maxlength="64">
|
|
</label>
|
|
<div class="field-hint">Alphanumeric, hyphens, underscores, dots. Used as the Vault key.</div>
|
|
<label>Display name
|
|
<input type="text" id="em-display-name" placeholder="e.g. Web Server 01">
|
|
</label>
|
|
<label>Type
|
|
<select id="em-type">
|
|
<option value="ssh">SSH</option>
|
|
<option value="rdp">RDP</option>
|
|
<option value="vnc">VNC</option>
|
|
<option value="spice">SPICE</option>
|
|
<option value="proxmox">Proxmox VE console</option>
|
|
<option value="web">Web</option>
|
|
<option value="vdi">VDI (Docker)</option>
|
|
</select>
|
|
</label>
|
|
<div id="em-ssh-fields">
|
|
<label>Hostname
|
|
<input type="text" id="em-hostname" placeholder="10.0.1.5">
|
|
</label>
|
|
<label>Port
|
|
<input type="number" id="em-port" value="22">
|
|
</label>
|
|
<label>Username
|
|
<input type="text" id="em-username">
|
|
</label>
|
|
<label>Password
|
|
<div class="pw-wrap"><input type="password" id="em-password"><button type="button" class="pw-toggle" onclick="togglePw(this)" title="Show/hide">●</button></div>
|
|
</label>
|
|
<label>Private key (PEM)
|
|
<textarea id="em-private-key" rows="3" style="resize:vertical;font-size:0.85em" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"></textarea>
|
|
</label>
|
|
<label style="margin-top:0.8em">
|
|
<input type="checkbox" id="em-ssh-prompt-creds" style="display:inline;width:auto;margin-right:0.4em"> Prompt for credentials at connect time
|
|
</label>
|
|
<div class="field-hint">When enabled, users will be asked for username/password even if credentials are stored.</div>
|
|
</div>
|
|
<div id="em-rdp-fields" style="display:none">
|
|
<label>Hostname
|
|
<input type="text" id="em-rdp-hostname" placeholder="10.0.2.3">
|
|
</label>
|
|
<label>Port
|
|
<input type="number" id="em-rdp-port" value="3389">
|
|
</label>
|
|
<label>Username
|
|
<input type="text" id="em-rdp-username">
|
|
</label>
|
|
<label>Password
|
|
<div class="pw-wrap"><input type="password" id="em-rdp-password"><button type="button" class="pw-toggle" onclick="togglePw(this)" title="Show/hide">●</button></div>
|
|
</label>
|
|
<label>Domain
|
|
<input type="text" id="em-rdp-domain">
|
|
</label>
|
|
<label>Security
|
|
<select id="em-rdp-security">
|
|
<option value="">Default</option>
|
|
<option value="any">Any</option>
|
|
<option value="nla">NLA</option>
|
|
<option value="tls">TLS</option>
|
|
<option value="rdp">RDP</option>
|
|
</select>
|
|
</label>
|
|
<label style="margin-top:0.8em">
|
|
<input type="checkbox" id="em-rdp-ignore-cert" style="display:inline;width:auto;margin-right:0.4em"> Ignore certificate errors
|
|
</label>
|
|
<label>NLA Auth Package
|
|
<select id="em-rdp-auth-pkg">
|
|
<option value="">Server default (NTLM)</option>
|
|
<option value="ntlm">NTLM</option>
|
|
<option value="kerberos">Kerberos</option>
|
|
<option value="negotiate">Negotiate (Kerberos first, NTLM fallback)</option>
|
|
</select>
|
|
</label>
|
|
<div class="field-hint">Leave blank to use the server default (NTLM unless overridden in <code>config.toml</code> <code>[rdp] default_auth_pkg</code>). Kerberos and Negotiate need a domain-joined host with a KDC reachable via DNS; when that isn't set up the RDP connection hangs silently, so NTLM is the safe default.</div>
|
|
<label>KDC URL <span style="color:#666;font-size:0.85em">(Kerberos only)</span>
|
|
<input type="text" id="em-rdp-kdc-url" placeholder="e.g. https://dc.example.com/KdcProxy">
|
|
</label>
|
|
<div class="field-hint">Optional. Kerberos Key Distribution Center proxy URL.</div>
|
|
<label>Monitors <span style="color:var(--text-muted);font-size:0.85em">(RDP Display Control; the host must allow multiple monitors)</span>
|
|
<input type="number" id="em-rdp-monitors" min="1" max="4" value="1">
|
|
</label>
|
|
<label style="margin-top:0.8em">
|
|
<input type="checkbox" id="em-rdp-prompt-creds" style="display:inline;width:auto;margin-right:0.4em"> Prompt for credentials at connect time
|
|
</label>
|
|
<div class="field-hint">When enabled, users will be asked for username/password even if credentials are stored.</div>
|
|
</div>
|
|
<div id="em-vnc-fields" style="display:none">
|
|
<label>Hostname
|
|
<input type="text" id="em-vnc-hostname" placeholder="10.0.3.1">
|
|
</label>
|
|
<label>Port
|
|
<input type="number" id="em-vnc-port" value="5900">
|
|
</label>
|
|
<label>Password
|
|
<div class="pw-wrap"><input type="password" id="em-vnc-password"><button type="button" class="pw-toggle" onclick="togglePw(this)" title="Show/hide">●</button></div>
|
|
</label>
|
|
<label>Color depth
|
|
<select id="em-vnc-color-depth">
|
|
<option value="">Default (24-bit)</option>
|
|
<option value="8">8-bit</option>
|
|
<option value="16">16-bit</option>
|
|
<option value="24">24-bit</option>
|
|
<option value="32">32-bit</option>
|
|
</select>
|
|
</label>
|
|
<label style="margin-top:0.8em">
|
|
<input type="checkbox" id="em-vnc-prompt-creds" style="display:inline;width:auto;margin-right:0.4em"> Prompt for credentials at connect time
|
|
</label>
|
|
<div class="field-hint">When enabled, users will be asked for a password even if one is stored.</div>
|
|
</div>
|
|
<div id="em-spice-fields" style="display:none">
|
|
<label>Hostname
|
|
<input type="text" id="em-spice-hostname" placeholder="10.0.3.1">
|
|
</label>
|
|
<label>Port
|
|
<input type="number" id="em-spice-port" value="5900">
|
|
</label>
|
|
<label>Password
|
|
<div class="pw-wrap"><input type="password" id="em-spice-password"><button type="button" class="pw-toggle" onclick="togglePw(this)" title="Show/hide">●</button></div>
|
|
</label>
|
|
<label>Color depth
|
|
<select id="em-spice-color-depth">
|
|
<option value="">Default (24-bit)</option>
|
|
<option value="8">8-bit</option>
|
|
<option value="16">16-bit</option>
|
|
<option value="24">24-bit</option>
|
|
<option value="32">32-bit</option>
|
|
</select>
|
|
</label>
|
|
<label style="margin-top:0.8em">
|
|
<input type="checkbox" id="em-spice-tls" style="display:inline;width:auto;margin-right:0.4em"> Connect using TLS
|
|
</label>
|
|
<label>TLS port <span style="color:var(--text-muted);font-size:0.85em">(optional, if different from Port)</span>
|
|
<input type="number" id="em-spice-tls-port" placeholder="(same as Port)">
|
|
</label>
|
|
<label>Ignore certificate
|
|
<input type="checkbox" id="em-spice-ignore-cert" style="display:inline;width:auto;margin-right:0.4em"> Accept any TLS certificate (insecure)
|
|
</label>
|
|
<label>CA certificate <span style="color:var(--text-muted);font-size:0.85em">(PEM, optional)</span>
|
|
<textarea id="em-spice-ca-cert" rows="3" placeholder="-----BEGIN CERTIFICATE-----" style="font-family:monospace;font-size:0.85em"></textarea>
|
|
</label>
|
|
<label>Certificate subject <span style="color:var(--text-muted);font-size:0.85em">(optional)</span>
|
|
<input type="text" id="em-spice-cert-subject" placeholder="OU=..., CN=...">
|
|
</label>
|
|
<label>Proxy <span style="color:var(--text-muted);font-size:0.85em">(optional)</span>
|
|
<input type="text" id="em-spice-proxy" placeholder="http://proxy.example.com:3128">
|
|
</label>
|
|
<label style="margin-top:0.8em">
|
|
<input type="checkbox" id="em-spice-prompt-creds" style="display:inline;width:auto;margin-right:0.4em"> Prompt for credentials at connect time
|
|
</label>
|
|
<div class="field-hint">Direct SPICE displays (e.g. libvirt/QEMU consoles). The TLS, CA, certificate-subject and proxy fields support connecting through a SPICE proxy. For Proxmox VE VMs, use the "Proxmox VE console" type instead (it brokers the one-time ticket automatically).</div>
|
|
</div>
|
|
<div id="em-proxmox-fields" style="display:none">
|
|
<label>PVE API URL
|
|
<input type="text" id="em-proxmox-url" placeholder="https://pve.example.com:8006">
|
|
</label>
|
|
<label>Node <span style="color:var(--text-muted);font-size:0.85em">(optional — auto-detected from VM ID)</span>
|
|
<input type="text" id="em-proxmox-node" placeholder="(auto-detect)">
|
|
</label>
|
|
<label>VM ID
|
|
<input type="number" id="em-proxmox-vmid" placeholder="100">
|
|
</label>
|
|
<label>Token ID
|
|
<input type="text" id="em-proxmox-token-id" placeholder="user@realm!tokenname">
|
|
</label>
|
|
<label>Token secret
|
|
<div class="pw-wrap"><input type="password" id="em-proxmox-token-secret" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"><button type="button" class="pw-toggle" onclick="togglePw(this)" title="Show/hide">●</button></div>
|
|
</label>
|
|
<label>Color depth
|
|
<select id="em-proxmox-color-depth">
|
|
<option value="">Default (24-bit)</option>
|
|
<option value="8">8-bit</option>
|
|
<option value="16">16-bit</option>
|
|
<option value="24">24-bit</option>
|
|
<option value="32">32-bit</option>
|
|
</select>
|
|
</label>
|
|
<label>Monitors <span style="color:var(--text-muted);font-size:0.85em">(the guest must support multiple SPICE heads)</span>
|
|
<input type="number" id="em-proxmox-monitors" min="1" max="4" value="1">
|
|
</label>
|
|
<label style="margin-top:0.8em">
|
|
<input type="checkbox" id="em-proxmox-verify-tls" style="display:inline;width:auto;margin-right:0.4em"> Verify the PVE cluster TLS certificate
|
|
</label>
|
|
<div class="field-hint">Opens a Proxmox VE VM console via SPICE. rustguac calls the PVE <code>spiceproxy</code> API at connect to fetch a one-time ticket, so nothing sensitive is stored beyond the API token. Leave <strong>Node</strong> blank to auto-detect which cluster node hosts the VM. The token needs <code>VM.Console</code> (and <code>VM.Audit</code> for node auto-detect) on the target VM. PVE ships a self-signed cluster certificate, so leave TLS verification off unless the cluster CA is trusted.</div>
|
|
</div>
|
|
<div id="em-web-fields" style="display:none">
|
|
<label>URL
|
|
<input type="text" id="em-url" placeholder="https://example.com">
|
|
</label>
|
|
<div class="field-hint">Use <code>$RUSTGUAC_USERNAME</code> and <code>$RUSTGUAC_PASSWORD</code> in the URL for basic auth (e.g. <code>https://$RUSTGUAC_USERNAME:$RUSTGUAC_PASSWORD@host</code>).</div>
|
|
<label>Banner <span style="color:var(--text-muted);font-size:0.85em">(optional)</span>
|
|
<input type="text" id="em-banner" placeholder="Shown before session starts (user must click Continue)">
|
|
</label>
|
|
<div style="margin-top:1em;padding-top:0.8em;border-top:1px solid var(--border)">
|
|
<label style="cursor:pointer;color:var(--accent);font-size:0.95em" id="em-automation-toggle">
|
|
<span id="em-automation-arrow">▶</span> Automation
|
|
</label>
|
|
<div id="em-automation-fields" style="display:none">
|
|
<div class="field-hint" style="margin-bottom:0.5em">Credentials and scripts for automated login. Username and password are also used for URL substitution (<code>$RUSTGUAC_USERNAME</code> / <code>$RUSTGUAC_PASSWORD</code>).</div>
|
|
<label>Username <span style="color:var(--text-muted);font-size:0.85em">(optional)</span>
|
|
<input type="text" id="em-web-username" placeholder="For URL substitution or login script">
|
|
</label>
|
|
<label>Password <span style="color:var(--text-muted);font-size:0.85em">(optional)</span>
|
|
<div class="pw-wrap"><input type="password" id="em-web-password"><button type="button" class="pw-toggle" onclick="togglePw(this)" title="Show/hide">●</button></div>
|
|
</label>
|
|
<label>Login Script <span style="color:var(--text-muted);font-size:0.85em">(optional)</span>
|
|
<select id="em-login-script">
|
|
<option value="">None</option>
|
|
</select>
|
|
</label>
|
|
<div class="field-hint">Server-side script from the scripts directory. Receives CDP port and credentials as env vars. Runs after Chromium spawns.</div>
|
|
<div style="margin-top:0.8em;padding-top:0.8em;border-top:1px solid var(--border)">
|
|
<label style="cursor:pointer;color:var(--accent);font-size:0.95em" id="em-autofill-toggle">
|
|
<span id="em-autofill-arrow">▶</span> Autofill
|
|
</label>
|
|
<div id="em-autofill-fields" style="display:none">
|
|
<div class="field-hint" style="margin-bottom:0.5em">Pre-populate Chromium's autofill database so saved credentials appear on matching login forms. Use <code>$USERNAME</code> and <code>$PASSWORD</code> to substitute the entry's credentials.</div>
|
|
<div id="em-autofill-rows"></div>
|
|
<button type="button" id="em-autofill-add" style="margin-top:0.3em;padding:0.3em 0.8em;font-size:0.85em;background:var(--input);color:var(--accent);border:1px solid var(--border);border-radius:4px;cursor:pointer">+ Add site</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div style="margin-top:1em;padding-top:0.8em;border-top:1px solid var(--border)">
|
|
<label style="cursor:pointer;color:var(--accent);font-size:0.95em" id="em-allowed-domains-toggle">
|
|
<span id="em-allowed-domains-arrow">▶</span> Allowed Domains
|
|
</label>
|
|
<div id="em-allowed-domains-fields" style="display:none">
|
|
<div class="field-hint" style="margin-bottom:0.5em">Restrict which domains the browser can reach. When set, all other domains are blocked via DNS. Leave empty to allow all sites.</div>
|
|
<div id="em-allowed-domains-list"></div>
|
|
<button type="button" id="em-allowed-domains-add" style="margin-top:0.3em;padding:0.3em 0.8em;font-size:0.85em;background:var(--input);color:var(--accent);border:1px solid var(--border);border-radius:4px;cursor:pointer">+ Add domain</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div id="em-vdi-fields" style="display:none">
|
|
<label>Container Image
|
|
<input type="text" id="em-vdi-image" placeholder="e.g. myregistry/xrdp-desktop:latest">
|
|
</label>
|
|
<div class="field-hint">Docker image with xrdp on port 3389. By default the entrypoint should read <code>VDI_USERNAME</code> / <code>VDI_PASSWORD</code> env vars (rustguac derives them from your identity and generates an ephemeral password per connect). For images with a baked-in account, fill in the Container username / password fields below. Container is ephemeral - persists while idle, then removed.</div>
|
|
<label>CPU Limit <span style="color:var(--text-muted);font-size:0.85em">(optional, cores)</span>
|
|
<input type="number" id="em-vdi-cpu" step="0.5" min="0" placeholder="e.g. 2.0 (0 = use default)">
|
|
</label>
|
|
<label>Memory Limit <span style="color:var(--text-muted);font-size:0.85em">(optional, MB)</span>
|
|
<input type="number" id="em-vdi-memory" min="0" placeholder="e.g. 2048 (0 = use default)">
|
|
</label>
|
|
<label>Environment Variables <span style="color:var(--text-muted);font-size:0.85em">(optional, KEY=VALUE per line)</span>
|
|
<textarea id="em-vdi-env" rows="3" style="resize:vertical;font-size:0.85em" placeholder="TZ=Australia/Brisbane LANG=en_US.UTF-8"></textarea>
|
|
</label>
|
|
<label>Container username <span style="color:var(--text-muted);font-size:0.85em">(optional, overrides auto-derive)</span>
|
|
<input type="text" id="em-vdi-container-username" placeholder="Leave blank to derive from operator identity">
|
|
</label>
|
|
<label>Container password <span style="color:var(--text-muted);font-size:0.85em">(optional, overrides ephemeral)</span>
|
|
<input type="password" id="em-vdi-container-password" placeholder="Leave blank to generate per-connect" autocomplete="new-password">
|
|
</label>
|
|
<div class="field-hint">Use these only when the container image has a fixed account (Pattern B in the <a href="/docs.html#vdi" style="color:var(--accent)">VDI docs</a>). When set, rustguac uses these creds for the RDP login into the container, and the same container is shared by everyone connecting through this entry (deterministic name from the username). When left blank, rustguac derives a per-operator username and generates a fresh password every connect. Both fields support <a href="/docs.html#credential-variables" style="color:var(--accent)">credential variables</a> (e.g. <code>$corp_username</code>).</div>
|
|
<label>Idle Timeout <span style="color:var(--text-muted);font-size:0.85em">(optional, minutes)</span>
|
|
<input type="number" id="em-vdi-idle-timeout" min="0" placeholder="e.g. 60 (0 = use global default)">
|
|
</label>
|
|
<label>Banner <span style="color:var(--text-muted);font-size:0.85em">(optional)</span>
|
|
<input type="text" id="em-vdi-banner" placeholder="Shown before session starts (user must click Continue)">
|
|
</label>
|
|
</div>
|
|
<div id="em-remoteapp-section" style="display:none;margin-top:1em;padding-top:0.8em;border-top:1px solid var(--border)">
|
|
<label style="cursor:pointer;color:var(--accent);font-size:0.95em" id="em-remoteapp-toggle">
|
|
<span id="em-remoteapp-arrow">▶</span> RemoteApp (RAIL)
|
|
</label>
|
|
<div id="em-remoteapp-fields" style="display:none">
|
|
<div class="field-hint" style="margin-bottom:0.5em">Launch a specific application instead of the full desktop.</div>
|
|
<label>Program path
|
|
<input type="text" id="em-remote-app" placeholder="e.g. ||calc or C:\Windows\notepad.exe">
|
|
</label>
|
|
<div class="field-hint">Use || prefix for alternate shell, or full path to executable.</div>
|
|
<label>Working directory
|
|
<input type="text" id="em-remote-app-dir" placeholder="e.g. C:\Users\Public">
|
|
</label>
|
|
<label>Arguments
|
|
<input type="text" id="em-remote-app-args" placeholder="e.g. /path/to/file">
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div id="em-drive-section" style="display:none;margin-top:0.8em;">
|
|
<label>
|
|
<input type="checkbox" id="em-enable-drive" style="display:inline;width:auto;margin-right:0.4em"> Enable file transfer
|
|
<div style="color:#888;font-size:0.75em;margin-top:0.2em;">RDP: mounts a shared drive. SSH: enables SFTP file browser.</div>
|
|
<div id="em-drive-warning" style="display:none;color:var(--primary);font-size:0.8em;margin-top:0.3em">Server has no [drive] section in config — file transfer will not work. See <a href="/docs.html" style="color:var(--accent)">Docs</a> for setup instructions.</div>
|
|
</label>
|
|
</div>
|
|
<div id="em-recording-section" style="display:none;margin-top:0.8em;padding-top:0.8em;border-top:1px solid var(--border)">
|
|
<label style="cursor:pointer;color:var(--accent);font-size:0.95em" id="em-recording-toggle">
|
|
<span id="em-recording-arrow">▶</span> Recording Settings
|
|
</label>
|
|
<div id="em-recording-fields" style="display:none">
|
|
<div class="field-hint" style="margin-bottom:0.5em">Override global recording settings for this entry.</div>
|
|
<label style="margin-top:0.5em">
|
|
<input type="checkbox" id="em-override-recording" style="display:inline;width:auto;margin-right:0.4em"> Override recording settings
|
|
</label>
|
|
<div id="em-recording-sub" style="display:none;margin-left:1.5em;margin-top:0.5em">
|
|
<label>
|
|
<input type="checkbox" id="em-enable-recording" style="display:inline;width:auto;margin-right:0.4em" checked> Enable recording
|
|
</label>
|
|
<label style="margin-top:0.5em">Max recordings to keep
|
|
<input type="number" id="em-max-recordings" value="0" min="0" style="width:100px">
|
|
</label>
|
|
<div class="field-hint">0 = unlimited. Oldest recordings are deleted when the limit is exceeded.</div>
|
|
</div>
|
|
<div id="em-typescript-row" style="display:none;margin-top:0.6em;padding-top:0.5em;border-top:1px dashed var(--border)">
|
|
<label>
|
|
<input type="checkbox" id="em-record-typescript" style="display:inline;width:auto;margin-right:0.4em"> Enable typescript recording for this session
|
|
</label>
|
|
<div class="field-hint">SSH only. Writes a plain-text terminal log via guacd, for audit. Requires <code>[recording] typescript_path</code> set on the server. Off by default.</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div id="em-video-perf-section" style="display:none;margin-top:0.8em;padding-top:0.8em;border-top:1px solid var(--border)">
|
|
<label style="cursor:pointer;color:var(--accent);font-size:0.95em" id="em-video-perf-toggle">
|
|
<span id="em-video-perf-arrow">▶</span> Video Performance
|
|
</label>
|
|
<div id="em-video-perf-fields" style="display:none">
|
|
<div class="field-hint" style="margin-bottom:0.5em">RDP graphics pipeline and encoding settings. For video-heavy workloads, enable GFX + Desktop Composition + H.264.</div>
|
|
<label style="margin-top:0.5em">
|
|
<input type="checkbox" id="em-enable-gfx" style="display:inline;width:auto;margin-right:0.4em"> Enable Graphics Pipeline (GFX)
|
|
<div style="color:#888;font-size:0.75em;margin-top:0.2em;">Required for H.264 and RemoteFX. Enables modern RDP graphics codecs (32-bit colour).</div>
|
|
</label>
|
|
<label style="margin-top:0.3em">
|
|
<input type="checkbox" id="em-enable-desktop-comp" style="display:inline;width:auto;margin-right:0.4em"> Enable Desktop Composition
|
|
<div style="color:#888;font-size:0.75em;margin-top:0.2em;">Enables DWM compositing for smoother video overlays and transparency.</div>
|
|
</label>
|
|
<label style="margin-top:0.3em">
|
|
<input type="checkbox" id="em-force-lossless" style="display:inline;width:auto;margin-right:0.4em"> Force Lossless
|
|
<div style="color:#888;font-size:0.75em;margin-top:0.2em;">PNG only — crisp text but high bandwidth. Not compatible with H.264 passthrough.</div>
|
|
</label>
|
|
<label style="margin-top:0.3em">
|
|
<input type="checkbox" id="em-enable-h264" style="display:inline;width:auto;margin-right:0.4em"> H.264 Passthrough
|
|
<div style="color:#888;font-size:0.75em;margin-top:0.2em;">Passes H.264 video direct to browser (low latency, hardware decoded). Requires GFX enabled and xrdp with x264 on the target server.</div>
|
|
</label>
|
|
<div class="field-hint" style="margin-top:0.8em;margin-bottom:0.5em">Desktop appearance. Off by default to save bandwidth; enable for a normal-looking Windows desktop.</div>
|
|
<label style="margin-top:0.3em">
|
|
<input type="checkbox" id="em-enable-wallpaper" style="display:inline;width:auto;margin-right:0.4em"> Show Wallpaper
|
|
<div style="color:#888;font-size:0.75em;margin-top:0.2em;">Displays the remote desktop background image instead of a solid colour.</div>
|
|
</label>
|
|
<label style="margin-top:0.3em">
|
|
<input type="checkbox" id="em-enable-theming" style="display:inline;width:auto;margin-right:0.4em"> Enable Theming
|
|
<div style="color:#888;font-size:0.75em;margin-top:0.2em;">Renders window borders and controls with the desktop's visual styles.</div>
|
|
</label>
|
|
<label style="margin-top:0.3em">
|
|
<input type="checkbox" id="em-enable-full-window-drag" style="display:inline;width:auto;margin-right:0.4em"> Full-Window Drag
|
|
<div style="color:#888;font-size:0.75em;margin-top:0.2em;">Shows window contents while dragging instead of just an outline.</div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div id="em-clipboard-section" style="margin-top:0.8em">
|
|
<label>
|
|
<input type="checkbox" id="em-disable-copy" style="display:inline;width:auto;margin-right:0.4em"> Disable clipboard copy (server → client)
|
|
<div style="color:#888;font-size:0.75em;margin-top:0.2em;">Prevents users from copying text out of the remote session.</div>
|
|
</label>
|
|
<label style="margin-top:0.3em">
|
|
<input type="checkbox" id="em-disable-paste" style="display:inline;width:auto;margin-right:0.4em"> Disable clipboard paste (client → server)
|
|
<div style="color:#888;font-size:0.75em;margin-top:0.2em;">Prevents users from pasting text into the remote session.</div>
|
|
</label>
|
|
</div>
|
|
<div id="em-tunnel-section" style="display:none;margin-top:1em;padding-top:0.8em;border-top:1px solid var(--border)">
|
|
<label style="cursor:pointer;color:var(--accent);font-size:0.95em" id="em-tunnel-toggle">
|
|
<span id="em-tunnel-arrow">▶</span> SSH Tunnel / Jump Hosts
|
|
</label>
|
|
<div id="em-tunnel-fields" style="display:none">
|
|
<div class="field-hint" style="margin-bottom:0.5em">Route this connection through one or more SSH bastion hosts.</div>
|
|
<div id="em-flow-diagram" class="flow-diagram"></div>
|
|
<div id="em-hops-list"></div>
|
|
<button type="button" class="btn-add" id="em-add-hop" style="margin-top:8px">+ Add Jump Host</button>
|
|
</div>
|
|
</div>
|
|
<div id="em-sharing-section" style="margin-top:1em;padding-top:0.8em;border-top:1px solid var(--border)">
|
|
<label style="text-transform:none;letter-spacing:0;cursor:pointer">
|
|
<input type="checkbox" id="em-allow-sharing" style="display:inline;width:auto;margin-right:0.5em">
|
|
Allow session sharing
|
|
</label>
|
|
<div class="field-hint">
|
|
When enabled, users can generate a read-only Share URL for active sessions of this entry.
|
|
Off by default — admins opt in per entry.
|
|
</div>
|
|
<label style="text-transform:none;letter-spacing:0;cursor:pointer;margin-top:0.8em">
|
|
<input type="checkbox" id="em-auto-open-singleton" style="display:inline;width:auto;margin-right:0.5em">
|
|
Auto-open when this is the user's only entry
|
|
</label>
|
|
<div class="field-hint">
|
|
On first visit to Connections, if the user has exactly one visible entry and
|
|
this box is ticked, connect automatically (replaces the current tab — browsers
|
|
block new-tab popups without a click). Kiosk-style single-purpose accounts.
|
|
</div>
|
|
<label style="text-transform:none;letter-spacing:0;cursor:pointer;margin-top:0.8em">
|
|
<input type="checkbox" id="em-fullscreen-on-connect" style="display:inline;width:auto;margin-right:0.5em">
|
|
Open in fullscreen on connect
|
|
</label>
|
|
<div class="field-hint">
|
|
On the first user gesture after the session connects, enter browser fullscreen
|
|
and lock the Escape key so it reaches the remote session (Chromium-based
|
|
browsers; Firefox / Safari fall back to standard fullscreen). Reduces the risk
|
|
of users accidentally closing the rustguac tab.
|
|
</div>
|
|
<label style="text-transform:none;letter-spacing:0;cursor:pointer;margin-top:0.8em">
|
|
<input type="checkbox" id="em-autohide-side-tabs" style="display:inline;width:auto;margin-right:0.5em">
|
|
Auto-hide clipboard/files side tabs
|
|
</label>
|
|
<div class="field-hint">
|
|
Hide the Clipboard and Files tabs pinned to the left edge when idle; they
|
|
reappear when you move the pointer to the left edge of the session. Reduces
|
|
on-screen clutter over the remote display.
|
|
</div>
|
|
</div>
|
|
<div id="em-move-section" style="display:none;margin-top:1em;padding-top:0.8em;border-top:1px solid var(--border)">
|
|
<label>Move to folder
|
|
<select id="em-move-target">
|
|
<option value="">(keep in current folder)</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<div id="em-error" style="color:var(--primary);margin-top:0.5em"></div>
|
|
<div class="modal-actions">
|
|
<button id="em-save">Save</button>
|
|
<button class="btn-cancel" id="em-cancel">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Credential prompt modal -->
|
|
<div class="modal-overlay" id="my-creds-modal">
|
|
<div class="modal" style="max-width:500px">
|
|
<h3 id="my-creds-title">My Credentials</h3>
|
|
<p id="my-creds-desc" style="color:var(--text-muted);font-size:0.85em;margin-top:0">Store your credentials securely in Vault. Connections entries that use credential variables will auto-fill from these values.</p>
|
|
<input id="my-creds-search" type="text" placeholder="Filter credentials..." autocomplete="off" style="display:none">
|
|
<div id="my-creds-loading" style="color:var(--text-muted)">Loading...</div>
|
|
<div id="my-creds-empty" style="display:none;color:var(--text-muted);font-style:italic;margin:1em 0">No credential variables configured in connections entries yet.</div>
|
|
<div id="my-creds-list"></div>
|
|
<div id="my-creds-error" style="color:var(--primary);margin-top:0.5em"></div>
|
|
<div class="modal-actions">
|
|
<button class="btn-connect" id="my-creds-save">Save</button>
|
|
<button class="btn-cancel" id="my-creds-close">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Share session modal -->
|
|
<div class="modal-overlay" id="share-modal">
|
|
<div class="modal">
|
|
<h3>Share Session</h3>
|
|
<p style="color:var(--text-muted);font-size:var(--fz-sm);margin:0 0 var(--s-3) 0">
|
|
Send this URL to someone to let them join your session as a read-only viewer.
|
|
</p>
|
|
<label>Share URL</label>
|
|
<div class="share-url-box">
|
|
<code id="share-modal-url"></code>
|
|
<button type="button" id="share-modal-copy" class="btn-add">Copy</button>
|
|
</div>
|
|
<div class="share-warning">
|
|
<span class="share-warning-icon">⚠</span>
|
|
Anyone with this link and network access to this server can watch the session until it ends.
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button class="btn-cancel" id="share-modal-close">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Onboarding wizard -->
|
|
<div class="modal-overlay" id="onboarding-modal">
|
|
<div class="modal" style="max-width:520px;min-width:380px">
|
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.3em">
|
|
<h3 id="onboard-title" style="margin:0"></h3>
|
|
<span id="onboard-step-indicator" style="color:var(--text-dim);font-size:0.85em"></span>
|
|
</div>
|
|
<div id="onboard-body" style="color:var(--text);font-size:0.92em;line-height:1.65;min-height:80px"></div>
|
|
<div style="display:flex;align-items:center;gap:0.6em;margin-top:1.2em">
|
|
<button id="onboard-back" class="btn-cancel">Back</button>
|
|
<button id="onboard-next" class="btn-connect">Next</button>
|
|
<span style="flex:1"></span>
|
|
<label style="color:var(--text-dim);font-size:0.8em;cursor:pointer;white-space:nowrap">
|
|
<input type="checkbox" id="onboard-dismiss" style="display:inline;width:auto;margin-right:0.3em"> Don't show again
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-overlay" id="cred-modal">
|
|
<div class="modal">
|
|
<h3 id="cred-modal-title">Enter Credentials</h3>
|
|
<p style="color:var(--text-muted);font-size:0.9em;margin-top:0" id="cred-modal-desc">This entry requires credentials to connect.</p>
|
|
<div id="cred-username-row">
|
|
<label>Username
|
|
<input type="text" id="cred-username" autocomplete="username">
|
|
</label>
|
|
</div>
|
|
<label>Password
|
|
<input type="password" id="cred-password" autocomplete="current-password">
|
|
</label>
|
|
<div id="cred-domain-row">
|
|
<label>Domain
|
|
<input type="text" id="cred-domain" placeholder="optional">
|
|
</label>
|
|
</div>
|
|
<div id="cred-error" style="color:var(--primary);margin-top:0.5em"></div>
|
|
<div class="modal-actions">
|
|
<button class="btn-connect" id="cred-connect">Connect</button>
|
|
<button class="btn-cancel" id="cred-cancel">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
function togglePw(btn){var inp=btn.previousElementSibling;if(inp.type==='password'){inp.type='text';btn.textContent='\u25CB'}else{inp.type='password';btn.textContent='\u25CF'}}
|
|
function applyThemeColors(colors){var r=document.documentElement.style;for(var k in colors)r.setProperty('--'+k.replace(/_/g,'-'),colors[k]);var s=document.getElementById('bg-pattern-style');if(!s){s=document.createElement('style');s.id='bg-pattern-style';document.head.appendChild(s)}s.textContent=colors.bg_pattern&&colors.bg_pattern!=='none'?'body{background-image:'+colors.bg_pattern+';background-attachment:fixed}':'';localStorage.setItem('rustguac_theme_colors',JSON.stringify(colors))}
|
|
var _themePresets={},_adminPreset='aurora';
|
|
var _themeDescriptions={dark:'Navy & cyan \u2014 the default',light:'Clean white & blue','high-contrast':'Maximum readability',terminal:'Retro green-on-black',nord:'Arctic, muted blues',corporate:'Slate & steel blue',aurora:'Midnight blue with ambient glow',jaguar:'Racing green & gold'};
|
|
function initTheme(t){if(!t)return;_themePresets=t.presets||{};_adminPreset=t.admin_preset||'aurora';var u=localStorage.getItem('rustguac_theme'),active=u&&_themePresets[u]?u:_adminPreset,colors=(active===_adminPreset)?t.admin_colors:_themePresets[active];if(colors)applyThemeColors(colors);if(t.logo_url){var l=document.getElementById('site-logo');if(l){if(l.src!==t.logo_url&&!l.src.endsWith(t.logo_url))l.src=t.logo_url;l.style.display=''}}var menu=document.getElementById('um-theme-list');if(menu){menu.innerHTML='';Object.keys(_themePresets).forEach(function(name){var item=document.createElement('div');item.className='um-item'+(name===active?' active':'');var sw=document.createElement('span');sw.className='um-swatch';var p=_themePresets[name];sw.style.background='linear-gradient(135deg,'+p.primary+' 50%,'+p.accent+' 50%)';item.appendChild(sw);var info=document.createElement('div');info.className='um-theme-info';var nm=document.createElement('span');nm.className='um-theme-name';nm.textContent=name;info.appendChild(nm);var desc=document.createElement('span');desc.className='um-theme-desc';desc.textContent=_themeDescriptions[name]||'';info.appendChild(desc);item.appendChild(info);item.addEventListener('click',function(){localStorage.setItem('rustguac_theme',name);applyThemeColors(_themePresets[name]);menu.querySelectorAll('.um-item').forEach(function(el){el.classList.remove('active')});item.classList.add('active');document.getElementById('user-menu').style.display='none'});menu.appendChild(item)})}}
|
|
var _ub=document.getElementById('user-menu-btn');if(_ub)_ub.addEventListener('click',function(e){e.stopPropagation();var m=document.getElementById('user-menu');m.style.display=m.style.display==='block'?'none':'block'});document.addEventListener('click',function(){var m=document.getElementById('user-menu');if(m)m.style.display='none'});
|
|
var _driveConfigured = false;
|
|
fetch('/api/auth/status').then(function(r){return r.json()}).then(function(d){
|
|
if(d.site_title){document.title=d.site_title+' - Connections';document.querySelector('h1').textContent=d.site_title;}
|
|
_driveConfigured = !!d.drive_configured;
|
|
initTheme(d.theme);
|
|
});
|
|
var apiKey = sessionStorage.getItem('rustguac_api_key');
|
|
|
|
if (!apiKey) {
|
|
fetch('/api/me', { credentials: 'same-origin' })
|
|
.then(function(res) {
|
|
if (res.status === 401 || res.status === 403) window.location.href = '/';
|
|
})
|
|
.catch(function() {});
|
|
}
|
|
|
|
document.getElementById('logout-item').addEventListener('click', function() {
|
|
sessionStorage.removeItem('rustguac_api_key');
|
|
fetch('/auth/logout', { credentials: 'same-origin' })
|
|
.finally(function() { window.location.href = '/'; });
|
|
});
|
|
|
|
function apiHeaders(extra) {
|
|
var h = {};
|
|
if (apiKey) h['Authorization'] = 'Bearer ' + apiKey;
|
|
if (extra) { for (var k in extra) h[k] = extra[k]; }
|
|
return h;
|
|
}
|
|
|
|
var roleLevel = { admin: 4, poweruser: 3, operator: 2, viewer: 1 };
|
|
var isAdmin = false;
|
|
var currentUserRole = 'viewer';
|
|
var selectedFolder = null; // { name, scope, description, path, has_children }
|
|
var folders = []; // top-level folders from batch load
|
|
var unavailableScopes = []; // scopes whose Vault backend is down (multi-Vault DR); greyed in the tree
|
|
var setCredVars = null; // Set of credential variable names the user has set (null = not yet loaded)
|
|
var lastEntriesRender = null; // { entries, scope, folder } — re-render when cred status arrives
|
|
var folderEntryCounts = {}; // "scope|path" -> count
|
|
var folderEntries = {}; // "scope|path" -> entries array
|
|
var subfolderCache = {}; // "scope|path" -> [FolderInfo] (lazy-loaded children)
|
|
var expandedPaths = {}; // "scope|path" -> true (tree expansion state; persisted)
|
|
var EXPANDED_STORAGE_KEY = 'rustguac_connections_expanded';
|
|
var SELECTED_STORAGE_KEY = 'rustguac_connections_selected';
|
|
// Quick-find search state
|
|
var searchIndex = null; // [{scope, folderPath, entry, label, host, haystack}]
|
|
var searchIndexLoading = false;
|
|
var searchQuery = ''; // current query (lowercased & trimmed)
|
|
var searchActive = false; // entries-content showing results, not folder entries
|
|
function folderKey(scope, path) { return scope + '|' + path; }
|
|
function folderPath(f) { return f.path || f.name; }
|
|
|
|
// Persist tree expansion across page loads so users don't have to
|
|
// reopen the same folders every time. Per-browser (localStorage),
|
|
// not per-user. Stale keys for deleted folders are harmless: the
|
|
// restore walk drops anything findFolder() can't resolve.
|
|
function saveExpandedPaths() {
|
|
try { localStorage.setItem(EXPANDED_STORAGE_KEY, JSON.stringify(expandedPaths)); } catch (e) {}
|
|
}
|
|
function loadExpandedPaths() {
|
|
try {
|
|
var raw = localStorage.getItem(EXPANDED_STORAGE_KEY);
|
|
if (raw) {
|
|
var parsed = JSON.parse(raw);
|
|
if (parsed && typeof parsed === 'object') expandedPaths = parsed;
|
|
}
|
|
} catch (e) { expandedPaths = {}; }
|
|
}
|
|
loadExpandedPaths();
|
|
|
|
// Persist the selected folder so reopening the page re-opens the
|
|
// same folder, not the alphabetical first.
|
|
function saveSelectedFolder() {
|
|
try {
|
|
if (selectedFolder) {
|
|
localStorage.setItem(SELECTED_STORAGE_KEY, JSON.stringify({
|
|
scope: selectedFolder.scope,
|
|
path: folderPath(selectedFolder)
|
|
}));
|
|
} else {
|
|
localStorage.removeItem(SELECTED_STORAGE_KEY);
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
function loadSelectedFolder() {
|
|
try {
|
|
var raw = localStorage.getItem(SELECTED_STORAGE_KEY);
|
|
if (!raw) return null;
|
|
var parsed = JSON.parse(raw);
|
|
if (parsed && parsed.scope && parsed.path) return parsed;
|
|
} catch (e) {}
|
|
return null;
|
|
}
|
|
|
|
// Restore the expanded tree depth-first: shallow paths must load
|
|
// before their descendants so findFolder() can resolve deeper keys
|
|
// (deeper keys live in subfolderCache which is populated by
|
|
// loadSubfolders of their parent). Drops any stale keys along the
|
|
// way. Calls the callback when the walk is done.
|
|
function restoreExpandedTree(cb) {
|
|
var keys = Object.keys(expandedPaths);
|
|
if (keys.length === 0) { if (cb) cb(); return; }
|
|
keys.sort(function (a, b) {
|
|
var depth = function (k) { return (k.split('|')[1] || '').split('/').length; };
|
|
return depth(a) - depth(b);
|
|
});
|
|
var queue = keys.slice();
|
|
function processNext() {
|
|
if (queue.length === 0) { if (cb) cb(); return; }
|
|
var key = queue.shift();
|
|
var sep = key.indexOf('|');
|
|
if (sep < 0) { processNext(); return; }
|
|
var scope = key.slice(0, sep);
|
|
var path = key.slice(sep + 1);
|
|
var f = findFolder(scope, path);
|
|
if (!f) {
|
|
// Folder no longer exists (deleted, renamed, ACL change);
|
|
// drop the stale key so it doesn't drift forever.
|
|
delete expandedPaths[key];
|
|
processNext();
|
|
return;
|
|
}
|
|
if (subfolderCache[key]) { processNext(); return; }
|
|
loadSubfolders(scope, path, processNext);
|
|
}
|
|
processNext();
|
|
}
|
|
var currentEntries = []; // entries for the currently selected folder
|
|
var loginScriptsCache = null; // cached list from /api/login-scripts
|
|
var knownGroupsCache = null; // cached list from /api/auth/known-groups
|
|
var fmGroups = []; // allowed_groups being edited in the folder modal
|
|
|
|
function loadKnownGroups(cb) {
|
|
if (knownGroupsCache !== null) { if (cb) cb(); return; }
|
|
fetch('/api/auth/known-groups', { headers: apiHeaders(), credentials: 'same-origin' })
|
|
.then(function(r) { return r.ok ? r.json() : { groups: [] }; })
|
|
.then(function(data) {
|
|
knownGroupsCache = (data && data.groups) ? data.groups : [];
|
|
if (cb) cb();
|
|
})
|
|
.catch(function() { knownGroupsCache = []; if (cb) cb(); });
|
|
}
|
|
|
|
function renderFmGroupChips() {
|
|
var list = document.getElementById('fm-groups-chips');
|
|
list.innerHTML = '';
|
|
fmGroups.forEach(function(g) {
|
|
var chip = document.createElement('span');
|
|
chip.className = 'chip';
|
|
chip.textContent = g;
|
|
var rm = document.createElement('button');
|
|
rm.type = 'button';
|
|
rm.className = 'chip-remove';
|
|
rm.textContent = '\u00D7';
|
|
rm.title = 'Remove';
|
|
rm.addEventListener('click', function() {
|
|
fmGroups = fmGroups.filter(function(x) { return x !== g; });
|
|
renderFmGroupChips();
|
|
populateFmGroupOptions();
|
|
});
|
|
chip.appendChild(rm);
|
|
list.appendChild(chip);
|
|
});
|
|
}
|
|
|
|
var fmComboActive = -1; // active combo-menu index for keyboard nav
|
|
|
|
function fmGroupOptionsFiltered() {
|
|
var q = (document.getElementById('fm-groups-input').value || '').trim().toLowerCase();
|
|
return (knownGroupsCache || []).filter(function(g) {
|
|
if (fmGroups.indexOf(g) !== -1) return false; // already selected
|
|
if (!q) return true;
|
|
return g.toLowerCase().indexOf(q) !== -1;
|
|
});
|
|
}
|
|
|
|
function populateFmGroupOptions() {
|
|
var menu = document.getElementById('fm-groups-menu');
|
|
menu.innerHTML = '';
|
|
var opts = fmGroupOptionsFiltered();
|
|
var q = (document.getElementById('fm-groups-input').value || '').trim();
|
|
fmComboActive = opts.length > 0 ? 0 : -1;
|
|
|
|
if (opts.length === 0 && !q) {
|
|
var empty = document.createElement('div');
|
|
empty.className = 'combo-item empty';
|
|
empty.textContent = (knownGroupsCache && knownGroupsCache.length === 0)
|
|
? 'No known groups yet — type a group name.'
|
|
: 'All known groups already selected.';
|
|
menu.appendChild(empty);
|
|
return;
|
|
}
|
|
|
|
opts.forEach(function(g, i) {
|
|
var item = document.createElement('div');
|
|
item.className = 'combo-item' + (i === fmComboActive ? ' active' : '');
|
|
item.setAttribute('role', 'option');
|
|
item.textContent = g;
|
|
item.addEventListener('mousedown', function(ev) {
|
|
ev.preventDefault(); // keep input focused
|
|
pickFmGroup(g);
|
|
});
|
|
item.addEventListener('mouseenter', function() {
|
|
fmComboActive = i;
|
|
updateFmComboActive();
|
|
});
|
|
menu.appendChild(item);
|
|
});
|
|
|
|
if (q && opts.indexOf(q) === -1 && fmGroups.indexOf(q) === -1) {
|
|
// Show "+ add custom" row so user knows Enter will create a custom group
|
|
var custom = document.createElement('div');
|
|
custom.className = 'combo-item';
|
|
custom.innerHTML = '+ Add "<strong>' + escapeHtml(q) + '</strong>" <span class="combo-hint">(custom)</span>';
|
|
custom.addEventListener('mousedown', function(ev) {
|
|
ev.preventDefault();
|
|
pickFmGroup(q);
|
|
});
|
|
menu.appendChild(custom);
|
|
}
|
|
}
|
|
|
|
function updateFmComboActive() {
|
|
var items = document.querySelectorAll('#fm-groups-menu .combo-item');
|
|
for (var i = 0; i < items.length; i++) {
|
|
items[i].classList.toggle('active', i === fmComboActive);
|
|
}
|
|
}
|
|
|
|
function openFmCombo() {
|
|
document.getElementById('fm-groups-combo').classList.add('open');
|
|
populateFmGroupOptions();
|
|
}
|
|
function closeFmCombo() {
|
|
document.getElementById('fm-groups-combo').classList.remove('open');
|
|
}
|
|
|
|
function pickFmGroup(g) {
|
|
g = (g || '').trim();
|
|
if (!g) return;
|
|
if (fmGroups.indexOf(g) === -1) fmGroups.push(g);
|
|
var inp = document.getElementById('fm-groups-input');
|
|
inp.value = '';
|
|
renderFmGroupChips();
|
|
populateFmGroupOptions();
|
|
inp.focus();
|
|
}
|
|
|
|
// Adds whatever is currently typed in the group input as one or
|
|
// more chips. Returns silently if the input is empty — the Save
|
|
// handler calls this to flush pending typing, and must not
|
|
// silently add the combo's highlighted suggestion (that caused a
|
|
// bug where editing any folder tacked on the first unselected
|
|
// known group).
|
|
function addFmGroupFromInput() {
|
|
var inp = document.getElementById('fm-groups-input');
|
|
var raw = (inp.value || '').trim();
|
|
if (!raw) return;
|
|
// Allow comma-separated bulk paste
|
|
raw.split(',').forEach(function(part) {
|
|
var g = part.trim();
|
|
if (g && fmGroups.indexOf(g) === -1) fmGroups.push(g);
|
|
});
|
|
inp.value = '';
|
|
renderFmGroupChips();
|
|
populateFmGroupOptions();
|
|
}
|
|
|
|
// Explicit handler for when the user presses Enter with nothing
|
|
// typed: pick the currently highlighted combo suggestion.
|
|
function pickFmGroupFromCombo() {
|
|
var opts = fmGroupOptionsFiltered();
|
|
if (fmComboActive >= 0 && fmComboActive < opts.length) {
|
|
pickFmGroup(opts[fmComboActive]);
|
|
}
|
|
}
|
|
|
|
// GitHub #103: auto-open the user's only entry if it has
|
|
// auto_open_if_singleton set. Fires once per browser session
|
|
// (sessionStorage-gated) so an accidental refresh of Connections
|
|
// doesn't spawn another client tab — the user can always click
|
|
// the entry card to reconnect manually. Logout clears the flag,
|
|
// so the next fresh login auto-opens again.
|
|
function maybeAutoOpenSingleton() {
|
|
try {
|
|
if (sessionStorage.getItem('rustguac_auto_opened') === '1') return;
|
|
} catch (e) { /* no sessionStorage */ }
|
|
|
|
// Gather every entry the user currently sees across top-level
|
|
// folders from the batch response. Subfolders aren't in the
|
|
// initial batch (lazy-loaded) so this deliberately only
|
|
// considers top-level — keeps the behaviour predictable.
|
|
var allEntries = [];
|
|
Object.keys(folderEntries).forEach(function(key) {
|
|
(folderEntries[key] || []).forEach(function(e) {
|
|
allEntries.push({ key: key, entry: e });
|
|
});
|
|
});
|
|
if (allEntries.length !== 1) return;
|
|
var hit = allEntries[0];
|
|
if (!hit.entry || !hit.entry.auto_open_if_singleton) return;
|
|
|
|
// Decode the folder key: "<scope>|<path>"
|
|
var sep = hit.key.indexOf('|');
|
|
if (sep < 0) return;
|
|
var scope = hit.key.slice(0, sep);
|
|
var path = hit.key.slice(sep + 1);
|
|
var name = hit.entry.name;
|
|
try { sessionStorage.setItem('rustguac_auto_opened', '1'); } catch (e) {}
|
|
|
|
// Use the same connect path the table's Connect button uses,
|
|
// minus the UI chrome. Open the resulting session in a new tab.
|
|
fetch('/api/addressbook/folders/' + encodeURIComponent(scope)
|
|
+ '/' + encodeURIComponent(path)
|
|
+ '/entries/' + encodeURIComponent(name) + '/connect', {
|
|
method: 'POST',
|
|
headers: Object.assign({'Content-Type': 'application/json'}, apiHeaders()),
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify({ width: window.innerWidth, height: window.innerHeight })
|
|
})
|
|
.then(function(res) { return res.json(); })
|
|
.then(function(data) {
|
|
if (data && data.session_id) {
|
|
// Browsers block window.open after an async fetch
|
|
// (no user gesture), so replace the current tab.
|
|
// Kiosk-style single-entry users see only the client;
|
|
// to return to Connections they navigate back.
|
|
var label = encodeURIComponent(hit.entry.display_name || hit.entry.name);
|
|
window.location.href = '/client/' + data.session_id + '?name=' + label;
|
|
}
|
|
})
|
|
.catch(function() { /* silent — user can still connect manually */ });
|
|
}
|
|
|
|
function populateLoginScriptDropdown(selectedValue) {
|
|
var sel = document.getElementById('em-login-script');
|
|
sel.innerHTML = '<option value="">None</option>';
|
|
if (loginScriptsCache) {
|
|
loginScriptsCache.forEach(function(s) {
|
|
var opt = document.createElement('option');
|
|
opt.value = s;
|
|
opt.textContent = s;
|
|
sel.appendChild(opt);
|
|
});
|
|
}
|
|
sel.value = selectedValue || '';
|
|
}
|
|
|
|
function loadLoginScripts(cb) {
|
|
if (loginScriptsCache !== null) { if (cb) cb(); return; }
|
|
fetch('/api/login-scripts', { headers: apiHeaders(), credentials: 'same-origin' })
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
loginScriptsCache = data.scripts || [];
|
|
if (cb) cb();
|
|
})
|
|
.catch(function() {
|
|
loginScriptsCache = [];
|
|
if (cb) cb();
|
|
});
|
|
}
|
|
|
|
function showError(msg) {
|
|
document.getElementById('global-error').textContent = msg;
|
|
}
|
|
function clearError() {
|
|
document.getElementById('global-error').textContent = '';
|
|
}
|
|
// Transient success/info message using the same global banner. Clears after 4s.
|
|
function showInfo(msg) {
|
|
var el = document.getElementById('global-error');
|
|
el.textContent = msg;
|
|
setTimeout(function() {
|
|
if (el.textContent === msg) el.textContent = '';
|
|
}, 4000);
|
|
}
|
|
|
|
// Check user role and vault status
|
|
function init() {
|
|
fetch('/api/me', { headers: apiHeaders(), credentials: 'same-origin' })
|
|
.then(function(res) {
|
|
if (res.status === 401 || res.status === 403) { window.location.href = '/'; return; }
|
|
if (!res.ok) throw new Error('server returned ' + res.status);
|
|
return res.json();
|
|
})
|
|
.then(function(data) {
|
|
if (!data) return;
|
|
isAdmin = data.role === 'admin';
|
|
currentUserRole = data.role || 'viewer';
|
|
var level = roleLevel[data.role] || 0;
|
|
if (level < 3) {
|
|
document.getElementById('sessions-link').style.display = 'none';
|
|
}
|
|
if (level >= 3) {
|
|
document.getElementById('reports-link').style.display = '';
|
|
}
|
|
if (isAdmin) {
|
|
document.getElementById('admin-link').style.display = '';
|
|
var els = document.querySelectorAll('.admin-only');
|
|
for (var i = 0; i < els.length; i++) els[i].style.display = '';
|
|
}
|
|
if (level >= 2) {
|
|
document.getElementById('tokens-link').style.display = '';
|
|
}
|
|
if (apiKey) {
|
|
document.getElementById('admin-link').style.display = '';
|
|
document.getElementById('tokens-link').style.display = '';
|
|
}
|
|
if (!data.vault_enabled) {
|
|
if (data.vault_configured) {
|
|
// Vault is configured but not reachable yet
|
|
document.getElementById('vault-unavailable').style.display = '';
|
|
// Auto-retry every 15s
|
|
setTimeout(function() {
|
|
document.getElementById('vault-unavailable').style.display = 'none';
|
|
init();
|
|
}, 15000);
|
|
} else {
|
|
// Vault not configured at all
|
|
document.getElementById('no-vault').style.display = '';
|
|
}
|
|
return;
|
|
}
|
|
loadFolders();
|
|
loadCredStatus();
|
|
loadActiveSessions();
|
|
setInterval(loadActiveSessions, 10000);
|
|
startOnboarding(currentUserRole);
|
|
})
|
|
.catch(function(err) {
|
|
showError('Failed to check authentication: ' + err.message);
|
|
});
|
|
}
|
|
|
|
// ── Share session modal ─────────────────────────────
|
|
function openShareModal(url) {
|
|
var box = document.getElementById('share-modal-url');
|
|
box.textContent = url;
|
|
document.getElementById('share-modal').classList.add('active');
|
|
// Auto-select the URL so Ctrl-C just works
|
|
try {
|
|
var range = document.createRange();
|
|
range.selectNodeContents(box);
|
|
var sel = window.getSelection();
|
|
sel.removeAllRanges();
|
|
sel.addRange(range);
|
|
} catch (e) {}
|
|
}
|
|
function closeShareModal() {
|
|
document.getElementById('share-modal').classList.remove('active');
|
|
}
|
|
function copyToClipboard(text, onOk, onFail) {
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(text).then(onOk).catch(function() { fallback(); });
|
|
} else {
|
|
fallback();
|
|
}
|
|
function fallback() {
|
|
var ta = document.createElement('textarea');
|
|
ta.value = text;
|
|
ta.style.position = 'fixed';
|
|
ta.style.opacity = '0';
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
var ok = false;
|
|
try { ok = document.execCommand('copy'); } catch (e) {}
|
|
document.body.removeChild(ta);
|
|
if (ok) onOk(); else if (onFail) onFail();
|
|
}
|
|
}
|
|
(function wireShareModal() {
|
|
var copyBtn = document.getElementById('share-modal-copy');
|
|
if (copyBtn) {
|
|
copyBtn.addEventListener('click', function() {
|
|
var url = document.getElementById('share-modal-url').textContent;
|
|
copyToClipboard(url, function() {
|
|
copyBtn.textContent = 'Copied';
|
|
setTimeout(function() { copyBtn.textContent = 'Copy'; }, 2000);
|
|
});
|
|
});
|
|
}
|
|
var closeBtn = document.getElementById('share-modal-close');
|
|
if (closeBtn) closeBtn.addEventListener('click', closeShareModal);
|
|
var overlay = document.getElementById('share-modal');
|
|
if (overlay) overlay.addEventListener('click', function(ev) {
|
|
if (ev.target === overlay) closeShareModal();
|
|
});
|
|
})();
|
|
|
|
function loadActiveSessions() {
|
|
// Fetch active sessions and dormant VDI containers in parallel
|
|
var sessionsP = fetch('/api/sessions', { headers: apiHeaders(), credentials: 'same-origin' }).then(function(r) { return r.ok ? r.json() : []; }).catch(function() { return []; });
|
|
var vdiP = fetch('/api/vdi/containers', { headers: apiHeaders(), credentials: 'same-origin' }).then(function(r) { return r.ok ? r.json() : []; }).catch(function() { return []; });
|
|
|
|
Promise.all([sessionsP, vdiP]).then(function(results) {
|
|
var sessions = results[0] || [];
|
|
var vdiContainers = results[1] || [];
|
|
|
|
// Active/pending sessions (all visible to the current user)
|
|
var activeSessions = sessions.filter(function(s) {
|
|
return s.status === 'active' || s.status === 'pending';
|
|
});
|
|
|
|
// Active session container IDs (to avoid duplicating in VDI list)
|
|
var activeContainerSessions = {};
|
|
activeSessions.forEach(function(s) {
|
|
if (s.session_type === 'vdi') activeContainerSessions[s.hostname] = true;
|
|
});
|
|
|
|
// Dormant VDI containers (running but no active session)
|
|
var dormantContainers = vdiContainers.filter(function(c) {
|
|
return !c.has_active_session;
|
|
});
|
|
|
|
var grid = document.getElementById('active-sessions-grid');
|
|
var section = document.getElementById('active-sessions');
|
|
var total = activeSessions.length + dormantContainers.length;
|
|
|
|
if (total === 0) {
|
|
section.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
section.style.display = '';
|
|
document.getElementById('active-sessions-count').textContent = '(' + total + ')';
|
|
grid.innerHTML = '';
|
|
|
|
// Render active sessions
|
|
activeSessions.forEach(function(s) {
|
|
var card = document.createElement('div');
|
|
card.className = 'session-card';
|
|
card.title = 'Click to reconnect';
|
|
var thumbUrl = s.thumbnail_url || '';
|
|
var name = s.entry_display_name || s.hostname || s.url || '?';
|
|
var typeBadge = '<span class="type-badge type-' + escapeHtml(s.session_type) + '">' + escapeHtml(s.session_type) + '</span>';
|
|
var shareUrl = s.share_url ? (window.location.origin + s.share_url) : '';
|
|
var actionsHtml = '<div class="card-actions">';
|
|
if (shareUrl) {
|
|
actionsHtml += '<button type="button" class="btn-add" data-share-url="' + escapeAttr(shareUrl) + '" title="Copy a URL to let someone watch this session">Share</button>';
|
|
}
|
|
actionsHtml += '<button type="button" class="btn-add btn-danger" data-terminate="' + escapeAttr(s.session_id) + '" data-name="' + escapeAttr(name) + '" title="End this session now">Terminate</button>';
|
|
actionsHtml += '</div>';
|
|
card.innerHTML = '<img class="session-card-thumb" src="' + escapeHtml(thumbUrl) + (thumbUrl ? '?t=' + Date.now() : '') + '" onerror="this.style.display=\'none\'" alt="">'
|
|
+ '<div class="session-card-info"><div class="card-name">' + escapeHtml(name) + '</div>'
|
|
+ '<div class="card-meta">' + typeBadge + ' <span>' + escapeHtml(s.username || '') + '</span></div></div>'
|
|
+ actionsHtml;
|
|
card.title = 'Click to open this session';
|
|
var shareBtn = card.querySelector('[data-share-url]');
|
|
if (shareBtn) {
|
|
shareBtn.addEventListener('click', function(ev) {
|
|
ev.stopPropagation();
|
|
openShareModal(shareBtn.getAttribute('data-share-url'));
|
|
});
|
|
}
|
|
var termBtn = card.querySelector('[data-terminate]');
|
|
if (termBtn) {
|
|
termBtn.addEventListener('click', function(ev) {
|
|
ev.stopPropagation();
|
|
var sid = termBtn.getAttribute('data-terminate');
|
|
var label = termBtn.getAttribute('data-name') || sid;
|
|
if (!confirm('Terminate session "' + label + '"? Any unsaved work will be lost.')) return;
|
|
termBtn.disabled = true;
|
|
termBtn.textContent = 'Ending...';
|
|
fetch('/api/sessions/' + sid, {
|
|
method: 'DELETE',
|
|
headers: apiHeaders(),
|
|
credentials: 'same-origin'
|
|
}).then(function(res) {
|
|
if (!res.ok) return res.text().then(function(t) { throw new Error(t || 'terminate failed'); });
|
|
loadActiveSessions();
|
|
}).catch(function(err) {
|
|
termBtn.disabled = false;
|
|
termBtn.textContent = 'Terminate';
|
|
showError('Terminate failed: ' + err.message);
|
|
});
|
|
});
|
|
}
|
|
card.addEventListener('click', function() {
|
|
// Active session: open the existing session's client URL.
|
|
// We used to POST to /connect here which minted a fresh
|
|
// session every click — caused duplicates when users
|
|
// clicked back to an existing tab. Dormant VDI containers
|
|
// below still need /connect since there's no live
|
|
// session to attach to.
|
|
if (s.client_url) {
|
|
window.open(s.client_url, '_blank');
|
|
}
|
|
});
|
|
grid.appendChild(card);
|
|
});
|
|
|
|
// Render dormant VDI containers
|
|
dormantContainers.forEach(function(c) {
|
|
var card = document.createElement('div');
|
|
card.className = 'session-card dormant';
|
|
card.title = 'Dormant desktop — click to reconnect';
|
|
var thumbUrl = c.thumbnail_url || '';
|
|
var name = c.image || c.container_name;
|
|
card.innerHTML = '<img class="session-card-thumb" src="' + escapeHtml(thumbUrl) + (thumbUrl ? '?t=' + Date.now() : '') + '" onerror="this.style.display=\'none\'" alt="">'
|
|
+ '<div class="session-card-info"><div class="card-name">' + escapeHtml(name) + '</div>'
|
|
+ '<div class="card-meta"><span class="type-badge type-vdi">vdi</span> <span>dormant</span></div></div>';
|
|
if (c.entry_key) {
|
|
card.addEventListener('click', function() {
|
|
// Reconnect via connections connect
|
|
var parts = c.entry_key.split('/');
|
|
if (parts.length === 3) {
|
|
var scope = parts[0], folder = parts[1], entry = parts[2];
|
|
fetch('/api/addressbook/folders/' + encodeURIComponent(scope) + '/' + encodeURIComponent(folder) + '/entries/' + encodeURIComponent(entry) + '/connect', {
|
|
method: 'POST',
|
|
headers: Object.assign({'Content-Type': 'application/json'}, apiHeaders()),
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify({ width: window.innerWidth, height: window.innerHeight })
|
|
}).then(function(res) { return res.json(); }).then(function(data) {
|
|
if (data.session_id) window.open('/client/' + data.session_id, '_blank');
|
|
else showError(data.error || 'Failed to reconnect');
|
|
}).catch(function(e) { showError('Reconnect failed: ' + e.message); });
|
|
}
|
|
});
|
|
}
|
|
grid.appendChild(card);
|
|
});
|
|
});
|
|
}
|
|
|
|
function loadFolders() {
|
|
fetch('/api/addressbook', { headers: apiHeaders(), credentials: 'same-origin' })
|
|
.then(function(res) {
|
|
if (res.status === 503) {
|
|
document.getElementById('vault-unavailable').style.display = '';
|
|
setTimeout(function() {
|
|
document.getElementById('vault-unavailable').style.display = 'none';
|
|
loadFolders();
|
|
}, 15000);
|
|
return null;
|
|
}
|
|
if (res.status === 404) {
|
|
document.getElementById('no-vault').style.display = '';
|
|
return null;
|
|
}
|
|
if (!res.ok) return res.text().then(function(t) { throw new Error(t); });
|
|
return res.json();
|
|
})
|
|
.then(function(data) {
|
|
if (!data) return;
|
|
var allFolders = data.folders || [];
|
|
unavailableScopes = data.unavailable_scopes || [];
|
|
// Populate folder list and entry cache from batch response
|
|
folders = allFolders.map(function(f) {
|
|
return {
|
|
name: f.name,
|
|
scope: f.scope,
|
|
description: f.description || '',
|
|
path: f.path || f.name,
|
|
has_children: !!f.has_children
|
|
};
|
|
});
|
|
folderEntries = {};
|
|
folderEntryCounts = {};
|
|
allFolders.forEach(function(f) {
|
|
var key = folderKey(f.scope, f.path || f.name);
|
|
folderEntries[key] = f.entries || [];
|
|
folderEntryCounts[key] = (f.entries || []).length;
|
|
});
|
|
if (folders.length === 0 && unavailableScopes.length === 0 && isAdmin) {
|
|
document.getElementById('empty-state').style.display = '';
|
|
document.getElementById('main-content').style.display = 'none';
|
|
} else if (folders.length === 0) {
|
|
document.getElementById('main-content').style.display = '';
|
|
renderFolders();
|
|
} else {
|
|
document.getElementById('empty-state').style.display = 'none';
|
|
document.getElementById('main-content').style.display = '';
|
|
renderFolders();
|
|
// Restore: expanded tree first (populates subfolderCache so
|
|
// deep selections can resolve via findFolder), then the
|
|
// selected folder, then fall back to folders[0] if nothing
|
|
// else picked up.
|
|
restoreExpandedTree(function() {
|
|
renderFolders();
|
|
if (!selectedFolder) {
|
|
var saved = loadSelectedFolder();
|
|
if (saved) {
|
|
var f = findFolder(saved.scope, saved.path);
|
|
if (f) {
|
|
selectedFolder = f;
|
|
renderFolders();
|
|
loadEntries(f.scope, folderPath(f));
|
|
maybeAutoOpenSingleton();
|
|
return;
|
|
}
|
|
}
|
|
// Nothing restored; pick the first folder.
|
|
selectedFolder = folders[0];
|
|
saveSelectedFolder();
|
|
renderFolders();
|
|
loadEntries(folders[0].scope, folderPath(folders[0]));
|
|
}
|
|
maybeAutoOpenSingleton();
|
|
});
|
|
loadSearchIndex();
|
|
return;
|
|
}
|
|
maybeAutoOpenSingleton();
|
|
loadSearchIndex();
|
|
})
|
|
.catch(function(err) {
|
|
showError('Failed to load folders: ' + err.message);
|
|
// Still show the layout so users can see something
|
|
document.getElementById('main-content').style.display = '';
|
|
});
|
|
}
|
|
|
|
function renderFolders() {
|
|
var ul = document.getElementById('folder-list');
|
|
ul.innerHTML = '';
|
|
// Greyed placeholders for scopes whose Vault backend is unreachable
|
|
// (multi-Vault DR). Empty in a healthy single-Vault deployment, so
|
|
// this adds nothing to the quick-start experience.
|
|
(unavailableScopes || []).forEach(function(scope) {
|
|
ul.appendChild(buildUnavailableScopeNode(scope));
|
|
});
|
|
if (folders.length === 0) {
|
|
if (!unavailableScopes || unavailableScopes.length === 0) {
|
|
var empty = document.createElement('li');
|
|
empty.style.cssText = 'color:#666;cursor:default;padding:1em 0.8em';
|
|
empty.textContent = 'No folders visible to your account.';
|
|
ul.appendChild(empty);
|
|
}
|
|
return;
|
|
}
|
|
folders.forEach(function(f) {
|
|
ul.appendChild(buildFolderNode(f, true));
|
|
});
|
|
}
|
|
|
|
// A non-interactive, greyed tree node shown when a scope's Vault backend
|
|
// is down, so the outage is visible instead of the scope silently
|
|
// vanishing. The page's /api/me poll re-runs init() every 15s, which
|
|
// refreshes this state.
|
|
function buildUnavailableScopeNode(scope) {
|
|
var label = scope === 'instance'
|
|
? 'Local'
|
|
: (scope.charAt(0).toUpperCase() + scope.slice(1));
|
|
var li = document.createElement('li');
|
|
li.className = 'folder-unavailable';
|
|
li.title = 'The Vault serving ' + label.toLowerCase()
|
|
+ ' connections is unreachable. Retrying automatically.';
|
|
var row = document.createElement('div');
|
|
row.className = 'folder-row';
|
|
row.style.cssText = 'opacity:0.55;cursor:default';
|
|
var icon = document.createElement('span');
|
|
icon.className = 'tree-chevron';
|
|
icon.style.color = 'var(--status-error)';
|
|
icon.textContent = '⚠'; // warning sign
|
|
row.appendChild(icon);
|
|
var name = document.createElement('span');
|
|
name.className = 'folder-name';
|
|
name.textContent = label + ' connections unavailable';
|
|
row.appendChild(name);
|
|
li.appendChild(row);
|
|
return li;
|
|
}
|
|
|
|
function buildFolderNode(f, showScope) {
|
|
var path = folderPath(f);
|
|
var key = folderKey(f.scope, path);
|
|
var li = document.createElement('li');
|
|
li.setAttribute('data-path', path);
|
|
li.setAttribute('data-scope', f.scope);
|
|
if (selectedFolder && selectedFolder.scope === f.scope && folderPath(selectedFolder) === path) {
|
|
li.className = 'selected';
|
|
}
|
|
|
|
var row = document.createElement('div');
|
|
row.className = 'folder-row';
|
|
|
|
var chevron = document.createElement('span');
|
|
chevron.className = 'tree-chevron';
|
|
var isExpanded = !!expandedPaths[key];
|
|
if (f.has_children) {
|
|
// Full-size triangles (U+25BC/U+25B6), matching every other
|
|
// collapsible on the page; the small variants (U+25BE/U+25B8)
|
|
// rendered as faint, hard-to-see ink even at 20px.
|
|
chevron.textContent = isExpanded ? '\u25BC' : '\u25B6';
|
|
if (isExpanded) chevron.classList.add('expanded');
|
|
chevron.addEventListener('click', function(ev) {
|
|
ev.stopPropagation();
|
|
toggleFolder(f);
|
|
});
|
|
} else {
|
|
chevron.classList.add('leaf');
|
|
chevron.textContent = '\u25B6';
|
|
}
|
|
row.appendChild(chevron);
|
|
|
|
var main = document.createElement('div');
|
|
main.className = 'folder-row-main';
|
|
var nameRow = document.createElement('div');
|
|
nameRow.className = 'folder-name-row';
|
|
var nameSpan = document.createElement('span');
|
|
nameSpan.textContent = f.name;
|
|
nameRow.appendChild(nameSpan);
|
|
if (showScope) {
|
|
var scopeBadge = document.createElement('span');
|
|
scopeBadge.className = 'folder-scope';
|
|
scopeBadge.setAttribute('data-scope', f.scope);
|
|
scopeBadge.title = f.scope === 'shared'
|
|
? 'Shared — visible across all rustguac instances'
|
|
: 'Instance — this server only';
|
|
nameRow.appendChild(scopeBadge);
|
|
}
|
|
main.appendChild(nameRow);
|
|
if (folderEntryCounts[key] !== undefined) {
|
|
var countSpan = document.createElement('span');
|
|
countSpan.className = 'folder-count';
|
|
countSpan.textContent = folderEntryCounts[key] + ' entries';
|
|
main.appendChild(countSpan);
|
|
}
|
|
if (f.description) {
|
|
var desc = document.createElement('span');
|
|
desc.className = 'folder-desc';
|
|
desc.textContent = f.description;
|
|
main.appendChild(desc);
|
|
}
|
|
row.appendChild(main);
|
|
|
|
row.addEventListener('click', function() {
|
|
selectedFolder = f;
|
|
saveSelectedFolder();
|
|
renderFolders();
|
|
loadEntries(f.scope, path);
|
|
});
|
|
li.appendChild(row);
|
|
|
|
if (f.has_children && isExpanded) {
|
|
var childrenUl = document.createElement('ul');
|
|
childrenUl.className = 'tree-children';
|
|
var kids = subfolderCache[key];
|
|
if (!kids) {
|
|
var loadingLi = document.createElement('li');
|
|
loadingLi.style.color = 'var(--text-dim)';
|
|
loadingLi.style.cursor = 'default';
|
|
loadingLi.style.padding = '0.3em 0.5em';
|
|
loadingLi.textContent = 'Loading...';
|
|
childrenUl.appendChild(loadingLi);
|
|
} else if (kids.length === 0) {
|
|
var emptyLi = document.createElement('li');
|
|
emptyLi.style.color = 'var(--text-dim)';
|
|
emptyLi.style.cursor = 'default';
|
|
emptyLi.style.padding = '0.3em 0.5em';
|
|
emptyLi.style.fontSize = '0.85em';
|
|
emptyLi.textContent = '(empty)';
|
|
childrenUl.appendChild(emptyLi);
|
|
} else {
|
|
kids.forEach(function(child) {
|
|
childrenUl.appendChild(buildFolderNode(child, false));
|
|
});
|
|
}
|
|
li.appendChild(childrenUl);
|
|
}
|
|
|
|
return li;
|
|
}
|
|
|
|
function toggleFolder(f) {
|
|
var path = folderPath(f);
|
|
var key = folderKey(f.scope, path);
|
|
if (expandedPaths[key]) {
|
|
delete expandedPaths[key];
|
|
saveExpandedPaths();
|
|
renderFolders();
|
|
} else {
|
|
expandedPaths[key] = true;
|
|
saveExpandedPaths();
|
|
renderFolders();
|
|
if (!subfolderCache[key]) {
|
|
loadSubfolders(f.scope, path, function() { renderFolders(); });
|
|
}
|
|
}
|
|
}
|
|
|
|
function loadSubfolders(scope, path, cb) {
|
|
var key = folderKey(scope, path);
|
|
fetch('/api/addressbook/folders/' + encodeURIComponent(scope) + '/' + encodeURIComponent(path) + '/subfolders', {
|
|
headers: apiHeaders(),
|
|
credentials: 'same-origin'
|
|
})
|
|
.then(function(res) {
|
|
if (!res.ok) return res.text().then(function(t) { throw new Error(t); });
|
|
return res.json();
|
|
})
|
|
.then(function(data) {
|
|
subfolderCache[key] = (data || []).map(function(sf) {
|
|
return {
|
|
name: sf.name,
|
|
scope: sf.scope,
|
|
description: sf.description || '',
|
|
path: sf.path || (path + '/' + sf.name),
|
|
has_children: !!sf.has_children
|
|
};
|
|
});
|
|
if (cb) cb();
|
|
})
|
|
.catch(function(err) {
|
|
subfolderCache[key] = [];
|
|
showError('Failed to load subfolders: ' + err.message);
|
|
if (cb) cb();
|
|
});
|
|
}
|
|
|
|
function findFolder(scope, path) {
|
|
// Search top-level + subfolder cache for a folder object by scope/path.
|
|
for (var i = 0; i < folders.length; i++) {
|
|
if (folders[i].scope === scope && folderPath(folders[i]) === path) return folders[i];
|
|
}
|
|
for (var k in subfolderCache) {
|
|
var arr = subfolderCache[k];
|
|
for (var j = 0; j < arr.length; j++) {
|
|
if (arr[j].scope === scope && folderPath(arr[j]) === path) return arr[j];
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ── Quick-find search ──────────────────────────────────────────────
|
|
// Index is fetched once after loadFolders() and after any mutation that
|
|
// already triggers a refresh. Search runs entirely client-side against
|
|
// the in-memory haystack.
|
|
|
|
function loadSearchIndex() {
|
|
var input = document.getElementById('connections-search');
|
|
if (!input) return;
|
|
searchIndexLoading = true;
|
|
input.placeholder = 'Indexing...';
|
|
input.disabled = true;
|
|
fetch('/api/addressbook/search-index', { headers: apiHeaders(), credentials: 'same-origin' })
|
|
.then(function(res) {
|
|
if (!res.ok) return res.text().then(function(t) { throw new Error(t); });
|
|
return res.json();
|
|
})
|
|
.then(function(data) {
|
|
var rows = (data && data.entries) || [];
|
|
searchIndex = rows.map(function(r) {
|
|
var e = r.entry;
|
|
var label = e.display_name || e.name;
|
|
var host = e.session_type === 'web' ? (e.url || '')
|
|
: e.session_type === 'vdi' ? (e.container_image || '')
|
|
: e.session_type === 'proxmox' ? ((e.proxmox_url || '') + (e.proxmox_vmid ? ' · VM ' + e.proxmox_vmid : ''))
|
|
: (e.hostname || '');
|
|
var haystack = [
|
|
e.name,
|
|
e.display_name || '',
|
|
host,
|
|
e.username || '',
|
|
e.proxmox_token_id || '',
|
|
e.url || '',
|
|
e.domain || '',
|
|
e.session_type,
|
|
r.folder_path
|
|
].join(' ').toLowerCase();
|
|
return {
|
|
scope: r.scope,
|
|
folderPath: r.folder_path,
|
|
entry: e,
|
|
label: label,
|
|
host: host,
|
|
haystack: haystack
|
|
};
|
|
});
|
|
searchIndexLoading = false;
|
|
input.placeholder = 'Search ' + searchIndex.length + ' connection' + (searchIndex.length === 1 ? '' : 's') + '... (press / )';
|
|
input.disabled = false;
|
|
if (searchQuery) runSearch();
|
|
// The full entry set is now known — refresh the proactive
|
|
// "credentials to set up" banner against it.
|
|
updateCredBanner();
|
|
})
|
|
.catch(function(err) {
|
|
searchIndexLoading = false;
|
|
input.placeholder = 'Search unavailable';
|
|
input.disabled = false;
|
|
if (window.console) console.warn('Search index failed:', err.message);
|
|
});
|
|
}
|
|
|
|
function runSearch() {
|
|
var input = document.getElementById('connections-search');
|
|
if (!input) return;
|
|
var q = (input.value || '').trim().toLowerCase();
|
|
searchQuery = q;
|
|
if (!q) {
|
|
if (searchActive) {
|
|
searchActive = false;
|
|
if (selectedFolder) {
|
|
loadEntries(selectedFolder.scope, folderPath(selectedFolder));
|
|
} else {
|
|
document.getElementById('entries-content').innerHTML =
|
|
'<p class="empty">Select a folder to view entries.</p>';
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
searchActive = true;
|
|
if (!searchIndex) {
|
|
document.getElementById('entries-content').innerHTML =
|
|
'<p class="empty">Indexing connections, please wait...</p>';
|
|
return;
|
|
}
|
|
var tokens = q.split(/\s+/).filter(Boolean);
|
|
var matches = [];
|
|
for (var i = 0; i < searchIndex.length; i++) {
|
|
var row = searchIndex[i];
|
|
var ok = true;
|
|
for (var t = 0; t < tokens.length; t++) {
|
|
if (row.haystack.indexOf(tokens[t]) === -1) { ok = false; break; }
|
|
}
|
|
if (!ok) continue;
|
|
var first = tokens[0];
|
|
var lcLabel = row.label.toLowerCase();
|
|
var lcName = row.entry.name.toLowerCase();
|
|
var score = 0;
|
|
if (lcLabel.indexOf(first) === 0 || lcName.indexOf(first) === 0) score += 100;
|
|
else if (lcLabel.indexOf(first) !== -1 || lcName.indexOf(first) !== -1) score += 50;
|
|
if ((row.host || '').toLowerCase().indexOf(first) !== -1) score += 20;
|
|
if (row.folderPath.toLowerCase().indexOf(first) !== -1) score += 10;
|
|
matches.push({ row: row, score: score });
|
|
}
|
|
matches.sort(function(a, b) {
|
|
if (b.score !== a.score) return b.score - a.score;
|
|
if (a.row.folderPath !== b.row.folderPath) return a.row.folderPath.localeCompare(b.row.folderPath);
|
|
return a.row.label.localeCompare(b.row.label);
|
|
});
|
|
renderSearchResults(matches, q, tokens);
|
|
}
|
|
|
|
function renderSearchResults(matches, query, tokens) {
|
|
var content = document.getElementById('entries-content');
|
|
if (matches.length === 0) {
|
|
content.innerHTML = '<div class="empty-state" style="padding:2em;margin:1em 0">' +
|
|
'<p style="color:var(--text-dim);margin:0">No matches for ' + escapeHtml(query) + '</p>' +
|
|
'</div>';
|
|
return;
|
|
}
|
|
var maxResults = 50;
|
|
var more = matches.length - maxResults;
|
|
var shown = matches.slice(0, maxResults);
|
|
var html = '<table class="entries-table"><thead><tr>' +
|
|
'<th>Name</th><th>Type</th><th>Host</th><th>User</th><th>Folder</th><th></th><th></th>' +
|
|
'</tr></thead><tbody>';
|
|
shown.forEach(function(m) {
|
|
var r = m.row;
|
|
var e = r.entry;
|
|
var typeLabel = e.session_type.toUpperCase();
|
|
if (e.auth_pkg) typeLabel += ' <span style="font-size:0.75em;color:#888">(' + escapeHtml(e.auth_pkg) + ')</span>';
|
|
var typeCls = 'type-' + e.session_type;
|
|
var needsPrompt = (e.prompt_credentials || !e.has_credentials) && e.session_type !== 'web' && e.session_type !== 'proxmox';
|
|
var connectLabel = needsPrompt ? 'Login...' : 'Connect';
|
|
var userCol = e.session_type === 'proxmox'
|
|
? highlight(e.proxmox_token_id || '', tokens)
|
|
: (e.username ? highlight(e.username, tokens) : '');
|
|
if (needsPrompt && userCol) userCol += ' <span style="color:#888;font-size:0.8em" title="Credentials prompted at connect">[prompt]</span>';
|
|
var scopeIcon = r.scope === 'shared' ? '⊕' : '▣';
|
|
html += '<tr>';
|
|
html += '<td>' + highlight(r.label, tokens) + '</td>';
|
|
html += '<td><span class="type-badge ' + typeCls + '">' + typeLabel + '</span></td>';
|
|
html += '<td>' + highlight(r.host || '', tokens) + '</td>';
|
|
html += '<td>' + userCol + '</td>';
|
|
html += '<td><span class="search-folder-cell"><span class="folder-scope">' + scopeIcon + '</span> ' + highlight(r.folderPath, tokens) + '</span></td>';
|
|
html += '<td class="connect-cell"><button class="btn-connect" data-connect="' + escapeAttr(e.name) + '" data-scope="' + escapeAttr(r.scope) + '" data-folder="' + escapeAttr(r.folderPath) + '">' + connectLabel + '</button>'
|
|
+ '<button class="btn-connect btn-connect-win" title="Open in a new window" data-connect-win="' + escapeAttr(e.name) + '" data-scope="' + escapeAttr(r.scope) + '" data-folder="' + escapeAttr(r.folderPath) + '">⧉</button></td>';
|
|
html += '<td><a href="#" class="search-open-folder" data-open-scope="' + escapeAttr(r.scope) + '" data-open-folder="' + escapeAttr(r.folderPath) + '" title="Open this folder">↗ open folder</a></td>';
|
|
html += '</tr>';
|
|
});
|
|
html += '</tbody></table>';
|
|
if (more > 0) {
|
|
html += '<div class="search-more">+' + more + ' more match' + (more === 1 ? '' : 'es') + ' — refine your search to narrow results</div>';
|
|
}
|
|
content.innerHTML = html;
|
|
}
|
|
|
|
function highlight(text, tokens) {
|
|
var safe = escapeHtml(text || '');
|
|
if (!tokens || !tokens.length) return safe;
|
|
var escaped = tokens
|
|
.filter(function(t) { return t.length > 0; })
|
|
.map(function(t) { return t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); });
|
|
if (!escaped.length) return safe;
|
|
var re = new RegExp('(' + escaped.join('|') + ')', 'gi');
|
|
return safe.replace(re, '<mark>$1</mark>');
|
|
}
|
|
|
|
function openFolderFromSearch(scope, targetPath) {
|
|
var input = document.getElementById('connections-search');
|
|
if (input) input.value = '';
|
|
searchQuery = '';
|
|
searchActive = false;
|
|
|
|
var parts = targetPath.split('/');
|
|
var ancestors = [];
|
|
for (var i = 1; i < parts.length; i++) {
|
|
ancestors.push(parts.slice(0, i).join('/'));
|
|
}
|
|
ancestors.forEach(function(p) {
|
|
expandedPaths[folderKey(scope, p)] = true;
|
|
});
|
|
saveExpandedPaths();
|
|
|
|
var idx = 0;
|
|
function loadNext() {
|
|
if (idx >= ancestors.length) {
|
|
var target = findFolder(scope, targetPath);
|
|
if (target) {
|
|
selectedFolder = target;
|
|
saveSelectedFolder();
|
|
renderFolders();
|
|
loadEntries(scope, targetPath);
|
|
setTimeout(function() {
|
|
try {
|
|
var sel = '.folder-list li[data-scope="' + scope + '"][data-path="' + targetPath.replace(/"/g, '\\"') + '"]';
|
|
var row = document.querySelector(sel);
|
|
if (row && row.scrollIntoView) row.scrollIntoView({ block: 'nearest' });
|
|
} catch (_) { /* selector edge case — non-fatal */ }
|
|
}, 80);
|
|
} else {
|
|
renderFolders();
|
|
}
|
|
return;
|
|
}
|
|
var p = ancestors[idx++];
|
|
var key = folderKey(scope, p);
|
|
if (subfolderCache[key]) {
|
|
loadNext();
|
|
} else {
|
|
loadSubfolders(scope, p, loadNext);
|
|
}
|
|
}
|
|
loadNext();
|
|
}
|
|
|
|
function loadEntries(scope, folderPath, forceRefresh) {
|
|
var header = document.getElementById('entries-header');
|
|
header.style.display = '';
|
|
document.getElementById('entries-title').textContent = folderPath;
|
|
var f = findFolder(scope, folderPath);
|
|
document.getElementById('entries-desc').textContent = f && f.description ? f.description : '';
|
|
|
|
var cacheKey = folderKey(scope, folderPath);
|
|
var cached = folderEntries[cacheKey];
|
|
|
|
// Use cache if available and not forcing refresh (e.g. after mutation)
|
|
if (cached && !forceRefresh) {
|
|
currentEntries = cached;
|
|
folderEntryCounts[cacheKey] = cached.length;
|
|
renderFolders();
|
|
renderEntries(cached, scope, folderPath);
|
|
return;
|
|
}
|
|
|
|
var content = document.getElementById('entries-content');
|
|
content.innerHTML = '<p class="empty">Loading...</p>';
|
|
|
|
fetch('/api/addressbook/folders/' + encodeURIComponent(scope) + '/' + encodeURIComponent(folderPath) + '/entries', {
|
|
headers: apiHeaders(),
|
|
credentials: 'same-origin'
|
|
})
|
|
.then(function(res) {
|
|
if (!res.ok) return res.text().then(function(t) { throw new Error(t); });
|
|
return res.json();
|
|
})
|
|
.then(function(entries) {
|
|
currentEntries = entries;
|
|
folderEntries[cacheKey] = entries;
|
|
folderEntryCounts[cacheKey] = entries.length;
|
|
renderFolders(); // Update count badge
|
|
renderEntries(entries, scope, folderPath);
|
|
})
|
|
.catch(function(err) {
|
|
content.innerHTML = '<p class="empty">' + escapeHtml(err.message) + '</p>';
|
|
});
|
|
}
|
|
|
|
// Credential variables an entry references that the user hasn't set
|
|
// yet. Empty until the cred status has loaded (setCredVars !== null).
|
|
function entryUnsetVars(e) {
|
|
if (!setCredVars || !e || !e.credential_variables) return [];
|
|
return e.credential_variables.filter(function(v) { return !setCredVars.has(v); });
|
|
}
|
|
|
|
// Fetch which credential variables the user has set, then refresh the
|
|
// proactive banner and re-render the entry list so pills appear.
|
|
function loadCredStatus() {
|
|
fetch('/api/me/credentials', { headers: apiHeaders(), credentials: 'same-origin' })
|
|
.then(function(r) { return r.ok ? r.json() : null; })
|
|
.then(function(data) {
|
|
if (!data || !data.credentials) return;
|
|
setCredVars = new Set();
|
|
Object.keys(data.credentials).forEach(function(k) {
|
|
if (data.credentials[k] && data.credentials[k].set) setCredVars.add(k);
|
|
});
|
|
updateCredBanner();
|
|
refreshEntriesView();
|
|
})
|
|
.catch(function() {});
|
|
}
|
|
|
|
// Distinct credential variables referenced by any visible entry that the
|
|
// user hasn't set. Prefer the (comprehensive) search index; fall back to
|
|
// whatever folder entries are cached.
|
|
// localStorage remembers which unset credentials the user dismissed, so
|
|
// the banner stays gone across refreshes. It only re-appears if a
|
|
// genuinely NEW unset credential shows up (not merely because the set
|
|
// shrank after a partial save) — dismissing silences today's set, not
|
|
// future needs.
|
|
var CREDS_DISMISS_KEY = 'rustguac_creds_banner_dismissed';
|
|
var currentUnsetCreds = []; // names behind the banner right now (for dismiss)
|
|
function getDismissedCreds() {
|
|
try {
|
|
return new Set((localStorage.getItem(CREDS_DISMISS_KEY) || '').split(',').filter(Boolean));
|
|
} catch (e) { return new Set(); }
|
|
}
|
|
|
|
function updateCredBanner() {
|
|
var banner = document.getElementById('creds-banner');
|
|
if (!banner) return;
|
|
if (!setCredVars) { banner.style.display = 'none'; return; }
|
|
var entries = [];
|
|
if (searchIndex) {
|
|
entries = searchIndex.map(function(r) { return r.entry; });
|
|
} else {
|
|
Object.keys(folderEntries).forEach(function(k) {
|
|
(folderEntries[k] || []).forEach(function(e) { entries.push(e); });
|
|
});
|
|
}
|
|
var unset = {};
|
|
entries.forEach(function(e) {
|
|
entryUnsetVars(e).forEach(function(v) { unset[v] = true; });
|
|
});
|
|
var names = Object.keys(unset);
|
|
currentUnsetCreds = names;
|
|
if (names.length === 0) { banner.style.display = 'none'; return; }
|
|
// Stay hidden only if every currently-unset credential was already
|
|
// dismissed; a new one re-alerts.
|
|
var dismissed = getDismissedCreds();
|
|
if (names.every(function(n) { return dismissed.has(n); })) {
|
|
banner.style.display = 'none';
|
|
return;
|
|
}
|
|
document.getElementById('creds-banner-text').textContent =
|
|
'You have ' + names.length + ' credential' + (names.length === 1 ? '' : 's')
|
|
+ ' to set up before some connections will work.';
|
|
banner.style.display = 'flex';
|
|
}
|
|
|
|
function refreshEntriesView() {
|
|
if (lastEntriesRender && !searchActive) {
|
|
renderEntries(lastEntriesRender.entries, lastEntriesRender.scope, lastEntriesRender.folder);
|
|
}
|
|
}
|
|
|
|
function renderEntries(entries, scope, folder) {
|
|
lastEntriesRender = { entries: entries, scope: scope, folder: folder };
|
|
var content = document.getElementById('entries-content');
|
|
if (entries.length === 0) {
|
|
if (isAdmin) {
|
|
content.innerHTML = '<div class="empty-state" style="padding:2em;margin:1em 0">' +
|
|
'<p style="color:#888;margin:0 0 1em 0">No entries in this folder.</p>' +
|
|
'<button class="btn-add" id="btn-empty-add-entry" style="font-size:1em;padding:0.5em 1.2em">+ Add First Entry</button>' +
|
|
'</div>';
|
|
document.getElementById('btn-empty-add-entry').addEventListener('click', function() {
|
|
openNewEntry();
|
|
});
|
|
} else {
|
|
content.innerHTML = '<p class="empty">No entries in this folder.</p>';
|
|
}
|
|
return;
|
|
}
|
|
|
|
var html = '<table class="entries-table"><thead><tr>' +
|
|
'<th>Name</th><th>Type</th><th>Host</th><th>User</th><th></th>';
|
|
if (isAdmin) html += '<th></th><th></th><th></th>';
|
|
html += '</tr></thead><tbody>';
|
|
|
|
entries.forEach(function(e) {
|
|
var label = e.display_name || e.name;
|
|
var hostBase = e.session_type === 'web' ? (e.url || '')
|
|
: e.session_type === 'vdi' ? (e.container_image || '')
|
|
: e.session_type === 'proxmox' ? ((e.proxmox_url || '') + (e.proxmox_vmid ? ' · VM ' + e.proxmox_vmid : ''))
|
|
: (e.hostname || '');
|
|
if (e.port && e.session_type !== 'web' && e.session_type !== 'vdi') hostBase += ':' + e.port;
|
|
var hostSuffix = '';
|
|
if (e.jump_hosts && e.jump_hosts.length > 0) {
|
|
var hopNames = e.jump_hosts.map(function(h) { return h.hostname; }).join(' \u2192 ');
|
|
hostSuffix = ' <span style="color:#888;font-size:0.8em" title="via ' + escapeAttr(hopNames) + '">via ' + escapeHtml(hopNames) + '</span>';
|
|
}
|
|
var typeCls = 'type-' + e.session_type;
|
|
|
|
html += '<tr>';
|
|
var unsetVars = entryUnsetVars(e);
|
|
var credPill = unsetVars.length > 0
|
|
? ' <span title="Set these first: ' + escapeAttr(unsetVars.join(', ')) + '" style="background:var(--status-pending);color:var(--bg);border-radius:8px;padding:0.05em 0.5em;font-size:0.72em;font-weight:bold;white-space:nowrap;vertical-align:middle">credentials needed</span>'
|
|
: '';
|
|
html += '<td>' + escapeHtml(label) + credPill + '</td>';
|
|
var typeLabel = e.session_type.toUpperCase();
|
|
if (e.auth_pkg) typeLabel += ' <span style="font-size:0.75em;color:#888">(' + escapeHtml(e.auth_pkg) + ')</span>';
|
|
html += '<td><span class="type-badge ' + typeCls + '">' + typeLabel + '</span></td>';
|
|
html += '<td>' + escapeHtml(hostBase) + hostSuffix + '</td>';
|
|
var userCol = e.session_type === 'proxmox'
|
|
? escapeHtml(e.proxmox_token_id || '')
|
|
: (e.username ? escapeHtml(e.username) : '');
|
|
var needsPrompt = (e.prompt_credentials || !e.has_credentials) && e.session_type !== 'web' && e.session_type !== 'proxmox';
|
|
if (needsPrompt) userCol += ' <span style="color:#888;font-size:0.8em" title="Credentials will be prompted at connect time">[prompt]</span>';
|
|
html += '<td>' + userCol + '</td>';
|
|
var connectLabel = needsPrompt ? 'Login...' : 'Connect';
|
|
html += '<td class="connect-cell"><button class="btn-connect" data-connect="' + escapeAttr(e.name) + '" data-scope="' + escapeAttr(scope) + '" data-folder="' + escapeAttr(folder) + '">' + connectLabel + '</button>'
|
|
+ '<button class="btn-connect btn-connect-win" title="Open in a new window" data-connect-win="' + escapeAttr(e.name) + '" data-scope="' + escapeAttr(scope) + '" data-folder="' + escapeAttr(folder) + '">⧉</button></td>';
|
|
if (isAdmin) {
|
|
html += '<td><button class="btn-small" data-edit-entry="' + escapeAttr(e.name) + '" data-scope="' + escapeAttr(scope) + '" data-folder="' + escapeAttr(folder) + '" data-type="' + escapeAttr(e.session_type) + '">edit</button></td>';
|
|
html += '<td><button class="btn-small" data-clone-entry="' + escapeAttr(e.name) + '" data-scope="' + escapeAttr(scope) + '" data-folder="' + escapeAttr(folder) + '">clone</button></td>';
|
|
html += '<td><button class="btn-small" data-delete-entry="' + escapeAttr(e.name) + '" data-scope="' + escapeAttr(scope) + '" data-folder="' + escapeAttr(folder) + '">delete</button></td>';
|
|
}
|
|
html += '</tr>';
|
|
});
|
|
|
|
html += '</tbody></table>';
|
|
content.innerHTML = html;
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
var d = document.createElement('div');
|
|
d.textContent = s;
|
|
return d.innerHTML;
|
|
}
|
|
|
|
function escapeAttr(s) {
|
|
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
// Connect to an entry (with optional credential prompt)
|
|
var pendingConnect = null; // { scope, folder, name, btn }
|
|
|
|
function doConnect(scope, folder, name, btn, extraBody, newWindow) {
|
|
var isWinBtn = !!(btn.getAttribute && btn.getAttribute('data-connect-win'));
|
|
btn.disabled = true;
|
|
if (!isWinBtn) btn.textContent = 'Connecting...';
|
|
clearError();
|
|
// A window opened AFTER the async connect fetch is popup-blocked (no
|
|
// user gesture), so open it synchronously now (we are still inside the
|
|
// click gesture) and navigate it once the session exists.
|
|
var targetWin = null;
|
|
if (newWindow) {
|
|
targetWin = window.open('', '_blank',
|
|
'width=1280,height=800,menubar=no,toolbar=no,location=no,status=no');
|
|
}
|
|
var body = extraBody || {};
|
|
// Send browser dimensions so RDP sessions start at correct resolution
|
|
var bw = window.innerWidth, bh = window.innerHeight;
|
|
if (!body.width && bw > 0 && bh > 0) {
|
|
body.width = bw;
|
|
body.height = bh;
|
|
if (!body.dpi) body.dpi = Math.round((window.devicePixelRatio || 1) * 96);
|
|
}
|
|
fetch('/api/addressbook/folders/' + encodeURIComponent(scope) + '/' + encodeURIComponent(folder) + '/entries/' + encodeURIComponent(name) + '/connect', {
|
|
method: 'POST',
|
|
headers: apiHeaders({ 'Content-Type': 'application/json' }),
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify(body)
|
|
})
|
|
.then(function(res) {
|
|
if (!res.ok) {
|
|
if (res.status === 412) {
|
|
return res.json().then(function(j) {
|
|
var err = new Error('missing_credentials');
|
|
err.missing = j.missing_variables || [];
|
|
throw err;
|
|
});
|
|
}
|
|
return res.text().then(function(t) { throw new Error(t); });
|
|
}
|
|
return res.json();
|
|
})
|
|
.then(function(data) {
|
|
var url = data.client_url + '?name=' + encodeURIComponent(name);
|
|
if (targetWin && !targetWin.closed) targetWin.location.href = url;
|
|
else window.open(url, '_blank');
|
|
})
|
|
.catch(function(err) {
|
|
if (targetWin && !targetWin.closed) targetWin.close();
|
|
if (err.message === 'missing_credentials') {
|
|
// Guided, resumable flow: prompt only the missing variables,
|
|
// then re-run this exact connect on save instead of
|
|
// dead-ending the user in the full credential editor.
|
|
openMyCredentials({
|
|
onlyVars: err.missing || [],
|
|
heading: 'Enter the credentials needed to connect to "' + name + '".',
|
|
onSaved: function() {
|
|
doConnect(scope, folder, name, btn, extraBody, newWindow);
|
|
}
|
|
});
|
|
} else {
|
|
showError(err.message);
|
|
}
|
|
})
|
|
.finally(function() {
|
|
btn.disabled = false;
|
|
if (!isWinBtn) btn.textContent = 'Connect';
|
|
});
|
|
}
|
|
|
|
function showCredPrompt(entry, scope, folder, btn, newWindow) {
|
|
pendingConnect = { scope: scope, folder: folder, name: entry.name, btn: btn, newWindow: !!newWindow };
|
|
var label = entry.display_name || entry.name;
|
|
document.getElementById('cred-modal-title').textContent = 'Credentials for ' + label;
|
|
var isRdp = entry.session_type === 'rdp';
|
|
var isVnc = entry.session_type === 'vnc';
|
|
document.getElementById('cred-domain-row').style.display = isRdp ? '' : 'none';
|
|
// VNC has password only — hide username row
|
|
document.getElementById('cred-username-row').style.display = isVnc ? 'none' : '';
|
|
// Pre-fill username if the entry has one stored
|
|
document.getElementById('cred-username').value = isVnc ? '' : (entry.username || '');
|
|
document.getElementById('cred-password').value = '';
|
|
document.getElementById('cred-domain').value = entry.domain || '';
|
|
document.getElementById('cred-error').textContent = '';
|
|
document.getElementById('cred-modal').classList.add('active');
|
|
// Focus password if username is pre-filled or VNC, otherwise focus username
|
|
if (entry.username || isVnc) {
|
|
document.getElementById('cred-password').focus();
|
|
} else {
|
|
document.getElementById('cred-username').focus();
|
|
}
|
|
}
|
|
|
|
document.getElementById('cred-cancel').addEventListener('click', function() {
|
|
document.getElementById('cred-modal').classList.remove('active');
|
|
if (pendingConnect && pendingConnect.btn) {
|
|
pendingConnect.btn.disabled = false;
|
|
if (!pendingConnect.btn.getAttribute('data-connect-win'))
|
|
pendingConnect.btn.textContent = 'Connect';
|
|
}
|
|
pendingConnect = null;
|
|
});
|
|
|
|
document.getElementById('cred-connect').addEventListener('click', function() {
|
|
if (!pendingConnect) return;
|
|
var pw = document.getElementById('cred-password').value;
|
|
if (!pw) {
|
|
document.getElementById('cred-error').textContent = 'Password is required.';
|
|
return;
|
|
}
|
|
var body = { password: pw };
|
|
var u = document.getElementById('cred-username').value.trim();
|
|
if (u) body.username = u;
|
|
var d = document.getElementById('cred-domain').value.trim();
|
|
if (d) body.domain = d;
|
|
document.getElementById('cred-modal').classList.remove('active');
|
|
doConnect(pendingConnect.scope, pendingConnect.folder, pendingConnect.name, pendingConnect.btn, body, pendingConnect.newWindow);
|
|
pendingConnect = null;
|
|
});
|
|
|
|
// Submit on Enter in the credential prompt
|
|
document.getElementById('cred-password').addEventListener('keydown', function(e) {
|
|
if (e.key === 'Enter') document.getElementById('cred-connect').click();
|
|
});
|
|
|
|
document.getElementById('entries-content').addEventListener('click', function(e) {
|
|
var btn = e.target;
|
|
|
|
// Open folder from a search result row
|
|
var openLink = btn.closest && btn.closest('.search-open-folder');
|
|
if (openLink) {
|
|
e.preventDefault();
|
|
openFolderFromSearch(
|
|
openLink.getAttribute('data-open-scope'),
|
|
openLink.getAttribute('data-open-folder')
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (btn.getAttribute('data-connect') || btn.getAttribute('data-connect-win')) {
|
|
var newWindow = !!btn.getAttribute('data-connect-win');
|
|
var name = btn.getAttribute('data-connect') || btn.getAttribute('data-connect-win');
|
|
var scope = btn.getAttribute('data-scope');
|
|
var folder = btn.getAttribute('data-folder');
|
|
clearError();
|
|
|
|
// Find the entry data. When the table is showing search results,
|
|
// currentEntries is the previously-selected folder's entries —
|
|
// look in searchIndex instead.
|
|
var entry = null;
|
|
if (searchActive && searchIndex) {
|
|
for (var si = 0; si < searchIndex.length; si++) {
|
|
var r = searchIndex[si];
|
|
if (r.scope === scope && r.folderPath === folder && r.entry.name === name) {
|
|
entry = r.entry; break;
|
|
}
|
|
}
|
|
} else {
|
|
for (var i = 0; i < currentEntries.length; i++) {
|
|
if (currentEntries[i].name === name) { entry = currentEntries[i]; break; }
|
|
}
|
|
}
|
|
|
|
// Prompt for credentials if prompt_credentials is set or no stored creds
|
|
// (RDP and SSH entries without passwords/keys)
|
|
if (entry && (entry.prompt_credentials || !entry.has_credentials) && entry.session_type !== 'web' && entry.session_type !== 'vdi' && entry.session_type !== 'proxmox') {
|
|
showCredPrompt(entry, scope, folder, btn, newWindow);
|
|
} else {
|
|
doConnect(scope, folder, name, btn, undefined, newWindow);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Delete entry
|
|
if (btn.getAttribute('data-delete-entry')) {
|
|
var name = btn.getAttribute('data-delete-entry');
|
|
var scope = btn.getAttribute('data-scope');
|
|
var folder = btn.getAttribute('data-folder');
|
|
if (!confirm('Delete entry "' + name + '"?')) return;
|
|
fetch('/api/addressbook/folders/' + encodeURIComponent(scope) + '/' + encodeURIComponent(folder) + '/entries/' + encodeURIComponent(name), {
|
|
method: 'DELETE',
|
|
headers: apiHeaders(),
|
|
credentials: 'same-origin'
|
|
}).then(function(res) {
|
|
if (!res.ok) return res.text().then(function(t) { showError(t); });
|
|
loadEntries(scope, folder, true);
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Clone entry
|
|
if (btn.getAttribute('data-clone-entry')) {
|
|
var name = btn.getAttribute('data-clone-entry');
|
|
var scope = btn.getAttribute('data-scope');
|
|
var folder = btn.getAttribute('data-folder');
|
|
var entry = currentEntries.find(function(e) { return e.name === name; });
|
|
if (!entry) return;
|
|
// Open as new entry, pre-populated with cloned data
|
|
openEditEntry(name, scope, folder, entry.session_type);
|
|
// Switch to create mode: enable name field, clear edit mode, set copy name
|
|
entryEditMode = null;
|
|
document.getElementById('entry-modal-title').textContent = 'Clone: ' + name;
|
|
var nameField = document.getElementById('em-name');
|
|
nameField.disabled = false;
|
|
nameField.value = name + '-copy';
|
|
entryTypeSelect.disabled = false;
|
|
// Hide move-to-folder (not relevant for new entries)
|
|
document.getElementById('em-move-section').style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
// Edit entry
|
|
if (btn.getAttribute('data-edit-entry')) {
|
|
openEditEntry(
|
|
btn.getAttribute('data-edit-entry'),
|
|
btn.getAttribute('data-scope'),
|
|
btn.getAttribute('data-folder'),
|
|
btn.getAttribute('data-type')
|
|
);
|
|
}
|
|
});
|
|
|
|
// ── Folder modal ──
|
|
|
|
var folderEditMode = null; // null = create, {scope, name, path} = edit
|
|
var subfolderParent = null; // null, or { scope, path } when creating a subfolder
|
|
|
|
function openNewFolder() {
|
|
folderEditMode = null;
|
|
subfolderParent = null;
|
|
document.getElementById('folder-modal-title').textContent = 'New Folder';
|
|
document.getElementById('fm-name').value = '';
|
|
document.getElementById('fm-name').disabled = false;
|
|
document.getElementById('fm-desc').value = '';
|
|
document.getElementById('fm-scope').value = 'shared';
|
|
document.getElementById('fm-scope').disabled = false;
|
|
document.getElementById('fm-error').textContent = '';
|
|
document.getElementById('fm-groups-input').value = '';
|
|
document.getElementById('fm-inherit').checked = false;
|
|
fmGroups = [];
|
|
renderFmGroupChips();
|
|
loadKnownGroups(populateFmGroupOptions);
|
|
document.getElementById('folder-modal').classList.add('active');
|
|
}
|
|
|
|
function openNewSubfolder() {
|
|
if (!selectedFolder) return;
|
|
folderEditMode = null;
|
|
subfolderParent = { scope: selectedFolder.scope, path: folderPath(selectedFolder) };
|
|
document.getElementById('folder-modal-title').textContent = 'New Subfolder in ' + subfolderParent.path;
|
|
document.getElementById('fm-name').value = '';
|
|
document.getElementById('fm-name').disabled = false;
|
|
document.getElementById('fm-desc').value = '';
|
|
document.getElementById('fm-scope').value = subfolderParent.scope;
|
|
document.getElementById('fm-scope').disabled = true;
|
|
document.getElementById('fm-error').textContent = '';
|
|
document.getElementById('fm-groups-input').value = '';
|
|
document.getElementById('fm-inherit').checked = true;
|
|
fmGroups = [];
|
|
renderFmGroupChips();
|
|
loadKnownGroups(populateFmGroupOptions);
|
|
document.getElementById('folder-modal').classList.add('active');
|
|
}
|
|
|
|
document.getElementById('btn-new-folder').addEventListener('click', openNewFolder);
|
|
document.getElementById('btn-empty-create-folder').addEventListener('click', openNewFolder);
|
|
document.getElementById('btn-new-subfolder').addEventListener('click', openNewSubfolder);
|
|
|
|
document.getElementById('btn-edit-folder').addEventListener('click', function() {
|
|
if (!selectedFolder) return;
|
|
var selPath = folderPath(selectedFolder);
|
|
folderEditMode = { scope: selectedFolder.scope, name: selectedFolder.name, path: selPath };
|
|
document.getElementById('folder-modal-title').textContent = 'Edit Folder: ' + selPath;
|
|
document.getElementById('fm-name').value = selectedFolder.name;
|
|
document.getElementById('fm-name').disabled = true;
|
|
document.getElementById('fm-desc').value = selectedFolder.description || '';
|
|
document.getElementById('fm-scope').value = selectedFolder.scope;
|
|
document.getElementById('fm-scope').disabled = true;
|
|
document.getElementById('fm-error').textContent = '';
|
|
|
|
// Reset chip state and kick off known-groups load, then fetch current config.
|
|
document.getElementById('fm-groups-input').value = '';
|
|
fmGroups = [];
|
|
renderFmGroupChips();
|
|
loadKnownGroups(populateFmGroupOptions);
|
|
fetch('/api/addressbook/folders/' + encodeURIComponent(selectedFolder.scope) + '/' + encodeURIComponent(selPath) + '/config', {
|
|
headers: apiHeaders(),
|
|
credentials: 'same-origin'
|
|
}).then(function(res) {
|
|
if (res.ok) return res.json();
|
|
return null;
|
|
}).then(function(data) {
|
|
if (data && Array.isArray(data.allowed_groups)) {
|
|
fmGroups = data.allowed_groups.slice();
|
|
renderFmGroupChips();
|
|
populateFmGroupOptions();
|
|
}
|
|
document.getElementById('fm-inherit').checked = !!(data && data.inherit_from_parent);
|
|
}).catch(function() {});
|
|
|
|
document.getElementById('folder-modal').classList.add('active');
|
|
});
|
|
|
|
document.getElementById('fm-cancel').addEventListener('click', function() {
|
|
document.getElementById('folder-modal').classList.remove('active');
|
|
});
|
|
|
|
document.getElementById('fm-groups-add').addEventListener('click', addFmGroupFromInput);
|
|
(function wireFmCombo() {
|
|
var inp = document.getElementById('fm-groups-input');
|
|
inp.addEventListener('focus', openFmCombo);
|
|
inp.addEventListener('input', populateFmGroupOptions);
|
|
inp.addEventListener('keydown', function(ev) {
|
|
var opts = fmGroupOptionsFiltered();
|
|
if (ev.key === 'ArrowDown') {
|
|
ev.preventDefault();
|
|
openFmCombo();
|
|
if (fmComboActive < opts.length - 1) fmComboActive++;
|
|
updateFmComboActive();
|
|
} else if (ev.key === 'ArrowUp') {
|
|
ev.preventDefault();
|
|
if (fmComboActive > 0) fmComboActive--;
|
|
updateFmComboActive();
|
|
} else if (ev.key === 'Enter') {
|
|
ev.preventDefault();
|
|
if (inp.value.trim()) {
|
|
addFmGroupFromInput();
|
|
} else {
|
|
pickFmGroupFromCombo();
|
|
}
|
|
} else if (ev.key === 'Escape') {
|
|
closeFmCombo();
|
|
inp.blur();
|
|
}
|
|
});
|
|
document.addEventListener('mousedown', function(ev) {
|
|
var combo = document.getElementById('fm-groups-combo');
|
|
if (combo && !combo.contains(ev.target)) closeFmCombo();
|
|
});
|
|
})();
|
|
|
|
document.getElementById('fm-save').addEventListener('click', function() {
|
|
var name = document.getElementById('fm-name').value.trim();
|
|
var desc = document.getElementById('fm-desc').value.trim();
|
|
// Include anything still typed in the group input but not chipped yet
|
|
addFmGroupFromInput();
|
|
var groups = fmGroups.slice();
|
|
var scope = document.getElementById('fm-scope').value;
|
|
var errEl = document.getElementById('fm-error');
|
|
|
|
if (!name || !/^[a-zA-Z0-9_.-]+$/.test(name)) {
|
|
errEl.textContent = 'Name must be alphanumeric, hyphens, underscores, dots only.';
|
|
return;
|
|
}
|
|
|
|
errEl.textContent = '';
|
|
var saveBtn = document.getElementById('fm-save');
|
|
saveBtn.disabled = true;
|
|
saveBtn.textContent = 'Saving...';
|
|
|
|
if (folderEditMode) {
|
|
fetch('/api/addressbook/folders/' + encodeURIComponent(folderEditMode.scope) + '/' + encodeURIComponent(folderEditMode.path || folderEditMode.name), {
|
|
method: 'PUT',
|
|
headers: apiHeaders({ 'Content-Type': 'application/json' }),
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify({ allowed_groups: groups, description: desc, inherit_from_parent: document.getElementById('fm-inherit').checked })
|
|
})
|
|
.then(function(res) {
|
|
if (!res.ok) return res.text().then(function(t) { throw new Error(t); });
|
|
document.getElementById('folder-modal').classList.remove('active');
|
|
// Update selected folder description
|
|
if (selectedFolder) selectedFolder.description = desc;
|
|
loadFolders();
|
|
})
|
|
.catch(function(err) { errEl.textContent = err.message; })
|
|
.finally(function() { saveBtn.disabled = false; saveBtn.textContent = 'Save'; });
|
|
} else {
|
|
var fullPath = subfolderParent ? subfolderParent.path + '/' + name : name;
|
|
var postScope = subfolderParent ? subfolderParent.scope : scope;
|
|
fetch('/api/addressbook/folders', {
|
|
method: 'POST',
|
|
headers: apiHeaders({ 'Content-Type': 'application/json' }),
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify({ name: fullPath, allowed_groups: groups, description: desc, scope: postScope, inherit_from_parent: document.getElementById('fm-inherit').checked })
|
|
})
|
|
.then(function(res) {
|
|
if (!res.ok) return res.text().then(function(t) { throw new Error(t); });
|
|
document.getElementById('folder-modal').classList.remove('active');
|
|
if (subfolderParent) {
|
|
// Expand parent so the new child shows, then invalidate cache and reload.
|
|
var parentScope = subfolderParent.scope;
|
|
var parentPath = subfolderParent.path;
|
|
var parentKey = folderKey(parentScope, parentPath);
|
|
expandedPaths[parentKey] = true;
|
|
saveExpandedPaths();
|
|
delete subfolderCache[parentKey];
|
|
var parent = findFolder(parentScope, parentPath);
|
|
if (parent) parent.has_children = true;
|
|
selectedFolder = { name: name, scope: postScope, description: desc, path: fullPath, has_children: false };
|
|
saveSelectedFolder();
|
|
subfolderParent = null;
|
|
loadSubfolders(parentScope, parentPath, function() {
|
|
renderFolders();
|
|
loadEntries(selectedFolder.scope, fullPath, true);
|
|
});
|
|
} else {
|
|
selectedFolder = { name: name, scope: scope, description: desc, path: name, has_children: false };
|
|
saveSelectedFolder();
|
|
loadFolders();
|
|
}
|
|
})
|
|
.catch(function(err) { errEl.textContent = err.message; })
|
|
.finally(function() { saveBtn.disabled = false; saveBtn.textContent = 'Save'; });
|
|
}
|
|
});
|
|
|
|
document.getElementById('btn-delete-folder').addEventListener('click', function() {
|
|
if (!selectedFolder) return;
|
|
var selPath = folderPath(selectedFolder);
|
|
var hasSubtree = !!(selectedFolder.has_children);
|
|
var warning = hasSubtree
|
|
? 'Delete folder "' + selPath + '" AND all its subfolders and entries?\n\nThis recursively removes the entire subtree and cannot be undone.'
|
|
: 'Delete folder "' + selPath + '" and all its entries?\n\nThis cannot be undone.';
|
|
if (!confirm(warning)) return;
|
|
fetch('/api/addressbook/folders/' + encodeURIComponent(selectedFolder.scope) + '/' + encodeURIComponent(selPath), {
|
|
method: 'DELETE',
|
|
headers: apiHeaders(),
|
|
credentials: 'same-origin'
|
|
}).then(function(res) {
|
|
if (!res.ok) return res.text().then(function(t) { showError(t); });
|
|
return res.json().catch(function() { return null; });
|
|
}).then(function(data) {
|
|
if (data && (data.subfolders_deleted || data.entries_deleted)) {
|
|
var msg = 'Deleted ' + (data.entries_deleted || 0) + ' entr' + ((data.entries_deleted === 1) ? 'y' : 'ies');
|
|
if (data.subfolders_deleted) {
|
|
msg += ' across ' + data.subfolders_deleted + ' subfolder' + ((data.subfolders_deleted === 1) ? '' : 's');
|
|
}
|
|
msg += '.';
|
|
showInfo(msg);
|
|
}
|
|
selectedFolder = null;
|
|
saveSelectedFolder();
|
|
subfolderCache = {}; // drop all cached subfolder listings; they may reference the deleted path
|
|
document.getElementById('entries-header').style.display = 'none';
|
|
document.getElementById('entries-content').innerHTML = '<p class="empty">Select a folder to view entries.</p>';
|
|
loadFolders();
|
|
});
|
|
});
|
|
|
|
// ── Entry modal ──
|
|
|
|
var entryEditMode = null; // null = create, {scope, folder, name} = edit
|
|
var entryTypeSelect = document.getElementById('em-type');
|
|
|
|
entryTypeSelect.addEventListener('change', function() {
|
|
document.getElementById('em-ssh-fields').style.display = 'none';
|
|
document.getElementById('em-rdp-fields').style.display = 'none';
|
|
document.getElementById('em-vnc-fields').style.display = 'none';
|
|
document.getElementById('em-spice-fields').style.display = 'none';
|
|
document.getElementById('em-proxmox-fields').style.display = 'none';
|
|
document.getElementById('em-web-fields').style.display = 'none';
|
|
document.getElementById('em-vdi-fields').style.display = 'none';
|
|
var val = entryTypeSelect.value;
|
|
if (val === 'ssh') document.getElementById('em-ssh-fields').style.display = '';
|
|
else if (val === 'rdp') document.getElementById('em-rdp-fields').style.display = '';
|
|
else if (val === 'vnc') document.getElementById('em-vnc-fields').style.display = '';
|
|
else if (val === 'spice') document.getElementById('em-spice-fields').style.display = '';
|
|
else if (val === 'proxmox') document.getElementById('em-proxmox-fields').style.display = '';
|
|
else if (val === 'web') {
|
|
document.getElementById('em-web-fields').style.display = '';
|
|
loadLoginScripts(function() { populateLoginScriptDropdown(''); });
|
|
}
|
|
else if (val === 'vdi') document.getElementById('em-vdi-fields').style.display = '';
|
|
// Show drive option for SSH and RDP only
|
|
document.getElementById('em-drive-section').style.display = (val === 'ssh' || val === 'rdp') ? '' : 'none';
|
|
// Show RemoteApp for RDP only
|
|
document.getElementById('em-remoteapp-section').style.display = val === 'rdp' ? '' : 'none';
|
|
// Show Video Performance for RDP only
|
|
document.getElementById('em-video-perf-section').style.display = val === 'rdp' ? '' : 'none';
|
|
// Show tunnel option for VDI excluded (local containers)
|
|
document.getElementById('em-tunnel-section').style.display = val === 'vdi' ? 'none' : '';
|
|
// Show recording settings for all types
|
|
document.getElementById('em-recording-section').style.display = '';
|
|
// Typescript recording is SSH-only (#159)
|
|
document.getElementById('em-typescript-row').style.display = val === 'ssh' ? '' : 'none';
|
|
});
|
|
|
|
// Toggle tunnel fields visibility
|
|
document.getElementById('em-tunnel-toggle').addEventListener('click', function() {
|
|
var fields = document.getElementById('em-tunnel-fields');
|
|
var arrow = document.getElementById('em-tunnel-arrow');
|
|
if (fields.style.display === 'none') {
|
|
fields.style.display = '';
|
|
arrow.innerHTML = '▼';
|
|
} else {
|
|
fields.style.display = 'none';
|
|
arrow.innerHTML = '▶';
|
|
}
|
|
});
|
|
|
|
// Toggle RemoteApp fields visibility
|
|
document.getElementById('em-remoteapp-toggle').addEventListener('click', function() {
|
|
var fields = document.getElementById('em-remoteapp-fields');
|
|
var arrow = document.getElementById('em-remoteapp-arrow');
|
|
if (fields.style.display === 'none') {
|
|
fields.style.display = '';
|
|
arrow.innerHTML = '▼';
|
|
} else {
|
|
fields.style.display = 'none';
|
|
arrow.innerHTML = '▶';
|
|
}
|
|
});
|
|
|
|
// Toggle Recording Settings fields visibility
|
|
document.getElementById('em-recording-toggle').addEventListener('click', function() {
|
|
var fields = document.getElementById('em-recording-fields');
|
|
var arrow = document.getElementById('em-recording-arrow');
|
|
if (fields.style.display === 'none') {
|
|
fields.style.display = '';
|
|
arrow.innerHTML = '▼';
|
|
} else {
|
|
fields.style.display = 'none';
|
|
arrow.innerHTML = '▶';
|
|
}
|
|
});
|
|
|
|
// Toggle recording sub-fields when override checkbox changes
|
|
document.getElementById('em-override-recording').addEventListener('change', function() {
|
|
document.getElementById('em-recording-sub').style.display = this.checked ? '' : 'none';
|
|
});
|
|
|
|
// Toggle Video Performance fields visibility
|
|
document.getElementById('em-video-perf-toggle').addEventListener('click', function() {
|
|
var fields = document.getElementById('em-video-perf-fields');
|
|
var arrow = document.getElementById('em-video-perf-arrow');
|
|
if (fields.style.display === 'none') {
|
|
fields.style.display = '';
|
|
arrow.innerHTML = '▼';
|
|
} else {
|
|
fields.style.display = 'none';
|
|
arrow.innerHTML = '▶';
|
|
}
|
|
});
|
|
|
|
// Toggle Autofill fields visibility
|
|
document.getElementById('em-enable-drive').addEventListener('change', function() {
|
|
var warn = document.getElementById('em-drive-warning');
|
|
warn.style.display = (this.checked && !_driveConfigured) ? '' : 'none';
|
|
});
|
|
document.getElementById('em-automation-toggle').addEventListener('click', function() {
|
|
var fields = document.getElementById('em-automation-fields');
|
|
var arrow = document.getElementById('em-automation-arrow');
|
|
if (fields.style.display === 'none') {
|
|
fields.style.display = '';
|
|
arrow.innerHTML = '▼';
|
|
} else {
|
|
fields.style.display = 'none';
|
|
arrow.innerHTML = '▶';
|
|
}
|
|
});
|
|
|
|
document.getElementById('em-autofill-toggle').addEventListener('click', function() {
|
|
var fields = document.getElementById('em-autofill-fields');
|
|
var arrow = document.getElementById('em-autofill-arrow');
|
|
if (fields.style.display === 'none') {
|
|
fields.style.display = '';
|
|
arrow.innerHTML = '▼';
|
|
} else {
|
|
fields.style.display = 'none';
|
|
arrow.innerHTML = '▶';
|
|
}
|
|
});
|
|
|
|
// ── Autofill row management ──
|
|
var autofillRows = []; // Array of {url, username, password}
|
|
|
|
function renderAutofillRows() {
|
|
var container = document.getElementById('em-autofill-rows');
|
|
container.innerHTML = '';
|
|
autofillRows.forEach(function(row, i) {
|
|
var div = document.createElement('div');
|
|
div.style.cssText = 'display:flex;gap:0.4em;align-items:center;margin-bottom:0.3em';
|
|
div.innerHTML = '<input type="text" placeholder="https://example.com" value="' + escapeHtml(row.url) + '" style="flex:2;padding:0.3em 0.5em;font-size:0.9em;background:var(--input);color:var(--text);border:1px solid var(--border);border-radius:4px" data-af-idx="' + i + '" data-af-field="url">'
|
|
+ '<input type="text" placeholder="$USERNAME" value="' + escapeHtml(row.username) + '" style="flex:1;padding:0.3em 0.5em;font-size:0.9em;background:var(--input);color:var(--text);border:1px solid var(--border);border-radius:4px" data-af-idx="' + i + '" data-af-field="username">'
|
|
+ '<input type="text" placeholder="$PASSWORD" value="' + escapeHtml(row.password) + '" style="flex:1;padding:0.3em 0.5em;font-size:0.9em;background:var(--input);color:var(--text);border:1px solid var(--border);border-radius:4px" data-af-idx="' + i + '" data-af-field="password">'
|
|
+ '<button type="button" style="background:none;border:none;color:var(--primary);cursor:pointer;font-size:1.1em;padding:0 0.3em" data-af-remove="' + i + '" title="Remove">×</button>';
|
|
container.appendChild(div);
|
|
});
|
|
// Bind input change events
|
|
container.querySelectorAll('[data-af-field]').forEach(function(input) {
|
|
input.addEventListener('input', function() {
|
|
var idx = parseInt(this.getAttribute('data-af-idx'));
|
|
var field = this.getAttribute('data-af-field');
|
|
autofillRows[idx][field] = this.value;
|
|
});
|
|
});
|
|
container.querySelectorAll('[data-af-remove]').forEach(function(btn) {
|
|
btn.addEventListener('click', function() {
|
|
var idx = parseInt(this.getAttribute('data-af-remove'));
|
|
autofillRows.splice(idx, 1);
|
|
renderAutofillRows();
|
|
});
|
|
});
|
|
}
|
|
|
|
document.getElementById('em-autofill-add').addEventListener('click', function() {
|
|
var urlVal = document.getElementById('em-url').value.trim();
|
|
autofillRows.push({ url: urlVal || 'https://', username: '$USERNAME', password: '$PASSWORD' });
|
|
renderAutofillRows();
|
|
// Auto-expand the section
|
|
document.getElementById('em-autofill-fields').style.display = '';
|
|
document.getElementById('em-autofill-arrow').innerHTML = '▼';
|
|
});
|
|
|
|
// Toggle Allowed Domains fields visibility
|
|
document.getElementById('em-allowed-domains-toggle').addEventListener('click', function() {
|
|
var fields = document.getElementById('em-allowed-domains-fields');
|
|
var arrow = document.getElementById('em-allowed-domains-arrow');
|
|
if (fields.style.display === 'none') {
|
|
fields.style.display = '';
|
|
arrow.innerHTML = '▼';
|
|
} else {
|
|
fields.style.display = 'none';
|
|
arrow.innerHTML = '▶';
|
|
}
|
|
});
|
|
|
|
// ── Allowed Domains management ──
|
|
var allowedDomains = []; // Array of strings
|
|
|
|
function renderAllowedDomains() {
|
|
var container = document.getElementById('em-allowed-domains-list');
|
|
container.innerHTML = '';
|
|
allowedDomains.forEach(function(domain, i) {
|
|
var div = document.createElement('div');
|
|
div.style.cssText = 'display:flex;gap:0.4em;align-items:center;margin-bottom:0.3em';
|
|
div.innerHTML = '<input type="text" placeholder="example.com" value="' + escapeHtml(domain) + '" style="flex:1;padding:0.3em 0.5em;font-size:0.9em;background:var(--input);color:var(--text);border:1px solid var(--border);border-radius:4px" data-ad-idx="' + i + '">'
|
|
+ '<button type="button" style="background:none;border:none;color:var(--primary);cursor:pointer;font-size:1.1em;padding:0 0.3em" data-ad-remove="' + i + '" title="Remove">×</button>';
|
|
container.appendChild(div);
|
|
});
|
|
container.querySelectorAll('[data-ad-idx]').forEach(function(input) {
|
|
input.addEventListener('input', function() {
|
|
var idx = parseInt(this.getAttribute('data-ad-idx'));
|
|
allowedDomains[idx] = this.value;
|
|
});
|
|
});
|
|
container.querySelectorAll('[data-ad-remove]').forEach(function(btn) {
|
|
btn.addEventListener('click', function() {
|
|
var idx = parseInt(this.getAttribute('data-ad-remove'));
|
|
allowedDomains.splice(idx, 1);
|
|
renderAllowedDomains();
|
|
});
|
|
});
|
|
}
|
|
|
|
document.getElementById('em-allowed-domains-add').addEventListener('click', function() {
|
|
var urlVal = document.getElementById('em-url').value.trim();
|
|
var defaultDomain = '';
|
|
try { defaultDomain = new URL(urlVal).hostname; } catch(e) {}
|
|
allowedDomains.push(defaultDomain || '');
|
|
renderAllowedDomains();
|
|
document.getElementById('em-allowed-domains-fields').style.display = '';
|
|
document.getElementById('em-allowed-domains-arrow').innerHTML = '▼';
|
|
});
|
|
|
|
// ── Multi-hop management ──
|
|
var hopList = []; // Array of {hostname, port, username, password, private_key, expanded}
|
|
|
|
function renderHops() {
|
|
var container = document.getElementById('em-hops-list');
|
|
container.innerHTML = '';
|
|
hopList.forEach(function(hop, i) {
|
|
var card = document.createElement('div');
|
|
card.className = 'hop-card';
|
|
|
|
// Header
|
|
var header = document.createElement('div');
|
|
header.className = 'hop-card-header';
|
|
|
|
var num = document.createElement('span');
|
|
num.className = 'hop-num';
|
|
num.textContent = '#' + (i + 1);
|
|
header.appendChild(num);
|
|
|
|
var hostWrap = document.createElement('span');
|
|
hostWrap.className = 'hop-hostname';
|
|
var hostInput = document.createElement('input');
|
|
hostInput.type = 'text';
|
|
hostInput.placeholder = 'bastion.example.com';
|
|
hostInput.value = hop.hostname || '';
|
|
hostInput.setAttribute('data-hop', i);
|
|
hostInput.setAttribute('data-field', 'hostname');
|
|
hostInput.addEventListener('input', onHopFieldChange);
|
|
hostInput.addEventListener('click', function(e) { e.stopPropagation(); });
|
|
hostWrap.appendChild(hostInput);
|
|
header.appendChild(hostWrap);
|
|
|
|
var toggle = document.createElement('button');
|
|
toggle.type = 'button';
|
|
toggle.className = 'hop-remove';
|
|
toggle.textContent = hop.expanded ? 'collapse' : 'expand';
|
|
toggle.setAttribute('data-hop', i);
|
|
toggle.addEventListener('click', function(e) {
|
|
e.stopPropagation();
|
|
hopList[i].expanded = !hopList[i].expanded;
|
|
renderHops();
|
|
});
|
|
header.appendChild(toggle);
|
|
|
|
var removeBtn = document.createElement('button');
|
|
removeBtn.type = 'button';
|
|
removeBtn.className = 'hop-remove';
|
|
removeBtn.textContent = 'remove';
|
|
removeBtn.setAttribute('data-hop', i);
|
|
removeBtn.addEventListener('click', function(e) {
|
|
e.stopPropagation();
|
|
hopList.splice(i, 1);
|
|
renderHops();
|
|
updateFlowDiagram();
|
|
});
|
|
header.appendChild(removeBtn);
|
|
|
|
card.appendChild(header);
|
|
|
|
// Body (collapsible)
|
|
var body = document.createElement('div');
|
|
body.className = 'hop-card-body' + (hop.expanded ? ' expanded' : '');
|
|
|
|
var fpDisplay = hop.host_key_fingerprint || (hop.host_key ? 'pinned' : '');
|
|
var fpColor = hop.host_key ? 'color:var(--accent,#0a0)' : 'color:var(--text-muted,#888)';
|
|
var fpLabel = hop.host_key ? fpDisplay : 'not pinned';
|
|
var verifyLabel = hop.host_key ? 'Re-verify' : 'Verify Host Key';
|
|
|
|
body.innerHTML =
|
|
'<label>SSH Port<input type="number" data-hop="' + i + '" data-field="port" value="' + (hop.port || 22) + '"></label>' +
|
|
'<label>Username<input type="text" data-hop="' + i + '" data-field="username" value="' + escapeAttr(hop.username || '') + '"></label>' +
|
|
'<label>Password<input type="password" data-hop="' + i + '" data-field="password" value="' + escapeAttr(hop.password || '') + '"></label>' +
|
|
'<label>Private key (PEM)<textarea data-hop="' + i + '" data-field="private_key" rows="2" style="resize:vertical;font-size:0.85em" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----">' + escapeHtml(hop.private_key || '') + '</textarea></label>' +
|
|
'<div style="margin-top:0.5em;display:flex;align-items:center;gap:0.6em">' +
|
|
'<button type="button" class="hop-verify-btn" data-hop="' + i + '" style="padding:0.3em 0.7em;font-size:0.85em;font-family:monospace;cursor:pointer;background:var(--input,#222);color:var(--accent,#0f0);border:1px solid var(--border,#333);border-radius:3px">' + verifyLabel + '</button>' +
|
|
'<span class="hop-fp" data-hop="' + i + '" style="font-size:0.8em;' + fpColor + '">' + escapeHtml(fpLabel) + '</span>' +
|
|
'</div>';
|
|
|
|
var inputs = body.querySelectorAll('input, textarea');
|
|
for (var j = 0; j < inputs.length; j++) {
|
|
inputs[j].addEventListener('input', onHopFieldChange);
|
|
}
|
|
|
|
var verifyBtn = body.querySelector('.hop-verify-btn');
|
|
verifyBtn.addEventListener('click', function(e) {
|
|
e.stopPropagation();
|
|
var idx = parseInt(this.getAttribute('data-hop'));
|
|
probeHostKey(idx);
|
|
});
|
|
|
|
card.appendChild(body);
|
|
container.appendChild(card);
|
|
});
|
|
updateFlowDiagram();
|
|
}
|
|
|
|
function probeHostKey(hopIndex) {
|
|
var hop = hopList[hopIndex];
|
|
if (!hop || !hop.hostname || !hop.hostname.trim()) {
|
|
alert('Enter a hostname first.');
|
|
return;
|
|
}
|
|
var btn = document.querySelector('.hop-verify-btn[data-hop="' + hopIndex + '"]');
|
|
var fpSpan = document.querySelector('.hop-fp[data-hop="' + hopIndex + '"]');
|
|
if (btn) { btn.disabled = true; btn.textContent = 'Probing...'; }
|
|
|
|
fetch('/api/ssh/probe-host-key', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({ hostname: hop.hostname.trim(), port: hop.port || 22 })
|
|
}).then(function(r) { return r.json().then(function(d) { return {ok: r.ok, data: d}; }); })
|
|
.then(function(res) {
|
|
if (!res.ok) {
|
|
alert('Probe failed: ' + (res.data.error || 'unknown error'));
|
|
if (btn) { btn.disabled = false; btn.textContent = hop.host_key ? 'Re-verify' : 'Verify Host Key'; }
|
|
return;
|
|
}
|
|
var msg = 'Host key for ' + hop.hostname.trim() + ':' + (hop.port || 22) + ':\n\n' +
|
|
res.data.fingerprint + ' (' + res.data.algorithm + ')\n\n' +
|
|
'Trust this key?';
|
|
if (confirm(msg)) {
|
|
hopList[hopIndex].host_key = res.data.host_key;
|
|
hopList[hopIndex].host_key_fingerprint = res.data.fingerprint;
|
|
renderHops();
|
|
} else {
|
|
if (btn) { btn.disabled = false; btn.textContent = hop.host_key ? 'Re-verify' : 'Verify Host Key'; }
|
|
}
|
|
}).catch(function(e) {
|
|
alert('Probe failed: ' + e.message);
|
|
if (btn) { btn.disabled = false; btn.textContent = hop.host_key ? 'Re-verify' : 'Verify Host Key'; }
|
|
});
|
|
}
|
|
|
|
function onHopFieldChange(e) {
|
|
var idx = parseInt(e.target.getAttribute('data-hop'));
|
|
var field = e.target.getAttribute('data-field');
|
|
if (field === 'port') {
|
|
hopList[idx][field] = parseInt(e.target.value) || 22;
|
|
} else {
|
|
hopList[idx][field] = e.target.value;
|
|
}
|
|
if (field === 'hostname') updateFlowDiagram();
|
|
}
|
|
|
|
function updateFlowDiagram() {
|
|
var diag = document.getElementById('em-flow-diagram');
|
|
if (!diag) return;
|
|
var html = '<span class="flow-node flow-node-you">You</span>';
|
|
hopList.forEach(function(hop) {
|
|
var label = hop.hostname || '???';
|
|
html += '<span class="flow-arrow">→</span><span class="flow-node flow-node-hop">' + escapeHtml(label) + '</span>';
|
|
});
|
|
// Add target
|
|
var type = entryTypeSelect.value;
|
|
var targetHost = '';
|
|
var targetPort = '';
|
|
var typeLabel = type.toUpperCase();
|
|
if (type === 'ssh') {
|
|
targetHost = document.getElementById('em-hostname').value || '???';
|
|
targetPort = document.getElementById('em-port').value || '22';
|
|
} else if (type === 'rdp') {
|
|
targetHost = document.getElementById('em-rdp-hostname').value || '???';
|
|
targetPort = document.getElementById('em-rdp-port').value || '3389';
|
|
} else if (type === 'vnc') {
|
|
targetHost = document.getElementById('em-vnc-hostname').value || '???';
|
|
targetPort = document.getElementById('em-vnc-port').value || '5900';
|
|
} else if (type === 'spice') {
|
|
targetHost = document.getElementById('em-spice-hostname').value || '???';
|
|
targetPort = document.getElementById('em-spice-port').value || '5900';
|
|
} else if (type === 'proxmox') {
|
|
targetHost = document.getElementById('em-proxmox-node').value || '???';
|
|
targetPort = document.getElementById('em-proxmox-vmid').value || '???';
|
|
} else if (type === 'web') {
|
|
try {
|
|
var u = new URL(document.getElementById('em-url').value);
|
|
targetHost = u.hostname || '???';
|
|
targetPort = u.port || (u.protocol === 'https:' ? '443' : '80');
|
|
} catch(e) {
|
|
targetHost = '???';
|
|
targetPort = '80';
|
|
}
|
|
}
|
|
if (targetHost) {
|
|
html += '<span class="flow-arrow">→</span><span class="flow-node flow-node-target">' + escapeHtml(targetHost) + ':' + escapeHtml(targetPort) + ' ' + typeLabel + '</span>';
|
|
}
|
|
// Show URL rewrite warning for web sessions with hops
|
|
var warn = '';
|
|
if (type === 'web' && hopList.length > 0) {
|
|
warn = '<div style="color:var(--primary);font-size:0.8em;margin-top:0.4em">Note: The URL will be rewritten to 127.0.0.1:{tunnel_port} inside the headless browser. TLS certificate errors are expected if the target uses HTTPS.</div>';
|
|
}
|
|
diag.innerHTML = hopList.length > 0 ? html + warn : '';
|
|
}
|
|
|
|
document.getElementById('em-add-hop').addEventListener('click', function() {
|
|
hopList.push({ hostname: '', port: 22, username: '', password: '', private_key: '', expanded: true });
|
|
renderHops();
|
|
});
|
|
|
|
function openNewEntry() {
|
|
if (!selectedFolder) return;
|
|
entryEditMode = null;
|
|
document.getElementById('entry-modal-title').textContent = 'New Entry in ' + folderPath(selectedFolder);
|
|
document.getElementById('em-name').value = '';
|
|
document.getElementById('em-name').disabled = false;
|
|
document.getElementById('em-display-name').value = '';
|
|
entryTypeSelect.value = 'ssh';
|
|
entryTypeSelect.disabled = false;
|
|
entryTypeSelect.dispatchEvent(new Event('change'));
|
|
clearEntryFields();
|
|
document.getElementById('em-move-section').style.display = 'none';
|
|
document.getElementById('em-error').textContent = '';
|
|
document.getElementById('entry-modal').classList.add('active');
|
|
}
|
|
|
|
document.getElementById('btn-new-entry').addEventListener('click', openNewEntry);
|
|
|
|
function clearEntryFields() {
|
|
document.getElementById('em-hostname').value = '';
|
|
document.getElementById('em-port').value = '22';
|
|
document.getElementById('em-username').value = '';
|
|
document.getElementById('em-password').value = '';
|
|
document.getElementById('em-private-key').value = '';
|
|
document.getElementById('em-rdp-hostname').value = '';
|
|
document.getElementById('em-rdp-port').value = '3389';
|
|
document.getElementById('em-rdp-username').value = '';
|
|
document.getElementById('em-rdp-password').value = '';
|
|
document.getElementById('em-rdp-domain').value = '';
|
|
document.getElementById('em-rdp-security').value = '';
|
|
document.getElementById('em-rdp-ignore-cert').checked = false;
|
|
document.getElementById('em-rdp-auth-pkg').value = '';
|
|
document.getElementById('em-rdp-kdc-url').value = '';
|
|
document.getElementById('em-rdp-prompt-creds').checked = false;
|
|
document.getElementById('em-ssh-prompt-creds').checked = false;
|
|
document.getElementById('em-vnc-hostname').value = '';
|
|
document.getElementById('em-vnc-port').value = '5900';
|
|
document.getElementById('em-vnc-password').value = '';
|
|
document.getElementById('em-vnc-color-depth').value = '';
|
|
document.getElementById('em-vnc-prompt-creds').checked = false;
|
|
document.getElementById('em-spice-hostname').value = '';
|
|
document.getElementById('em-spice-port').value = '5900';
|
|
document.getElementById('em-spice-password').value = '';
|
|
document.getElementById('em-spice-color-depth').value = '';
|
|
document.getElementById('em-spice-tls').checked = false;
|
|
document.getElementById('em-spice-tls-port').value = '';
|
|
document.getElementById('em-spice-ignore-cert').checked = false;
|
|
document.getElementById('em-spice-ca-cert').value = '';
|
|
document.getElementById('em-spice-cert-subject').value = '';
|
|
document.getElementById('em-spice-proxy').value = '';
|
|
document.getElementById('em-spice-prompt-creds').checked = false;
|
|
document.getElementById('em-proxmox-url').value = '';
|
|
document.getElementById('em-proxmox-node').value = '';
|
|
document.getElementById('em-proxmox-vmid').value = '';
|
|
document.getElementById('em-proxmox-token-id').value = '';
|
|
document.getElementById('em-proxmox-token-secret').value = '';
|
|
document.getElementById('em-proxmox-token-secret').placeholder = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx';
|
|
document.getElementById('em-proxmox-color-depth').value = '';
|
|
document.getElementById('em-proxmox-monitors').value = '1';
|
|
document.getElementById('em-proxmox-verify-tls').checked = false;
|
|
document.getElementById('em-url').value = '';
|
|
document.getElementById('em-banner').value = '';
|
|
document.getElementById('em-web-username').value = '';
|
|
document.getElementById('em-web-password').value = '';
|
|
document.getElementById('em-login-script').value = '';
|
|
document.getElementById('em-automation-fields').style.display = 'none';
|
|
document.getElementById('em-automation-arrow').innerHTML = '▶';
|
|
autofillRows = [];
|
|
renderAutofillRows();
|
|
document.getElementById('em-autofill-fields').style.display = 'none';
|
|
document.getElementById('em-autofill-arrow').innerHTML = '▶';
|
|
allowedDomains = [];
|
|
renderAllowedDomains();
|
|
document.getElementById('em-allowed-domains-fields').style.display = 'none';
|
|
document.getElementById('em-allowed-domains-arrow').innerHTML = '▶';
|
|
document.getElementById('em-enable-gfx').checked = false;
|
|
document.getElementById('em-enable-desktop-comp').checked = false;
|
|
document.getElementById('em-force-lossless').checked = false;
|
|
document.getElementById('em-enable-h264').checked = false;
|
|
document.getElementById('em-enable-wallpaper').checked = false;
|
|
document.getElementById('em-enable-theming').checked = false;
|
|
document.getElementById('em-enable-full-window-drag').checked = false;
|
|
document.getElementById('em-video-perf-fields').style.display = 'none';
|
|
document.getElementById('em-video-perf-arrow').innerHTML = '▶';
|
|
document.getElementById('em-disable-copy').checked = false;
|
|
document.getElementById('em-disable-paste').checked = false;
|
|
document.getElementById('em-allow-sharing').checked = false;
|
|
document.getElementById('em-auto-open-singleton').checked = false;
|
|
document.getElementById('em-fullscreen-on-connect').checked = false;
|
|
document.getElementById('em-autohide-side-tabs').checked = false;
|
|
document.getElementById('em-enable-drive').checked = false;
|
|
document.getElementById('em-drive-warning').style.display = 'none';
|
|
document.getElementById('em-remote-app').value = '';
|
|
document.getElementById('em-remote-app-dir').value = '';
|
|
document.getElementById('em-remote-app-args').value = '';
|
|
document.getElementById('em-remoteapp-fields').style.display = 'none';
|
|
document.getElementById('em-remoteapp-arrow').innerHTML = '▶';
|
|
document.getElementById('em-vdi-image').value = '';
|
|
document.getElementById('em-vdi-cpu').value = '';
|
|
document.getElementById('em-vdi-memory').value = '';
|
|
document.getElementById('em-vdi-env').value = '';
|
|
document.getElementById('em-vdi-container-username').value = '';
|
|
document.getElementById('em-vdi-container-password').value = '';
|
|
document.getElementById('em-vdi-container-password').placeholder = 'Leave blank to generate per-connect';
|
|
document.getElementById('em-vdi-idle-timeout').value = '';
|
|
document.getElementById('em-vdi-banner').value = '';
|
|
document.getElementById('em-override-recording').checked = false;
|
|
document.getElementById('em-enable-recording').checked = true;
|
|
document.getElementById('em-max-recordings').value = '0';
|
|
document.getElementById('em-recording-sub').style.display = 'none';
|
|
document.getElementById('em-record-typescript').checked = false;
|
|
document.getElementById('em-recording-fields').style.display = 'none';
|
|
document.getElementById('em-recording-arrow').innerHTML = '▶';
|
|
hopList = [];
|
|
renderHops();
|
|
document.getElementById('em-tunnel-fields').style.display = 'none';
|
|
document.getElementById('em-tunnel-arrow').innerHTML = '▶';
|
|
}
|
|
|
|
function openEditEntry(name, scope, folder, type) {
|
|
entryEditMode = { scope: scope, folder: folder, name: name };
|
|
document.getElementById('entry-modal-title').textContent = 'Edit: ' + name;
|
|
document.getElementById('em-name').value = name;
|
|
document.getElementById('em-name').disabled = true;
|
|
document.getElementById('em-display-name').value = '';
|
|
document.getElementById('em-error').textContent = '';
|
|
clearEntryFields();
|
|
|
|
// Pre-select the type from the table data
|
|
if (type) {
|
|
entryTypeSelect.value = type;
|
|
entryTypeSelect.disabled = true;
|
|
entryTypeSelect.dispatchEvent(new Event('change'));
|
|
}
|
|
|
|
// Populate move-to-folder dropdown (includes top-level + any loaded subfolders)
|
|
var moveSelect = document.getElementById('em-move-target');
|
|
moveSelect.innerHTML = '<option value="">(keep in current folder)</option>';
|
|
var moveCandidates = [];
|
|
folders.forEach(function(f) { moveCandidates.push(f); });
|
|
for (var ck in subfolderCache) {
|
|
subfolderCache[ck].forEach(function(f) { moveCandidates.push(f); });
|
|
}
|
|
moveCandidates.forEach(function(f) {
|
|
var p = folderPath(f);
|
|
if (p === folder && f.scope === scope) return;
|
|
var opt = document.createElement('option');
|
|
opt.value = f.scope + '|' + p;
|
|
opt.textContent = p + ' (' + f.scope + ')';
|
|
moveSelect.appendChild(opt);
|
|
});
|
|
document.getElementById('em-move-section').style.display = moveCandidates.length > 1 ? '' : 'none';
|
|
|
|
// Pre-fill with existing entry data from the loaded entries
|
|
var entryData = null;
|
|
for (var i = 0; i < currentEntries.length; i++) {
|
|
if (currentEntries[i].name === name) {
|
|
entryData = currentEntries[i];
|
|
break;
|
|
}
|
|
}
|
|
if (entryData) {
|
|
if (entryData.display_name && entryData.display_name !== name) {
|
|
document.getElementById('em-display-name').value = entryData.display_name;
|
|
}
|
|
if (type === 'ssh') {
|
|
document.getElementById('em-hostname').value = entryData.hostname || '';
|
|
document.getElementById('em-port').value = entryData.port || '22';
|
|
document.getElementById('em-username').value = entryData.username || '';
|
|
document.getElementById('em-ssh-prompt-creds').checked = !!entryData.prompt_credentials;
|
|
} else if (type === 'rdp') {
|
|
document.getElementById('em-rdp-hostname').value = entryData.hostname || '';
|
|
document.getElementById('em-rdp-port').value = entryData.port || '3389';
|
|
document.getElementById('em-rdp-username').value = entryData.username || '';
|
|
document.getElementById('em-rdp-domain').value = entryData.domain || '';
|
|
if (entryData.security) {
|
|
document.getElementById('em-rdp-security').value = entryData.security;
|
|
}
|
|
document.getElementById('em-rdp-ignore-cert').checked = !!entryData.ignore_cert;
|
|
document.getElementById('em-rdp-auth-pkg').value = entryData.auth_pkg || '';
|
|
document.getElementById('em-rdp-kdc-url').value = entryData.kdc_url || '';
|
|
document.getElementById('em-rdp-monitors').value = entryData.max_monitors || 1;
|
|
document.getElementById('em-rdp-prompt-creds').checked = !!entryData.prompt_credentials;
|
|
} else if (type === 'vnc') {
|
|
document.getElementById('em-vnc-hostname').value = entryData.hostname || '';
|
|
document.getElementById('em-vnc-port').value = entryData.port || '5900';
|
|
document.getElementById('em-vnc-color-depth').value = entryData.color_depth ? String(entryData.color_depth) : '';
|
|
document.getElementById('em-vnc-prompt-creds').checked = !!entryData.prompt_credentials;
|
|
} else if (type === 'spice') {
|
|
document.getElementById('em-spice-hostname').value = entryData.hostname || '';
|
|
document.getElementById('em-spice-port').value = entryData.port || '5900';
|
|
document.getElementById('em-spice-color-depth').value = entryData.color_depth ? String(entryData.color_depth) : '';
|
|
document.getElementById('em-spice-tls').checked = !!entryData.spice_tls;
|
|
document.getElementById('em-spice-tls-port').value = entryData.spice_tls_port || '';
|
|
document.getElementById('em-spice-ignore-cert').checked = !!entryData.ignore_cert;
|
|
document.getElementById('em-spice-ca-cert').value = entryData.spice_ca_cert || '';
|
|
document.getElementById('em-spice-cert-subject').value = entryData.spice_cert_subject || '';
|
|
document.getElementById('em-spice-proxy').value = entryData.spice_proxy || '';
|
|
document.getElementById('em-spice-prompt-creds').checked = !!entryData.prompt_credentials;
|
|
} else if (type === 'proxmox') {
|
|
document.getElementById('em-proxmox-url').value = entryData.proxmox_url || '';
|
|
document.getElementById('em-proxmox-node').value = entryData.proxmox_node || '';
|
|
document.getElementById('em-proxmox-vmid').value = entryData.proxmox_vmid || '';
|
|
document.getElementById('em-proxmox-color-depth').value = entryData.color_depth ? String(entryData.color_depth) : '';
|
|
document.getElementById('em-proxmox-monitors').value = entryData.max_monitors || 1;
|
|
document.getElementById('em-proxmox-verify-tls').checked = !!entryData.proxmox_verify_tls;
|
|
document.getElementById('em-proxmox-token-id').value = entryData.proxmox_token_id || '';
|
|
// Secret is never returned; show a hint if one is stored.
|
|
var secEl = document.getElementById('em-proxmox-token-secret');
|
|
secEl.value = '';
|
|
secEl.placeholder = entryData.has_proxmox_token_secret ? '(leave blank to keep existing)' : 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx';
|
|
} else if (type === 'web') {
|
|
document.getElementById('em-url').value = entryData.url || '';
|
|
document.getElementById('em-banner').value = entryData.banner || '';
|
|
document.getElementById('em-web-username').value = entryData.username || '';
|
|
loadLoginScripts(function() { populateLoginScriptDropdown(entryData.login_script || ''); });
|
|
// Auto-expand automation if any automation fields are set
|
|
if (entryData.username || entryData.login_script || entryData.autofill) {
|
|
document.getElementById('em-automation-fields').style.display = '';
|
|
document.getElementById('em-automation-arrow').innerHTML = '▼';
|
|
}
|
|
// Populate autofill rows
|
|
if (entryData.autofill) {
|
|
try {
|
|
var afData = typeof entryData.autofill === 'string' ? JSON.parse(entryData.autofill) : entryData.autofill;
|
|
if (Array.isArray(afData) && afData.length > 0) {
|
|
autofillRows = afData.map(function(r) {
|
|
return { url: r.url || '', username: r.username || '$USERNAME', password: r.password || '$PASSWORD' };
|
|
});
|
|
renderAutofillRows();
|
|
document.getElementById('em-autofill-fields').style.display = '';
|
|
document.getElementById('em-autofill-arrow').innerHTML = '▼';
|
|
}
|
|
} catch(e) { /* ignore bad JSON */ }
|
|
}
|
|
// Populate allowed domains
|
|
if (entryData.allowed_domains && Array.isArray(entryData.allowed_domains) && entryData.allowed_domains.length > 0) {
|
|
allowedDomains = entryData.allowed_domains.slice();
|
|
renderAllowedDomains();
|
|
document.getElementById('em-allowed-domains-fields').style.display = '';
|
|
document.getElementById('em-allowed-domains-arrow').innerHTML = '▼';
|
|
}
|
|
} else if (type === 'vdi') {
|
|
document.getElementById('em-vdi-image').value = entryData.container_image || '';
|
|
document.getElementById('em-vdi-cpu').value = entryData.container_cpu_limit || '';
|
|
document.getElementById('em-vdi-memory').value = entryData.container_memory_limit || '';
|
|
if (entryData.container_env) {
|
|
var envLines = Object.keys(entryData.container_env).map(function(k) {
|
|
return k + '=' + entryData.container_env[k];
|
|
}).join('\n');
|
|
document.getElementById('em-vdi-env').value = envLines;
|
|
}
|
|
document.getElementById('em-vdi-container-username').value = entryData.container_username || '';
|
|
// Password field is left blank by design; the entry retains the stored value
|
|
// unless a new one is typed. has_container_password shows whether one is set.
|
|
var cpw = document.getElementById('em-vdi-container-password');
|
|
cpw.value = '';
|
|
cpw.placeholder = entryData.has_container_password
|
|
? '(stored password unchanged unless you type a new one)'
|
|
: 'Leave blank to generate per-connect';
|
|
document.getElementById('em-vdi-idle-timeout').value = entryData.container_idle_timeout_mins || '';
|
|
document.getElementById('em-vdi-banner').value = entryData.banner || '';
|
|
}
|
|
if (type === 'ssh' || type === 'rdp') {
|
|
document.getElementById('em-enable-drive').checked = !!entryData.enable_drive;
|
|
document.getElementById('em-drive-warning').style.display = (entryData.enable_drive && !_driveConfigured) ? '' : 'none';
|
|
}
|
|
// Populate RemoteApp fields (RDP)
|
|
if (type === 'rdp' && (entryData.remote_app || entryData.remote_app_dir || entryData.remote_app_args)) {
|
|
document.getElementById('em-remote-app').value = entryData.remote_app || '';
|
|
document.getElementById('em-remote-app-dir').value = entryData.remote_app_dir || '';
|
|
document.getElementById('em-remote-app-args').value = entryData.remote_app_args || '';
|
|
document.getElementById('em-remoteapp-fields').style.display = '';
|
|
document.getElementById('em-remoteapp-arrow').innerHTML = '▼';
|
|
}
|
|
// Populate recording override fields
|
|
if (entryData.enable_recording !== null && entryData.enable_recording !== undefined) {
|
|
document.getElementById('em-override-recording').checked = true;
|
|
document.getElementById('em-recording-sub').style.display = '';
|
|
document.getElementById('em-enable-recording').checked = entryData.enable_recording !== false;
|
|
document.getElementById('em-max-recordings').value = entryData.max_recordings || '0';
|
|
document.getElementById('em-recording-fields').style.display = '';
|
|
document.getElementById('em-recording-arrow').innerHTML = '▼';
|
|
} else if (entryData.max_recordings) {
|
|
document.getElementById('em-override-recording').checked = true;
|
|
document.getElementById('em-recording-sub').style.display = '';
|
|
document.getElementById('em-enable-recording').checked = true;
|
|
document.getElementById('em-max-recordings').value = entryData.max_recordings;
|
|
document.getElementById('em-recording-fields').style.display = '';
|
|
document.getElementById('em-recording-arrow').innerHTML = '▼';
|
|
}
|
|
// Typescript recording flag (SSH only; #159)
|
|
document.getElementById('em-typescript-row').style.display = type === 'ssh' ? '' : 'none';
|
|
document.getElementById('em-record-typescript').checked = !!entryData.record_typescript;
|
|
if (entryData.record_typescript) {
|
|
document.getElementById('em-recording-fields').style.display = '';
|
|
document.getElementById('em-recording-arrow').innerHTML = '▼';
|
|
}
|
|
// Populate video performance settings (RDP only)
|
|
if (type === 'rdp') {
|
|
document.getElementById('em-enable-gfx').checked = !!entryData.enable_gfx;
|
|
document.getElementById('em-enable-desktop-comp').checked = !!entryData.enable_desktop_composition;
|
|
document.getElementById('em-force-lossless').checked = !!entryData.force_lossless;
|
|
document.getElementById('em-enable-h264').checked = !!entryData.enable_h264;
|
|
document.getElementById('em-enable-wallpaper').checked = !!entryData.enable_wallpaper;
|
|
document.getElementById('em-enable-theming').checked = !!entryData.enable_theming;
|
|
document.getElementById('em-enable-full-window-drag').checked = !!entryData.enable_full_window_drag;
|
|
if (entryData.enable_gfx || entryData.enable_desktop_composition || entryData.force_lossless || entryData.enable_h264 || entryData.enable_wallpaper || entryData.enable_theming || entryData.enable_full_window_drag) {
|
|
document.getElementById('em-video-perf-fields').style.display = '';
|
|
document.getElementById('em-video-perf-arrow').innerHTML = '▼';
|
|
}
|
|
}
|
|
// Populate clipboard settings
|
|
document.getElementById('em-disable-copy').checked = !!entryData.disable_copy;
|
|
document.getElementById('em-disable-paste').checked = !!entryData.disable_paste;
|
|
// Populate per-entry sharing flag
|
|
document.getElementById('em-allow-sharing').checked = !!entryData.allow_sharing;
|
|
// Populate auto-open singleton flag
|
|
document.getElementById('em-auto-open-singleton').checked = !!entryData.auto_open_if_singleton;
|
|
// Populate fullscreen-on-connect flag
|
|
document.getElementById('em-fullscreen-on-connect').checked = !!entryData.fullscreen_on_connect;
|
|
// Populate autohide-side-tabs flag
|
|
document.getElementById('em-autohide-side-tabs').checked = !!entryData.autohide_side_tabs;
|
|
// Populate hop list from jump_hosts array
|
|
if (entryData.jump_hosts && entryData.jump_hosts.length > 0) {
|
|
hopList = entryData.jump_hosts.map(function(h) {
|
|
return { hostname: h.hostname || '', port: h.port || 22, username: h.username || '', password: '', private_key: '', host_key: h.host_key || '', host_key_fingerprint: h.host_key_fingerprint || '', expanded: false };
|
|
});
|
|
renderHops();
|
|
// Auto-expand the tunnel section
|
|
document.getElementById('em-tunnel-fields').style.display = '';
|
|
document.getElementById('em-tunnel-arrow').innerHTML = '▼';
|
|
}
|
|
}
|
|
|
|
document.getElementById('entry-modal').classList.add('active');
|
|
}
|
|
|
|
document.getElementById('em-cancel').addEventListener('click', function() {
|
|
document.getElementById('entry-modal').classList.remove('active');
|
|
entryTypeSelect.disabled = false;
|
|
});
|
|
|
|
document.getElementById('em-save').addEventListener('click', function() {
|
|
var name = document.getElementById('em-name').value.trim();
|
|
var displayName = document.getElementById('em-display-name').value.trim();
|
|
var type = entryTypeSelect.value;
|
|
var errEl = document.getElementById('em-error');
|
|
|
|
if (!name || !/^[a-zA-Z0-9_.-]+$/.test(name)) {
|
|
errEl.textContent = 'Name must be alphanumeric, hyphens, underscores, dots only.';
|
|
return;
|
|
}
|
|
|
|
var entry = { type: type };
|
|
if (displayName) entry.display_name = displayName;
|
|
|
|
if (type === 'ssh') {
|
|
var h = document.getElementById('em-hostname').value.trim();
|
|
if (h) entry.hostname = h;
|
|
var p = parseInt(document.getElementById('em-port').value);
|
|
if (p) entry.port = p;
|
|
var u = document.getElementById('em-username').value.trim();
|
|
if (u) entry.username = u;
|
|
var pw = document.getElementById('em-password').value;
|
|
if (pw) entry.password = pw;
|
|
var pk = document.getElementById('em-private-key').value.trim();
|
|
if (pk) entry.private_key = pk;
|
|
if (document.getElementById('em-ssh-prompt-creds').checked) entry.prompt_credentials = true;
|
|
} else if (type === 'rdp') {
|
|
var h = document.getElementById('em-rdp-hostname').value.trim();
|
|
if (h) entry.hostname = h;
|
|
var p = parseInt(document.getElementById('em-rdp-port').value);
|
|
if (p) entry.port = p;
|
|
var u = document.getElementById('em-rdp-username').value.trim();
|
|
if (u) entry.username = u;
|
|
var pw = document.getElementById('em-rdp-password').value;
|
|
if (pw) entry.password = pw;
|
|
var dom = document.getElementById('em-rdp-domain').value.trim();
|
|
if (dom) entry.domain = dom;
|
|
var sec = document.getElementById('em-rdp-security').value;
|
|
if (sec) entry.security = sec;
|
|
entry.ignore_cert = document.getElementById('em-rdp-ignore-cert').checked;
|
|
var authPkg = document.getElementById('em-rdp-auth-pkg').value;
|
|
if (authPkg) entry.auth_pkg = authPkg;
|
|
var kdcUrl = document.getElementById('em-rdp-kdc-url').value.trim();
|
|
if (kdcUrl) entry.kdc_url = kdcUrl;
|
|
var rmon = parseInt(document.getElementById('em-rdp-monitors').value);
|
|
if (rmon > 1) entry.max_monitors = rmon;
|
|
if (document.getElementById('em-rdp-prompt-creds').checked) entry.prompt_credentials = true;
|
|
} else if (type === 'vnc') {
|
|
var h = document.getElementById('em-vnc-hostname').value.trim();
|
|
if (h) entry.hostname = h;
|
|
var p = parseInt(document.getElementById('em-vnc-port').value);
|
|
if (p) entry.port = p;
|
|
var pw = document.getElementById('em-vnc-password').value;
|
|
if (pw) entry.password = pw;
|
|
var cd = document.getElementById('em-vnc-color-depth').value;
|
|
if (cd) entry.color_depth = parseInt(cd);
|
|
if (document.getElementById('em-vnc-prompt-creds').checked) entry.prompt_credentials = true;
|
|
} else if (type === 'spice') {
|
|
var h = document.getElementById('em-spice-hostname').value.trim();
|
|
if (h) entry.hostname = h;
|
|
var p = parseInt(document.getElementById('em-spice-port').value);
|
|
if (p) entry.port = p;
|
|
var pw = document.getElementById('em-spice-password').value;
|
|
if (pw) entry.password = pw;
|
|
var cd = document.getElementById('em-spice-color-depth').value;
|
|
if (cd) entry.color_depth = parseInt(cd);
|
|
entry.spice_tls = document.getElementById('em-spice-tls').checked;
|
|
var tp = parseInt(document.getElementById('em-spice-tls-port').value);
|
|
if (tp) entry.spice_tls_port = tp;
|
|
entry.ignore_cert = document.getElementById('em-spice-ignore-cert').checked;
|
|
var ca = document.getElementById('em-spice-ca-cert').value.trim();
|
|
if (ca) entry.spice_ca_cert = ca;
|
|
var cs = document.getElementById('em-spice-cert-subject').value.trim();
|
|
if (cs) entry.spice_cert_subject = cs;
|
|
var px = document.getElementById('em-spice-proxy').value.trim();
|
|
if (px) entry.spice_proxy = px;
|
|
if (document.getElementById('em-spice-prompt-creds').checked) entry.prompt_credentials = true;
|
|
} else if (type === 'proxmox') {
|
|
var purl = document.getElementById('em-proxmox-url').value.trim();
|
|
if (purl) entry.proxmox_url = purl;
|
|
var pnode = document.getElementById('em-proxmox-node').value.trim();
|
|
if (pnode) entry.proxmox_node = pnode;
|
|
var pvmid = parseInt(document.getElementById('em-proxmox-vmid').value);
|
|
if (pvmid) entry.proxmox_vmid = pvmid;
|
|
var ptid = document.getElementById('em-proxmox-token-id').value.trim();
|
|
if (ptid) entry.proxmox_token_id = ptid;
|
|
// Only send the secret if the user entered one (blank = keep existing).
|
|
var psec = document.getElementById('em-proxmox-token-secret').value.trim();
|
|
if (psec) entry.proxmox_token_secret = psec;
|
|
var pcd = document.getElementById('em-proxmox-color-depth').value;
|
|
if (pcd) entry.color_depth = parseInt(pcd);
|
|
var pmon = parseInt(document.getElementById('em-proxmox-monitors').value);
|
|
if (pmon > 1) entry.max_monitors = pmon;
|
|
entry.proxmox_verify_tls = document.getElementById('em-proxmox-verify-tls').checked;
|
|
} else if (type === 'web') {
|
|
var url = document.getElementById('em-url').value.trim();
|
|
if (url) entry.url = url;
|
|
var bn = document.getElementById('em-banner').value.trim();
|
|
if (bn) entry.banner = bn;
|
|
var wu = document.getElementById('em-web-username').value.trim();
|
|
if (wu) entry.username = wu;
|
|
var wpw = document.getElementById('em-web-password').value;
|
|
if (wpw) entry.password = wpw;
|
|
var ls = document.getElementById('em-login-script').value.trim();
|
|
if (ls) entry.login_script = ls;
|
|
// Serialize autofill rows
|
|
var afValid = autofillRows.filter(function(r) { return r.url && r.url.trim(); });
|
|
if (afValid.length > 0) {
|
|
entry.autofill = JSON.stringify(afValid);
|
|
}
|
|
// Serialize allowed domains
|
|
var adValid = allowedDomains.filter(function(d) { return d && d.trim(); });
|
|
if (adValid.length > 0) {
|
|
entry.allowed_domains = adValid;
|
|
}
|
|
} else if (type === 'vdi') {
|
|
var img = document.getElementById('em-vdi-image').value.trim();
|
|
if (img) entry.container_image = img;
|
|
var cpu = parseFloat(document.getElementById('em-vdi-cpu').value);
|
|
if (cpu > 0) entry.container_cpu_limit = cpu;
|
|
var mem = parseInt(document.getElementById('em-vdi-memory').value);
|
|
if (mem > 0) entry.container_memory_limit = mem;
|
|
var envText = document.getElementById('em-vdi-env').value.trim();
|
|
if (envText) {
|
|
var envMap = {};
|
|
envText.split('\n').forEach(function(line) {
|
|
var eq = line.indexOf('=');
|
|
if (eq > 0) envMap[line.substring(0, eq).trim()] = line.substring(eq + 1);
|
|
});
|
|
if (Object.keys(envMap).length > 0) entry.container_env = envMap;
|
|
}
|
|
var cu = document.getElementById('em-vdi-container-username').value.trim();
|
|
if (cu) entry.container_username = cu;
|
|
// Password: only send when user typed something. Empty preserves the stored value
|
|
// server-side (see ab_update_entry in api.rs).
|
|
var cp = document.getElementById('em-vdi-container-password').value;
|
|
if (cp) entry.container_password = cp;
|
|
var idleT = parseInt(document.getElementById('em-vdi-idle-timeout').value);
|
|
if (idleT > 0) entry.container_idle_timeout_mins = idleT;
|
|
var vbn = document.getElementById('em-vdi-banner').value.trim();
|
|
if (vbn) entry.banner = vbn;
|
|
}
|
|
|
|
// Drive setting for SSH and RDP
|
|
if (type === 'ssh' || type === 'rdp') {
|
|
entry.enable_drive = document.getElementById('em-enable-drive').checked;
|
|
}
|
|
|
|
// RemoteApp (RDP only)
|
|
if (type === 'rdp') {
|
|
var ra = document.getElementById('em-remote-app').value.trim();
|
|
var raDir = document.getElementById('em-remote-app-dir').value.trim();
|
|
var raArgs = document.getElementById('em-remote-app-args').value.trim();
|
|
if (ra) entry.remote_app = ra;
|
|
if (raDir) entry.remote_app_dir = raDir;
|
|
if (raArgs) entry.remote_app_args = raArgs;
|
|
}
|
|
|
|
// Recording overrides
|
|
if (document.getElementById('em-override-recording').checked) {
|
|
entry.enable_recording = document.getElementById('em-enable-recording').checked;
|
|
var maxRec = parseInt(document.getElementById('em-max-recordings').value) || 0;
|
|
if (maxRec > 0) entry.max_recordings = maxRec;
|
|
}
|
|
// Typescript recording (SSH only; #159). Off by default.
|
|
if (type === 'ssh' && document.getElementById('em-record-typescript').checked) {
|
|
entry.record_typescript = true;
|
|
}
|
|
|
|
// Video performance settings (RDP only)
|
|
if (type === 'rdp') {
|
|
if (document.getElementById('em-enable-gfx').checked) entry.enable_gfx = true;
|
|
if (document.getElementById('em-enable-desktop-comp').checked) entry.enable_desktop_composition = true;
|
|
if (document.getElementById('em-force-lossless').checked) entry.force_lossless = true;
|
|
if (document.getElementById('em-enable-h264').checked) entry.enable_h264 = true;
|
|
if (document.getElementById('em-enable-wallpaper').checked) entry.enable_wallpaper = true;
|
|
if (document.getElementById('em-enable-theming').checked) entry.enable_theming = true;
|
|
if (document.getElementById('em-enable-full-window-drag').checked) entry.enable_full_window_drag = true;
|
|
}
|
|
|
|
// Clipboard settings (all types)
|
|
if (document.getElementById('em-disable-copy').checked) entry.disable_copy = true;
|
|
if (document.getElementById('em-disable-paste').checked) entry.disable_paste = true;
|
|
|
|
// Per-entry sharing (admin opt-in; gates the Share button on
|
|
// Connections active-session cards)
|
|
if (document.getElementById('em-allow-sharing').checked) entry.allow_sharing = true;
|
|
// Auto-open if this is the user's only visible entry (#103)
|
|
if (document.getElementById('em-auto-open-singleton').checked) entry.auto_open_if_singleton = true;
|
|
// Open in fullscreen on connect (#154)
|
|
if (document.getElementById('em-fullscreen-on-connect').checked) entry.fullscreen_on_connect = true;
|
|
// Auto-hide the clipboard/files side tabs when idle
|
|
if (document.getElementById('em-autohide-side-tabs').checked) entry.autohide_side_tabs = true;
|
|
|
|
// Tunnel / jump host settings — multi-hop (all types)
|
|
if (type === 'ssh' || type === 'rdp' || type === 'vnc' || type === 'web') {
|
|
var hops = hopList.filter(function(h) { return h.hostname && h.hostname.trim(); });
|
|
if (hops.length > 0) {
|
|
entry.jump_hosts = hops.map(function(h) {
|
|
var hop = { hostname: h.hostname.trim(), port: h.port || 22, username: h.username || '' };
|
|
if (h.password) hop.password = h.password;
|
|
if (h.private_key) hop.private_key = h.private_key;
|
|
if (h.host_key) hop.host_key = h.host_key;
|
|
return hop;
|
|
});
|
|
}
|
|
}
|
|
|
|
errEl.textContent = '';
|
|
var scope = selectedFolder.scope;
|
|
var folder = folderPath(selectedFolder);
|
|
|
|
var saveBtn = document.getElementById('em-save');
|
|
saveBtn.disabled = true;
|
|
saveBtn.textContent = 'Saving...';
|
|
|
|
// Check if we need to move the entry
|
|
var moveTarget = document.getElementById('em-move-target').value;
|
|
|
|
if (entryEditMode) {
|
|
// Update existing entry
|
|
var updatePromise = fetch('/api/addressbook/folders/' + encodeURIComponent(entryEditMode.scope) + '/' + encodeURIComponent(entryEditMode.folder) + '/entries/' + encodeURIComponent(entryEditMode.name), {
|
|
method: 'PUT',
|
|
headers: apiHeaders({ 'Content-Type': 'application/json' }),
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify(entry)
|
|
})
|
|
.then(function(res) {
|
|
if (!res.ok) return res.text().then(function(t) { throw new Error(t); });
|
|
return res.json();
|
|
});
|
|
|
|
updatePromise.then(function() {
|
|
if (moveTarget) {
|
|
// Move = create in new folder + delete from old
|
|
var sep = moveTarget.indexOf('|');
|
|
var targetScope = sep >= 0 ? moveTarget.slice(0, sep) : moveTarget;
|
|
var targetFolder = sep >= 0 ? moveTarget.slice(sep + 1) : '';
|
|
var body = { name: entryEditMode.name };
|
|
for (var k in entry) body[k] = entry[k];
|
|
|
|
return fetch('/api/addressbook/folders/' + encodeURIComponent(targetScope) + '/' + encodeURIComponent(targetFolder) + '/entries', {
|
|
method: 'POST',
|
|
headers: apiHeaders({ 'Content-Type': 'application/json' }),
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify(body)
|
|
}).then(function(res) {
|
|
if (!res.ok) return res.text().then(function(t) { throw new Error('Move failed: ' + t); });
|
|
// Delete from original folder
|
|
return fetch('/api/addressbook/folders/' + encodeURIComponent(entryEditMode.scope) + '/' + encodeURIComponent(entryEditMode.folder) + '/entries/' + encodeURIComponent(entryEditMode.name), {
|
|
method: 'DELETE',
|
|
headers: apiHeaders(),
|
|
credentials: 'same-origin'
|
|
});
|
|
}).then(function() {
|
|
// Switch to the target folder
|
|
selectedFolder = findFolder(targetScope, targetFolder) || selectedFolder;
|
|
saveSelectedFolder();
|
|
});
|
|
}
|
|
})
|
|
.then(function() {
|
|
document.getElementById('entry-modal').classList.remove('active');
|
|
entryTypeSelect.disabled = false;
|
|
loadEntries(selectedFolder.scope, folderPath(selectedFolder), true);
|
|
})
|
|
.catch(function(err) { errEl.textContent = err.message; })
|
|
.finally(function() { saveBtn.disabled = false; saveBtn.textContent = 'Save'; });
|
|
} else {
|
|
// Create new entry
|
|
var body = { name: name };
|
|
for (var k in entry) body[k] = entry[k];
|
|
fetch('/api/addressbook/folders/' + encodeURIComponent(scope) + '/' + encodeURIComponent(folder) + '/entries', {
|
|
method: 'POST',
|
|
headers: apiHeaders({ 'Content-Type': 'application/json' }),
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify(body)
|
|
})
|
|
.then(function(res) {
|
|
if (!res.ok) return res.text().then(function(t) { throw new Error(t); });
|
|
document.getElementById('entry-modal').classList.remove('active');
|
|
entryTypeSelect.disabled = false;
|
|
loadEntries(scope, folder, true);
|
|
})
|
|
.catch(function(err) { errEl.textContent = err.message; })
|
|
.finally(function() { saveBtn.disabled = false; saveBtn.textContent = 'Save'; });
|
|
}
|
|
});
|
|
|
|
// Close modals on overlay click
|
|
document.querySelectorAll('.modal-overlay').forEach(function(el) {
|
|
el.addEventListener('click', function(e) {
|
|
if (e.target === el) {
|
|
el.classList.remove('active');
|
|
entryTypeSelect.disabled = false;
|
|
}
|
|
});
|
|
});
|
|
|
|
// Close modals on Escape key
|
|
document.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Escape') {
|
|
document.querySelectorAll('.modal-overlay.active').forEach(function(el) {
|
|
el.classList.remove('active');
|
|
entryTypeSelect.disabled = false;
|
|
});
|
|
}
|
|
});
|
|
|
|
// Quick-find: '/' focuses search, Esc clears + blurs.
|
|
// Skip when typing in any input/textarea or when a modal is open.
|
|
var _searchInput = document.getElementById('connections-search');
|
|
if (_searchInput) {
|
|
_searchInput.addEventListener('input', runSearch);
|
|
_searchInput.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Escape') {
|
|
if (_searchInput.value) {
|
|
_searchInput.value = '';
|
|
runSearch();
|
|
} else {
|
|
_searchInput.blur();
|
|
}
|
|
e.stopPropagation();
|
|
}
|
|
});
|
|
}
|
|
document.addEventListener('keydown', function(e) {
|
|
if (e.key !== '/' || e.ctrlKey || e.metaKey || e.altKey) return;
|
|
var t = document.activeElement;
|
|
if (t) {
|
|
var tag = t.tagName;
|
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable) return;
|
|
}
|
|
if (document.querySelector('.modal-overlay.active')) return;
|
|
var input = document.getElementById('connections-search');
|
|
if (input && !input.disabled) {
|
|
e.preventDefault();
|
|
input.focus();
|
|
input.select();
|
|
}
|
|
});
|
|
|
|
// ── My Credentials ──
|
|
// Callback to run after a successful credential save (used by the
|
|
// resumable connect flow); cleared whenever the modal opens/closes.
|
|
var credsSavedCallback = null;
|
|
// When set, restrict the editor to just these variable names (focused
|
|
// "credentials needed to connect" mode).
|
|
var credsOnlyVars = null;
|
|
|
|
document.getElementById('my-creds-item').addEventListener('click', function() {
|
|
document.getElementById('user-menu').style.display = 'none';
|
|
openMyCredentials();
|
|
});
|
|
var _mcNav = document.getElementById('my-creds-nav');
|
|
if (_mcNav) _mcNav.addEventListener('click', function(e) {
|
|
e.preventDefault();
|
|
openMyCredentials();
|
|
});
|
|
var _cbSetup = document.getElementById('creds-banner-setup');
|
|
if (_cbSetup) _cbSetup.addEventListener('click', function() { openMyCredentials(); });
|
|
var _cbDismiss = document.getElementById('creds-banner-dismiss');
|
|
if (_cbDismiss) _cbDismiss.addEventListener('click', function() {
|
|
// Persist the dismissal so it survives refresh; remember exactly
|
|
// which credentials were silenced so a new one can still re-alert.
|
|
try { localStorage.setItem(CREDS_DISMISS_KEY, currentUnsetCreds.join(',')); } catch (e) {}
|
|
document.getElementById('creds-banner').style.display = 'none';
|
|
});
|
|
document.getElementById('my-creds-close').addEventListener('click', function() {
|
|
document.getElementById('my-creds-modal').classList.remove('active');
|
|
credsSavedCallback = null;
|
|
credsOnlyVars = null;
|
|
});
|
|
document.getElementById('my-creds-save').addEventListener('click', saveMyCredentials);
|
|
var _mcSearch = document.getElementById('my-creds-search');
|
|
if (_mcSearch) _mcSearch.addEventListener('input', function() { filterMyCreds(this.value); });
|
|
|
|
// Live-filter the credential list by variable name / domain, hiding
|
|
// empty groups and force-expanding matching ones while a query is active.
|
|
function filterMyCreds(q) {
|
|
q = (q || '').trim().toLowerCase();
|
|
document.querySelectorAll('#my-creds-list .cred-group').forEach(function(group) {
|
|
var anyVisible = false;
|
|
group.querySelectorAll('.cred-item').forEach(function(item) {
|
|
var match = !q || item.getAttribute('data-cred-search').indexOf(q) !== -1;
|
|
item.style.display = match ? '' : 'none';
|
|
if (match) anyVisible = true;
|
|
});
|
|
group.style.display = anyVisible ? '' : 'none';
|
|
if (q) group.classList.remove('collapsed');
|
|
});
|
|
}
|
|
|
|
// opts (all optional): { onlyVars: [names], heading: "...", onSaved: fn }
|
|
// onlyVars focuses the editor on specific variables; onSaved runs after
|
|
// a successful save (the resumable connect flow).
|
|
function openMyCredentials(opts) {
|
|
opts = opts || {};
|
|
credsSavedCallback = opts.onSaved || null;
|
|
credsOnlyVars = opts.onlyVars || null;
|
|
var modal = document.getElementById('my-creds-modal');
|
|
var list = document.getElementById('my-creds-list');
|
|
var loading = document.getElementById('my-creds-loading');
|
|
var empty = document.getElementById('my-creds-empty');
|
|
var errEl = document.getElementById('my-creds-error');
|
|
var descEl = document.getElementById('my-creds-desc');
|
|
if (descEl) {
|
|
descEl.textContent = opts.heading
|
|
|| 'Store your credentials securely in Vault. Connections entries that use credential variables will auto-fill from these values.';
|
|
}
|
|
list.innerHTML = '';
|
|
loading.style.display = '';
|
|
empty.style.display = 'none';
|
|
errEl.textContent = '';
|
|
modal.classList.add('active');
|
|
|
|
// Fetch required variables and user's current values in parallel
|
|
Promise.all([
|
|
fetch('/api/credential-variables', { headers: apiHeaders(), credentials: 'same-origin' }).then(function(r) { return r.json(); }),
|
|
fetch('/api/me/credentials', { headers: apiHeaders(), credentials: 'same-origin' }).then(function(r) { return r.json(); })
|
|
]).then(function(results) {
|
|
loading.style.display = 'none';
|
|
var varsData = results[0];
|
|
var credsData = results[1];
|
|
var allVars = varsData.variables || {};
|
|
var domains = varsData.domains || {};
|
|
var saved = credsData.credentials || {};
|
|
// Multi-Vault: when creds_split, each credential can be stored
|
|
// shared (fleet-wide) or local (this instance). Single-Vault
|
|
// deployments report creds_split=false and get no toggle.
|
|
var credsSplit = !!credsData.creds_split;
|
|
var credDefaultScope = credsData.default_scope || 'local';
|
|
var varNames = Object.keys(allVars).sort();
|
|
|
|
if (varNames.length === 0) {
|
|
empty.style.display = '';
|
|
return;
|
|
}
|
|
|
|
// Group by domain prefix. Each domain is a collapsible group and
|
|
// each credential a .cred-item carrying search text, so the
|
|
// filter box and group folds keep long lists navigable.
|
|
var domainKeys = Object.keys(domains).sort();
|
|
var totalVars = 0;
|
|
domainKeys.forEach(function(domain) {
|
|
// In focused mode, keep only the requested variables and skip
|
|
// domains that end up empty.
|
|
var vars = domains[domain].filter(function(v) {
|
|
return !credsOnlyVars || credsOnlyVars.indexOf(v.name) !== -1;
|
|
});
|
|
if (vars.length === 0) return;
|
|
totalVars += vars.length;
|
|
|
|
vars.sort(function(a, b) {
|
|
// username before password before key before other
|
|
function suffixOrder(n) {
|
|
if (n.endsWith('_username')) return 0;
|
|
if (n.endsWith('_password')) return 1;
|
|
if (n.endsWith('_domain')) return 2;
|
|
if (n.endsWith('_key')) return 3;
|
|
return 4;
|
|
}
|
|
var oa = suffixOrder(a.name), ob = suffixOrder(b.name);
|
|
return oa !== ob ? oa - ob : a.name.localeCompare(b.name);
|
|
});
|
|
|
|
var group = document.createElement('div');
|
|
group.className = 'cred-group';
|
|
|
|
var head = document.createElement('div');
|
|
head.className = 'cred-group-head';
|
|
var caret = document.createElement('span');
|
|
caret.className = 'cred-group-caret';
|
|
caret.textContent = '▼'; // full-size triangle (U+25BC); rotates to ▶ when collapsed
|
|
head.appendChild(caret);
|
|
var headText = document.createElement('span');
|
|
headText.textContent = domain;
|
|
head.appendChild(headText);
|
|
var headCount = document.createElement('span');
|
|
headCount.className = 'cred-group-count';
|
|
headCount.textContent = vars.length;
|
|
head.appendChild(headCount);
|
|
head.addEventListener('click', function() {
|
|
group.classList.toggle('collapsed');
|
|
});
|
|
group.appendChild(head);
|
|
|
|
var body = document.createElement('div');
|
|
body.className = 'cred-group-body';
|
|
group.appendChild(body);
|
|
|
|
vars.forEach(function(v) {
|
|
var name = v.name;
|
|
var isSecret = name.endsWith('_password') || name.endsWith('_key');
|
|
var displayName = name.replace(domain + '_', '');
|
|
|
|
var item = document.createElement('div');
|
|
item.className = 'cred-item';
|
|
item.setAttribute('data-cred-search',
|
|
(name + ' ' + displayName + ' ' + domain).toLowerCase());
|
|
|
|
var row = document.createElement('label');
|
|
row.style.cssText = 'display:block;margin-top:0.5em;color:var(--text-muted);font-size:0.9em';
|
|
row.textContent = displayName;
|
|
var hint = document.createElement('span');
|
|
hint.style.cssText = 'color:var(--text-dim);font-size:0.8em;margin-left:0.5em';
|
|
hint.textContent = '(' + v.entry_count + ' ' + (v.entry_count === 1 ? 'entry' : 'entries') + ')';
|
|
row.appendChild(hint);
|
|
|
|
var input;
|
|
if (name.endsWith('_key')) {
|
|
input = document.createElement('textarea');
|
|
input.rows = 3;
|
|
input.placeholder = 'Paste SSH private key';
|
|
} else {
|
|
input = document.createElement('input');
|
|
input.type = isSecret ? 'password' : 'text';
|
|
input.placeholder = isSecret ? '••••••••' : '';
|
|
}
|
|
input.setAttribute('data-var', name);
|
|
input.className = 'my-cred-input';
|
|
|
|
// Pre-fill with saved value (non-secret) or leave blank (secret shows placeholder)
|
|
var sv = saved[name];
|
|
if (sv) {
|
|
if (isSecret) {
|
|
input.placeholder = '(saved — leave blank to keep)';
|
|
} else {
|
|
input.value = sv.display || '';
|
|
}
|
|
}
|
|
|
|
row.appendChild(input);
|
|
item.appendChild(row);
|
|
|
|
// Per-credential shared/local toggle (multi-Vault only).
|
|
if (credsSplit) {
|
|
var origScope = (sv && sv.scope) ? sv.scope : credDefaultScope;
|
|
var scopeRow = document.createElement('div');
|
|
scopeRow.style.cssText = 'margin:0.2em 0 0.4em 0.2em;font-size:0.8em;color:var(--text-dim)';
|
|
var scopeLabel = document.createElement('label');
|
|
scopeLabel.style.cssText = 'display:inline-flex;align-items:center;gap:0.35em;cursor:pointer';
|
|
var cb = document.createElement('input');
|
|
cb.type = 'checkbox';
|
|
cb.className = 'my-cred-scope';
|
|
cb.setAttribute('data-var', name);
|
|
cb.setAttribute('data-scope-orig', origScope);
|
|
cb.checked = (origScope === 'shared');
|
|
cb.style.cssText = 'width:auto;margin:0';
|
|
scopeLabel.appendChild(cb);
|
|
var scopeTxt = document.createElement('span');
|
|
scopeTxt.textContent = 'Shared across sites';
|
|
scopeTxt.title = 'Store this credential in the shared Vault so it propagates to every site. Unchecked keeps it local to this instance (survives a central-Vault outage).';
|
|
scopeLabel.appendChild(scopeTxt);
|
|
scopeRow.appendChild(scopeLabel);
|
|
item.appendChild(scopeRow);
|
|
}
|
|
|
|
body.appendChild(item);
|
|
});
|
|
|
|
list.appendChild(group);
|
|
});
|
|
|
|
// Reveal the filter box once the list is long enough to warrant
|
|
// it (never in focused single-connect mode).
|
|
var searchEl = document.getElementById('my-creds-search');
|
|
if (searchEl) {
|
|
searchEl.value = '';
|
|
searchEl.style.display = (!credsOnlyVars && totalVars > 6) ? '' : 'none';
|
|
}
|
|
}).catch(function(err) {
|
|
loading.style.display = 'none';
|
|
errEl.textContent = 'Failed to load: ' + err.message;
|
|
});
|
|
}
|
|
|
|
function saveMyCredentials() {
|
|
var inputs = document.querySelectorAll('.my-cred-input');
|
|
var creds = {};
|
|
var scopes = {};
|
|
// Current toggle state per variable (present only in multi-Vault).
|
|
var scopeState = {}; // name -> 'shared' | 'local'
|
|
var scopeMoved = {}; // name -> true if toggled from its original
|
|
document.querySelectorAll('.my-cred-scope').forEach(function(cb) {
|
|
var n = cb.getAttribute('data-var');
|
|
var cur = cb.checked ? 'shared' : 'local';
|
|
scopeState[n] = cur;
|
|
scopeMoved[n] = (cur !== cb.getAttribute('data-scope-orig'));
|
|
});
|
|
var hasChanges = false;
|
|
inputs.forEach(function(el) {
|
|
var name = el.getAttribute('data-var');
|
|
var val = el.value.trim();
|
|
// Send when the value was entered OR only the scope moved (a
|
|
// blank value tells the backend to keep the existing value and
|
|
// just move it to the new scope).
|
|
if (val || scopeMoved[name]) {
|
|
creds[name] = val;
|
|
if (name in scopeState) scopes[name] = scopeState[name];
|
|
hasChanges = true;
|
|
}
|
|
});
|
|
if (!hasChanges) {
|
|
document.getElementById('my-creds-modal').classList.remove('active');
|
|
return;
|
|
}
|
|
var errEl = document.getElementById('my-creds-error');
|
|
var btn = document.getElementById('my-creds-save');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Saving...';
|
|
errEl.textContent = '';
|
|
|
|
fetch('/api/me/credentials', {
|
|
method: 'PUT',
|
|
headers: apiHeaders({ 'Content-Type': 'application/json' }),
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify({ credentials: creds, scopes: scopes })
|
|
})
|
|
.then(function(res) {
|
|
if (!res.ok) return res.json().then(function(j) { throw new Error(j.error || 'Save failed'); });
|
|
return res.json();
|
|
})
|
|
.then(function() {
|
|
document.getElementById('my-creds-modal').classList.remove('active');
|
|
// Refresh the "credentials to set up" banner and per-entry pills.
|
|
loadCredStatus();
|
|
// Resume whatever was blocked on credentials (e.g. a connect).
|
|
var cb = credsSavedCallback;
|
|
credsSavedCallback = null;
|
|
credsOnlyVars = null;
|
|
if (cb) cb();
|
|
})
|
|
.catch(function(err) {
|
|
errEl.textContent = err.message;
|
|
})
|
|
.finally(function() {
|
|
btn.disabled = false;
|
|
btn.textContent = 'Save';
|
|
});
|
|
}
|
|
|
|
// ── Onboarding Wizard ──
|
|
var onboardSteps = [
|
|
{ title: 'Welcome to rustguac', minRole: 1, body:
|
|
'This is your remote access gateway. Browse connection entries organised by folder, then click <span class="onboard-hl">Connect</span> to launch a session in a new browser tab.' +
|
|
'<br><br>This quick tour covers the essentials — you can dismiss it at any time.'
|
|
},
|
|
{ title: 'Navigation', minRole: 1, body:
|
|
'The nav bar at the top links to:' +
|
|
'<br>• <span class="onboard-hl">Connections</span> — your connection entries' +
|
|
'<br>• <span class="onboard-hl">Sessions</span> — active and recent sessions' +
|
|
'<br>• <span class="onboard-hl">Recordings</span> — session playback' +
|
|
'<br>• <span class="onboard-hl">Docs</span> — built-in documentation' +
|
|
'<br><br>The <span class="onboard-hl">Settings</span> menu (top right) has theme selection, credential management, and this tour.'
|
|
},
|
|
{ title: 'My Credentials', minRole: 2, body:
|
|
'Some entries use credential variables like <code>$domain_username</code>. Before connecting, set your personal credentials:' +
|
|
'<br><br>Click <span class="onboard-hl">Settings</span> → <span class="onboard-hl">My Credentials</span> to fill in your values. They are stored securely in Vault and applied automatically when you connect.'
|
|
},
|
|
{ title: 'Connect vs Login', minRole: 2, body:
|
|
'Connections entries have two connection modes:' +
|
|
'<br><br>• <span class="onboard-hl">Connect</span> — launches the session directly using the saved settings.' +
|
|
'<br>• <span class="onboard-hl">Login</span> — runs an automated login script after connecting (e.g. filling in a web login form). If an entry shows Login, the admin has configured an auto-login workflow for it.'
|
|
},
|
|
{ title: 'Clipboard', minRole: 2, body:
|
|
'<span class="onboard-key">Ctrl</span>+<span class="onboard-key">V</span> pastes from your browser clipboard into the remote session automatically.' +
|
|
'<br><br>For advanced use (copy from remote, send arbitrary text), press <span class="onboard-key">Ctrl</span>+<span class="onboard-key">Alt</span>+<span class="onboard-key">Shift</span> or click the <span class="onboard-hl">Clipboard</span> tab on the left edge of the session window.'
|
|
},
|
|
{ title: 'File Transfer', minRole: 2, body:
|
|
'RDP sessions with drive redirection enabled show a <span class="onboard-hl">Files</span> tab on the right edge of the session window.' +
|
|
'<br><br>Use it to upload and download files via a shared virtual drive. SSH sessions support SFTP file transfer instead.' +
|
|
'<br><br>Toggle the file panel with <span class="onboard-key">Ctrl</span>+<span class="onboard-key">Alt</span>+<span class="onboard-key">F</span>.'
|
|
},
|
|
{ title: 'Ad-hoc Sessions', minRole: 3, body:
|
|
'As a power user, you can create one-off sessions from the <span class="onboard-hl">Sessions</span> page — useful for quick connections that aren\'t in the connections.' +
|
|
'<br><br>Choose SSH, RDP, VNC, or Web and enter connection details directly.'
|
|
},
|
|
{ title: 'Managing the Connections', minRole: 4, body:
|
|
'As an admin, you can:' +
|
|
'<br>• Create and edit <span class="onboard-hl">folders</span> with OIDC group permissions' +
|
|
'<br>• Add, edit, clone, and delete <span class="onboard-hl">entries</span>' +
|
|
'<br>• Configure credential variables, login scripts, and recording overrides per entry' +
|
|
'<br>• Manage users and API tokens from the <span class="onboard-hl">Admin</span> page'
|
|
}
|
|
];
|
|
var onboardCurrent = 0;
|
|
var onboardFiltered = [];
|
|
|
|
function startOnboarding(role) {
|
|
if (localStorage.getItem('rustguac_onboarding_dismissed')) return;
|
|
var level = roleLevel[role] || 1;
|
|
onboardFiltered = onboardSteps.filter(function(s) { return s.minRole <= level; });
|
|
if (onboardFiltered.length === 0) return;
|
|
onboardCurrent = 0;
|
|
renderOnboardStep();
|
|
document.getElementById('onboard-dismiss').checked = false;
|
|
document.getElementById('onboarding-modal').classList.add('active');
|
|
}
|
|
|
|
function renderOnboardStep() {
|
|
var step = onboardFiltered[onboardCurrent];
|
|
document.getElementById('onboard-title').textContent = step.title;
|
|
document.getElementById('onboard-body').innerHTML = step.body;
|
|
document.getElementById('onboard-step-indicator').textContent = (onboardCurrent + 1) + ' / ' + onboardFiltered.length;
|
|
document.getElementById('onboard-back').style.display = onboardCurrent === 0 ? 'none' : '';
|
|
var nextBtn = document.getElementById('onboard-next');
|
|
nextBtn.textContent = (onboardCurrent === onboardFiltered.length - 1) ? 'Done' : 'Next';
|
|
}
|
|
|
|
function closeOnboarding() {
|
|
document.getElementById('onboarding-modal').classList.remove('active');
|
|
if (document.getElementById('onboard-dismiss').checked) {
|
|
localStorage.setItem('rustguac_onboarding_dismissed', '1');
|
|
}
|
|
}
|
|
|
|
document.getElementById('onboard-next').addEventListener('click', function() {
|
|
if (onboardCurrent < onboardFiltered.length - 1) {
|
|
onboardCurrent++;
|
|
renderOnboardStep();
|
|
} else {
|
|
localStorage.setItem('rustguac_onboarding_dismissed', '1');
|
|
closeOnboarding();
|
|
}
|
|
});
|
|
document.getElementById('onboard-back').addEventListener('click', function() {
|
|
if (onboardCurrent > 0) {
|
|
onboardCurrent--;
|
|
renderOnboardStep();
|
|
}
|
|
});
|
|
document.getElementById('onboarding-modal').addEventListener('click', function(e) {
|
|
if (e.target === this) closeOnboarding();
|
|
});
|
|
|
|
document.getElementById('show-tour-item').addEventListener('click', function() {
|
|
document.getElementById('user-menu').style.display = 'none';
|
|
localStorage.removeItem('rustguac_onboarding_dismissed');
|
|
startOnboarding(currentUserRole);
|
|
});
|
|
|
|
init();
|
|
</script>
|
|
</body>
|
|
</html>
|