Files
rustguac/static/addressbook.html
T
Dave Kempe 799cdefd57 v1.0.0: H.264 passthrough, per-entry toggle, Docker fix
H.264 passthrough for RDP via guac_display worker integration:
- Raw H.264 NAL units from xrdp x264 pass through to browser
  WebCodecs VideoDecoder, bypassing server-side decode/re-encode
- Frame queue preserves all H.264 frames across deferred flushes
- Per-entry toggle in address book (enable_h264 field)
- Keyframe gate ensures decoder initializes correctly
- Per-connection callback (no static global concurrency bug)
- Queue capped at 120 frames to prevent unbounded growth

guacd patch (004-h264-display-worker):
- display-priv.h: h264 frame queue on guac_display_layer
- display-plan.c: flush h264 queue during plan_apply, skip IMG ops
- display-layer.c: set_h264 API with queue append and cap
- rdpgfx.c: SurfaceCommand wrapper saves NAL before GDI handler
- settings.c/h: enable-h264 connection parameter, conditional GfxH264

Other changes:
- Docker: fix FreeRDP plugin path for drive/audio channels (#87)
- contrib/setup-xrdp-gfx.sh: full xrdp x264 rebuild from Debian sid
- docs/rdp-video-performance.md: H.264 passthrough documentation
- Address book UI: H.264 checkbox with dependency descriptions
2026-03-28 21:52:28 +11:00

2375 lines
132 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: 0.95em; 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); }
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; }
</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"><span id="user-menu-btn" title="Preferences">&#9881; 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">&#128273; My Credentials</div><div class="um-item" id="show-tour-item">&#9432; Welcome Tour</div><div class="um-divider"></div><div class="um-item um-logout" id="logout-item">Logout</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 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>
</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">&#9679;</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">&#9679;</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">&#9679;</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">&#9654;</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">&#9679;</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">&#9654;</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">&#9654;</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-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">&#9654;</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">&#9654;</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">&#9654;</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 &rarr; 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 &rarr; 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">&#9654;</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();
startOnboarding(currentUserRole);
})
.catch(function(err) {
showError('Failed to check authentication: ' + err.message);
});
}
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.hostname || '');
if (e.port && e.session_type !== 'web') 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, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// 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) {
window.open(data.client_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') {
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';
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(''); });
}
// 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 all session types
document.getElementById('em-tunnel-section').style.display = '';
// 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 = '&#9660;';
} else {
fields.style.display = 'none';
arrow.innerHTML = '&#9654;';
}
});
// 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 = '&#9660;';
} else {
fields.style.display = 'none';
arrow.innerHTML = '&#9654;';
}
});
// 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 = '&#9660;';
} else {
fields.style.display = 'none';
arrow.innerHTML = '&#9654;';
}
});
// 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 = '&#9660;';
} else {
fields.style.display = 'none';
arrow.innerHTML = '&#9654;';
}
});
// 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 = '&#9660;';
} else {
fields.style.display = 'none';
arrow.innerHTML = '&#9654;';
}
});
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 = '&#9660;';
} else {
fields.style.display = 'none';
arrow.innerHTML = '&#9654;';
}
});
// ── 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">&times;</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 = '&#9660;';
});
// 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 = '&#9660;';
} else {
fields.style.display = 'none';
arrow.innerHTML = '&#9654;';
}
});
// ── 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">&times;</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 = '&#9660;';
});
// ── 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">&rarr;</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">&rarr;</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 = '&#9654;';
autofillRows = [];
renderAutofillRows();
document.getElementById('em-autofill-fields').style.display = 'none';
document.getElementById('em-autofill-arrow').innerHTML = '&#9654;';
allowedDomains = [];
renderAllowedDomains();
document.getElementById('em-allowed-domains-fields').style.display = 'none';
document.getElementById('em-allowed-domains-arrow').innerHTML = '&#9654;';
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 = '&#9654;';
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 = '&#9654;';
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 = '&#9654;';
hopList = [];
renderHops();
document.getElementById('em-tunnel-fields').style.display = 'none';
document.getElementById('em-tunnel-arrow').innerHTML = '&#9654;';
}
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 = '&#9660;';
}
// 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 = '&#9660;';
}
} 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 = '&#9660;';
}
}
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 = '&#9660;';
}
// 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 = '&#9660;';
} 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 = '&#9660;';
}
// 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_comp;
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_comp || entryData.force_lossless || entryData.enable_h264) {
document.getElementById('em-video-perf-fields').style.display = '';
document.getElementById('em-video-perf-arrow').innerHTML = '&#9660;';
}
}
// 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 = '&#9660;';
}
}
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;
}
}
// 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_comp = 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 &mdash; you can dismiss it at any time.'
},
{ title: 'Navigation', minRole: 1, body:
'The nav bar at the top links to:' +
'<br>&bull; <span class="onboard-hl">Address Book</span> &mdash; your connection entries' +
'<br>&bull; <span class="onboard-hl">Sessions</span> &mdash; active and recent sessions' +
'<br>&bull; <span class="onboard-hl">Recordings</span> &mdash; session playback' +
'<br>&bull; <span class="onboard-hl">Docs</span> &mdash; 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> &rarr; <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>&bull; <span class="onboard-hl">Connect</span> &mdash; launches the session directly using the saved settings.' +
'<br>&bull; <span class="onboard-hl">Login</span> &mdash; 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 &mdash; 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>&bull; Create and edit <span class="onboard-hl">folders</span> with OIDC group permissions' +
'<br>&bull; Add, edit, clone, and delete <span class="onboard-hl">entries</span>' +
'<br>&bull; Configure credential variables, login scripts, and recording overrides per entry' +
'<br>&bull; 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>