mirror of
https://github.com/sol1/rustguac.git
synced 2026-09-10 09:35:45 +00:00
958b675fd2
check_server_key no longer blindly accepts all keys. Jump hosts now support a host_key field stored in Vault alongside credentials. - New POST /api/ssh/probe-host-key endpoint probes an SSH server and returns its public key, fingerprint, and algorithm - Address book UI: "Verify Host Key" button per jump host probes the server, shows fingerprint for confirmation, stores key on save - TunnelHandler verifies the server key against the stored key on connect — rejects with detailed error on mismatch - Unpinned keys accepted with TOFU warning log for backward compat - host_key preserved through credential merging on entry update Closes #95
2651 lines
149 KiB
HTML
2651 lines
149 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>rustguac - Address Book</title>
|
|
<style>
|
|
:root { --primary: #e94560; --primary-hover: #c73652; --accent: #5bc0be; --accent-hover: #4aa3a1; --bg: #1a1a2e; --surface: #16213e; --input: #0f3460; --text: #e0e0e0; --text-muted: #aaa; --border: #333; --text-dim: #666; --text-on-primary: #fff; --btn-disabled: #555; --status-pending: #f0c040; --status-active: #5bc0be; --status-completed: #888; --status-error: #e94560; --status-expired: #666; --type-ssh-bg: #1b4332; --type-ssh-fg: #52b788; --type-rdp-bg: #3d1f00; --type-rdp-fg: #f0a050; --type-vnc-bg: #2d1b4e; --type-vnc-fg: #b07ff0; --type-web-bg: #1a1a4e; --type-web-fg: #7b8ff0; --hop-bg: #1b4332; --hop-fg: #52b788; --bg-pattern: none; }
|
|
body { font-family: monospace; background-color: var(--bg); background-image: var(--bg-pattern); background-attachment: fixed; min-height: 100vh; color: var(--text); padding: 2em; font-size: 18px; }
|
|
h1 { color: var(--primary); }
|
|
a { color: var(--accent); }
|
|
nav { margin-top: 0.8em; margin-bottom: 1.5em; padding-bottom: 0.8em; border-bottom: 1px solid var(--border); font-size: 0.95em; }
|
|
nav a { margin-right: 1.5em; text-decoration: none; }
|
|
nav a:hover { text-decoration: underline; }
|
|
nav .active { color: var(--primary); font-weight: bold; }
|
|
#user-menu-wrapper { position: relative; float: right; }
|
|
#user-menu-btn { cursor: pointer; font-size: 1.3em; color: var(--text-muted); }
|
|
#user-menu-btn:hover { color: var(--text); }
|
|
#user-menu { display:none; position:absolute; right:0; top:1.8em; background:var(--surface); border:1px solid var(--border); border-radius:6px; min-width:220px; z-index:50; padding:0.4em 0; }
|
|
.um-section-label { padding:0.4em 0.9em; font-size:0.8em; color:var(--text-dim); text-transform:uppercase; letter-spacing:0.05em; }
|
|
.um-item { padding:0.5em 0.9em; cursor:pointer; display:flex; align-items:center; gap:0.6em; font-size:0.9em; }
|
|
.um-item:hover { background:var(--input); }
|
|
.um-item.active { color:var(--accent); }
|
|
.um-theme-info { display:flex; flex-direction:column; }
|
|
.um-theme-name { font-weight:bold; }
|
|
.um-theme-desc { font-size:0.8em; color:var(--text-dim); }
|
|
.um-swatch { display:inline-block; width:16px; height:16px; border-radius:50%; border:1px solid var(--border); flex-shrink:0; }
|
|
.um-divider { height:1px; background:var(--border); margin:0.3em 0; }
|
|
.um-logout { color:var(--text-muted); }
|
|
.um-logout:hover { color:var(--primary); background:var(--input); }
|
|
|
|
.layout { display: flex; gap: 2em; }
|
|
.sidebar { min-width: 220px; max-width: 280px; }
|
|
.main { flex: 1; min-width: 0; }
|
|
|
|
.sidebar-header { display: flex; align-items: center; gap: 0.5em; margin-bottom: 0.5em; }
|
|
.sidebar-header strong { flex: 1; }
|
|
|
|
.folder-list { list-style: none; padding: 0; margin: 0; }
|
|
.folder-list li {
|
|
padding: 0.5em 0.8em;
|
|
cursor: pointer;
|
|
border-radius: 3px;
|
|
margin-bottom: 2px;
|
|
}
|
|
.folder-list li:hover { background: var(--surface); }
|
|
.folder-list li.selected { background: var(--input); color: var(--accent); }
|
|
.folder-name-row { display: flex; align-items: center; gap: 0.5em; flex-wrap: wrap; }
|
|
.folder-scope { font-size: 0.7em; color: var(--text-dim); background: var(--input); padding: 0.1em 0.4em; border-radius: 2px; }
|
|
.folder-list li.selected .folder-scope { background: var(--surface); }
|
|
.folder-desc { font-size: 0.8em; color: var(--text-dim); display: block; margin-top: 0.15em; }
|
|
.folder-count { font-size: 0.7em; color: var(--text-dim); display: block; margin-top: 0.15em; }
|
|
|
|
.entries-table { border-collapse: collapse; width: 100%; }
|
|
.entries-table th, .entries-table td {
|
|
text-align: left; padding: 0.4em 0.8em; border-bottom: 1px solid var(--border);
|
|
}
|
|
.entries-table th { color: var(--text-muted); font-size: 0.85em; }
|
|
.entries-table tr:hover { background: var(--surface); }
|
|
.type-badge {
|
|
display: inline-block;
|
|
padding: 0.1em 0.5em;
|
|
border-radius: 3px;
|
|
font-size: 0.85em;
|
|
font-weight: bold;
|
|
}
|
|
.type-ssh { background: var(--type-ssh-bg); color: var(--type-ssh-fg); }
|
|
.type-rdp { background: var(--type-rdp-bg); color: var(--type-rdp-fg); }
|
|
.type-vnc { background: var(--type-vnc-bg); color: var(--type-vnc-fg); }
|
|
.type-web { background: var(--type-web-bg); color: var(--type-web-fg); }
|
|
.type-vdi { background: var(--type-vdi-bg); color: var(--type-vdi-fg); }
|
|
|
|
button {
|
|
padding: 0.4em 1em;
|
|
background: var(--primary);
|
|
color: var(--text-on-primary);
|
|
border: none;
|
|
font-family: monospace;
|
|
font-size: 0.9em;
|
|
border-radius: 3px;
|
|
cursor: pointer;
|
|
}
|
|
button:hover { background: var(--primary-hover); }
|
|
button:disabled { background: var(--btn-disabled); cursor: default; }
|
|
.btn-connect { background: var(--accent); color: var(--bg); font-weight: bold; }
|
|
.btn-connect:hover { background: var(--accent-hover); }
|
|
.btn-small {
|
|
background: none; border: none; color: var(--primary);
|
|
cursor: pointer; font-family: monospace; padding: 0; font-size: 0.9em;
|
|
}
|
|
.btn-small:hover { text-decoration: underline; }
|
|
.btn-add { background: var(--input); color: var(--accent); border: 1px solid var(--border); }
|
|
.btn-add:hover { background: var(--surface); }
|
|
|
|
.empty { color: var(--text-dim); margin: 2em 0; }
|
|
#global-error { color: var(--primary); margin-top: 0.8em; white-space: pre-wrap; }
|
|
.no-vault { color: var(--text-muted); margin: 2em 0; }
|
|
|
|
/* Empty state card */
|
|
.empty-state {
|
|
border: 2px dashed var(--border);
|
|
border-radius: 8px;
|
|
padding: 3em 2em;
|
|
text-align: center;
|
|
margin: 2em 0;
|
|
}
|
|
.empty-state h3 { color: var(--accent); margin-bottom: 0.5em; }
|
|
.empty-state p { color: #888; margin-bottom: 1.5em; }
|
|
.empty-state button { font-size: 1.1em; padding: 0.6em 1.5em; }
|
|
|
|
/* Folder action bar */
|
|
.folder-actions {
|
|
display: flex; align-items: center; gap: 0.6em; margin-bottom: 1em;
|
|
flex-wrap: wrap;
|
|
}
|
|
.folder-actions strong { font-size: 1.1em; }
|
|
.folder-actions .desc { color: #666; font-size: 0.9em; }
|
|
|
|
/* Modal overlay */
|
|
.modal-overlay {
|
|
display: none;
|
|
position: fixed;
|
|
top: 0; left: 0; right: 0; bottom: 0;
|
|
background: rgba(0,0,0,0.7);
|
|
z-index: 100;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
.modal-overlay.active { display: flex; }
|
|
.modal {
|
|
background: var(--surface);
|
|
border-radius: 6px;
|
|
padding: 1.5em;
|
|
min-width: 400px;
|
|
max-width: 520px;
|
|
max-height: 85vh;
|
|
overflow-y: auto;
|
|
}
|
|
.modal h3 { color: var(--primary); margin-top: 0; }
|
|
.modal label { display: block; margin-top: 0.8em; color: var(--text-muted); font-size: 0.9em; }
|
|
.modal input, .modal select, .modal textarea {
|
|
display: block;
|
|
width: 100%;
|
|
padding: 0.4em;
|
|
margin-top: 0.2em;
|
|
background: var(--input);
|
|
border: 1px solid var(--border);
|
|
color: var(--text);
|
|
font-family: monospace;
|
|
font-size: 1em;
|
|
border-radius: 3px;
|
|
box-sizing: border-box;
|
|
}
|
|
.modal input:focus, .modal select:focus, .modal textarea:focus { outline: none; border-color: var(--primary); }
|
|
.modal-actions { margin-top: 1.2em; display: flex; gap: 0.8em; }
|
|
.modal-actions .btn-cancel { background: var(--border); }
|
|
.modal-actions .btn-cancel:hover { background: #444; }
|
|
#onboard-body code { background: var(--input); padding: 0.1em 0.4em; border-radius: 3px; font-size: 0.9em; }
|
|
.onboard-key { display:inline-block; background:var(--surface); border:1px solid var(--border); border-radius:3px; padding:0.1em 0.45em; font-size:0.85em; color:var(--text); }
|
|
.onboard-hl { color: var(--accent); font-weight: bold; }
|
|
|
|
.field-hint { font-size: 0.8em; color: var(--text-dim); margin-top: 0.2em; }
|
|
.pw-wrap { position: relative; }
|
|
.pw-wrap input { padding-right: 2.2em; }
|
|
.pw-toggle { position: absolute; right: 0.4em; top: 50%; transform: translateY(-50%); background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 0.85em; padding: 0.2em; line-height: 1; width: auto; }
|
|
.pw-toggle:hover { color: var(--text); }
|
|
|
|
/* Hop cards */
|
|
.hop-card {
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
margin-bottom: 0.6em;
|
|
}
|
|
.hop-card-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5em;
|
|
padding: 0.4em 0.6em;
|
|
cursor: pointer;
|
|
font-size: 0.9em;
|
|
}
|
|
.hop-card-header:hover { background: var(--surface); }
|
|
.hop-card-header .hop-num {
|
|
color: var(--accent);
|
|
font-weight: bold;
|
|
min-width: 2em;
|
|
}
|
|
.hop-card-header .hop-hostname {
|
|
flex: 1;
|
|
color: var(--text);
|
|
}
|
|
.hop-card-header .hop-hostname input {
|
|
display: inline;
|
|
width: 100%;
|
|
padding: 0.2em 0.4em;
|
|
background: var(--input);
|
|
border: 1px solid var(--border);
|
|
color: var(--text);
|
|
font-family: monospace;
|
|
font-size: 1em;
|
|
border-radius: 3px;
|
|
box-sizing: border-box;
|
|
}
|
|
.hop-card-header .hop-remove {
|
|
color: var(--primary);
|
|
cursor: pointer;
|
|
font-size: 0.85em;
|
|
background: none;
|
|
border: none;
|
|
padding: 0.2em 0.4em;
|
|
font-family: monospace;
|
|
}
|
|
.hop-card-header .hop-remove:hover { text-decoration: underline; }
|
|
.hop-card-body {
|
|
padding: 0.4em 0.6em 0.6em 2.6em;
|
|
display: none;
|
|
}
|
|
.hop-card-body.expanded { display: block; }
|
|
.hop-card-body label { margin-top: 0.5em; }
|
|
.hop-card-body input, .hop-card-body textarea {
|
|
display: block;
|
|
width: 100%;
|
|
padding: 0.3em;
|
|
margin-top: 0.2em;
|
|
background: var(--input);
|
|
border: 1px solid var(--border);
|
|
color: var(--text);
|
|
font-family: monospace;
|
|
font-size: 0.95em;
|
|
border-radius: 3px;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
/* Flow diagram */
|
|
.flow-diagram {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0;
|
|
flex-wrap: wrap;
|
|
margin-bottom: 0.8em;
|
|
font-size: 0.85em;
|
|
}
|
|
.flow-node {
|
|
display: inline-block;
|
|
padding: 0.2em 0.6em;
|
|
border-radius: 3px;
|
|
white-space: nowrap;
|
|
}
|
|
.flow-node-you { background: var(--input); color: var(--text-muted); }
|
|
.flow-node-hop { background: var(--hop-bg); color: var(--hop-fg); }
|
|
.flow-node-target { background: var(--input); color: var(--accent); font-weight: bold; }
|
|
.flow-arrow { color: var(--text-muted); margin: 0 0.3em; }
|
|
.session-card {
|
|
flex: 0 0 auto;
|
|
width: 280px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
cursor: pointer;
|
|
transition: border-color 0.2s;
|
|
}
|
|
.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: 0.5em 0.8em;
|
|
font-size: 0.85em;
|
|
}
|
|
.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: 0.85em;
|
|
margin-top: 0.2em;
|
|
display: flex;
|
|
gap: 0.5em;
|
|
align-items: center;
|
|
}
|
|
.session-card-info .card-meta .type-badge {
|
|
font-size: 0.8em;
|
|
}
|
|
.session-card.dormant { opacity: 0.75; }
|
|
.session-card.dormant .session-card-thumb { filter: brightness(0.7); }
|
|
</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>
|
|
<img id="site-logo" src="/logo.svg" style="max-height:40px;vertical-align:middle;margin-right:0.5em" alt=""><h1 style="display:inline;vertical-align:middle">rustguac</h1>
|
|
<nav>
|
|
<a href="/addressbook.html" class="active">Address Book</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" style="color:var(--text-muted);text-decoration:none;margin-right:1em">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">
|
|
Address book 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>Address book 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" style="display:none;margin-bottom:1.5em">
|
|
<div style="display:flex;align-items:center;gap:0.5em;margin-bottom:0.8em">
|
|
<strong style="color:var(--accent)">Active Sessions</strong>
|
|
<span id="active-sessions-count" style="color:var(--text-muted);font-size:0.85em"></span>
|
|
</div>
|
|
<div id="active-sessions-grid" style="display:flex;gap:1em;overflow-x:auto;padding-bottom:0.5em"></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;padding:0.2em 0.6em;font-size:0.85em" 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>
|
|
<span style="flex:1"></span>
|
|
<button class="btn-add admin-only" id="btn-new-entry" style="display:none;padding:0.3em 0.8em;font-size:0.9em">+ add entry</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 (comma-separated)
|
|
<input type="text" id="fm-groups" placeholder="e.g. sysadmins,devops">
|
|
</label>
|
|
<div class="field-hint">OIDC groups that can see this folder. Leave empty for admin-only access.</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="">Default (negotiate)</option>
|
|
<option value="ntlm">NTLM</option>
|
|
<option value="kerberos">Kerberos</option>
|
|
</select>
|
|
</label>
|
|
<div class="field-hint">Force a specific NLA authentication package. Kerberos requires domain-joined machines.</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. Must accept <code>VDI_USERNAME</code> and <code>VDI_PASSWORD</code> env vars. 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>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>
|
|
</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>
|
|
</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:0.3em;padding:0.2em 0.8em;font-size:0.85em">+ Add Jump Host</button>
|
|
</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. Address book 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 address book 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>
|
|
|
|
<!-- 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='dark';
|
|
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||'dark';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+' - Address Book';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 }
|
|
var folders = [];
|
|
var folderEntryCounts = {}; // "scope/name" -> count
|
|
var folderEntries = {}; // "scope/name" -> entries array (from batch load)
|
|
var currentEntries = []; // entries for the currently selected folder
|
|
var loginScriptsCache = null; // cached list from /api/login-scripts
|
|
|
|
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 = '';
|
|
}
|
|
|
|
// 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);
|
|
});
|
|
}
|
|
|
|
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>';
|
|
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>';
|
|
card.addEventListener('click', function() {
|
|
if (s.address_book_entry) {
|
|
// Reconnect via address book connect (creates new session to same target)
|
|
var parts = s.address_book_entry.split('/');
|
|
if (parts.length === 3) {
|
|
fetch('/api/addressbook/folders/' + encodeURIComponent(parts[0]) + '/' + encodeURIComponent(parts[1]) + '/entries/' + encodeURIComponent(parts[2]) + '/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 + '?name=' + encodeURIComponent(s.entry_display_name || ''), '_blank');
|
|
else showError(data.error || 'Failed to reconnect');
|
|
}).catch(function(e) { showError('Reconnect failed: ' + e.message); });
|
|
return;
|
|
}
|
|
}
|
|
// Fallback: open existing session URL (ad-hoc sessions)
|
|
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 address book 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 || '' };
|
|
});
|
|
folderEntries = {};
|
|
folderEntryCounts = {};
|
|
allFolders.forEach(function(f) {
|
|
var key = f.scope + '/' + 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();
|
|
// Auto-select first folder if none selected
|
|
if (!selectedFolder && folders.length > 0) {
|
|
selectedFolder = folders[0];
|
|
renderFolders();
|
|
loadEntries(folders[0].scope, folders[0].name);
|
|
}
|
|
}
|
|
})
|
|
.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) {
|
|
var li = document.createElement('li');
|
|
li.setAttribute('data-name', f.name);
|
|
li.setAttribute('data-scope', f.scope);
|
|
if (selectedFolder && selectedFolder.name === f.name && selectedFolder.scope === f.scope) {
|
|
li.className = 'selected';
|
|
}
|
|
var row = document.createElement('div');
|
|
row.className = 'folder-name-row';
|
|
var nameSpan = document.createElement('span');
|
|
nameSpan.textContent = f.name;
|
|
row.appendChild(nameSpan);
|
|
var scopeBadge = document.createElement('span');
|
|
scopeBadge.className = 'folder-scope';
|
|
scopeBadge.textContent = f.scope;
|
|
row.appendChild(scopeBadge);
|
|
li.appendChild(row);
|
|
var countKey = f.scope + '/' + f.name;
|
|
if (folderEntryCounts[countKey] !== undefined) {
|
|
var countSpan = document.createElement('span');
|
|
countSpan.className = 'folder-count';
|
|
countSpan.textContent = folderEntryCounts[countKey] + ' entries';
|
|
li.appendChild(countSpan);
|
|
}
|
|
if (f.description) {
|
|
var desc = document.createElement('span');
|
|
desc.className = 'folder-desc';
|
|
desc.textContent = f.description;
|
|
li.appendChild(desc);
|
|
}
|
|
li.addEventListener('click', function() {
|
|
selectedFolder = f;
|
|
renderFolders();
|
|
loadEntries(f.scope, f.name);
|
|
});
|
|
ul.appendChild(li);
|
|
});
|
|
}
|
|
|
|
function loadEntries(scope, folder, forceRefresh) {
|
|
var header = document.getElementById('entries-header');
|
|
header.style.display = '';
|
|
document.getElementById('entries-title').textContent = folder;
|
|
var f = folders.find(function(x) { return x.name === folder && x.scope === scope; });
|
|
document.getElementById('entries-desc').textContent = f && f.description ? f.description : '';
|
|
|
|
var cacheKey = scope + '/' + folder;
|
|
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, folder);
|
|
return;
|
|
}
|
|
|
|
var content = document.getElementById('entries-content');
|
|
content.innerHTML = '<p class="empty">Loading...</p>';
|
|
|
|
fetch('/api/addressbook/folders/' + encodeURIComponent(scope) + '/' + encodeURIComponent(folder) + '/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, folder);
|
|
})
|
|
.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;
|
|
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 to check if credentials are stored
|
|
var entry = null;
|
|
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} = edit
|
|
|
|
function openNewFolder() {
|
|
folderEditMode = 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-groups').value = '';
|
|
document.getElementById('fm-scope').value = 'shared';
|
|
document.getElementById('fm-scope').disabled = false;
|
|
document.getElementById('fm-error').textContent = '';
|
|
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-edit-folder').addEventListener('click', function() {
|
|
if (!selectedFolder) return;
|
|
folderEditMode = { scope: selectedFolder.scope, name: selectedFolder.name };
|
|
document.getElementById('folder-modal-title').textContent = 'Edit Folder: ' + selectedFolder.name;
|
|
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 = '';
|
|
|
|
// Load current config for groups
|
|
fetch('/api/addressbook/folders/' + encodeURIComponent(selectedFolder.scope) + '/' + encodeURIComponent(selectedFolder.name) + '/config', {
|
|
headers: apiHeaders(),
|
|
credentials: 'same-origin'
|
|
}).then(function(res) {
|
|
if (res.ok) return res.json();
|
|
return null;
|
|
}).then(function(data) {
|
|
if (data && data.allowed_groups) {
|
|
document.getElementById('fm-groups').value = data.allowed_groups.join(', ');
|
|
}
|
|
}).catch(function() {});
|
|
|
|
document.getElementById('fm-groups').value = '';
|
|
document.getElementById('folder-modal').classList.add('active');
|
|
});
|
|
|
|
document.getElementById('fm-cancel').addEventListener('click', function() {
|
|
document.getElementById('folder-modal').classList.remove('active');
|
|
});
|
|
|
|
document.getElementById('fm-save').addEventListener('click', function() {
|
|
var name = document.getElementById('fm-name').value.trim();
|
|
var desc = document.getElementById('fm-desc').value.trim();
|
|
var groups = document.getElementById('fm-groups').value.split(',').map(function(g) { return g.trim(); }).filter(Boolean);
|
|
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.name), {
|
|
method: 'PUT',
|
|
headers: apiHeaders({ 'Content-Type': 'application/json' }),
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify({ allowed_groups: groups, description: desc })
|
|
})
|
|
.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 {
|
|
fetch('/api/addressbook/folders', {
|
|
method: 'POST',
|
|
headers: apiHeaders({ 'Content-Type': 'application/json' }),
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify({ name: name, allowed_groups: groups, description: desc, scope: scope })
|
|
})
|
|
.then(function(res) {
|
|
if (!res.ok) return res.text().then(function(t) { throw new Error(t); });
|
|
document.getElementById('folder-modal').classList.remove('active');
|
|
// Auto-select the new folder
|
|
selectedFolder = { name: name, scope: scope, description: desc };
|
|
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;
|
|
if (!confirm('Delete folder "' + selectedFolder.name + '" and ALL its entries? This cannot be undone.')) return;
|
|
fetch('/api/addressbook/folders/' + encodeURIComponent(selectedFolder.scope) + '/' + encodeURIComponent(selectedFolder.name), {
|
|
method: 'DELETE',
|
|
headers: apiHeaders(),
|
|
credentials: 'same-origin'
|
|
}).then(function(res) {
|
|
if (!res.ok) return res.text().then(function(t) { showError(t); });
|
|
selectedFolder = null;
|
|
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 = '';
|
|
});
|
|
|
|
// 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 ' + selectedFolder.name;
|
|
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-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-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-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-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
|
|
var moveSelect = document.getElementById('em-move-target');
|
|
moveSelect.innerHTML = '<option value="">(keep in current folder)</option>';
|
|
folders.forEach(function(f) {
|
|
if (f.name !== folder || f.scope !== scope) {
|
|
var opt = document.createElement('option');
|
|
opt.value = f.scope + '/' + f.name;
|
|
opt.textContent = f.name + ' (' + f.scope + ')';
|
|
moveSelect.appendChild(opt);
|
|
}
|
|
});
|
|
document.getElementById('em-move-section').style.display = folders.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-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 = '▼';
|
|
}
|
|
// 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;
|
|
if (entryData.enable_gfx || entryData.enable_desktop_composition || entryData.force_lossless || entryData.enable_h264) {
|
|
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 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 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;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
// 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;
|
|
|
|
// 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 = selectedFolder.name;
|
|
|
|
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 parts = moveTarget.split('/');
|
|
var targetScope = parts[0];
|
|
var targetFolder = parts.slice(1).join('/');
|
|
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 = folders.find(function(f) { return f.scope === targetScope && f.name === targetFolder; }) || selectedFolder;
|
|
});
|
|
}
|
|
})
|
|
.then(function() {
|
|
document.getElementById('entry-modal').classList.remove('active');
|
|
entryTypeSelect.disabled = false;
|
|
loadEntries(selectedFolder.scope, selectedFolder.name, 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;
|
|
});
|
|
}
|
|
});
|
|
|
|
// ── 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">Address Book</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:
|
|
'Address book 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 address book.' +
|
|
'<br><br>Choose SSH, RDP, VNC, or Web and enter connection details directly.'
|
|
},
|
|
{ title: 'Managing the Address Book', 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>
|