mirror of
https://github.com/sol1/rustguac.git
synced 2026-09-10 17:47:13 +00:00
0c98aba190
- Add optional `banner` field to address book entries (shown before session starts, user must click Continue). No longer auto-populates from display_name. - Restructure web entry form: username, password, login script, and autofill collapsed under a collapsible "Automation" section. - Filter login scripts dropdown to .js/.sh/.py files only (skip package.json etc.) - Fix CDP/login scripts: change DeveloperToolsAvailability policy from 2 (disabled) to 0. DevTools UI remains blocked by chrome://* URLBlocklist. Fixes login script automation that was silently broken by the v0.6.0 security hardening. - Update Dockerfile, debian/postinst, install.sh with corrected policy. - Update docs/security.md and docs/web-sessions.md.
1990 lines
108 KiB
HTML
1990 lines
108 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-bottom: 1.5em; font-size: 0.95em; }
|
|
nav a { margin-right: 1.5em; text-decoration: none; }
|
|
nav a:hover { text-decoration: underline; }
|
|
nav .active { color: var(--primary); font-weight: bold; }
|
|
#user-menu-wrapper { position: relative; float: right; }
|
|
#user-menu-btn { cursor: pointer; font-size: 1.3em; color: var(--text-muted); }
|
|
#user-menu-btn:hover { color: var(--text); }
|
|
#user-menu { display:none; position:absolute; right:0; top:1.8em; background:var(--surface); border:1px solid var(--border); border-radius:6px; min-width:220px; z-index:50; padding:0.4em 0; }
|
|
.um-section-label { padding:0.4em 0.9em; font-size:0.8em; color:var(--text-dim); text-transform:uppercase; letter-spacing:0.05em; }
|
|
.um-item { padding:0.5em 0.9em; cursor:pointer; display:flex; align-items:center; gap:0.6em; font-size:0.9em; }
|
|
.um-item:hover { background:var(--input); }
|
|
.um-item.active { color:var(--accent); }
|
|
.um-theme-info { display:flex; flex-direction:column; }
|
|
.um-theme-name { font-weight:bold; }
|
|
.um-theme-desc { font-size:0.8em; color:var(--text-dim); }
|
|
.um-swatch { display:inline-block; width:16px; height:16px; border-radius:50%; border:1px solid var(--border); flex-shrink:0; }
|
|
.um-divider { height:1px; background:var(--border); margin:0.3em 0; }
|
|
.um-logout { color:var(--text-muted); }
|
|
.um-logout:hover { color:var(--primary); background:var(--input); }
|
|
|
|
.layout { display: flex; gap: 2em; }
|
|
.sidebar { min-width: 220px; max-width: 280px; }
|
|
.main { flex: 1; min-width: 0; }
|
|
|
|
.sidebar-header { display: flex; align-items: center; gap: 0.5em; margin-bottom: 0.5em; }
|
|
.sidebar-header strong { flex: 1; }
|
|
|
|
.folder-list { list-style: none; padding: 0; margin: 0; }
|
|
.folder-list li {
|
|
padding: 0.5em 0.8em;
|
|
cursor: pointer;
|
|
border-radius: 3px;
|
|
margin-bottom: 2px;
|
|
}
|
|
.folder-list li:hover { background: var(--surface); }
|
|
.folder-list li.selected { background: var(--input); color: var(--accent); }
|
|
.folder-name-row { display: flex; align-items: center; gap: 0.5em; flex-wrap: wrap; }
|
|
.folder-scope { font-size: 0.7em; color: var(--text-dim); background: var(--input); padding: 0.1em 0.4em; border-radius: 2px; }
|
|
.folder-list li.selected .folder-scope { background: var(--surface); }
|
|
.folder-desc { font-size: 0.8em; color: var(--text-dim); display: block; margin-top: 0.15em; }
|
|
.folder-count { font-size: 0.7em; color: var(--text-dim); display: block; margin-top: 0.15em; }
|
|
|
|
.entries-table { border-collapse: collapse; width: 100%; }
|
|
.entries-table th, .entries-table td {
|
|
text-align: left; padding: 0.4em 0.8em; border-bottom: 1px solid var(--border);
|
|
}
|
|
.entries-table th { color: var(--text-muted); font-size: 0.85em; }
|
|
.entries-table tr:hover { background: var(--surface); }
|
|
.type-badge {
|
|
display: inline-block;
|
|
padding: 0.1em 0.5em;
|
|
border-radius: 3px;
|
|
font-size: 0.85em;
|
|
font-weight: bold;
|
|
}
|
|
.type-ssh { background: var(--type-ssh-bg); color: var(--type-ssh-fg); }
|
|
.type-rdp { background: var(--type-rdp-bg); color: var(--type-rdp-fg); }
|
|
.type-vnc { background: var(--type-vnc-bg); color: var(--type-vnc-fg); }
|
|
.type-web { background: var(--type-web-bg); color: var(--type-web-fg); }
|
|
|
|
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; }
|
|
|
|
.field-hint { font-size: 0.8em; color: var(--text-dim); margin-top: 0.2em; }
|
|
|
|
/* 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="/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">⚙</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 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
|
|
<input type="password" id="em-password">
|
|
</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
|
|
<input type="password" id="em-rdp-password">
|
|
</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
|
|
<input type="password" id="em-vnc-password">
|
|
</label>
|
|
<label>Color depth
|
|
<select id="em-vnc-color-depth">
|
|
<option value="">Default (24-bit)</option>
|
|
<option value="8">8-bit</option>
|
|
<option value="16">16-bit</option>
|
|
<option value="24">24-bit</option>
|
|
<option value="32">32-bit</option>
|
|
</select>
|
|
</label>
|
|
<label style="margin-top:0.8em">
|
|
<input type="checkbox" id="em-vnc-prompt-creds" style="display:inline;width:auto;margin-right:0.4em"> Prompt for credentials at connect time
|
|
</label>
|
|
<div class="field-hint">When enabled, users will be asked for a password even if one is stored.</div>
|
|
</div>
|
|
<div id="em-web-fields" style="display:none">
|
|
<label>URL
|
|
<input type="text" id="em-url" placeholder="https://example.com">
|
|
</label>
|
|
<div class="field-hint">Use <code>$RUSTGUAC_USERNAME</code> and <code>$RUSTGUAC_PASSWORD</code> in the URL for basic auth (e.g. <code>https://$RUSTGUAC_USERNAME:$RUSTGUAC_PASSWORD@host</code>).</div>
|
|
<label>Banner <span style="color:var(--text-muted);font-size:0.85em">(optional)</span>
|
|
<input type="text" id="em-banner" placeholder="Shown before session starts (user must click Continue)">
|
|
</label>
|
|
<div style="margin-top:1em;padding-top:0.8em;border-top:1px solid var(--border)">
|
|
<label style="cursor:pointer;color:var(--accent);font-size:0.95em" id="em-automation-toggle">
|
|
<span id="em-automation-arrow">▶</span> Automation
|
|
</label>
|
|
<div id="em-automation-fields" style="display:none">
|
|
<div class="field-hint" style="margin-bottom:0.5em">Credentials and scripts for automated login. Username and password are also used for URL substitution (<code>$RUSTGUAC_USERNAME</code> / <code>$RUSTGUAC_PASSWORD</code>).</div>
|
|
<label>Username <span style="color:var(--text-muted);font-size:0.85em">(optional)</span>
|
|
<input type="text" id="em-web-username" placeholder="For URL substitution or login script">
|
|
</label>
|
|
<label>Password <span style="color:var(--text-muted);font-size:0.85em">(optional)</span>
|
|
<input type="password" id="em-web-password">
|
|
</label>
|
|
<label>Login Script <span style="color:var(--text-muted);font-size:0.85em">(optional)</span>
|
|
<select id="em-login-script">
|
|
<option value="">None</option>
|
|
</select>
|
|
</label>
|
|
<div class="field-hint">Server-side script from the scripts directory. Receives CDP port and credentials as env vars. Runs after Chromium spawns.</div>
|
|
<div style="margin-top:0.8em;padding-top:0.8em;border-top:1px solid var(--border)">
|
|
<label style="cursor:pointer;color:var(--accent);font-size:0.95em" id="em-autofill-toggle">
|
|
<span id="em-autofill-arrow">▶</span> Autofill
|
|
</label>
|
|
<div id="em-autofill-fields" style="display:none">
|
|
<div class="field-hint" style="margin-bottom:0.5em">Pre-populate Chromium's autofill database so saved credentials appear on matching login forms. Use <code>$USERNAME</code> and <code>$PASSWORD</code> to substitute the entry's credentials.</div>
|
|
<div id="em-autofill-rows"></div>
|
|
<button type="button" id="em-autofill-add" style="margin-top:0.3em;padding:0.3em 0.8em;font-size:0.85em;background:var(--input);color:var(--accent);border:1px solid var(--border);border-radius:4px;cursor:pointer">+ Add site</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div style="margin-top:1em;padding-top:0.8em;border-top:1px solid var(--border)">
|
|
<label style="cursor:pointer;color:var(--accent);font-size:0.95em" id="em-allowed-domains-toggle">
|
|
<span id="em-allowed-domains-arrow">▶</span> Allowed Domains
|
|
</label>
|
|
<div id="em-allowed-domains-fields" style="display:none">
|
|
<div class="field-hint" style="margin-bottom:0.5em">Restrict which domains the browser can reach. When set, all other domains are blocked via DNS. Leave empty to allow all sites.</div>
|
|
<div id="em-allowed-domains-list"></div>
|
|
<button type="button" id="em-allowed-domains-add" style="margin-top:0.3em;padding:0.3em 0.8em;font-size:0.85em;background:var(--input);color:var(--accent);border:1px solid var(--border);border-radius:4px;cursor:pointer">+ Add domain</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div id="em-remoteapp-section" style="display:none;margin-top:1em;padding-top:0.8em;border-top:1px solid var(--border)">
|
|
<label style="cursor:pointer;color:var(--accent);font-size:0.95em" id="em-remoteapp-toggle">
|
|
<span id="em-remoteapp-arrow">▶</span> RemoteApp (RAIL)
|
|
</label>
|
|
<div id="em-remoteapp-fields" style="display:none">
|
|
<div class="field-hint" style="margin-bottom:0.5em">Launch a specific application instead of the full desktop.</div>
|
|
<label>Program path
|
|
<input type="text" id="em-remote-app" placeholder="e.g. ||calc or C:\Windows\notepad.exe">
|
|
</label>
|
|
<div class="field-hint">Use || prefix for alternate shell, or full path to executable.</div>
|
|
<label>Working directory
|
|
<input type="text" id="em-remote-app-dir" placeholder="e.g. C:\Users\Public">
|
|
</label>
|
|
<label>Arguments
|
|
<input type="text" id="em-remote-app-args" placeholder="e.g. /path/to/file">
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div id="em-drive-section" style="display:none;margin-top:0.8em;">
|
|
<label>
|
|
<input type="checkbox" id="em-enable-drive" style="display:inline;width:auto;margin-right:0.4em"> Enable file transfer
|
|
<div style="color:#888;font-size:0.75em;margin-top:0.2em;">RDP: mounts a shared drive. SSH: enables SFTP file browser.</div>
|
|
</label>
|
|
</div>
|
|
<div id="em-recording-section" style="display:none;margin-top:0.8em;padding-top:0.8em;border-top:1px solid var(--border)">
|
|
<label style="cursor:pointer;color:var(--accent);font-size:0.95em" id="em-recording-toggle">
|
|
<span id="em-recording-arrow">▶</span> Recording Settings
|
|
</label>
|
|
<div id="em-recording-fields" style="display:none">
|
|
<div class="field-hint" style="margin-bottom:0.5em">Override global recording settings for this entry.</div>
|
|
<label style="margin-top:0.5em">
|
|
<input type="checkbox" id="em-override-recording" style="display:inline;width:auto;margin-right:0.4em"> Override recording settings
|
|
</label>
|
|
<div id="em-recording-sub" style="display:none;margin-left:1.5em;margin-top:0.5em">
|
|
<label>
|
|
<input type="checkbox" id="em-enable-recording" style="display:inline;width:auto;margin-right:0.4em" checked> Enable recording
|
|
</label>
|
|
<label style="margin-top:0.5em">Max recordings to keep
|
|
<input type="number" id="em-max-recordings" value="0" min="0" style="width:100px">
|
|
</label>
|
|
<div class="field-hint">0 = unlimited. Oldest recordings are deleted when the limit is exceeded.</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div id="em-clipboard-section" style="margin-top:0.8em">
|
|
<label>
|
|
<input type="checkbox" id="em-disable-copy" style="display:inline;width:auto;margin-right:0.4em"> Disable clipboard copy (server → client)
|
|
<div style="color:#888;font-size:0.75em;margin-top:0.2em;">Prevents users from copying text out of the remote session.</div>
|
|
</label>
|
|
<label style="margin-top:0.3em">
|
|
<input type="checkbox" id="em-disable-paste" style="display:inline;width:auto;margin-right:0.4em"> Disable clipboard paste (client → server)
|
|
<div style="color:#888;font-size:0.75em;margin-top:0.2em;">Prevents users from pasting text into the remote session.</div>
|
|
</label>
|
|
</div>
|
|
<div id="em-tunnel-section" style="display:none;margin-top:1em;padding-top:0.8em;border-top:1px solid var(--border)">
|
|
<label style="cursor:pointer;color:var(--accent);font-size:0.95em" id="em-tunnel-toggle">
|
|
<span id="em-tunnel-arrow">▶</span> SSH Tunnel / Jump Hosts
|
|
</label>
|
|
<div id="em-tunnel-fields" style="display:none">
|
|
<div class="field-hint" style="margin-bottom:0.5em">Route this connection through one or more SSH bastion hosts.</div>
|
|
<div id="em-flow-diagram" class="flow-diagram"></div>
|
|
<div id="em-hops-list"></div>
|
|
<button type="button" class="btn-add" id="em-add-hop" style="margin-top:0.3em;padding:0.2em 0.8em;font-size:0.85em">+ Add Jump Host</button>
|
|
</div>
|
|
</div>
|
|
<div id="em-move-section" style="display:none;margin-top:1em;padding-top:0.8em;border-top:1px solid var(--border)">
|
|
<label>Move to folder
|
|
<select id="em-move-target">
|
|
<option value="">(keep in current folder)</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<div id="em-error" style="color:var(--primary);margin-top:0.5em"></div>
|
|
<div class="modal-actions">
|
|
<button id="em-save">Save</button>
|
|
<button class="btn-cancel" id="em-cancel">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Credential prompt modal -->
|
|
<div class="modal-overlay" id="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 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){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'});
|
|
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;}
|
|
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 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';
|
|
var level = roleLevel[data.role] || 0;
|
|
if (level < 3) {
|
|
document.getElementById('sessions-link').style.display = 'none';
|
|
}
|
|
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();
|
|
})
|
|
.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, '&').replace(/"/g, '"').replace(/'/g, ''').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
// Connect to an entry (with optional credential prompt)
|
|
var pendingConnect = null; // { scope, folder, name, btn }
|
|
|
|
function doConnect(scope, folder, name, btn, extraBody) {
|
|
btn.disabled = true;
|
|
btn.textContent = 'Connecting...';
|
|
clearError();
|
|
var body = extraBody || {};
|
|
// Send browser dimensions so RDP sessions start at correct resolution
|
|
var bw = window.innerWidth, bh = window.innerHeight;
|
|
if (!body.width && bw > 0 && bh > 0) {
|
|
body.width = bw;
|
|
body.height = bh;
|
|
if (!body.dpi) body.dpi = Math.round((window.devicePixelRatio || 1) * 96);
|
|
}
|
|
fetch('/api/addressbook/folders/' + encodeURIComponent(scope) + '/' + encodeURIComponent(folder) + '/entries/' + encodeURIComponent(name) + '/connect', {
|
|
method: 'POST',
|
|
headers: apiHeaders({ 'Content-Type': 'application/json' }),
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify(body)
|
|
})
|
|
.then(function(res) {
|
|
if (!res.ok) 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) {
|
|
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 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 = '▼';
|
|
} else {
|
|
fields.style.display = 'none';
|
|
arrow.innerHTML = '▶';
|
|
}
|
|
});
|
|
|
|
// Toggle RemoteApp fields visibility
|
|
document.getElementById('em-remoteapp-toggle').addEventListener('click', function() {
|
|
var fields = document.getElementById('em-remoteapp-fields');
|
|
var arrow = document.getElementById('em-remoteapp-arrow');
|
|
if (fields.style.display === 'none') {
|
|
fields.style.display = '';
|
|
arrow.innerHTML = '▼';
|
|
} else {
|
|
fields.style.display = 'none';
|
|
arrow.innerHTML = '▶';
|
|
}
|
|
});
|
|
|
|
// Toggle Recording Settings fields visibility
|
|
document.getElementById('em-recording-toggle').addEventListener('click', function() {
|
|
var fields = document.getElementById('em-recording-fields');
|
|
var arrow = document.getElementById('em-recording-arrow');
|
|
if (fields.style.display === 'none') {
|
|
fields.style.display = '';
|
|
arrow.innerHTML = '▼';
|
|
} else {
|
|
fields.style.display = 'none';
|
|
arrow.innerHTML = '▶';
|
|
}
|
|
});
|
|
|
|
// Toggle recording sub-fields when override checkbox changes
|
|
document.getElementById('em-override-recording').addEventListener('change', function() {
|
|
document.getElementById('em-recording-sub').style.display = this.checked ? '' : 'none';
|
|
});
|
|
|
|
// Toggle Autofill fields visibility
|
|
document.getElementById('em-automation-toggle').addEventListener('click', function() {
|
|
var fields = document.getElementById('em-automation-fields');
|
|
var arrow = document.getElementById('em-automation-arrow');
|
|
if (fields.style.display === 'none') {
|
|
fields.style.display = '';
|
|
arrow.innerHTML = '▼';
|
|
} else {
|
|
fields.style.display = 'none';
|
|
arrow.innerHTML = '▶';
|
|
}
|
|
});
|
|
|
|
document.getElementById('em-autofill-toggle').addEventListener('click', function() {
|
|
var fields = document.getElementById('em-autofill-fields');
|
|
var arrow = document.getElementById('em-autofill-arrow');
|
|
if (fields.style.display === 'none') {
|
|
fields.style.display = '';
|
|
arrow.innerHTML = '▼';
|
|
} else {
|
|
fields.style.display = 'none';
|
|
arrow.innerHTML = '▶';
|
|
}
|
|
});
|
|
|
|
// ── Autofill row management ──
|
|
var autofillRows = []; // Array of {url, username, password}
|
|
|
|
function renderAutofillRows() {
|
|
var container = document.getElementById('em-autofill-rows');
|
|
container.innerHTML = '';
|
|
autofillRows.forEach(function(row, i) {
|
|
var div = document.createElement('div');
|
|
div.style.cssText = 'display:flex;gap:0.4em;align-items:center;margin-bottom:0.3em';
|
|
div.innerHTML = '<input type="text" placeholder="https://example.com" value="' + escapeHtml(row.url) + '" style="flex:2;padding:0.3em 0.5em;font-size:0.9em;background:var(--input);color:var(--text);border:1px solid var(--border);border-radius:4px" data-af-idx="' + i + '" data-af-field="url">'
|
|
+ '<input type="text" placeholder="$USERNAME" value="' + escapeHtml(row.username) + '" style="flex:1;padding:0.3em 0.5em;font-size:0.9em;background:var(--input);color:var(--text);border:1px solid var(--border);border-radius:4px" data-af-idx="' + i + '" data-af-field="username">'
|
|
+ '<input type="text" placeholder="$PASSWORD" value="' + escapeHtml(row.password) + '" style="flex:1;padding:0.3em 0.5em;font-size:0.9em;background:var(--input);color:var(--text);border:1px solid var(--border);border-radius:4px" data-af-idx="' + i + '" data-af-field="password">'
|
|
+ '<button type="button" style="background:none;border:none;color:var(--primary);cursor:pointer;font-size:1.1em;padding:0 0.3em" data-af-remove="' + i + '" title="Remove">×</button>';
|
|
container.appendChild(div);
|
|
});
|
|
// Bind input change events
|
|
container.querySelectorAll('[data-af-field]').forEach(function(input) {
|
|
input.addEventListener('input', function() {
|
|
var idx = parseInt(this.getAttribute('data-af-idx'));
|
|
var field = this.getAttribute('data-af-field');
|
|
autofillRows[idx][field] = this.value;
|
|
});
|
|
});
|
|
container.querySelectorAll('[data-af-remove]').forEach(function(btn) {
|
|
btn.addEventListener('click', function() {
|
|
var idx = parseInt(this.getAttribute('data-af-remove'));
|
|
autofillRows.splice(idx, 1);
|
|
renderAutofillRows();
|
|
});
|
|
});
|
|
}
|
|
|
|
document.getElementById('em-autofill-add').addEventListener('click', function() {
|
|
var urlVal = document.getElementById('em-url').value.trim();
|
|
autofillRows.push({ url: urlVal || 'https://', username: '$USERNAME', password: '$PASSWORD' });
|
|
renderAutofillRows();
|
|
// Auto-expand the section
|
|
document.getElementById('em-autofill-fields').style.display = '';
|
|
document.getElementById('em-autofill-arrow').innerHTML = '▼';
|
|
});
|
|
|
|
// Toggle Allowed Domains fields visibility
|
|
document.getElementById('em-allowed-domains-toggle').addEventListener('click', function() {
|
|
var fields = document.getElementById('em-allowed-domains-fields');
|
|
var arrow = document.getElementById('em-allowed-domains-arrow');
|
|
if (fields.style.display === 'none') {
|
|
fields.style.display = '';
|
|
arrow.innerHTML = '▼';
|
|
} else {
|
|
fields.style.display = 'none';
|
|
arrow.innerHTML = '▶';
|
|
}
|
|
});
|
|
|
|
// ── Allowed Domains management ──
|
|
var allowedDomains = []; // Array of strings
|
|
|
|
function renderAllowedDomains() {
|
|
var container = document.getElementById('em-allowed-domains-list');
|
|
container.innerHTML = '';
|
|
allowedDomains.forEach(function(domain, i) {
|
|
var div = document.createElement('div');
|
|
div.style.cssText = 'display:flex;gap:0.4em;align-items:center;margin-bottom:0.3em';
|
|
div.innerHTML = '<input type="text" placeholder="example.com" value="' + escapeHtml(domain) + '" style="flex:1;padding:0.3em 0.5em;font-size:0.9em;background:var(--input);color:var(--text);border:1px solid var(--border);border-radius:4px" data-ad-idx="' + i + '">'
|
|
+ '<button type="button" style="background:none;border:none;color:var(--primary);cursor:pointer;font-size:1.1em;padding:0 0.3em" data-ad-remove="' + i + '" title="Remove">×</button>';
|
|
container.appendChild(div);
|
|
});
|
|
container.querySelectorAll('[data-ad-idx]').forEach(function(input) {
|
|
input.addEventListener('input', function() {
|
|
var idx = parseInt(this.getAttribute('data-ad-idx'));
|
|
allowedDomains[idx] = this.value;
|
|
});
|
|
});
|
|
container.querySelectorAll('[data-ad-remove]').forEach(function(btn) {
|
|
btn.addEventListener('click', function() {
|
|
var idx = parseInt(this.getAttribute('data-ad-remove'));
|
|
allowedDomains.splice(idx, 1);
|
|
renderAllowedDomains();
|
|
});
|
|
});
|
|
}
|
|
|
|
document.getElementById('em-allowed-domains-add').addEventListener('click', function() {
|
|
var urlVal = document.getElementById('em-url').value.trim();
|
|
var defaultDomain = '';
|
|
try { defaultDomain = new URL(urlVal).hostname; } catch(e) {}
|
|
allowedDomains.push(defaultDomain || '');
|
|
renderAllowedDomains();
|
|
document.getElementById('em-allowed-domains-fields').style.display = '';
|
|
document.getElementById('em-allowed-domains-arrow').innerHTML = '▼';
|
|
});
|
|
|
|
// ── Multi-hop management ──
|
|
var hopList = []; // Array of {hostname, port, username, password, private_key, expanded}
|
|
|
|
function renderHops() {
|
|
var container = document.getElementById('em-hops-list');
|
|
container.innerHTML = '';
|
|
hopList.forEach(function(hop, i) {
|
|
var card = document.createElement('div');
|
|
card.className = 'hop-card';
|
|
|
|
// Header
|
|
var header = document.createElement('div');
|
|
header.className = 'hop-card-header';
|
|
|
|
var num = document.createElement('span');
|
|
num.className = 'hop-num';
|
|
num.textContent = '#' + (i + 1);
|
|
header.appendChild(num);
|
|
|
|
var hostWrap = document.createElement('span');
|
|
hostWrap.className = 'hop-hostname';
|
|
var hostInput = document.createElement('input');
|
|
hostInput.type = 'text';
|
|
hostInput.placeholder = 'bastion.example.com';
|
|
hostInput.value = hop.hostname || '';
|
|
hostInput.setAttribute('data-hop', i);
|
|
hostInput.setAttribute('data-field', 'hostname');
|
|
hostInput.addEventListener('input', onHopFieldChange);
|
|
hostInput.addEventListener('click', function(e) { e.stopPropagation(); });
|
|
hostWrap.appendChild(hostInput);
|
|
header.appendChild(hostWrap);
|
|
|
|
var toggle = document.createElement('button');
|
|
toggle.type = 'button';
|
|
toggle.className = 'hop-remove';
|
|
toggle.textContent = hop.expanded ? 'collapse' : 'expand';
|
|
toggle.setAttribute('data-hop', i);
|
|
toggle.addEventListener('click', function(e) {
|
|
e.stopPropagation();
|
|
hopList[i].expanded = !hopList[i].expanded;
|
|
renderHops();
|
|
});
|
|
header.appendChild(toggle);
|
|
|
|
var removeBtn = document.createElement('button');
|
|
removeBtn.type = 'button';
|
|
removeBtn.className = 'hop-remove';
|
|
removeBtn.textContent = 'remove';
|
|
removeBtn.setAttribute('data-hop', i);
|
|
removeBtn.addEventListener('click', function(e) {
|
|
e.stopPropagation();
|
|
hopList.splice(i, 1);
|
|
renderHops();
|
|
updateFlowDiagram();
|
|
});
|
|
header.appendChild(removeBtn);
|
|
|
|
card.appendChild(header);
|
|
|
|
// Body (collapsible)
|
|
var body = document.createElement('div');
|
|
body.className = 'hop-card-body' + (hop.expanded ? ' expanded' : '');
|
|
|
|
body.innerHTML =
|
|
'<label>SSH Port<input type="number" data-hop="' + i + '" data-field="port" value="' + (hop.port || 22) + '"></label>' +
|
|
'<label>Username<input type="text" data-hop="' + i + '" data-field="username" value="' + escapeAttr(hop.username || '') + '"></label>' +
|
|
'<label>Password<input type="password" data-hop="' + i + '" data-field="password" value="' + escapeAttr(hop.password || '') + '"></label>' +
|
|
'<label>Private key (PEM)<textarea data-hop="' + i + '" data-field="private_key" rows="2" style="resize:vertical;font-size:0.85em" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----">' + escapeHtml(hop.private_key || '') + '</textarea></label>';
|
|
|
|
var inputs = body.querySelectorAll('input, textarea');
|
|
for (var j = 0; j < inputs.length; j++) {
|
|
inputs[j].addEventListener('input', onHopFieldChange);
|
|
}
|
|
|
|
card.appendChild(body);
|
|
container.appendChild(card);
|
|
});
|
|
updateFlowDiagram();
|
|
}
|
|
|
|
function onHopFieldChange(e) {
|
|
var idx = parseInt(e.target.getAttribute('data-hop'));
|
|
var field = e.target.getAttribute('data-field');
|
|
if (field === 'port') {
|
|
hopList[idx][field] = parseInt(e.target.value) || 22;
|
|
} else {
|
|
hopList[idx][field] = e.target.value;
|
|
}
|
|
if (field === 'hostname') updateFlowDiagram();
|
|
}
|
|
|
|
function updateFlowDiagram() {
|
|
var diag = document.getElementById('em-flow-diagram');
|
|
if (!diag) return;
|
|
var html = '<span class="flow-node flow-node-you">You</span>';
|
|
hopList.forEach(function(hop) {
|
|
var label = hop.hostname || '???';
|
|
html += '<span class="flow-arrow">→</span><span class="flow-node flow-node-hop">' + escapeHtml(label) + '</span>';
|
|
});
|
|
// Add target
|
|
var type = entryTypeSelect.value;
|
|
var targetHost = '';
|
|
var targetPort = '';
|
|
var typeLabel = type.toUpperCase();
|
|
if (type === 'ssh') {
|
|
targetHost = document.getElementById('em-hostname').value || '???';
|
|
targetPort = document.getElementById('em-port').value || '22';
|
|
} else if (type === 'rdp') {
|
|
targetHost = document.getElementById('em-rdp-hostname').value || '???';
|
|
targetPort = document.getElementById('em-rdp-port').value || '3389';
|
|
} else if (type === 'vnc') {
|
|
targetHost = document.getElementById('em-vnc-hostname').value || '???';
|
|
targetPort = document.getElementById('em-vnc-port').value || '5900';
|
|
} else if (type === 'web') {
|
|
try {
|
|
var u = new URL(document.getElementById('em-url').value);
|
|
targetHost = u.hostname || '???';
|
|
targetPort = u.port || (u.protocol === 'https:' ? '443' : '80');
|
|
} catch(e) {
|
|
targetHost = '???';
|
|
targetPort = '80';
|
|
}
|
|
}
|
|
if (targetHost) {
|
|
html += '<span class="flow-arrow">→</span><span class="flow-node flow-node-target">' + escapeHtml(targetHost) + ':' + escapeHtml(targetPort) + ' ' + typeLabel + '</span>';
|
|
}
|
|
// Show URL rewrite warning for web sessions with hops
|
|
var warn = '';
|
|
if (type === 'web' && hopList.length > 0) {
|
|
warn = '<div style="color:var(--primary);font-size:0.8em;margin-top:0.4em">Note: The URL will be rewritten to 127.0.0.1:{tunnel_port} inside the headless browser. TLS certificate errors are expected if the target uses HTTPS.</div>';
|
|
}
|
|
diag.innerHTML = hopList.length > 0 ? html + warn : '';
|
|
}
|
|
|
|
document.getElementById('em-add-hop').addEventListener('click', function() {
|
|
hopList.push({ hostname: '', port: 22, username: '', password: '', private_key: '', expanded: true });
|
|
renderHops();
|
|
});
|
|
|
|
function openNewEntry() {
|
|
if (!selectedFolder) return;
|
|
entryEditMode = null;
|
|
document.getElementById('entry-modal-title').textContent = 'New Entry in ' + selectedFolder.name;
|
|
document.getElementById('em-name').value = '';
|
|
document.getElementById('em-name').disabled = false;
|
|
document.getElementById('em-display-name').value = '';
|
|
entryTypeSelect.value = 'ssh';
|
|
entryTypeSelect.disabled = false;
|
|
entryTypeSelect.dispatchEvent(new Event('change'));
|
|
clearEntryFields();
|
|
document.getElementById('em-move-section').style.display = 'none';
|
|
document.getElementById('em-error').textContent = '';
|
|
document.getElementById('entry-modal').classList.add('active');
|
|
}
|
|
|
|
document.getElementById('btn-new-entry').addEventListener('click', openNewEntry);
|
|
|
|
function clearEntryFields() {
|
|
document.getElementById('em-hostname').value = '';
|
|
document.getElementById('em-port').value = '22';
|
|
document.getElementById('em-username').value = '';
|
|
document.getElementById('em-password').value = '';
|
|
document.getElementById('em-private-key').value = '';
|
|
document.getElementById('em-rdp-hostname').value = '';
|
|
document.getElementById('em-rdp-port').value = '3389';
|
|
document.getElementById('em-rdp-username').value = '';
|
|
document.getElementById('em-rdp-password').value = '';
|
|
document.getElementById('em-rdp-domain').value = '';
|
|
document.getElementById('em-rdp-security').value = '';
|
|
document.getElementById('em-rdp-ignore-cert').checked = false;
|
|
document.getElementById('em-rdp-auth-pkg').value = '';
|
|
document.getElementById('em-rdp-kdc-url').value = '';
|
|
document.getElementById('em-rdp-prompt-creds').checked = false;
|
|
document.getElementById('em-ssh-prompt-creds').checked = false;
|
|
document.getElementById('em-vnc-hostname').value = '';
|
|
document.getElementById('em-vnc-port').value = '5900';
|
|
document.getElementById('em-vnc-password').value = '';
|
|
document.getElementById('em-vnc-color-depth').value = '';
|
|
document.getElementById('em-vnc-prompt-creds').checked = false;
|
|
document.getElementById('em-url').value = '';
|
|
document.getElementById('em-banner').value = '';
|
|
document.getElementById('em-web-username').value = '';
|
|
document.getElementById('em-web-password').value = '';
|
|
document.getElementById('em-login-script').value = '';
|
|
document.getElementById('em-automation-fields').style.display = 'none';
|
|
document.getElementById('em-automation-arrow').innerHTML = '▶';
|
|
autofillRows = [];
|
|
renderAutofillRows();
|
|
document.getElementById('em-autofill-fields').style.display = 'none';
|
|
document.getElementById('em-autofill-arrow').innerHTML = '▶';
|
|
allowedDomains = [];
|
|
renderAllowedDomains();
|
|
document.getElementById('em-allowed-domains-fields').style.display = 'none';
|
|
document.getElementById('em-allowed-domains-arrow').innerHTML = '▶';
|
|
document.getElementById('em-disable-copy').checked = false;
|
|
document.getElementById('em-disable-paste').checked = false;
|
|
document.getElementById('em-enable-drive').checked = false;
|
|
document.getElementById('em-remote-app').value = '';
|
|
document.getElementById('em-remote-app-dir').value = '';
|
|
document.getElementById('em-remote-app-args').value = '';
|
|
document.getElementById('em-remoteapp-fields').style.display = 'none';
|
|
document.getElementById('em-remoteapp-arrow').innerHTML = '▶';
|
|
document.getElementById('em-override-recording').checked = false;
|
|
document.getElementById('em-enable-recording').checked = true;
|
|
document.getElementById('em-max-recordings').value = '0';
|
|
document.getElementById('em-recording-sub').style.display = 'none';
|
|
document.getElementById('em-recording-fields').style.display = 'none';
|
|
document.getElementById('em-recording-arrow').innerHTML = '▶';
|
|
hopList = [];
|
|
renderHops();
|
|
document.getElementById('em-tunnel-fields').style.display = 'none';
|
|
document.getElementById('em-tunnel-arrow').innerHTML = '▶';
|
|
}
|
|
|
|
function openEditEntry(name, scope, folder, type) {
|
|
entryEditMode = { scope: scope, folder: folder, name: name };
|
|
document.getElementById('entry-modal-title').textContent = 'Edit: ' + name;
|
|
document.getElementById('em-name').value = name;
|
|
document.getElementById('em-name').disabled = true;
|
|
document.getElementById('em-display-name').value = '';
|
|
document.getElementById('em-error').textContent = '';
|
|
clearEntryFields();
|
|
|
|
// Pre-select the type from the table data
|
|
if (type) {
|
|
entryTypeSelect.value = type;
|
|
entryTypeSelect.disabled = true;
|
|
entryTypeSelect.dispatchEvent(new Event('change'));
|
|
}
|
|
|
|
// Populate move-to-folder dropdown
|
|
var moveSelect = document.getElementById('em-move-target');
|
|
moveSelect.innerHTML = '<option value="">(keep in current folder)</option>';
|
|
folders.forEach(function(f) {
|
|
if (f.name !== folder || f.scope !== scope) {
|
|
var opt = document.createElement('option');
|
|
opt.value = f.scope + '/' + f.name;
|
|
opt.textContent = f.name + ' (' + f.scope + ')';
|
|
moveSelect.appendChild(opt);
|
|
}
|
|
});
|
|
document.getElementById('em-move-section').style.display = folders.length > 1 ? '' : 'none';
|
|
|
|
// Pre-fill with existing entry data from the loaded entries
|
|
var entryData = null;
|
|
for (var i = 0; i < currentEntries.length; i++) {
|
|
if (currentEntries[i].name === name) {
|
|
entryData = currentEntries[i];
|
|
break;
|
|
}
|
|
}
|
|
if (entryData) {
|
|
if (entryData.display_name && entryData.display_name !== name) {
|
|
document.getElementById('em-display-name').value = entryData.display_name;
|
|
}
|
|
if (type === 'ssh') {
|
|
document.getElementById('em-hostname').value = entryData.hostname || '';
|
|
document.getElementById('em-port').value = entryData.port || '22';
|
|
document.getElementById('em-username').value = entryData.username || '';
|
|
document.getElementById('em-ssh-prompt-creds').checked = !!entryData.prompt_credentials;
|
|
} else if (type === 'rdp') {
|
|
document.getElementById('em-rdp-hostname').value = entryData.hostname || '';
|
|
document.getElementById('em-rdp-port').value = entryData.port || '3389';
|
|
document.getElementById('em-rdp-username').value = entryData.username || '';
|
|
document.getElementById('em-rdp-domain').value = entryData.domain || '';
|
|
if (entryData.security) {
|
|
document.getElementById('em-rdp-security').value = entryData.security;
|
|
}
|
|
document.getElementById('em-rdp-ignore-cert').checked = !!entryData.ignore_cert;
|
|
document.getElementById('em-rdp-auth-pkg').value = entryData.auth_pkg || '';
|
|
document.getElementById('em-rdp-kdc-url').value = entryData.kdc_url || '';
|
|
document.getElementById('em-rdp-prompt-creds').checked = !!entryData.prompt_credentials;
|
|
} else if (type === 'vnc') {
|
|
document.getElementById('em-vnc-hostname').value = entryData.hostname || '';
|
|
document.getElementById('em-vnc-port').value = entryData.port || '5900';
|
|
document.getElementById('em-vnc-color-depth').value = entryData.color_depth ? String(entryData.color_depth) : '';
|
|
document.getElementById('em-vnc-prompt-creds').checked = !!entryData.prompt_credentials;
|
|
} else if (type === 'web') {
|
|
document.getElementById('em-url').value = entryData.url || '';
|
|
document.getElementById('em-banner').value = entryData.banner || '';
|
|
document.getElementById('em-web-username').value = entryData.username || '';
|
|
loadLoginScripts(function() { populateLoginScriptDropdown(entryData.login_script || ''); });
|
|
// Auto-expand automation if any automation fields are set
|
|
if (entryData.username || entryData.login_script || entryData.autofill) {
|
|
document.getElementById('em-automation-fields').style.display = '';
|
|
document.getElementById('em-automation-arrow').innerHTML = '▼';
|
|
}
|
|
// Populate autofill rows
|
|
if (entryData.autofill) {
|
|
try {
|
|
var afData = typeof entryData.autofill === 'string' ? JSON.parse(entryData.autofill) : entryData.autofill;
|
|
if (Array.isArray(afData) && afData.length > 0) {
|
|
autofillRows = afData.map(function(r) {
|
|
return { url: r.url || '', username: r.username || '$USERNAME', password: r.password || '$PASSWORD' };
|
|
});
|
|
renderAutofillRows();
|
|
document.getElementById('em-autofill-fields').style.display = '';
|
|
document.getElementById('em-autofill-arrow').innerHTML = '▼';
|
|
}
|
|
} catch(e) { /* ignore bad JSON */ }
|
|
}
|
|
// Populate allowed domains
|
|
if (entryData.allowed_domains && Array.isArray(entryData.allowed_domains) && entryData.allowed_domains.length > 0) {
|
|
allowedDomains = entryData.allowed_domains.slice();
|
|
renderAllowedDomains();
|
|
document.getElementById('em-allowed-domains-fields').style.display = '';
|
|
document.getElementById('em-allowed-domains-arrow').innerHTML = '▼';
|
|
}
|
|
}
|
|
if (type === 'ssh' || type === 'rdp') {
|
|
document.getElementById('em-enable-drive').checked = !!entryData.enable_drive;
|
|
}
|
|
// Populate RemoteApp fields (RDP)
|
|
if (type === 'rdp' && (entryData.remote_app || entryData.remote_app_dir || entryData.remote_app_args)) {
|
|
document.getElementById('em-remote-app').value = entryData.remote_app || '';
|
|
document.getElementById('em-remote-app-dir').value = entryData.remote_app_dir || '';
|
|
document.getElementById('em-remote-app-args').value = entryData.remote_app_args || '';
|
|
document.getElementById('em-remoteapp-fields').style.display = '';
|
|
document.getElementById('em-remoteapp-arrow').innerHTML = '▼';
|
|
}
|
|
// Populate recording override fields
|
|
if (entryData.enable_recording !== null && entryData.enable_recording !== undefined) {
|
|
document.getElementById('em-override-recording').checked = true;
|
|
document.getElementById('em-recording-sub').style.display = '';
|
|
document.getElementById('em-enable-recording').checked = entryData.enable_recording !== false;
|
|
document.getElementById('em-max-recordings').value = entryData.max_recordings || '0';
|
|
document.getElementById('em-recording-fields').style.display = '';
|
|
document.getElementById('em-recording-arrow').innerHTML = '▼';
|
|
} else if (entryData.max_recordings) {
|
|
document.getElementById('em-override-recording').checked = true;
|
|
document.getElementById('em-recording-sub').style.display = '';
|
|
document.getElementById('em-enable-recording').checked = true;
|
|
document.getElementById('em-max-recordings').value = entryData.max_recordings;
|
|
document.getElementById('em-recording-fields').style.display = '';
|
|
document.getElementById('em-recording-arrow').innerHTML = '▼';
|
|
}
|
|
// Populate clipboard settings
|
|
document.getElementById('em-disable-copy').checked = !!entryData.disable_copy;
|
|
document.getElementById('em-disable-paste').checked = !!entryData.disable_paste;
|
|
// Populate hop list from jump_hosts array
|
|
if (entryData.jump_hosts && entryData.jump_hosts.length > 0) {
|
|
hopList = entryData.jump_hosts.map(function(h) {
|
|
return { hostname: h.hostname || '', port: h.port || 22, username: h.username || '', password: '', private_key: '', expanded: false };
|
|
});
|
|
renderHops();
|
|
// Auto-expand the tunnel section
|
|
document.getElementById('em-tunnel-fields').style.display = '';
|
|
document.getElementById('em-tunnel-arrow').innerHTML = '▼';
|
|
}
|
|
}
|
|
|
|
document.getElementById('entry-modal').classList.add('active');
|
|
}
|
|
|
|
document.getElementById('em-cancel').addEventListener('click', function() {
|
|
document.getElementById('entry-modal').classList.remove('active');
|
|
entryTypeSelect.disabled = false;
|
|
});
|
|
|
|
document.getElementById('em-save').addEventListener('click', function() {
|
|
var name = document.getElementById('em-name').value.trim();
|
|
var displayName = document.getElementById('em-display-name').value.trim();
|
|
var type = entryTypeSelect.value;
|
|
var errEl = document.getElementById('em-error');
|
|
|
|
if (!name || !/^[a-zA-Z0-9_.-]+$/.test(name)) {
|
|
errEl.textContent = 'Name must be alphanumeric, hyphens, underscores, dots only.';
|
|
return;
|
|
}
|
|
|
|
var entry = { type: type };
|
|
if (displayName) entry.display_name = displayName;
|
|
|
|
if (type === 'ssh') {
|
|
var h = document.getElementById('em-hostname').value.trim();
|
|
if (h) entry.hostname = h;
|
|
var p = parseInt(document.getElementById('em-port').value);
|
|
if (p) entry.port = p;
|
|
var u = document.getElementById('em-username').value.trim();
|
|
if (u) entry.username = u;
|
|
var pw = document.getElementById('em-password').value;
|
|
if (pw) entry.password = pw;
|
|
var pk = document.getElementById('em-private-key').value.trim();
|
|
if (pk) entry.private_key = pk;
|
|
if (document.getElementById('em-ssh-prompt-creds').checked) entry.prompt_credentials = true;
|
|
} else if (type === 'rdp') {
|
|
var h = document.getElementById('em-rdp-hostname').value.trim();
|
|
if (h) entry.hostname = h;
|
|
var p = parseInt(document.getElementById('em-rdp-port').value);
|
|
if (p) entry.port = p;
|
|
var u = document.getElementById('em-rdp-username').value.trim();
|
|
if (u) entry.username = u;
|
|
var pw = document.getElementById('em-rdp-password').value;
|
|
if (pw) entry.password = pw;
|
|
var dom = document.getElementById('em-rdp-domain').value.trim();
|
|
if (dom) entry.domain = dom;
|
|
var sec = document.getElementById('em-rdp-security').value;
|
|
if (sec) entry.security = sec;
|
|
entry.ignore_cert = document.getElementById('em-rdp-ignore-cert').checked;
|
|
var authPkg = document.getElementById('em-rdp-auth-pkg').value;
|
|
if (authPkg) entry.auth_pkg = authPkg;
|
|
var kdcUrl = document.getElementById('em-rdp-kdc-url').value.trim();
|
|
if (kdcUrl) entry.kdc_url = kdcUrl;
|
|
if (document.getElementById('em-rdp-prompt-creds').checked) entry.prompt_credentials = true;
|
|
} else if (type === 'vnc') {
|
|
var h = document.getElementById('em-vnc-hostname').value.trim();
|
|
if (h) entry.hostname = h;
|
|
var p = parseInt(document.getElementById('em-vnc-port').value);
|
|
if (p) entry.port = p;
|
|
var pw = document.getElementById('em-vnc-password').value;
|
|
if (pw) entry.password = pw;
|
|
var cd = document.getElementById('em-vnc-color-depth').value;
|
|
if (cd) entry.color_depth = parseInt(cd);
|
|
if (document.getElementById('em-vnc-prompt-creds').checked) entry.prompt_credentials = true;
|
|
} else if (type === 'web') {
|
|
var url = document.getElementById('em-url').value.trim();
|
|
if (url) entry.url = url;
|
|
var bn = document.getElementById('em-banner').value.trim();
|
|
if (bn) entry.banner = bn;
|
|
var wu = document.getElementById('em-web-username').value.trim();
|
|
if (wu) entry.username = wu;
|
|
var wpw = document.getElementById('em-web-password').value;
|
|
if (wpw) entry.password = wpw;
|
|
var ls = document.getElementById('em-login-script').value.trim();
|
|
if (ls) entry.login_script = ls;
|
|
// Serialize autofill rows
|
|
var afValid = autofillRows.filter(function(r) { return r.url && r.url.trim(); });
|
|
if (afValid.length > 0) {
|
|
entry.autofill = JSON.stringify(afValid);
|
|
}
|
|
// Serialize allowed domains
|
|
var adValid = allowedDomains.filter(function(d) { return d && d.trim(); });
|
|
if (adValid.length > 0) {
|
|
entry.allowed_domains = adValid;
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
// 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;
|
|
});
|
|
}
|
|
});
|
|
|
|
init();
|
|
</script>
|
|
</body>
|
|
</html>
|