mirror of
https://github.com/sol1/rustguac.git
synced 2026-09-10 01:26:06 +00:00
e7c515654e
Add an autohide_side_tabs option (Option<bool>, default off) on the address book entry, threaded through the same path as fullscreen_on_connect (AddressBookEntry, EntryInfo, CreateSessionRequest, Session, SessionInfo, the API connect/quick-connect builders, and import defaults). When set, client.html slides the left-edge Clipboard and Files tabs off screen when idle and brings them back when the pointer nears the left edge; defaults preserve the current always-visible behaviour. Checkbox added to the entry editor.
3749 lines
204 KiB
HTML
3749 lines
204 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; }
|
|
|
|
.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; }
|
|
.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>
|
|
<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="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="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 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-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>My Credentials</h3>
|
|
<p 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>
|
|
<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 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();
|
|
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 || [];
|
|
// 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 && 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 = '';
|
|
if (folders.length === 0) {
|
|
ul.innerHTML = '<li style="color:#666;cursor:default;padding:1em 0.8em">No folders visible to your account.</li>';
|
|
return;
|
|
}
|
|
folders.forEach(function(f) {
|
|
ul.appendChild(buildFolderNode(f, true));
|
|
});
|
|
}
|
|
|
|
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) {
|
|
chevron.textContent = isExpanded ? '\u25BE' : '\u25B8';
|
|
if (isExpanded) chevron.classList.add('expanded');
|
|
chevron.addEventListener('click', function(ev) {
|
|
ev.stopPropagation();
|
|
toggleFolder(f);
|
|
});
|
|
} else {
|
|
chevron.classList.add('leaf');
|
|
chevron.textContent = '\u25B8';
|
|
}
|
|
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.hostname || '');
|
|
var haystack = [
|
|
e.name,
|
|
e.display_name || '',
|
|
host,
|
|
e.username || '',
|
|
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();
|
|
})
|
|
.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';
|
|
var connectLabel = needsPrompt ? 'Login...' : 'Connect';
|
|
var userCol = 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><button class="btn-connect" data-connect="' + escapeAttr(e.name) + '" data-scope="' + escapeAttr(r.scope) + '" data-folder="' + escapeAttr(r.folderPath) + '">' + connectLabel + '</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>';
|
|
});
|
|
}
|
|
|
|
function renderEntries(entries, scope, 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.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>';
|
|
html += '<td>' + escapeHtml(label) + '</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.username ? escapeHtml(e.username) : '';
|
|
var needsPrompt = (e.prompt_credentials || !e.has_credentials) && e.session_type !== 'web';
|
|
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><button class="btn-connect" data-connect="' + escapeAttr(e.name) + '" data-scope="' + escapeAttr(scope) + '" data-folder="' + escapeAttr(folder) + '">' + connectLabel + '</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) {
|
|
btn.disabled = true;
|
|
btn.textContent = 'Connecting...';
|
|
clearError();
|
|
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);
|
|
window.open(url, '_blank');
|
|
})
|
|
.catch(function(err) {
|
|
if (err.message === 'missing_credentials') {
|
|
showError('Missing credentials: ' + (err.missing || []).join(', ') + '. Set them in My Credentials (⚙ menu).');
|
|
openMyCredentials();
|
|
} else {
|
|
showError(err.message);
|
|
}
|
|
})
|
|
.finally(function() {
|
|
btn.disabled = false;
|
|
btn.textContent = 'Connect';
|
|
});
|
|
}
|
|
|
|
function showCredPrompt(entry, scope, folder, btn) {
|
|
pendingConnect = { scope: scope, folder: folder, name: entry.name, btn: btn };
|
|
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;
|
|
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 = 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')) {
|
|
var name = btn.getAttribute('data-connect');
|
|
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') {
|
|
showCredPrompt(entry, scope, folder, btn);
|
|
} else {
|
|
doConnect(scope, folder, name, btn);
|
|
}
|
|
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-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 === '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 === '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-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-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 === '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;
|
|
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 === '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 ──
|
|
document.getElementById('my-creds-item').addEventListener('click', function() {
|
|
document.getElementById('user-menu').style.display = 'none';
|
|
openMyCredentials();
|
|
});
|
|
document.getElementById('my-creds-close').addEventListener('click', function() {
|
|
document.getElementById('my-creds-modal').classList.remove('active');
|
|
});
|
|
document.getElementById('my-creds-save').addEventListener('click', saveMyCredentials);
|
|
|
|
function openMyCredentials() {
|
|
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');
|
|
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 || {};
|
|
var varNames = Object.keys(allVars).sort();
|
|
|
|
if (varNames.length === 0) {
|
|
empty.style.display = '';
|
|
return;
|
|
}
|
|
|
|
// Group by domain prefix
|
|
var domainKeys = Object.keys(domains).sort();
|
|
domainKeys.forEach(function(domain) {
|
|
var heading = document.createElement('div');
|
|
heading.style.cssText = 'color:var(--accent);font-weight:bold;margin-top:1em;margin-bottom:0.3em;font-size:0.95em;text-transform:uppercase';
|
|
heading.textContent = domain;
|
|
list.appendChild(heading);
|
|
|
|
var vars = domains[domain].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);
|
|
});
|
|
vars.forEach(function(v) {
|
|
var name = v.name;
|
|
var isSecret = name.endsWith('_password') || name.endsWith('_key');
|
|
var row = document.createElement('label');
|
|
row.style.cssText = 'display:block;margin-top:0.5em;color:var(--text-muted);font-size:0.9em';
|
|
var displayName = name.replace(domain + '_', '');
|
|
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);
|
|
list.appendChild(row);
|
|
});
|
|
});
|
|
}).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 hasChanges = false;
|
|
inputs.forEach(function(el) {
|
|
var name = el.getAttribute('data-var');
|
|
var val = el.value.trim();
|
|
if (val) {
|
|
creds[name] = val;
|
|
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 })
|
|
})
|
|
.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');
|
|
})
|
|
.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>
|