mirror of
https://github.com/sol1/rustguac.git
synced 2026-09-10 09:35:45 +00:00
fb0f965bb2
Logout link moved inside the float-right user-menu-wrapper so both Logout and Settings appear on the far right of the nav bar. Fixed addressbook.html Settings font size to match other pages (1.3em).
2597 lines
145 KiB
HTML
2597 lines
145 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' : '');
|
|
|
|
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>';
|
|
|
|
var inputs = body.querySelectorAll('input, textarea');
|
|
for (var j = 0; j < inputs.length; j++) {
|
|
inputs[j].addEventListener('input', onHopFieldChange);
|
|
}
|
|
|
|
card.appendChild(body);
|
|
container.appendChild(card);
|
|
});
|
|
updateFlowDiagram();
|
|
}
|
|
|
|
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: '', 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;
|
|
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>
|