mirror of
https://github.com/sol1/rustguac.git
synced 2026-10-25 21:19:45 +00:00
429268cc3a
The RDP keyboard layout could only be set per connection, and VDI desktops never got one, so a Swiss or German user on a desktop set up for their layout typed with a US keymap: Y and Z swapped and no umlauts. Settings > My keyboard layout on the Connections page is remembered in that browser and sent with each connect as keyboard_layout. The server uses it for an RDP entry left at the server default and for VDI desktops; a layout set on the entry still wins, and a name guacd has no keymap for is dropped. The entry editor also lists the nine guacd layouts it was missing.
6374 lines
354 KiB
HTML
6374 lines
354 KiB
HTML
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>rustguac - Connections</title>
|
||
<link rel="stylesheet" href="/rustguac.css">
|
||
<style>
|
||
/* ============================================================
|
||
Connections — page-specific styles only. Shared design
|
||
system lives in /rustguac.css.
|
||
============================================================ */
|
||
|
||
/* Two-column connections layout */
|
||
.layout { display: flex; gap: var(--s-5); align-items: flex-start; }
|
||
.sidebar {
|
||
min-width: 280px; max-width: 340px; flex-shrink: 0;
|
||
border-right: 1px solid var(--border);
|
||
padding-right: var(--s-4);
|
||
}
|
||
.main { flex: 1; min-width: 0; padding: 0 var(--s-5); }
|
||
|
||
/* Compact "open in new window" companion to the Connect button — a small
|
||
icon, not a full-size button */
|
||
.btn-connect-win {
|
||
margin-left: var(--s-1);
|
||
min-width: 0; width: auto; flex: none;
|
||
padding: 0 var(--s-1);
|
||
font-size: var(--fz-md); line-height: 1;
|
||
vertical-align: middle;
|
||
}
|
||
/* Keep Connect + its icon sibling on one line, same centerline */
|
||
.connect-cell { white-space: nowrap; }
|
||
.connect-cell .btn-connect { vertical-align: middle; }
|
||
|
||
.sidebar-header {
|
||
display: flex; align-items: center; gap: var(--s-2);
|
||
margin-bottom: var(--s-3); padding-bottom: var(--s-2);
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.sidebar-header strong {
|
||
flex: 1; text-transform: uppercase; letter-spacing: 0.14em;
|
||
font-size: var(--fz-sm); color: var(--text-muted); font-weight: normal;
|
||
}
|
||
|
||
/* Folder tree */
|
||
.folder-list { list-style: none; padding: 0; margin: 0; }
|
||
.folder-list ul { list-style: none; padding: 0; margin: 0; }
|
||
.folder-list li { cursor: pointer; margin-bottom: 1px; }
|
||
.folder-list li:hover > .folder-row { background: var(--surface); color: var(--text); }
|
||
.folder-list li.selected > .folder-row { background: var(--input); color: var(--text); border-left-color: var(--accent); }
|
||
.folder-row {
|
||
display: flex; align-items: center; gap: var(--s-2);
|
||
padding: var(--s-2) var(--s-2) var(--s-2) var(--s-1);
|
||
border-left: 3px solid transparent;
|
||
min-height: var(--ctl-md);
|
||
font-size: var(--fz-md);
|
||
transition: background .1s;
|
||
}
|
||
.tree-chevron {
|
||
display: inline-flex; align-items: center; justify-content: center;
|
||
width: 32px; height: 32px; flex-shrink: 0;
|
||
color: var(--accent); user-select: none;
|
||
font-size: 20px; font-weight: bold;
|
||
border-radius: var(--radius); cursor: pointer;
|
||
transition: background .1s, color .1s;
|
||
}
|
||
.tree-chevron:hover { color: var(--primary); background: var(--input); }
|
||
.tree-chevron.expanded { color: var(--primary); }
|
||
.tree-children {
|
||
padding-left: var(--s-4) !important;
|
||
margin-left: 15px !important;
|
||
border-left: 1px dashed var(--border);
|
||
}
|
||
|
||
.folder-row-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; line-height: 1.3; }
|
||
.folder-name-row { display: flex; align-items: center; gap: var(--s-2); min-width: 0; }
|
||
.folder-name-row > span:first-child {
|
||
flex: 1 1 auto; min-width: 0;
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
font-size: var(--fz-md);
|
||
}
|
||
.folder-scope {
|
||
flex-shrink: 0;
|
||
display: inline-flex; align-items: center; justify-content: center;
|
||
width: 24px; height: 24px;
|
||
font-size: 20px; color: var(--accent);
|
||
line-height: 1; cursor: help;
|
||
}
|
||
.folder-scope::before { display: block; }
|
||
.folder-scope[data-scope="shared"]::before { content: "\2295"; }
|
||
.folder-scope[data-scope="instance"]::before { content: "\25A3"; }
|
||
.folder-list li.selected > .folder-row .folder-scope { color: var(--accent); }
|
||
.folder-count { font-size: var(--fz-xs); color: var(--text-dim); letter-spacing: 0.02em; }
|
||
.folder-count::before { content: "\203A "; color: var(--border); }
|
||
.folder-desc {
|
||
font-size: var(--fz-xs); color: var(--text-dim);
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
}
|
||
|
||
/* Entries table */
|
||
.entries-table { border-collapse: collapse; width: 100%; margin-top: var(--s-3); border: 1px solid var(--border); }
|
||
.entries-table th, .entries-table td {
|
||
text-align: left;
|
||
padding: var(--s-3) var(--s-4);
|
||
border-bottom: 1px solid var(--border);
|
||
font-size: var(--fz-md);
|
||
vertical-align: middle;
|
||
}
|
||
.entries-table th {
|
||
color: var(--text-muted); font-size: var(--fz-sm); font-weight: normal;
|
||
text-transform: uppercase; letter-spacing: 0.12em;
|
||
background: var(--surface); line-height: 1.6;
|
||
}
|
||
.entries-table tbody tr:nth-child(even) { background: rgba(255,255,255,0.02); }
|
||
.entries-table tbody tr:hover { background: var(--surface); }
|
||
.entries-table tbody tr:last-child td { border-bottom: none; }
|
||
.entries-table td:last-child { text-align: right; white-space: nowrap; }
|
||
|
||
.entries-table .actions-cell { white-space: nowrap; text-align: right; }
|
||
/* Long names wrap inside their cell instead of widening the table past
|
||
the page: a dotted FQDN like db01.prod.example.internal has no
|
||
natural break point, so at any width a long enough one overran it
|
||
whatever the padding (#252). Buttons stay on one line. */
|
||
.entries-table td { overflow-wrap: anywhere; }
|
||
.entries-table td.connect-cell, .entries-table td.actions-cell { overflow-wrap: normal; }
|
||
.entries-table .actions-cell .btn-small { margin-left: var(--s-1); }
|
||
|
||
/* Bulk selection (admins). */
|
||
.entries-table .sel-cell { width: 2em; text-align: center; padding-right: 0; }
|
||
.entries-table .sel-cell input { width: auto; margin: 0; cursor: pointer; }
|
||
.entries-table tbody tr.selected { background: var(--surface); }
|
||
#bulk-bar {
|
||
position: sticky; bottom: 0; z-index: 2;
|
||
display: none; align-items: center; gap: var(--s-2);
|
||
margin-top: var(--s-2); padding: var(--s-2) var(--s-3);
|
||
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
|
||
}
|
||
#bulk-bar.active { display: flex; }
|
||
#bulk-bar .bulk-count { font-weight: bold; margin-right: auto; }
|
||
.bulk-folder-list {
|
||
max-height: 40vh; overflow-y: auto; margin-top: var(--s-1);
|
||
border: 1px solid var(--border); border-radius: var(--radius);
|
||
}
|
||
.bulk-folder-list label {
|
||
display: flex; align-items: center; gap: 0.5em; margin: 0;
|
||
padding: 0.3em 0.6em; cursor: pointer; text-transform: none; letter-spacing: 0;
|
||
}
|
||
.bulk-folder-list label:hover { background: var(--surface); }
|
||
.bulk-folder-list input { width: auto; margin: 0; }
|
||
.bulk-folder-list .scope-tag { margin-left: auto; color: var(--text-dim); font-size: var(--fz-sm); }
|
||
.bulk-folder-list .bulk-current { color: var(--text-dim); }
|
||
.bulk-preview { margin-top: var(--s-3); font-size: var(--fz-sm); line-height: 1.5; }
|
||
.bulk-preview .bulk-warn { color: var(--status-pending); }
|
||
.bulk-conflict label { display: flex; align-items: center; gap: 0.5em; text-transform: none; letter-spacing: 0; cursor: pointer; }
|
||
.bulk-conflict input { width: auto; margin: 0; }
|
||
.bulk-results ul { margin: var(--s-2) 0 0 0; padding-left: 1.2em; font-size: var(--fz-sm); max-height: 30vh; overflow-y: auto; }
|
||
.bulk-results .bulk-bad { color: var(--primary); }
|
||
#bulk-confirm.bulk-danger { color: var(--status-error); border-color: var(--status-error); }
|
||
#bulk-confirm.bulk-danger:hover:not(:disabled) { color: var(--bg); background: var(--status-error); }
|
||
|
||
/* Folder action bar */
|
||
.folder-actions {
|
||
display: flex; align-items: flex-start; gap: var(--s-3);
|
||
padding-bottom: var(--s-2);
|
||
border-bottom: 1px solid var(--border);
|
||
flex-wrap: wrap;
|
||
}
|
||
.folder-actions strong {
|
||
font-size: var(--fz-xl); color: var(--text);
|
||
font-weight: bold; letter-spacing: 0.02em; line-height: 1.2;
|
||
}
|
||
.folder-actions-btns {
|
||
display: flex; align-items: center; gap: var(--s-2);
|
||
flex-wrap: wrap; flex-shrink: 0; margin-left: auto;
|
||
}
|
||
.entries-heading {
|
||
flex: 1 1 auto; min-width: 0;
|
||
display: flex; flex-direction: column; gap: 2px;
|
||
}
|
||
.entries-title-row {
|
||
display: flex; align-items: baseline; gap: var(--s-3);
|
||
flex-wrap: wrap; min-width: 0;
|
||
}
|
||
.entries-key { color: var(--text-dim); font-size: var(--fz-xs); letter-spacing: 0.04em; }
|
||
.entries-key:empty { display: none; }
|
||
.folder-desc.folder-key { letter-spacing: 0.04em; }
|
||
.folder-actions .desc {
|
||
color: var(--text-muted); font-size: var(--fz-sm); font-style: italic; min-width: 0;
|
||
}
|
||
|
||
/* Narrow desktops (a 1710px-wide MacBook display, 1600px monitors):
|
||
the 18px monospace table plus three padded action cells overran
|
||
the main column by ~300px, with "delete" cut off at the right.
|
||
Tighten the chrome rather than hide columns: smaller side
|
||
padding and sidebar, 16px cells with less padding, a narrower
|
||
Connect button. Everything then fits with ~250px to spare. */
|
||
@media (max-width: 1800px) {
|
||
/* page padding at this width lives in rustguac.css, shared by
|
||
every page, so switching tabs does not shift the gutter */
|
||
.layout { gap: var(--s-4); }
|
||
.sidebar { min-width: 240px; max-width: 280px; padding-right: var(--s-3); }
|
||
.entries-table th, .entries-table td {
|
||
padding: var(--s-2) var(--s-3);
|
||
font-size: var(--fz-sm);
|
||
}
|
||
.entries-table th { font-size: var(--fz-xs); letter-spacing: 0.08em; }
|
||
.btn-connect { min-width: 110px; padding: 0 var(--s-3); }
|
||
.btn-connect-win { min-width: 0; padding: 0 var(--s-1); }
|
||
.folder-actions strong { font-size: var(--fz-lg); }
|
||
.folder-actions .desc { font-size: var(--fz-sm); }
|
||
}
|
||
/* Folder modal: default credentials */
|
||
.fm-defaults { margin: var(--s-3) 0; border: 1px solid var(--border); border-radius: var(--radius); padding: var(--s-2) var(--s-3); }
|
||
.fm-defaults summary { cursor: pointer; color: var(--text); font-size: var(--fz-md); }
|
||
.fm-defaults-table { width: 100%; border-collapse: collapse; margin-top: var(--s-2); }
|
||
.fm-defaults-table th { text-align: left; font-weight: normal; color: var(--text-muted); font-size: var(--fz-sm); padding: var(--s-1) var(--s-2) var(--s-1) 0; width: 3em; vertical-align: top; }
|
||
.fm-defaults-table td { padding: var(--s-1); vertical-align: top; }
|
||
.fm-defaults-table input, .fm-defaults-table textarea { width: 100%; margin: 0; font-size: var(--fz-sm); }
|
||
.fm-defaults-table textarea { min-height: var(--ctl-sm); resize: vertical; }
|
||
.fmd-clear-label { display: flex; align-items: center; gap: var(--s-1); font-size: var(--fz-xs); color: var(--text-dim); white-space: nowrap; padding-top: var(--s-2); }
|
||
|
||
/* Active sessions strip */
|
||
.active-sessions { margin-bottom: var(--s-5); }
|
||
.session-grid { display: flex; gap: var(--s-3); overflow-x: auto; padding-bottom: var(--s-2); }
|
||
.session-card {
|
||
flex: 0 0 auto; width: 280px;
|
||
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
|
||
overflow: hidden; cursor: pointer; transition: border-color .15s;
|
||
}
|
||
.session-card:hover { border-color: var(--accent); }
|
||
.session-card-thumb { width: 100%; height: 158px; object-fit: cover; background: #111; display: block; }
|
||
.session-card-info { padding: var(--s-2) var(--s-3); font-size: var(--fz-sm); }
|
||
.session-card-info .card-name {
|
||
font-weight: bold; color: var(--text);
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
}
|
||
.session-card-info .card-meta {
|
||
color: var(--text-muted); font-size: var(--fz-xs);
|
||
margin-top: var(--s-1);
|
||
display: flex; gap: var(--s-1); align-items: center;
|
||
}
|
||
.session-card-info .card-meta .type-badge { font-size: 10px; min-width: 36px; }
|
||
.session-card.dormant { opacity: 0.75; }
|
||
.session-card.dormant .session-card-thumb { filter: brightness(0.7); }
|
||
.card-actions {
|
||
display: flex;
|
||
gap: var(--s-1);
|
||
padding: 0 var(--s-3) var(--s-2);
|
||
margin-top: var(--s-1);
|
||
}
|
||
.card-actions button {
|
||
height: 28px;
|
||
padding: 0 var(--s-2);
|
||
font-size: var(--fz-xs);
|
||
font-weight: normal;
|
||
}
|
||
.card-actions .btn-danger {
|
||
color: var(--primary);
|
||
border-color: var(--border);
|
||
}
|
||
.card-actions .btn-danger:hover {
|
||
color: var(--text-on-primary);
|
||
background: var(--primary);
|
||
border-color: var(--primary);
|
||
}
|
||
|
||
/* Share URL display in the share modal */
|
||
.share-url-box {
|
||
display: flex;
|
||
align-items: stretch;
|
||
gap: var(--s-2);
|
||
margin-top: var(--s-1);
|
||
}
|
||
.share-url-box code {
|
||
flex: 1;
|
||
min-width: 0;
|
||
padding: var(--s-2) var(--s-3);
|
||
background: var(--input);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
font-size: var(--fz-sm);
|
||
word-break: break-all;
|
||
user-select: all;
|
||
overflow-x: auto;
|
||
white-space: nowrap;
|
||
}
|
||
.share-url-box button {
|
||
flex-shrink: 0;
|
||
}
|
||
.share-warning {
|
||
margin-top: var(--s-3);
|
||
padding: var(--s-2) var(--s-3);
|
||
border: 1px solid var(--status-pending);
|
||
border-left-width: 3px;
|
||
border-radius: var(--radius);
|
||
color: var(--status-pending);
|
||
font-size: var(--fz-sm);
|
||
line-height: 1.4;
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: var(--s-2);
|
||
}
|
||
.share-warning-icon {
|
||
font-size: var(--fz-md);
|
||
flex-shrink: 0;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
/* Chip-based group picker */
|
||
.chip-picker {
|
||
margin-top: var(--s-1);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
background: var(--input);
|
||
padding: var(--s-2);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--s-2);
|
||
}
|
||
.chip-list { display: flex; flex-wrap: wrap; gap: var(--s-1); min-height: 0; }
|
||
.chip-list:empty { display: none; }
|
||
.chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: var(--s-1);
|
||
padding: 2px var(--s-2);
|
||
background: var(--surface);
|
||
border: 1px solid var(--accent);
|
||
border-radius: var(--radius);
|
||
color: var(--accent);
|
||
font-size: var(--fz-sm);
|
||
letter-spacing: 0.04em;
|
||
}
|
||
.chip-remove {
|
||
background: none; border: none; padding: 0 0 0 var(--s-1);
|
||
color: var(--text-muted); cursor: pointer;
|
||
font-family: monospace; font-size: var(--fz-md);
|
||
font-weight: normal; line-height: 1; height: auto;
|
||
}
|
||
.chip-remove:hover { color: var(--primary); background: none; border-color: transparent; }
|
||
.chip-input-wrap {
|
||
display: flex;
|
||
gap: var(--s-2);
|
||
align-items: center;
|
||
}
|
||
.chip-input-wrap .combo { flex: 1; position: relative; }
|
||
.chip-input-wrap .combo input {
|
||
width: 100%;
|
||
margin-top: 0;
|
||
height: var(--ctl-sm);
|
||
background: var(--bg);
|
||
text-transform: none;
|
||
letter-spacing: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
.combo-menu {
|
||
display: none;
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
top: calc(var(--ctl-sm) + 2px);
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
max-height: 240px;
|
||
overflow-y: auto;
|
||
z-index: 120;
|
||
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
||
}
|
||
.combo.open .combo-menu { display: block; }
|
||
.combo-item {
|
||
padding: var(--s-2) var(--s-3);
|
||
font-size: var(--fz-sm);
|
||
color: var(--text);
|
||
cursor: pointer;
|
||
line-height: 1.3;
|
||
}
|
||
.combo-item:hover,
|
||
.combo-item.active {
|
||
background: var(--input);
|
||
color: var(--accent);
|
||
}
|
||
.combo-item.empty {
|
||
color: var(--text-dim);
|
||
cursor: default;
|
||
font-style: italic;
|
||
}
|
||
.combo-item.empty:hover { background: transparent; color: var(--text-dim); }
|
||
.combo-item .combo-hint {
|
||
color: var(--text-dim);
|
||
font-size: var(--fz-xs);
|
||
margin-left: var(--s-2);
|
||
}
|
||
|
||
/* Onboarding */
|
||
#onboard-body code { background: var(--input); padding: 1px var(--s-1); border-radius: var(--radius); font-size: 0.9em; }
|
||
.onboard-key {
|
||
display: inline-block; background: var(--surface);
|
||
border: 1px solid var(--border); border-radius: var(--radius);
|
||
padding: 1px var(--s-1); font-size: 0.85em; color: var(--text);
|
||
}
|
||
.onboard-hl { color: var(--accent); font-weight: bold; }
|
||
|
||
/* UX (#254) layout: sidebar scroll, indent, density */
|
||
/* Sidebar is sticky and scrolls on its own so a long folder tree never
|
||
drags the entries table off screen; .main keeps scrolling with the page. */
|
||
.sidebar {
|
||
position: sticky; top: var(--s-3);
|
||
align-self: flex-start;
|
||
max-height: calc(100vh - var(--s-3) * 2);
|
||
overflow-y: auto; overflow-x: hidden;
|
||
overscroll-behavior: contain;
|
||
scrollbar-width: thin;
|
||
scrollbar-color: var(--border) transparent;
|
||
}
|
||
.sidebar::-webkit-scrollbar { width: 6px; }
|
||
.sidebar::-webkit-scrollbar-track { background: transparent; }
|
||
.sidebar::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||
/* Header + tools slot pinned at the top of the sidebar while the list scrolls */
|
||
.sidebar-pin {
|
||
position: sticky; top: 0; z-index: 2;
|
||
display: flex; flex-direction: column;
|
||
background: var(--bg);
|
||
}
|
||
.sidebar-pin .sidebar-header { margin-bottom: var(--s-2); }
|
||
#sidebar-tools:not(:empty) { padding-bottom: var(--s-2); }
|
||
|
||
/* Rows start flush with the sidebar edge: the 3px accent bar is the only
|
||
thing before the content. A chevron is rendered only for folders with
|
||
children, so leaves carry no dead indent. */
|
||
.folder-row {
|
||
gap: var(--s-1);
|
||
padding: var(--s-1) var(--s-2) var(--s-1) var(--s-2);
|
||
min-height: 40px;
|
||
font-size: var(--fz-sm);
|
||
color: var(--text-muted);
|
||
transition: background .1s, color .1s;
|
||
}
|
||
.folder-list li:hover > .folder-row { color: var(--text); }
|
||
.folder-list li.selected > .folder-row { color: var(--text); }
|
||
.folder-list li.selected > .folder-row .folder-name { color: var(--text); font-weight: bold; }
|
||
.tree-chevron {
|
||
width: 22px; height: 28px; margin-left: -2px;
|
||
font-size: 13px;
|
||
}
|
||
.tree-children {
|
||
padding-left: 0 !important;
|
||
margin-left: 14px !important;
|
||
margin-top: 1px;
|
||
}
|
||
.folder-row-main { gap: 0; line-height: 1.3; }
|
||
.folder-name-row { gap: var(--s-2); }
|
||
.folder-name-row > .folder-name { font-size: var(--fz-sm); }
|
||
.folder-count {
|
||
flex-shrink: 0;
|
||
min-width: 1.6em; padding: 0 var(--s-1);
|
||
border-radius: 999px;
|
||
background: var(--surface);
|
||
color: var(--text-muted);
|
||
font-size: var(--fz-xs); line-height: 1.5;
|
||
text-align: center; letter-spacing: 0;
|
||
transition: background .1s, color .1s;
|
||
}
|
||
.folder-count::before { content: none; }
|
||
.folder-count[data-count="0"] { color: var(--text-dim); background: transparent; box-shadow: inset 0 0 0 1px var(--border); }
|
||
.folder-list li.selected > .folder-row .folder-count { background: var(--bg); color: var(--text); }
|
||
.folder-row .folder-scope {
|
||
width: 16px; height: 16px;
|
||
font-size: 16px;
|
||
color: var(--text-dim);
|
||
}
|
||
.folder-list li:hover > .folder-row .folder-scope,
|
||
.folder-list li.selected > .folder-row .folder-scope { color: var(--accent); }
|
||
.folder-desc { line-height: 1.25; }
|
||
|
||
/* UX (#254) selection feedback: jump to top, change animation, skeleton */
|
||
/* Title slides in from the left and the action bar's bottom border
|
||
flashes accent when the selected folder changes. The .changed class
|
||
is added by announceFolderShown() and removed on animationend so
|
||
it can replay on the next selection. */
|
||
#entries-header.changed #entries-title {
|
||
animation: rg-title-in 350ms ease-out both;
|
||
}
|
||
#entries-header.changed .folder-actions {
|
||
animation: rg-header-flash 700ms ease-out both;
|
||
}
|
||
@keyframes rg-title-in {
|
||
from { opacity: 0; transform: translateX(-4px); }
|
||
to { opacity: 1; transform: none; }
|
||
}
|
||
@keyframes rg-header-flash {
|
||
0% { border-bottom-color: var(--accent); }
|
||
60% { border-bottom-color: var(--accent); }
|
||
100% { border-bottom-color: var(--border); }
|
||
}
|
||
/* Skeleton: a real .entries-table so column padding, header height and
|
||
the 38px Connect-button row height are identical to the loaded
|
||
table; only the cells are replaced by shimmering bars. */
|
||
.entries-table.skeleton tbody tr:hover { background: inherit; }
|
||
.sk-bar {
|
||
display: inline-block; vertical-align: middle;
|
||
height: 1em; width: 60%; max-width: 100%;
|
||
border-radius: var(--radius);
|
||
background: var(--surface)
|
||
linear-gradient(90deg, transparent 0%, var(--input) 50%, transparent 100%)
|
||
no-repeat;
|
||
background-size: 45% 100%;
|
||
animation: rg-shimmer 1.2s ease-in-out infinite;
|
||
}
|
||
.sk-bar.sk-btn { height: var(--ctl-sm); width: 130px; }
|
||
.sk-bar.sk-badge { height: 1.4em; width: 3.2em; }
|
||
.sk-bar.sk-check { height: 16px; width: 16px; }
|
||
.entries-table.skeleton tbody tr:nth-child(2) td > .sk-bar:only-child:not([class*=" sk-"]) { width: 72%; }
|
||
.entries-table.skeleton tbody tr:nth-child(3) td > .sk-bar:only-child:not([class*=" sk-"]) { width: 48%; }
|
||
.entries-table.skeleton tbody tr:nth-child(4) td > .sk-bar:only-child:not([class*=" sk-"]) { width: 66%; }
|
||
@keyframes rg-shimmer {
|
||
from { background-position: -100% 0; }
|
||
to { background-position: 250% 0; }
|
||
}
|
||
@media (prefers-reduced-motion: reduce) {
|
||
#entries-header.changed #entries-title,
|
||
#entries-header.changed .folder-actions,
|
||
.sk-bar { animation: none; }
|
||
/* Still mark the change without motion: a static accent border
|
||
while .changed is on (flashEntriesHeader clears it by timer). */
|
||
#entries-header.changed .folder-actions { border-bottom-color: var(--accent); }
|
||
}
|
||
|
||
/* UX (#254) filter + keyboard */
|
||
#sidebar-tools { margin-bottom: var(--s-3); }
|
||
#sidebar-tools:empty { display: none; }
|
||
.folder-filter-wrap { position: relative; display: flex; align-items: center; }
|
||
#folder-filter {
|
||
flex: 1 1 auto; width: 100%; min-width: 0;
|
||
height: var(--ctl-sm); padding: 0 var(--s-2);
|
||
font-family: monospace; font-size: var(--fz-sm);
|
||
}
|
||
/* Native clear glyph is replaced by #folder-filter-clear (Firefox has none) */
|
||
#folder-filter::-webkit-search-cancel-button { -webkit-appearance: none; display: none; }
|
||
#folder-filter-count {
|
||
position: absolute; right: calc(var(--s-1) + 26px); top: 50%; transform: translateY(-50%);
|
||
font-size: var(--fz-xs); color: var(--text-dim); white-space: nowrap;
|
||
pointer-events: none; letter-spacing: 0.02em;
|
||
}
|
||
#folder-filter-count.no-match { color: var(--primary); }
|
||
#folder-filter-clear {
|
||
position: absolute; right: var(--s-1); top: 50%; transform: translateY(-50%);
|
||
width: 24px; height: 24px; padding: 0; margin: 0;
|
||
display: inline-flex; align-items: center; justify-content: center;
|
||
background: transparent; border: 0; border-radius: var(--radius);
|
||
color: var(--text-muted); font-family: monospace; font-size: var(--fz-md); line-height: 1;
|
||
cursor: pointer;
|
||
}
|
||
#folder-filter-clear:hover { color: var(--primary); background: var(--input); }
|
||
.folder-filter-wrap:not(.filtering) #folder-filter-count,
|
||
.folder-filter-wrap:not(.filtering) #folder-filter-clear { display: none; }
|
||
.folder-list li.filtered-out { display: none; }
|
||
.folder-row:focus { outline: none; }
|
||
.folder-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||
|
||
/* UX (#254) app shell: the header stays put, the sidebar and the
|
||
entries area are two independent scroll panes that always fit the
|
||
viewport. The earlier sticky sidebar was sized from the viewport,
|
||
not from where it starts below the header, so at the top of the
|
||
page its bottom hung off screen and the page had to be scrolled
|
||
before the list could be scrolled to its end. */
|
||
html, body { height: 100%; }
|
||
/* overflow: clip, not hidden: hidden still lets scripts scroll the
|
||
body (scrollIntoView on anything inside), which moved the header
|
||
out of view; clip makes the body unscrollable for good. The border
|
||
box is the full height so the page padding is inside the viewport. */
|
||
body { display: flex; flex-direction: column; overflow: clip; min-height: 0; box-sizing: border-box; }
|
||
#main-content { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
|
||
#main-content > :not(.layout) { flex: none; }
|
||
.layout { flex: 1 1 auto; min-height: 0; align-items: stretch; }
|
||
.sidebar {
|
||
position: static; top: auto; align-self: auto;
|
||
max-height: none; height: auto; min-height: 0;
|
||
display: flex; flex-direction: column;
|
||
overflow-y: auto; overflow-x: hidden;
|
||
padding: 0; margin: 0;
|
||
border: 1px solid var(--border); border-radius: var(--radius);
|
||
background: rgba(255,255,255,0.015);
|
||
}
|
||
.main { min-height: 0; overflow-y: auto; overflow-x: hidden; padding-left: var(--s-5); padding-right: var(--s-5); }
|
||
/* The folder header (name, description, search, actions, filters)
|
||
pins to the top of the entries pane, so it stays under the nav and
|
||
the active-sessions strip while a long table scrolls beneath it. */
|
||
#entries-header {
|
||
position: sticky; top: 0; z-index: 30;
|
||
background: var(--bg);
|
||
padding: var(--s-3) var(--s-4) var(--s-2);
|
||
margin-bottom: var(--s-3);
|
||
box-shadow: 0 6px 10px -8px rgba(0, 0, 0, 0.6);
|
||
}
|
||
#entries-header .folder-actions { margin: 0; }
|
||
.entries-toolbar {
|
||
display: flex; align-items: center; gap: var(--s-3);
|
||
flex-wrap: wrap; margin-top: var(--s-2);
|
||
}
|
||
.entries-toolbar #entries-filters { margin: 0; }
|
||
.entries-toolbar .folder-actions-btns { margin-left: auto; }
|
||
#entries-header .list-tools { margin-bottom: 0; }
|
||
|
||
/* Sidebar panel: one pinned header band (label, new-folder button,
|
||
filter) on a surface tone, then the list with hairline dividers.
|
||
Every band shares the same 12px horizontal padding so edges line up. */
|
||
.sidebar-pin {
|
||
background: var(--surface);
|
||
border-bottom: 1px solid var(--border);
|
||
padding: var(--s-2) var(--s-3);
|
||
gap: var(--s-2);
|
||
}
|
||
.sidebar-pin .sidebar-header {
|
||
margin: 0; padding: 0; border: 0;
|
||
min-height: 30px;
|
||
justify-content: space-between;
|
||
}
|
||
.sidebar-pin .sidebar-header strong { font-size: var(--fz-xs); letter-spacing: 0.14em; }
|
||
#btn-new-folder { height: 30px; padding: 0 var(--s-2); font-size: var(--fz-xs); }
|
||
#sidebar-tools { margin: 0; }
|
||
#sidebar-tools:not(:empty) { padding-bottom: 0; }
|
||
#folder-filter {
|
||
height: 34px; font-size: var(--fz-sm);
|
||
background: var(--bg); border: 1px solid var(--border);
|
||
}
|
||
.folder-list { padding: var(--s-1) 0 var(--s-2); }
|
||
.folder-list > li { margin: 0; }
|
||
.folder-list > li + li > .folder-row { border-top: 1px solid rgba(255,255,255,0.045); }
|
||
.folder-row {
|
||
padding: var(--s-2) var(--s-3) var(--s-2) calc(var(--s-3) - 3px);
|
||
min-height: 46px;
|
||
}
|
||
.folder-list li.selected > .folder-row { border-top-color: transparent; }
|
||
.folder-row-main { gap: 2px; }
|
||
.folder-desc { font-size: 12px; }
|
||
.tree-children { margin-left: var(--s-3) !important; }
|
||
/* Ad-hoc mode of the entry editor (Connect to a host): no name or
|
||
folder, Connect instead of Save, and for non-admins none of the
|
||
options only a saved entry may set. */
|
||
#entry-modal:not(.adhoc) .adhoc-only,
|
||
#entry-modal.adhoc .adhoc-hide,
|
||
#entry-modal.adhoc.adhoc-restricted .entry-only,
|
||
#entry-modal:not(.adhoc-restricted) .adhoc-restricted-only { display: none !important; }
|
||
/* A non-admin's ad-hoc request can't turn on a drive the site has off. */
|
||
#entry-modal.adhoc.adhoc-restricted.drive-off #em-drive-section { display: none !important; }
|
||
.var-pick { margin-top: 0.3em; font-size: 0.85em; width: auto; max-width: 100%; }
|
||
</style>
|
||
<script>(function(){var c=localStorage.getItem('rustguac_theme_colors');if(c){try{var o=JSON.parse(c),r=document.documentElement.style;for(var k in o)r.setProperty('--'+k.replace(/_/g,'-'),o[k]);if(o.bg_pattern&&o.bg_pattern!=='none'){var s=document.createElement('style');s.id='bg-pattern-style';s.textContent='body{background-image:'+o.bg_pattern+';background-attachment:fixed}';document.head.appendChild(s)}}catch(e){}}})();</script>
|
||
</head>
|
||
<body>
|
||
<div class="brand-row">
|
||
<img id="site-logo" src="/logo.svg" alt="">
|
||
<h1>rustguac</h1>
|
||
</div>
|
||
<nav>
|
||
<a href="/connections.html" class="active">Connections</a>
|
||
<a href="/sessions.html" id="sessions-link" data-min-role="poweruser">Sessions</a>
|
||
<a href="/recordings.html">Recordings</a>
|
||
<a href="/reports.html" id="reports-link" data-min-role="poweruser">Reports</a>
|
||
<a href="/docs.html">Docs</a>
|
||
<a href="/tokens.html" id="tokens-link" data-min-role="operator">Tokens</a>
|
||
<a href="/admin.html" id="admin-link" data-min-role="admin">Admin</a>
|
||
<a href="#" id="my-creds-nav" title="Manage your saved credential variables"><span class="nav-key">🔑</span> Credentials</a>
|
||
<span id="user-menu-wrapper">
|
||
<a href="#" id="logout-item">Logout</a>
|
||
<span id="user-menu-btn" title="Preferences">⚙ Settings</span>
|
||
<div id="user-menu">
|
||
<div class="um-section-label">Theme</div>
|
||
<div id="um-theme-list"></div>
|
||
<div class="um-divider"></div>
|
||
<div class="um-section-label">My keyboard layout</div>
|
||
<div class="um-kb" id="um-kb">
|
||
<select id="um-kb-layout" aria-label="My keyboard layout"></select>
|
||
<div class="um-kb-hint">For RDP and desktop connections that don't set their own. Remembered in this browser.</div>
|
||
</div>
|
||
<div class="um-divider"></div>
|
||
<div class="um-item" id="my-creds-item">🔑 My Credentials</div>
|
||
<div class="um-item" id="show-tour-item">ⓘ Welcome Tour</div>
|
||
</div>
|
||
</span>
|
||
</nav>
|
||
|
||
<div id="global-error"></div>
|
||
|
||
<div id="no-vault" class="no-vault" style="display:none">
|
||
Connections is not configured. A Vault/OpenBao backend is required.<br>
|
||
See <a href="/docs.html">Docs</a> for setup instructions.
|
||
</div>
|
||
|
||
<div id="vault-unavailable" class="no-vault" style="display:none">
|
||
<strong>Connections is temporarily unavailable.</strong><br>
|
||
Cannot reach the Vault server. The service will retry automatically every 30 seconds.<br>
|
||
Check the rustguac logs for details: <code>journalctl -u rustguac -f</code>
|
||
</div>
|
||
|
||
<div id="empty-state" class="empty-state" style="display:none">
|
||
<h3>No folders yet</h3>
|
||
<p>Create a folder to start organising connection entries.</p>
|
||
<button class="btn-connect" id="btn-empty-create-folder">Create First Folder</button>
|
||
</div>
|
||
|
||
<div id="main-content" style="display:none">
|
||
<div id="creds-banner" style="display:none;background:var(--input);border:1px solid var(--border);border-left:3px solid var(--status-pending);border-radius:var(--radius);padding:0.6em 0.9em;margin:0 0 0.8em 0;font-size:0.9em;display:none;align-items:center;gap:0.8em">
|
||
<span>🔑</span>
|
||
<span id="creds-banner-text" style="flex:1"></span>
|
||
<button class="btn-small" id="creds-banner-setup">Set up now</button>
|
||
<span id="creds-banner-dismiss" title="Dismiss" style="cursor:pointer;color:var(--text-dim);padding:0 0.3em">×</span>
|
||
</div>
|
||
<div id="active-sessions" class="active-sessions" style="display:none">
|
||
<div class="section-head">
|
||
<strong>Active Sessions</strong>
|
||
<span id="active-sessions-count" class="section-count"></span>
|
||
</div>
|
||
<div id="active-sessions-grid" class="session-grid"></div>
|
||
</div>
|
||
<div class="layout">
|
||
<div class="sidebar">
|
||
<div class="sidebar-pin">
|
||
<div class="sidebar-header">
|
||
<strong>Folders</strong>
|
||
<button class="btn-add admin-only" id="btn-new-folder" style="display:none" title="Create new folder">+ folder</button>
|
||
</div>
|
||
<!-- Slot for sidebar tools (folder filter box etc.); stays pinned with the header -->
|
||
<div id="sidebar-tools"></div>
|
||
</div>
|
||
<ul class="folder-list" id="folder-list"></ul>
|
||
</div>
|
||
<div class="main">
|
||
<div id="entries-header" style="display:none">
|
||
<div class="folder-actions">
|
||
<div class="entries-heading">
|
||
<div class="entries-title-row">
|
||
<strong id="entries-title"></strong>
|
||
<span id="entries-desc" class="desc"></span>
|
||
</div>
|
||
<span id="entries-key" class="entries-key"></span>
|
||
</div>
|
||
</div>
|
||
<div class="entries-toolbar">
|
||
<div id="entries-filters" class="list-tools"></div>
|
||
<div class="folder-actions-btns">
|
||
<button class="btn-add admin-only" id="btn-new-entry" style="display:none">+ add entry</button>
|
||
<button class="btn-add admin-only" id="btn-new-subfolder" style="display:none" title="Create subfolder under this folder">+ subfolder</button>
|
||
<button class="btn-small admin-only" id="btn-edit-folder" style="display:none" title="Edit folder settings">edit folder</button>
|
||
<button class="btn-small admin-only" id="btn-delete-folder" style="display:none" title="Delete folder and all entries">delete folder</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div id="entries-content">
|
||
<p class="empty">Select a folder to view entries.</p>
|
||
</div>
|
||
<div id="bulk-bar">
|
||
<span class="bulk-count" id="bulk-count"></span>
|
||
<button class="btn-add" id="bulk-move">Move to…</button>
|
||
<button class="btn-add" id="bulk-copy">Copy to…</button>
|
||
<button class="btn-small" id="bulk-delete">Delete</button>
|
||
<button class="btn-small" id="bulk-clear">Clear</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Search results overlay: floats over an untouched folder view -->
|
||
<!-- 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>Display name <span class="hint">(optional: shown as the title, with the name beneath)</span>
|
||
<input type="text" id="fm-display" placeholder="e.g. Dani & Fiori's Gourmet Pizzeria" maxlength="120">
|
||
</label>
|
||
<label>Description
|
||
<input type="text" id="fm-desc" placeholder="e.g. Infrastructure servers">
|
||
</label>
|
||
<label>Allowed groups</label>
|
||
<div class="chip-picker" id="fm-groups-picker">
|
||
<div class="chip-list" id="fm-groups-chips"></div>
|
||
<div class="chip-input-wrap">
|
||
<div class="combo" id="fm-groups-combo">
|
||
<input type="text" id="fm-groups-input" placeholder="Type or pick a group..." autocomplete="off" spellcheck="false">
|
||
<div class="combo-menu" id="fm-groups-menu" role="listbox"></div>
|
||
</div>
|
||
<button type="button" class="btn-add" id="fm-groups-add">Add</button>
|
||
</div>
|
||
</div>
|
||
<div class="field-hint">OIDC groups that can see this folder. Type to filter known groups or enter a custom one; Enter or Add to attach; × on a chip to remove.</div>
|
||
<label for="fm-users">Allowed users</label>
|
||
<textarea id="fm-users" rows="3" placeholder="jsmith@example.com" spellcheck="false" autocomplete="off"></textarea>
|
||
<div class="field-hint">Individual users who can see this folder, by login email, one per line. They don't need to have logged in yet. Leave both lists empty for admin-only access.</div>
|
||
<label style="display:flex;align-items:center;gap:0.5em;cursor:pointer">
|
||
<input type="checkbox" id="fm-inherit" style="width:auto">
|
||
Inherit permissions from parent folder
|
||
</label>
|
||
<div class="field-hint">When ticked, if this folder's own groups list doesn't grant a user access, the check walks up to the parent (and further ancestors if they also inherit). Uncheck to enforce this folder's groups list on its own. Top-level folders have no parent so this has no effect.</div>
|
||
<details class="fm-defaults" id="fm-defaults">
|
||
<summary>Default credentials</summary>
|
||
<div class="field-hint">Lent to entries in this folder that have no password or key of their own, per session type, so an entry created without secrets (a NetBox webhook, an import) connects straight away. An entry's own credentials always win; subfolders inherit per type, but only those that inherit permissions from this folder and share it with nobody extra. A web entry's default password is typed into the page, where the user can read it. Secrets are write-only: leave a field blank to keep what is stored, tick clear to drop a type.</div>
|
||
<table class="fm-defaults-table">
|
||
<tbody id="fm-defaults-rows"></tbody>
|
||
</table>
|
||
</details>
|
||
<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>
|
||
<p class="modal-hint adhoc-only" id="em-adhoc-hint">A one-off connection to any host. Nothing is saved. Credential fields accept your own <a href="/docs.html#credential-variables">credential variables</a>, such as <code>$my_ssh_key</code>.</p>
|
||
<datalist id="em-cred-vars"></datalist>
|
||
<div class="adhoc-hide">
|
||
<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>
|
||
</div>
|
||
<label>Type
|
||
<select id="em-type">
|
||
<option value="ssh">SSH</option>
|
||
<option value="rdp">RDP</option>
|
||
<option value="vnc">VNC</option>
|
||
<option value="spice">SPICE</option>
|
||
<option value="proxmox">Proxmox VE console</option>
|
||
<option value="web">Web</option>
|
||
<option value="vdi">VDI (Docker)</option>
|
||
</select>
|
||
</label>
|
||
<div id="em-ssh-fields">
|
||
<label>Hostname
|
||
<input type="text" id="em-hostname" placeholder="10.0.1.5">
|
||
</label>
|
||
<label>Port
|
||
<input type="number" id="em-port" value="22">
|
||
</label>
|
||
<label>Username
|
||
<input type="text" id="em-username">
|
||
</label>
|
||
<label>Password
|
||
<div class="pw-wrap"><input type="password" id="em-password"><button type="button" class="pw-toggle" onclick="togglePw(this)" title="Show/hide">●</button></div>
|
||
</label>
|
||
<label>Private key (PEM)
|
||
<textarea id="em-private-key" rows="3" style="resize:vertical;font-size:0.85em" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"></textarea>
|
||
</label>
|
||
<div class="adhoc-only">
|
||
<label style="margin-top:0.5em">
|
||
<input type="checkbox" id="em-ssh-generate-keypair" style="display:inline;width:auto;margin-right:0.4em"> Generate ephemeral SSH keypair
|
||
</label>
|
||
<div class="field-hint">A fresh Ed25519 key for this session only. Its public key is shown before connecting, to add to the host's <code>authorized_keys</code>.</div>
|
||
</div>
|
||
<label>Terminal font size <span style="color:var(--text-muted);font-size:0.85em">(optional)</span>
|
||
<input type="number" id="em-ssh-font-size" min="6" max="72" placeholder="12">
|
||
</label>
|
||
<div class="field-hint">Points. Leave blank for the default (12). Increase or decrease to tune readability — the terminal size no longer scales with display DPI.</div>
|
||
<div class="entry-only">
|
||
<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>
|
||
<div id="em-rdp-fields" style="display:none">
|
||
<label>Hostname
|
||
<input type="text" id="em-rdp-hostname" placeholder="10.0.2.3">
|
||
</label>
|
||
<label>Port
|
||
<input type="number" id="em-rdp-port" value="3389">
|
||
</label>
|
||
<label>Username
|
||
<input type="text" id="em-rdp-username">
|
||
</label>
|
||
<label>Password
|
||
<div class="pw-wrap"><input type="password" id="em-rdp-password"><button type="button" class="pw-toggle" onclick="togglePw(this)" title="Show/hide">●</button></div>
|
||
</label>
|
||
<label>Domain
|
||
<input type="text" id="em-rdp-domain">
|
||
</label>
|
||
<label>Security
|
||
<select id="em-rdp-security">
|
||
<option value="">Default</option>
|
||
<option value="any">Any</option>
|
||
<option value="nla">NLA</option>
|
||
<option value="tls">TLS</option>
|
||
<option value="rdp">RDP</option>
|
||
</select>
|
||
</label>
|
||
<label>Keyboard layout
|
||
<select id="em-rdp-layout">
|
||
<option value="">Server default (US)</option>
|
||
<option value="en-us-qwerty">English (US)</option>
|
||
<option value="en-gb-qwerty">English (UK)</option>
|
||
<option value="cs-cz-qwertz">Czech</option>
|
||
<option value="da-dk-qwerty">Danish</option>
|
||
<option value="fr-fr-azerty">French</option>
|
||
<option value="fr-be-azerty">French (Belgian)</option>
|
||
<option value="fr-ca-qwerty">French (Canadian)</option>
|
||
<option value="fr-ch-qwertz">French (Swiss)</option>
|
||
<option value="de-de-qwertz">German</option>
|
||
<option value="de-ch-qwertz">German (Swiss)</option>
|
||
<option value="hu-hu-qwertz">Hungarian</option>
|
||
<option value="it-it-qwerty">Italian</option>
|
||
<option value="ja-jp-qwerty">Japanese</option>
|
||
<option value="no-no-qwerty">Norwegian</option>
|
||
<option value="pl-pl-qwerty">Polish</option>
|
||
<option value="pt-pt-qwerty">Portuguese</option>
|
||
<option value="pt-br-qwerty">Portuguese (Brazilian)</option>
|
||
<option value="ro-ro-qwerty">Romanian</option>
|
||
<option value="es-es-qwerty">Spanish</option>
|
||
<option value="es-latam-qwerty">Spanish (Latin American)</option>
|
||
<option value="sv-se-qwerty">Swedish</option>
|
||
<option value="tr-tr-qwerty">Turkish-Q</option>
|
||
<option value="failsafe">Failsafe (Unicode)</option>
|
||
</select>
|
||
</label>
|
||
<label style="margin-top:0.8em">
|
||
<input type="checkbox" id="em-rdp-ignore-cert" style="display:inline;width:auto;margin-right:0.4em"> Ignore certificate errors
|
||
</label>
|
||
<label>NLA Auth Package
|
||
<select id="em-rdp-auth-pkg">
|
||
<option value="">Server default (NTLM)</option>
|
||
<option value="ntlm">NTLM</option>
|
||
<option value="kerberos">Kerberos</option>
|
||
<option value="negotiate">Negotiate (Kerberos first, NTLM fallback)</option>
|
||
</select>
|
||
</label>
|
||
<div class="field-hint">Leave blank to use the server default (NTLM unless overridden in <code>config.toml</code> <code>[rdp] default_auth_pkg</code>). Kerberos and Negotiate need a domain-joined host with a KDC reachable via DNS; when that isn't set up the RDP connection hangs silently, so NTLM is the safe default.</div>
|
||
<label>KDC URL <span style="color:#666;font-size:0.85em">(Kerberos only)</span>
|
||
<input type="text" id="em-rdp-kdc-url" placeholder="e.g. https://dc.example.com/KdcProxy">
|
||
</label>
|
||
<div class="field-hint">Optional. Kerberos Key Distribution Center proxy URL.</div>
|
||
<label>Monitors <span style="color:var(--text-muted);font-size:0.85em">(RDP Display Control; the host must allow multiple monitors)</span>
|
||
<input type="number" id="em-rdp-monitors" min="1" max="4" value="1">
|
||
</label>
|
||
<div class="entry-only">
|
||
<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>
|
||
<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>Username <span style="color:var(--text-muted);font-size:0.85em">(optional, for VeNCrypt / RealVNC / macOS)</span>
|
||
<input type="text" id="em-vnc-username">
|
||
</label>
|
||
<label>Password
|
||
<div class="pw-wrap"><input type="password" id="em-vnc-password"><button type="button" class="pw-toggle" onclick="togglePw(this)" title="Show/hide">●</button></div>
|
||
</label>
|
||
<label>Color depth
|
||
<select id="em-vnc-color-depth">
|
||
<option value="">Default (24-bit)</option>
|
||
<option value="8">8-bit</option>
|
||
<option value="16">16-bit</option>
|
||
<option value="24">24-bit</option>
|
||
<option value="32">32-bit</option>
|
||
</select>
|
||
</label>
|
||
<label style="text-transform:none;letter-spacing:0;cursor:pointer;margin-top:0.6em">
|
||
<input type="checkbox" id="em-vnc-resize" style="display:inline;width:auto;margin-right:0.5em">
|
||
Resize the remote desktop to fit the window
|
||
</label>
|
||
<div class="field-hint">Only for VNC servers with their own virtual screen, such as TigerVNC or Xvnc. Leave off for a physical console (UltraVNC, RealVNC, x11vnc): they can't resize, and the session stalls if asked to. When off, the screen is scaled to fit instead.</div>
|
||
<label style="margin-top:0.6em">Network
|
||
<select id="em-vnc-network">
|
||
<option value="">Auto (default)</option>
|
||
<option value="lan">LAN: fast, best picture</option>
|
||
<option value="wan">WAN: compressed, JPEG for photos and video</option>
|
||
<option value="slow">Slow link: least bandwidth, 16-bit colour</option>
|
||
</select>
|
||
</label>
|
||
<div class="field-hint">How the VNC server encodes the screen for rustguac, like UltraVNC Viewer's quick options. Auto lets the server and client agree; pick a preset only if a link is slow or the picture is poor. A colour depth set above takes precedence.</div>
|
||
<div class="entry-only">
|
||
<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 credentials even if they are stored.</div>
|
||
</div>
|
||
</div>
|
||
<div id="em-spice-fields" style="display:none">
|
||
<label>Hostname
|
||
<input type="text" id="em-spice-hostname" placeholder="10.0.3.1">
|
||
</label>
|
||
<label>Port
|
||
<input type="number" id="em-spice-port" value="5900">
|
||
</label>
|
||
<label>Password
|
||
<div class="pw-wrap"><input type="password" id="em-spice-password"><button type="button" class="pw-toggle" onclick="togglePw(this)" title="Show/hide">●</button></div>
|
||
</label>
|
||
<label>Color depth
|
||
<select id="em-spice-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-spice-tls" style="display:inline;width:auto;margin-right:0.4em"> Connect using TLS
|
||
</label>
|
||
<label>TLS port <span style="color:var(--text-muted);font-size:0.85em">(optional, if different from Port)</span>
|
||
<input type="number" id="em-spice-tls-port" placeholder="(same as Port)">
|
||
</label>
|
||
<label>Ignore certificate
|
||
<input type="checkbox" id="em-spice-ignore-cert" style="display:inline;width:auto;margin-right:0.4em"> Accept any TLS certificate (insecure)
|
||
</label>
|
||
<label>CA certificate <span style="color:var(--text-muted);font-size:0.85em">(PEM, optional)</span>
|
||
<textarea id="em-spice-ca-cert" rows="3" placeholder="-----BEGIN CERTIFICATE-----" style="font-family:monospace;font-size:0.85em"></textarea>
|
||
</label>
|
||
<label>Certificate subject <span style="color:var(--text-muted);font-size:0.85em">(optional)</span>
|
||
<input type="text" id="em-spice-cert-subject" placeholder="OU=..., CN=...">
|
||
</label>
|
||
<label>Proxy <span style="color:var(--text-muted);font-size:0.85em">(optional)</span>
|
||
<input type="text" id="em-spice-proxy" placeholder="http://proxy.example.com:3128">
|
||
</label>
|
||
<label style="margin-top:0.8em" class="entry-only">
|
||
<input type="checkbox" id="em-spice-prompt-creds" style="display:inline;width:auto;margin-right:0.4em"> Prompt for credentials at connect time
|
||
</label>
|
||
<div class="field-hint">Direct SPICE displays (e.g. libvirt/QEMU consoles). The TLS, CA, certificate-subject and proxy fields support connecting through a SPICE proxy. For Proxmox VE VMs, use the "Proxmox VE console" type instead (it brokers the one-time ticket automatically).</div>
|
||
</div>
|
||
<div id="em-proxmox-fields" style="display:none">
|
||
<label>PVE API URL
|
||
<input type="text" id="em-proxmox-url" placeholder="https://pve.example.com:8006">
|
||
</label>
|
||
<label>Node <span style="color:var(--text-muted);font-size:0.85em">(optional — auto-detected from VM ID)</span>
|
||
<input type="text" id="em-proxmox-node" placeholder="(auto-detect)">
|
||
</label>
|
||
<label>VM ID
|
||
<input type="number" id="em-proxmox-vmid" placeholder="100">
|
||
</label>
|
||
<label>Token ID
|
||
<input type="text" id="em-proxmox-token-id" placeholder="user@realm!tokenname">
|
||
</label>
|
||
<label>Token secret
|
||
<div class="pw-wrap"><input type="password" id="em-proxmox-token-secret" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"><button type="button" class="pw-toggle" onclick="togglePw(this)" title="Show/hide">●</button></div>
|
||
</label>
|
||
<label>Color depth
|
||
<select id="em-proxmox-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>Monitors <span style="color:var(--text-muted);font-size:0.85em">(the guest must support multiple SPICE heads)</span>
|
||
<input type="number" id="em-proxmox-monitors" min="1" max="4" value="1">
|
||
</label>
|
||
<label style="margin-top:0.8em">
|
||
<input type="checkbox" id="em-proxmox-verify-tls" style="display:inline;width:auto;margin-right:0.4em"> Verify the PVE cluster TLS certificate
|
||
</label>
|
||
<div class="field-hint">Opens a Proxmox VE VM console via SPICE. rustguac calls the PVE <code>spiceproxy</code> API at connect to fetch a one-time ticket, so nothing sensitive is stored beyond the API token. Leave <strong>Node</strong> blank to auto-detect which cluster node hosts the VM. The token needs <code>VM.Console</code> (and <code>VM.Audit</code> for node auto-detect) on the target VM. PVE ships a self-signed cluster certificate, so leave TLS verification off unless the cluster CA is trusted.</div>
|
||
</div>
|
||
<div id="em-web-fields" style="display:none">
|
||
<label>URL
|
||
<input type="text" id="em-url" placeholder="https://example.com">
|
||
</label>
|
||
<div class="field-hint">Use <code>$RUSTGUAC_USERNAME</code> and <code>$RUSTGUAC_PASSWORD</code> in the URL for basic auth (e.g. <code>https://$RUSTGUAC_USERNAME:$RUSTGUAC_PASSWORD@host</code>).</div>
|
||
<label>Banner <span style="color:var(--text-muted);font-size:0.85em">(optional)</span>
|
||
<input type="text" id="em-banner" placeholder="Shown before session starts (user must click Continue)">
|
||
</label>
|
||
<div style="margin-top:1em;padding-top:0.8em;border-top:1px solid var(--border)">
|
||
<label style="cursor:pointer;color:var(--accent);font-size:0.95em" id="em-automation-toggle">
|
||
<span id="em-automation-arrow">▶</span> Automation
|
||
</label>
|
||
<div id="em-automation-fields" style="display:none">
|
||
<div class="field-hint" style="margin-bottom:0.5em">Credentials and scripts for automated login. Username and password are also used for URL substitution (<code>$RUSTGUAC_USERNAME</code> / <code>$RUSTGUAC_PASSWORD</code>).</div>
|
||
<label>Username <span style="color:var(--text-muted);font-size:0.85em">(optional)</span>
|
||
<input type="text" id="em-web-username" placeholder="For URL substitution or login script">
|
||
</label>
|
||
<label>Password <span style="color:var(--text-muted);font-size:0.85em">(optional)</span>
|
||
<div class="pw-wrap"><input type="password" id="em-web-password"><button type="button" class="pw-toggle" onclick="togglePw(this)" title="Show/hide">●</button></div>
|
||
</label>
|
||
<label>Login Script <span style="color:var(--text-muted);font-size:0.85em">(optional)</span>
|
||
<select id="em-login-script">
|
||
<option value="">None</option>
|
||
</select>
|
||
</label>
|
||
<div class="field-hint">Server-side script from the scripts directory. Receives CDP port and credentials as env vars. Runs after Chromium spawns.</div>
|
||
<div style="margin-top:0.8em;padding-top:0.8em;border-top:1px solid var(--border)">
|
||
<label style="cursor:pointer;color:var(--accent);font-size:0.95em" id="em-autofill-toggle">
|
||
<span id="em-autofill-arrow">▶</span> Autofill
|
||
</label>
|
||
<div id="em-autofill-fields" style="display:none">
|
||
<div class="field-hint" style="margin-bottom:0.5em">Pre-populate Chromium's autofill database so saved credentials appear on matching login forms. Use <code>$USERNAME</code> and <code>$PASSWORD</code> to substitute the entry's credentials.</div>
|
||
<div id="em-autofill-rows"></div>
|
||
<button type="button" id="em-autofill-add" style="margin-top:0.3em;padding:0.3em 0.8em;font-size:0.85em;background:var(--input);color:var(--accent);border:1px solid var(--border);border-radius:4px;cursor:pointer">+ Add site</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div style="margin-top:1em;padding-top:0.8em;border-top:1px solid var(--border)">
|
||
<label style="cursor:pointer;color:var(--accent);font-size:0.95em" id="em-allowed-domains-toggle">
|
||
<span id="em-allowed-domains-arrow">▶</span> Allowed Domains
|
||
</label>
|
||
<div id="em-allowed-domains-fields" style="display:none">
|
||
<div class="field-hint" style="margin-bottom:0.5em">Restrict which domains the browser can reach. When set, all other domains are blocked via DNS. Leave empty to allow all sites.</div>
|
||
<div id="em-allowed-domains-list"></div>
|
||
<button type="button" id="em-allowed-domains-add" style="margin-top:0.3em;padding:0.3em 0.8em;font-size:0.85em;background:var(--input);color:var(--accent);border:1px solid var(--border);border-radius:4px;cursor:pointer">+ Add domain</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div id="em-vdi-fields" style="display:none">
|
||
<label>Container Image
|
||
<input type="text" id="em-vdi-image" placeholder="e.g. myregistry/xrdp-desktop:latest">
|
||
</label>
|
||
<div class="field-hint">Docker image with xrdp on port 3389. By default the entrypoint should read <code>VDI_USERNAME</code> / <code>VDI_PASSWORD</code> env vars (rustguac derives them from your identity and generates an ephemeral password per connect). For images with a baked-in account, fill in the Container username / password fields below. Container is ephemeral - persists while idle, then removed.</div>
|
||
<label>CPU Limit <span style="color:var(--text-muted);font-size:0.85em">(optional, cores)</span>
|
||
<input type="number" id="em-vdi-cpu" step="0.5" min="0" placeholder="e.g. 2.0 (0 = use default)">
|
||
</label>
|
||
<label>Memory Limit <span style="color:var(--text-muted);font-size:0.85em">(optional, MB)</span>
|
||
<input type="number" id="em-vdi-memory" min="0" placeholder="e.g. 2048 (0 = use default)">
|
||
</label>
|
||
<label>Environment Variables <span style="color:var(--text-muted);font-size:0.85em">(optional, KEY=VALUE per line)</span>
|
||
<textarea id="em-vdi-env" rows="3" style="resize:vertical;font-size:0.85em" placeholder="TZ=Australia/Brisbane LANG=en_US.UTF-8"></textarea>
|
||
</label>
|
||
<div class="entry-only">
|
||
<label>Container username <span style="color:var(--text-muted);font-size:0.85em">(optional, overrides auto-derive)</span>
|
||
<input type="text" id="em-vdi-container-username" placeholder="Leave blank to derive from operator identity">
|
||
</label>
|
||
<label>Container password <span style="color:var(--text-muted);font-size:0.85em">(optional, overrides ephemeral)</span>
|
||
<input type="password" id="em-vdi-container-password" placeholder="Leave blank to generate per-connect" autocomplete="new-password">
|
||
</label>
|
||
<div class="field-hint">Use these only when the container image has a fixed account (Pattern B in the <a href="/docs.html#vdi" style="color:var(--accent)">VDI docs</a>). When set, rustguac uses these creds for the RDP login into the container, and the same container is shared by everyone connecting through this entry (deterministic name from the username). When left blank, rustguac derives a per-operator username and generates a fresh password every connect. Both fields support <a href="/docs.html#credential-variables" style="color:var(--accent)">credential variables</a> (e.g. <code>$corp_username</code>).</div>
|
||
</div>
|
||
<label>Idle Timeout <span style="color:var(--text-muted);font-size:0.85em">(optional, minutes)</span>
|
||
<input type="number" id="em-vdi-idle-timeout" min="0" placeholder="e.g. 60 (0 = use global default)">
|
||
</label>
|
||
<div class="field-hint adhoc-restricted-only">CPU, memory and idle timeout above the site's defaults are capped at those defaults.</div>
|
||
<label>Banner <span style="color:var(--text-muted);font-size:0.85em">(optional)</span>
|
||
<input type="text" id="em-vdi-banner" placeholder="Shown before session starts (user must click Continue)">
|
||
</label>
|
||
</div>
|
||
<div id="em-remoteapp-section" style="display:none;margin-top:1em;padding-top:0.8em;border-top:1px solid var(--border)">
|
||
<label style="cursor:pointer;color:var(--accent);font-size:0.95em" id="em-remoteapp-toggle">
|
||
<span id="em-remoteapp-arrow">▶</span> RemoteApp (RAIL)
|
||
</label>
|
||
<div id="em-remoteapp-fields" style="display:none">
|
||
<div class="field-hint" style="margin-bottom:0.5em">Launch a specific application instead of the full desktop.</div>
|
||
<label>Program path
|
||
<input type="text" id="em-remote-app" placeholder="e.g. ||calc or C:\Windows\notepad.exe">
|
||
</label>
|
||
<div class="field-hint">Use || prefix for alternate shell, or full path to executable.</div>
|
||
<label>Working directory
|
||
<input type="text" id="em-remote-app-dir" placeholder="e.g. C:\Users\Public">
|
||
</label>
|
||
<label>Arguments
|
||
<input type="text" id="em-remote-app-args" placeholder="e.g. /path/to/file">
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div id="em-drive-section" style="display:none;margin-top:0.8em;">
|
||
<label>
|
||
<input type="checkbox" id="em-enable-drive" style="display:inline;width:auto;margin-right:0.4em"> Enable file transfer
|
||
<div style="color:#888;font-size:0.75em;margin-top:0.2em;">RDP: mounts a shared drive. SSH: enables SFTP file browser.</div>
|
||
<div id="em-drive-warning" style="display:none;color:var(--primary);font-size:0.8em;margin-top:0.3em">Server has no [drive] section in config — file transfer will not work. See <a href="/docs.html" style="color:var(--accent)">Docs</a> for setup instructions.</div>
|
||
</label>
|
||
</div>
|
||
<div id="em-recording-section" style="display:none;margin-top:0.8em;padding-top:0.8em;border-top:1px solid var(--border)">
|
||
<label style="cursor:pointer;color:var(--accent);font-size:0.95em" id="em-recording-toggle">
|
||
<span id="em-recording-arrow">▶</span> Recording Settings
|
||
</label>
|
||
<div id="em-recording-fields" style="display:none">
|
||
<div class="field-hint" style="margin-bottom:0.5em">Override global recording settings for this entry.</div>
|
||
<label style="margin-top:0.5em">
|
||
<input type="checkbox" id="em-override-recording" style="display:inline;width:auto;margin-right:0.4em"> Override recording settings
|
||
</label>
|
||
<div id="em-recording-sub" style="display:none;margin-left:1.5em;margin-top:0.5em">
|
||
<label>
|
||
<input type="checkbox" id="em-enable-recording" style="display:inline;width:auto;margin-right:0.4em" checked> Enable recording
|
||
</label>
|
||
<div class="entry-only">
|
||
<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 class="field-hint adhoc-restricted-only">Recording that is on for the whole site stays on.</div>
|
||
</div>
|
||
<div id="em-typescript-row" class="entry-only" style="display:none;margin-top:0.6em;padding-top:0.5em;border-top:1px dashed var(--border)">
|
||
<label>
|
||
<input type="checkbox" id="em-record-typescript" style="display:inline;width:auto;margin-right:0.4em"> Enable typescript recording for this session
|
||
</label>
|
||
<div class="field-hint">SSH only. Writes a plain-text terminal log via guacd, for audit. Requires <code>[recording] typescript_path</code> set on the server. Off by default.</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div id="em-video-perf-section" style="display:none;margin-top:0.8em;padding-top:0.8em;border-top:1px solid var(--border)">
|
||
<label style="cursor:pointer;color:var(--accent);font-size:0.95em" id="em-video-perf-toggle">
|
||
<span id="em-video-perf-arrow">▶</span> Video Performance
|
||
</label>
|
||
<div id="em-video-perf-fields" style="display:none">
|
||
<div class="field-hint" style="margin-bottom:0.5em">RDP graphics pipeline and encoding settings. For video-heavy workloads, enable GFX + Desktop Composition + H.264.</div>
|
||
<label style="margin-top:0.5em">
|
||
<input type="checkbox" id="em-enable-gfx" style="display:inline;width:auto;margin-right:0.4em"> Enable Graphics Pipeline (GFX)
|
||
<div style="color:#888;font-size:0.75em;margin-top:0.2em;">Required for H.264 and RemoteFX. Enables modern RDP graphics codecs (32-bit colour).</div>
|
||
</label>
|
||
<label style="margin-top:0.3em">
|
||
<input type="checkbox" id="em-enable-desktop-comp" style="display:inline;width:auto;margin-right:0.4em"> Enable Desktop Composition
|
||
<div style="color:#888;font-size:0.75em;margin-top:0.2em;">Enables DWM compositing for smoother video overlays and transparency.</div>
|
||
</label>
|
||
<label style="margin-top:0.3em">
|
||
<input type="checkbox" id="em-force-lossless" style="display:inline;width:auto;margin-right:0.4em"> Force Lossless
|
||
<div style="color:#888;font-size:0.75em;margin-top:0.2em;">PNG only — crisp text but high bandwidth. Not compatible with H.264 passthrough.</div>
|
||
</label>
|
||
<label style="margin-top:0.3em">
|
||
<input type="checkbox" id="em-enable-h264" style="display:inline;width:auto;margin-right:0.4em"> H.264 Passthrough
|
||
<div style="color:#888;font-size:0.75em;margin-top:0.2em;">Passes H.264 video direct to browser (low latency, hardware decoded). Requires GFX enabled and xrdp with x264 on the target server.</div>
|
||
</label>
|
||
<div class="field-hint" style="margin-top:0.8em;margin-bottom:0.5em">Desktop appearance. Off by default to save bandwidth; enable for a normal-looking Windows desktop.</div>
|
||
<label style="margin-top:0.3em">
|
||
<input type="checkbox" id="em-enable-wallpaper" style="display:inline;width:auto;margin-right:0.4em"> Show Wallpaper
|
||
<div style="color:#888;font-size:0.75em;margin-top:0.2em;">Displays the remote desktop background image instead of a solid colour.</div>
|
||
</label>
|
||
<label style="margin-top:0.3em">
|
||
<input type="checkbox" id="em-enable-theming" style="display:inline;width:auto;margin-right:0.4em"> Enable Theming
|
||
<div style="color:#888;font-size:0.75em;margin-top:0.2em;">Renders window borders and controls with the desktop's visual styles.</div>
|
||
</label>
|
||
<label style="margin-top:0.3em">
|
||
<input type="checkbox" id="em-enable-full-window-drag" style="display:inline;width:auto;margin-right:0.4em"> Full-Window Drag
|
||
<div style="color:#888;font-size:0.75em;margin-top:0.2em;">Shows window contents while dragging instead of just an outline.</div>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div id="em-clipboard-section" style="margin-top:0.8em">
|
||
<label>
|
||
<input type="checkbox" id="em-disable-copy" style="display:inline;width:auto;margin-right:0.4em"> Disable clipboard copy (server → client)
|
||
<div style="color:#888;font-size:0.75em;margin-top:0.2em;">Prevents users from copying text out of the remote session.</div>
|
||
</label>
|
||
<label style="margin-top:0.3em">
|
||
<input type="checkbox" id="em-disable-paste" style="display:inline;width:auto;margin-right:0.4em"> Disable clipboard paste (client → server)
|
||
<div style="color:#888;font-size:0.75em;margin-top:0.2em;">Prevents users from pasting text into the remote session.</div>
|
||
</label>
|
||
</div>
|
||
<div id="em-tunnel-section" style="display:none;margin-top:1em;padding-top:0.8em;border-top:1px solid var(--border)">
|
||
<label style="cursor:pointer;color:var(--accent);font-size:0.95em" id="em-tunnel-toggle">
|
||
<span id="em-tunnel-arrow">▶</span> SSH Tunnel / Jump Hosts
|
||
</label>
|
||
<div id="em-tunnel-fields" style="display:none">
|
||
<div class="field-hint" style="margin-bottom:0.5em">Route this connection through one or more SSH bastion hosts.</div>
|
||
<div id="em-flow-diagram" class="flow-diagram"></div>
|
||
<div id="em-hops-list"></div>
|
||
<button type="button" class="btn-add" id="em-add-hop" style="margin-top:8px">+ Add Jump Host</button>
|
||
</div>
|
||
</div>
|
||
<div id="em-wol-section" class="entry-only" 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-wol-toggle">
|
||
<span id="em-wol-arrow">▶</span> Wake-on-LAN
|
||
</label>
|
||
<div id="em-wol-fields" style="display:none">
|
||
<div class="field-hint" style="margin-bottom:0.5em">Send a Wake-on-LAN magic packet to power on the target before connecting. The host's NIC must have WoL enabled, and the packet must be able to reach its subnet.</div>
|
||
<label style="text-transform:none;letter-spacing:0;cursor:pointer">
|
||
<input type="checkbox" id="em-wol-enabled" style="display:inline;width:auto;margin-right:0.5em">
|
||
Send magic packet before connecting
|
||
</label>
|
||
<label>MAC address
|
||
<input type="text" id="em-wol-mac" placeholder="00:11:22:33:44:55">
|
||
</label>
|
||
<label>Broadcast address <span style="color:var(--text-muted);font-size:0.85em">(optional)</span>
|
||
<input type="text" id="em-wol-broadcast" placeholder="255.255.255.255">
|
||
</label>
|
||
<label>UDP port <span style="color:var(--text-muted);font-size:0.85em">(optional)</span>
|
||
<input type="number" id="em-wol-port" placeholder="9">
|
||
</label>
|
||
<label>Wait time (seconds) <span style="color:var(--text-muted);font-size:0.85em">(optional)</span>
|
||
<input type="number" id="em-wol-wait" placeholder="0">
|
||
</label>
|
||
<div class="field-hint">Seconds to wait after sending the packet before connecting, giving the host time to boot.</div>
|
||
</div>
|
||
</div>
|
||
<div id="em-sharing-section" style="margin-top:1em;padding-top:0.8em;border-top:1px solid var(--border)">
|
||
<div class="entry-only">
|
||
<label style="text-transform:none;letter-spacing:0;cursor:pointer">
|
||
<input type="checkbox" id="em-allow-sharing" style="display:inline;width:auto;margin-right:0.5em">
|
||
Allow session sharing
|
||
</label>
|
||
<div class="field-hint">
|
||
When enabled, users can share active sessions of this entry with a view-only link or a link that also gives control.
|
||
Off by default — admins opt in per entry.
|
||
</div>
|
||
<label style="text-transform:none;letter-spacing:0;cursor:pointer;margin-top:0.8em">
|
||
<input type="checkbox" id="em-auto-open-singleton" style="display:inline;width:auto;margin-right:0.5em">
|
||
Auto-open when this is the user's only entry
|
||
</label>
|
||
<div class="field-hint">
|
||
On first visit to Connections, if the user has exactly one visible entry and
|
||
this box is ticked, connect automatically (replaces the current tab — browsers
|
||
block new-tab popups without a click). Kiosk-style single-purpose accounts.
|
||
</div>
|
||
</div>
|
||
<label style="text-transform:none;letter-spacing:0;cursor:pointer;margin-top:0.8em">
|
||
<input type="checkbox" id="em-fullscreen-on-connect" style="display:inline;width:auto;margin-right:0.5em">
|
||
Open in fullscreen on connect
|
||
</label>
|
||
<div class="field-hint">
|
||
On the first user gesture after the session connects, enter browser fullscreen
|
||
and lock the Escape key so it reaches the remote session (Chromium-based
|
||
browsers; Firefox / Safari fall back to standard fullscreen). Reduces the risk
|
||
of users accidentally closing the rustguac tab.
|
||
</div>
|
||
<label style="text-transform:none;letter-spacing:0;cursor:pointer;margin-top:0.8em">
|
||
<input type="checkbox" id="em-autohide-side-tabs" style="display:inline;width:auto;margin-right:0.5em">
|
||
Auto-hide clipboard/files side tabs
|
||
</label>
|
||
<div class="field-hint">
|
||
Hide the Clipboard and Files tabs pinned to the left edge when idle; they
|
||
reappear when you move the pointer to the left edge of the session. Reduces
|
||
on-screen clutter over the remote display.
|
||
</div>
|
||
</div>
|
||
<div id="em-move-section" class="adhoc-hide" 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-saveas-section" class="adhoc-only" style="display:none;margin-top:1em;padding-top:0.8em;border-top:1px solid var(--border)">
|
||
<div class="field-hint" style="margin-bottom:0.5em">Save this connection as an entry. Credential variables are saved as references, not values.</div>
|
||
<label>Folder
|
||
<select id="em-saveas-folder"></select>
|
||
</label>
|
||
<label>Name
|
||
<input type="text" id="em-saveas-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>
|
||
</div>
|
||
<div id="em-error" style="color:var(--primary);margin-top:0.5em"></div>
|
||
<div class="modal-actions">
|
||
<button id="em-save" class="adhoc-hide">Save</button>
|
||
<button id="em-connect" class="adhoc-only">Connect</button>
|
||
<button id="em-saveas" class="btn-add adhoc-only" style="display:none">Save as entry</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 id="my-creds-title">My Credentials</h3>
|
||
<p id="my-creds-desc" style="color:var(--text-muted);font-size:0.85em;margin-top:0">Store your credentials securely in Vault. Connections entries that use credential variables will auto-fill from these values.</p>
|
||
<input id="my-creds-search" type="text" placeholder="Filter credentials..." autocomplete="off" style="display:none">
|
||
<div id="my-creds-loading" style="color:var(--text-muted)">Loading...</div>
|
||
<div id="my-creds-empty" style="display:none;color:var(--text-muted);font-style:italic;margin:1em 0">No credential variables configured in connections entries yet.</div>
|
||
<div id="my-creds-list"></div>
|
||
<div id="my-creds-error" style="color:var(--primary);margin-top:0.5em"></div>
|
||
<div class="modal-actions">
|
||
<button class="btn-connect" id="my-creds-save">Save</button>
|
||
<button class="btn-cancel" id="my-creds-close">Close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Share session modal -->
|
||
<!-- Bulk move / copy / delete -->
|
||
<div class="modal-overlay" id="bulk-modal">
|
||
<div class="modal" style="max-width:560px;min-width:380px">
|
||
<h3 id="bulk-title"></h3>
|
||
<div id="bulk-pick">
|
||
<label for="bulk-folder-filter">Target folder</label>
|
||
<input type="search" id="bulk-folder-filter" placeholder="Filter folders..." autocomplete="off">
|
||
<div class="bulk-folder-list" id="bulk-folder-list"></div>
|
||
</div>
|
||
<div class="bulk-preview" id="bulk-preview"></div>
|
||
<div class="bulk-conflict" id="bulk-conflict" style="display:none;margin-top:var(--s-2)">
|
||
<label><input type="radio" name="bulk-conflict" value="skip" checked> Leave those where they are</label>
|
||
<label><input type="radio" name="bulk-conflict" value="rename"> Give them a new name (name-2, name-3, ...)</label>
|
||
</div>
|
||
<div class="bulk-results" id="bulk-results" style="display:none"></div>
|
||
<div id="bulk-error" style="color:var(--primary);margin-top:0.5em"></div>
|
||
<div class="modal-actions">
|
||
<button class="btn-cancel" id="bulk-cancel">Cancel</button>
|
||
<button class="btn-add" id="bulk-confirm" disabled></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-overlay" id="share-modal">
|
||
<div class="modal">
|
||
<h3>Share Session</h3>
|
||
<p style="color:var(--text-muted);font-size:var(--fz-sm);margin:0 0 var(--s-3) 0">
|
||
Send one of these URLs to someone to let them join your session.
|
||
</p>
|
||
<label>View only</label>
|
||
<div class="share-url-box">
|
||
<code id="share-modal-url-ro"></code>
|
||
<button type="button" class="btn-add share-modal-copy" data-copy-from="share-modal-url-ro">Copy</button>
|
||
</div>
|
||
<label style="margin-top:var(--s-3)">View and control</label>
|
||
<div class="share-url-box">
|
||
<code id="share-modal-url"></code>
|
||
<button type="button" class="btn-add share-modal-copy" data-copy-from="share-modal-url">Copy</button>
|
||
</div>
|
||
<div class="share-warning">
|
||
<span class="share-warning-icon">⚠</span>
|
||
Anyone with a link and network access to this server can join until the session ends. The control link also lets them type and use the mouse and clipboard.
|
||
</div>
|
||
<div class="modal-actions">
|
||
<button class="btn-cancel" id="share-modal-close">Close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Onboarding wizard -->
|
||
<div class="modal-overlay" id="onboarding-modal">
|
||
<div class="modal" style="max-width:520px;min-width:380px;position:relative">
|
||
<button id="onboard-close" title="Skip the tour" style="position:absolute;top:0.4em;right:0.5em;background:none;border:none;color:var(--text-dim);font-size:1.7em;line-height:1;cursor:pointer;font-family:monospace;padding:0 0.2em;z-index:1">×</button>
|
||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.3em;gap:0.6em;padding-right:1.6em">
|
||
<h3 id="onboard-title" style="margin:0;flex:1"></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 src="/filters.js"></script>
|
||
<script>
|
||
function togglePw(btn){var inp=btn.previousElementSibling;if(inp.type==='password'){inp.type='text';btn.textContent='\u25CB'}else{inp.type='password';btn.textContent='\u25CF'}}
|
||
function applyThemeColors(colors){var r=document.documentElement.style;for(var k in colors)r.setProperty('--'+k.replace(/_/g,'-'),colors[k]);var s=document.getElementById('bg-pattern-style');if(!s){s=document.createElement('style');s.id='bg-pattern-style';document.head.appendChild(s)}s.textContent=colors.bg_pattern&&colors.bg_pattern!=='none'?'body{background-image:'+colors.bg_pattern+';background-attachment:fixed}':'';localStorage.setItem('rustguac_theme_colors',JSON.stringify(colors))}
|
||
var _themePresets={},_adminPreset='aurora';
|
||
var _themeDescriptions={dark:'Navy & cyan \u2014 the default',light:'Clean white & blue','high-contrast':'Maximum readability',terminal:'Retro green-on-black',nord:'Arctic, muted blues',corporate:'Slate & steel blue',aurora:'Midnight blue with ambient glow',jaguar:'Racing green & gold'};
|
||
function initTheme(t){if(!t)return;_themePresets=t.presets||{};_adminPreset=t.admin_preset||'aurora';var u=localStorage.getItem('rustguac_theme'),active=u&&_themePresets[u]?u:_adminPreset,colors=(active===_adminPreset)?t.admin_colors:_themePresets[active];if(colors)applyThemeColors(colors);if(t.logo_url){var l=document.getElementById('site-logo');if(l){if(l.src!==t.logo_url&&!l.src.endsWith(t.logo_url))l.src=t.logo_url;l.style.display=''}}var menu=document.getElementById('um-theme-list');if(menu){menu.innerHTML='';Object.keys(_themePresets).forEach(function(name){var item=document.createElement('div');item.className='um-item'+(name===active?' active':'');var sw=document.createElement('span');sw.className='um-swatch';var p=_themePresets[name];sw.style.background='linear-gradient(135deg,'+p.primary+' 50%,'+p.accent+' 50%)';item.appendChild(sw);var info=document.createElement('div');info.className='um-theme-info';var nm=document.createElement('span');nm.className='um-theme-name';nm.textContent=name;info.appendChild(nm);var desc=document.createElement('span');desc.className='um-theme-desc';desc.textContent=_themeDescriptions[name]||'';info.appendChild(desc);item.appendChild(info);item.addEventListener('click',function(){localStorage.setItem('rustguac_theme',name);applyThemeColors(_themePresets[name]);menu.querySelectorAll('.um-item').forEach(function(el){el.classList.remove('active')});item.classList.add('active');document.getElementById('user-menu').style.display='none'});menu.appendChild(item)})}}
|
||
// The keyboard on this device, sent with each connect so an RDP or
|
||
// desktop connection without a layout of its own types correctly.
|
||
// Per browser, like the theme: it describes the hardware.
|
||
function rgKeyboardLayout() {
|
||
try { return localStorage.getItem('rustguac_keyboard_layout') || undefined; } catch (e) { return undefined; }
|
||
}
|
||
(function() {
|
||
var sel = document.getElementById('um-kb-layout');
|
||
var src = document.getElementById('em-rdp-layout');
|
||
if (!sel || !src) return;
|
||
Array.prototype.forEach.call(src.options, function(o) {
|
||
var opt = document.createElement('option');
|
||
opt.value = o.value;
|
||
opt.textContent = o.value ? o.textContent : 'Not set (server default)';
|
||
sel.appendChild(opt);
|
||
});
|
||
sel.value = rgKeyboardLayout() || '';
|
||
if (sel.selectedIndex < 0) sel.value = '';
|
||
// Clicks bubble to the document handler that closes the menu.
|
||
document.getElementById('um-kb').addEventListener('click', function(e) { e.stopPropagation(); });
|
||
sel.addEventListener('change', function() {
|
||
try {
|
||
if (sel.value) localStorage.setItem('rustguac_keyboard_layout', sel.value);
|
||
else localStorage.removeItem('rustguac_keyboard_layout');
|
||
} catch (e) {}
|
||
});
|
||
})();
|
||
var _ub=document.getElementById('user-menu-btn');if(_ub)_ub.addEventListener('click',function(e){e.stopPropagation();var m=document.getElementById('user-menu');m.style.display=m.style.display==='block'?'none':'block'});document.addEventListener('click',function(){var m=document.getElementById('user-menu');if(m)m.style.display='none'});
|
||
var _driveConfigured = false;
|
||
fetch('/api/auth/status').then(function(r){return r.json()}).then(function(d){
|
||
if(d.site_title){document.title=d.site_title+' - Connections';document.querySelector('h1').textContent=d.site_title;}
|
||
_driveConfigured = !!d.drive_configured;
|
||
document.getElementById('entry-modal').classList.toggle('drive-off', !d.drive_enabled);
|
||
initTheme(d.theme);
|
||
});
|
||
var apiKey = sessionStorage.getItem('rustguac_api_key');
|
||
|
||
if (!apiKey) {
|
||
fetch('/api/me', { credentials: 'same-origin' })
|
||
.then(function(res) {
|
||
if (res.status === 401 || res.status === 403) window.location.href = '/';
|
||
})
|
||
.catch(function() {});
|
||
}
|
||
|
||
document.getElementById('logout-item').addEventListener('click', function() {
|
||
sessionStorage.removeItem('rustguac_api_key');
|
||
fetch('/auth/logout', { credentials: 'same-origin' })
|
||
.finally(function() { window.location.href = '/'; });
|
||
});
|
||
|
||
function apiHeaders(extra) {
|
||
var h = {};
|
||
if (apiKey) h['Authorization'] = 'Bearer ' + apiKey;
|
||
if (extra) { for (var k in extra) h[k] = extra[k]; }
|
||
return h;
|
||
}
|
||
|
||
var roleLevel = { admin: 4, poweruser: 3, operator: 2, viewer: 1 };
|
||
var isAdmin = false;
|
||
var currentUserRole = 'viewer';
|
||
var selectedFolder = null; // { name, scope, description, path, has_children }
|
||
var folders = []; // top-level folders from batch load
|
||
var unavailableScopes = []; // scopes whose Vault backend is down (multi-Vault DR); greyed in the tree
|
||
var setCredVars = null; // Set of credential variable names the user has set (null = not yet loaded)
|
||
var lastEntriesRender = null; // { entries, scope, folder } — re-render when cred status arrives
|
||
var folderEntryCounts = {}; // "scope|path" -> count
|
||
var folderEntries = {}; // "scope|path" -> entries array
|
||
var bulkSel = { key: null, names: new Set() }; // bulk selection, per folder
|
||
var bulkLastIndex = null; // last checkbox clicked, for shift-click ranges
|
||
var bulkState = null; // open bulk dialog: { action, items, source, target, folders }
|
||
var subfolderCache = {}; // "scope|path" -> [FolderInfo] (lazy-loaded children)
|
||
var expandedPaths = {}; // "scope|path" -> true (tree expansion state; persisted)
|
||
var EXPANDED_STORAGE_KEY = 'rustguac_connections_expanded';
|
||
var SELECTED_STORAGE_KEY = 'rustguac_connections_selected';
|
||
function folderKey(scope, path) { return scope + '|' + path; }
|
||
function folderPath(f) { return f.path || f.name; }
|
||
|
||
// Persist tree expansion across page loads so users don't have to
|
||
// reopen the same folders every time. Per-browser (localStorage),
|
||
// not per-user. Stale keys for deleted folders are harmless: the
|
||
// restore walk drops anything findFolder() can't resolve.
|
||
function saveExpandedPaths() {
|
||
try { localStorage.setItem(EXPANDED_STORAGE_KEY, JSON.stringify(expandedPaths)); } catch (e) {}
|
||
}
|
||
function loadExpandedPaths() {
|
||
try {
|
||
var raw = localStorage.getItem(EXPANDED_STORAGE_KEY);
|
||
if (raw) {
|
||
var parsed = JSON.parse(raw);
|
||
if (parsed && typeof parsed === 'object') expandedPaths = parsed;
|
||
}
|
||
} catch (e) { expandedPaths = {}; }
|
||
}
|
||
loadExpandedPaths();
|
||
|
||
// Persist the selected folder so reopening the page re-opens the
|
||
// same folder, not the alphabetical first.
|
||
function saveSelectedFolder() {
|
||
try {
|
||
if (selectedFolder) {
|
||
localStorage.setItem(SELECTED_STORAGE_KEY, JSON.stringify({
|
||
scope: selectedFolder.scope,
|
||
path: folderPath(selectedFolder)
|
||
}));
|
||
} else {
|
||
localStorage.removeItem(SELECTED_STORAGE_KEY);
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
function loadSelectedFolder() {
|
||
try {
|
||
var raw = localStorage.getItem(SELECTED_STORAGE_KEY);
|
||
if (!raw) return null;
|
||
var parsed = JSON.parse(raw);
|
||
if (parsed && parsed.scope && parsed.path) return parsed;
|
||
} catch (e) {}
|
||
return null;
|
||
}
|
||
|
||
// Restore the expanded tree depth-first: shallow paths must load
|
||
// before their descendants so findFolder() can resolve deeper keys
|
||
// (deeper keys live in subfolderCache which is populated by
|
||
// loadSubfolders of their parent). Drops any stale keys along the
|
||
// way. Calls the callback when the walk is done.
|
||
function restoreExpandedTree(cb) {
|
||
var keys = Object.keys(expandedPaths);
|
||
if (keys.length === 0) { if (cb) cb(); return; }
|
||
keys.sort(function (a, b) {
|
||
var depth = function (k) { return (k.split('|')[1] || '').split('/').length; };
|
||
return depth(a) - depth(b);
|
||
});
|
||
var queue = keys.slice();
|
||
function processNext() {
|
||
if (queue.length === 0) { if (cb) cb(); return; }
|
||
var key = queue.shift();
|
||
var sep = key.indexOf('|');
|
||
if (sep < 0) { processNext(); return; }
|
||
var scope = key.slice(0, sep);
|
||
var path = key.slice(sep + 1);
|
||
var f = findFolder(scope, path);
|
||
if (!f) {
|
||
// Folder no longer exists (deleted, renamed, ACL change);
|
||
// drop the stale key so it doesn't drift forever.
|
||
delete expandedPaths[key];
|
||
processNext();
|
||
return;
|
||
}
|
||
if (subfolderCache[key]) { processNext(); return; }
|
||
loadSubfolders(scope, path, processNext);
|
||
}
|
||
processNext();
|
||
}
|
||
var currentEntries = []; // entries for the currently selected folder
|
||
var loginScriptsCache = null; // cached list from /api/login-scripts
|
||
var knownGroupsCache = null; // cached list from /api/auth/known-groups
|
||
var fmGroups = []; // allowed_groups being edited in the folder modal
|
||
|
||
function loadKnownGroups(cb) {
|
||
if (knownGroupsCache !== null) { if (cb) cb(); return; }
|
||
fetch('/api/auth/known-groups', { headers: apiHeaders(), credentials: 'same-origin' })
|
||
.then(function(r) { return r.ok ? r.json() : { groups: [] }; })
|
||
.then(function(data) {
|
||
knownGroupsCache = (data && data.groups) ? data.groups : [];
|
||
if (cb) cb();
|
||
})
|
||
.catch(function() { knownGroupsCache = []; if (cb) cb(); });
|
||
}
|
||
|
||
function renderFmGroupChips() {
|
||
var list = document.getElementById('fm-groups-chips');
|
||
list.innerHTML = '';
|
||
fmGroups.forEach(function(g) {
|
||
var chip = document.createElement('span');
|
||
chip.className = 'chip';
|
||
chip.textContent = g;
|
||
var rm = document.createElement('button');
|
||
rm.type = 'button';
|
||
rm.className = 'chip-remove';
|
||
rm.textContent = '\u00D7';
|
||
rm.title = 'Remove';
|
||
rm.addEventListener('click', function() {
|
||
fmGroups = fmGroups.filter(function(x) { return x !== g; });
|
||
renderFmGroupChips();
|
||
populateFmGroupOptions();
|
||
});
|
||
chip.appendChild(rm);
|
||
list.appendChild(chip);
|
||
});
|
||
}
|
||
|
||
var fmComboActive = -1; // active combo-menu index for keyboard nav
|
||
|
||
function fmGroupOptionsFiltered() {
|
||
var q = (document.getElementById('fm-groups-input').value || '').trim().toLowerCase();
|
||
return (knownGroupsCache || []).filter(function(g) {
|
||
if (fmGroups.indexOf(g) !== -1) return false; // already selected
|
||
if (!q) return true;
|
||
return g.toLowerCase().indexOf(q) !== -1;
|
||
});
|
||
}
|
||
|
||
function populateFmGroupOptions() {
|
||
var menu = document.getElementById('fm-groups-menu');
|
||
menu.innerHTML = '';
|
||
var opts = fmGroupOptionsFiltered();
|
||
var q = (document.getElementById('fm-groups-input').value || '').trim();
|
||
fmComboActive = opts.length > 0 ? 0 : -1;
|
||
|
||
if (opts.length === 0 && !q) {
|
||
var empty = document.createElement('div');
|
||
empty.className = 'combo-item empty';
|
||
empty.textContent = (knownGroupsCache && knownGroupsCache.length === 0)
|
||
? 'No known groups yet — type a group name.'
|
||
: 'All known groups already selected.';
|
||
menu.appendChild(empty);
|
||
return;
|
||
}
|
||
|
||
opts.forEach(function(g, i) {
|
||
var item = document.createElement('div');
|
||
item.className = 'combo-item' + (i === fmComboActive ? ' active' : '');
|
||
item.setAttribute('role', 'option');
|
||
item.textContent = g;
|
||
item.addEventListener('mousedown', function(ev) {
|
||
ev.preventDefault(); // keep input focused
|
||
pickFmGroup(g);
|
||
});
|
||
item.addEventListener('mouseenter', function() {
|
||
fmComboActive = i;
|
||
updateFmComboActive();
|
||
});
|
||
menu.appendChild(item);
|
||
});
|
||
|
||
if (q && opts.indexOf(q) === -1 && fmGroups.indexOf(q) === -1) {
|
||
// Show "+ add custom" row so user knows Enter will create a custom group
|
||
var custom = document.createElement('div');
|
||
custom.className = 'combo-item';
|
||
custom.innerHTML = '+ Add "<strong>' + escapeHtml(q) + '</strong>" <span class="combo-hint">(custom)</span>';
|
||
custom.addEventListener('mousedown', function(ev) {
|
||
ev.preventDefault();
|
||
pickFmGroup(q);
|
||
});
|
||
menu.appendChild(custom);
|
||
}
|
||
}
|
||
|
||
function updateFmComboActive() {
|
||
var items = document.querySelectorAll('#fm-groups-menu .combo-item');
|
||
for (var i = 0; i < items.length; i++) {
|
||
items[i].classList.toggle('active', i === fmComboActive);
|
||
}
|
||
}
|
||
|
||
function openFmCombo() {
|
||
document.getElementById('fm-groups-combo').classList.add('open');
|
||
populateFmGroupOptions();
|
||
}
|
||
function closeFmCombo() {
|
||
document.getElementById('fm-groups-combo').classList.remove('open');
|
||
}
|
||
|
||
function pickFmGroup(g) {
|
||
g = (g || '').trim();
|
||
if (!g) return;
|
||
if (fmGroups.indexOf(g) === -1) fmGroups.push(g);
|
||
var inp = document.getElementById('fm-groups-input');
|
||
inp.value = '';
|
||
renderFmGroupChips();
|
||
populateFmGroupOptions();
|
||
inp.focus();
|
||
}
|
||
|
||
// Adds whatever is currently typed in the group input as one or
|
||
// more chips. Returns silently if the input is empty — the Save
|
||
// handler calls this to flush pending typing, and must not
|
||
// silently add the combo's highlighted suggestion (that caused a
|
||
// bug where editing any folder tacked on the first unselected
|
||
// known group).
|
||
function addFmGroupFromInput() {
|
||
var inp = document.getElementById('fm-groups-input');
|
||
var raw = (inp.value || '').trim();
|
||
if (!raw) return;
|
||
// Allow comma-separated bulk paste
|
||
raw.split(',').forEach(function(part) {
|
||
var g = part.trim();
|
||
if (g && fmGroups.indexOf(g) === -1) fmGroups.push(g);
|
||
});
|
||
inp.value = '';
|
||
renderFmGroupChips();
|
||
populateFmGroupOptions();
|
||
}
|
||
|
||
// Explicit handler for when the user presses Enter with nothing
|
||
// typed: pick the currently highlighted combo suggestion.
|
||
function pickFmGroupFromCombo() {
|
||
var opts = fmGroupOptionsFiltered();
|
||
if (fmComboActive >= 0 && fmComboActive < opts.length) {
|
||
pickFmGroup(opts[fmComboActive]);
|
||
}
|
||
}
|
||
|
||
// GitHub #103: auto-open the user's only entry if it has
|
||
// auto_open_if_singleton set. Fires once per browser session
|
||
// (sessionStorage-gated) so an accidental refresh of Connections
|
||
// doesn't spawn another client tab — the user can always click
|
||
// the entry card to reconnect manually. Logout clears the flag,
|
||
// so the next fresh login auto-opens again.
|
||
function maybeAutoOpenSingleton() {
|
||
try {
|
||
if (sessionStorage.getItem('rustguac_auto_opened') === '1') return;
|
||
} catch (e) { /* no sessionStorage */ }
|
||
|
||
// Gather every entry the user currently sees across top-level
|
||
// folders from the batch response. Subfolders aren't in the
|
||
// initial batch (lazy-loaded) so this deliberately only
|
||
// considers top-level — keeps the behaviour predictable.
|
||
var allEntries = [];
|
||
Object.keys(folderEntries).forEach(function(key) {
|
||
(folderEntries[key] || []).forEach(function(e) {
|
||
allEntries.push({ key: key, entry: e });
|
||
});
|
||
});
|
||
if (allEntries.length !== 1) return;
|
||
var hit = allEntries[0];
|
||
if (!hit.entry || !hit.entry.auto_open_if_singleton) return;
|
||
|
||
// Decode the folder key: "<scope>|<path>"
|
||
var sep = hit.key.indexOf('|');
|
||
if (sep < 0) return;
|
||
var scope = hit.key.slice(0, sep);
|
||
var path = hit.key.slice(sep + 1);
|
||
var name = hit.entry.name;
|
||
try { sessionStorage.setItem('rustguac_auto_opened', '1'); } catch (e) {}
|
||
|
||
// Use the same connect path the table's Connect button uses,
|
||
// minus the UI chrome. Open the resulting session in a new tab.
|
||
fetch('/api/addressbook/folders/' + encodeURIComponent(scope)
|
||
+ '/' + encodeURIComponent(path)
|
||
+ '/entries/' + encodeURIComponent(name) + '/connect', {
|
||
method: 'POST',
|
||
headers: Object.assign({'Content-Type': 'application/json'}, apiHeaders()),
|
||
credentials: 'same-origin',
|
||
body: JSON.stringify({ width: window.innerWidth, height: window.innerHeight, screen_width: screen.width, screen_height: screen.height, keyboard_layout: rgKeyboardLayout() })
|
||
})
|
||
.then(function(res) { return res.json(); })
|
||
.then(function(data) {
|
||
if (data && data.session_id) {
|
||
// Browsers block window.open after an async fetch
|
||
// (no user gesture), so replace the current tab.
|
||
// Kiosk-style single-entry users see only the client;
|
||
// to return to Connections they navigate back.
|
||
var label = encodeURIComponent(hit.entry.display_name || hit.entry.name);
|
||
window.location.href = '/client/' + data.session_id + '?name=' + label;
|
||
}
|
||
})
|
||
.catch(function() { /* silent — user can still connect manually */ });
|
||
}
|
||
|
||
function populateLoginScriptDropdown(selectedValue) {
|
||
var sel = document.getElementById('em-login-script');
|
||
sel.innerHTML = '<option value="">None</option>';
|
||
if (loginScriptsCache) {
|
||
loginScriptsCache.forEach(function(s) {
|
||
var opt = document.createElement('option');
|
||
opt.value = s;
|
||
opt.textContent = s;
|
||
sel.appendChild(opt);
|
||
});
|
||
}
|
||
sel.value = selectedValue || '';
|
||
}
|
||
|
||
function loadLoginScripts(cb) {
|
||
if (loginScriptsCache !== null) { if (cb) cb(); return; }
|
||
fetch('/api/login-scripts', { headers: apiHeaders(), credentials: 'same-origin' })
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(data) {
|
||
loginScriptsCache = data.scripts || [];
|
||
if (cb) cb();
|
||
})
|
||
.catch(function() {
|
||
loginScriptsCache = [];
|
||
if (cb) cb();
|
||
});
|
||
}
|
||
|
||
function showError(msg) {
|
||
document.getElementById('global-error').textContent = msg;
|
||
}
|
||
function clearError() {
|
||
document.getElementById('global-error').textContent = '';
|
||
}
|
||
// Transient success/info message using the same global banner. Clears after 4s.
|
||
function showInfo(msg) {
|
||
var el = document.getElementById('global-error');
|
||
el.textContent = msg;
|
||
setTimeout(function() {
|
||
if (el.textContent === msg) el.textContent = '';
|
||
}, 4000);
|
||
}
|
||
|
||
// Check user role and vault status
|
||
function init() {
|
||
fetch('/api/me', { headers: apiHeaders(), credentials: 'same-origin' })
|
||
.then(function(res) {
|
||
if (res.status === 401 || res.status === 403) { window.location.href = '/'; return; }
|
||
if (!res.ok) throw new Error('server returned ' + res.status);
|
||
return res.json();
|
||
})
|
||
.then(function(data) {
|
||
if (!data) return;
|
||
isAdmin = data.role === 'admin';
|
||
currentUserRole = data.role || 'viewer';
|
||
var level = roleLevel[data.role] || 0;
|
||
// Reconcile the server-written nav gate (see rustguac.css): the page
|
||
// GET carried no API key, so key users arrive on the anonymous variant.
|
||
document.documentElement.setAttribute('data-role-level', String((typeof apiKey !== 'undefined' && apiKey) ? 4 : level));
|
||
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 = '';
|
||
}
|
||
// Ad-hoc connections need no Vault, so this is settled before
|
||
// the no-Vault return below. Poweruser and up; an API key is
|
||
// always admin. The button itself is search.js's.
|
||
canAdhoc = level >= 3 || !!apiKey;
|
||
vaultReady = !!data.vault_enabled;
|
||
if (apiKey) {
|
||
// A key is never on the page GET, so the server served the anonymous variant.
|
||
document.documentElement.setAttribute('data-role-level', '4');
|
||
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 = '';
|
||
}
|
||
maybeOpenAdhocFromHash();
|
||
return;
|
||
}
|
||
// The first poll records the version this page is about to
|
||
// load; issued before the load so nothing in between is missed.
|
||
startLiveRefresh();
|
||
loadFolders(function() { rgHashOpen(); maybeOpenAdhocFromHash(); });
|
||
loadCredStatus();
|
||
loadActiveSessions();
|
||
setInterval(loadActiveSessions, 10000);
|
||
startOnboarding(currentUserRole);
|
||
})
|
||
.catch(function(err) {
|
||
showError('Failed to check authentication: ' + err.message);
|
||
});
|
||
}
|
||
|
||
// ── Share session modal ─────────────────────────────
|
||
function openShareModal(url, urlReadOnly) {
|
||
document.getElementById('share-modal-url').textContent = url;
|
||
var box = document.getElementById('share-modal-url-ro');
|
||
box.textContent = urlReadOnly;
|
||
document.getElementById('share-modal').classList.add('active');
|
||
// Auto-select the URL so Ctrl-C just works
|
||
try {
|
||
var range = document.createRange();
|
||
range.selectNodeContents(box);
|
||
var sel = window.getSelection();
|
||
sel.removeAllRanges();
|
||
sel.addRange(range);
|
||
} catch (e) {}
|
||
}
|
||
function closeShareModal() {
|
||
document.getElementById('share-modal').classList.remove('active');
|
||
}
|
||
function copyToClipboard(text, onOk, onFail) {
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(text).then(onOk).catch(function() { fallback(); });
|
||
} else {
|
||
fallback();
|
||
}
|
||
function fallback() {
|
||
var ta = document.createElement('textarea');
|
||
ta.value = text;
|
||
ta.style.position = 'fixed';
|
||
ta.style.opacity = '0';
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
var ok = false;
|
||
try { ok = document.execCommand('copy'); } catch (e) {}
|
||
document.body.removeChild(ta);
|
||
if (ok) onOk(); else if (onFail) onFail();
|
||
}
|
||
}
|
||
(function wireShareModal() {
|
||
document.querySelectorAll('.share-modal-copy').forEach(function(copyBtn) {
|
||
copyBtn.addEventListener('click', function() {
|
||
var url = document.getElementById(copyBtn.getAttribute('data-copy-from')).textContent;
|
||
copyToClipboard(url, function() {
|
||
copyBtn.textContent = 'Copied';
|
||
setTimeout(function() { copyBtn.textContent = 'Copy'; }, 2000);
|
||
});
|
||
});
|
||
});
|
||
var closeBtn = document.getElementById('share-modal-close');
|
||
if (closeBtn) closeBtn.addEventListener('click', closeShareModal);
|
||
var overlay = document.getElementById('share-modal');
|
||
if (overlay) overlay.addEventListener('click', function(ev) {
|
||
if (ev.target === overlay) closeShareModal();
|
||
});
|
||
})();
|
||
|
||
function loadActiveSessions() {
|
||
// Fetch active sessions and dormant VDI containers in parallel
|
||
var sessionsP = fetch('/api/sessions', { headers: apiHeaders(), credentials: 'same-origin' }).then(function(r) { return r.ok ? r.json() : []; }).catch(function() { return []; });
|
||
var vdiP = fetch('/api/vdi/containers', { headers: apiHeaders(), credentials: 'same-origin' }).then(function(r) { return r.ok ? r.json() : []; }).catch(function() { return []; });
|
||
|
||
Promise.all([sessionsP, vdiP]).then(function(results) {
|
||
var sessions = results[0] || [];
|
||
var vdiContainers = results[1] || [];
|
||
|
||
// Active/pending sessions (all visible to the current user)
|
||
var activeSessions = sessions.filter(function(s) {
|
||
return s.status === 'active' || s.status === 'pending';
|
||
});
|
||
|
||
// Active session container IDs (to avoid duplicating in VDI list)
|
||
var activeContainerSessions = {};
|
||
activeSessions.forEach(function(s) {
|
||
if (s.session_type === 'vdi') activeContainerSessions[s.hostname] = true;
|
||
});
|
||
|
||
// Dormant VDI containers (running but no active session)
|
||
var dormantContainers = vdiContainers.filter(function(c) {
|
||
return !c.has_active_session;
|
||
});
|
||
|
||
var grid = document.getElementById('active-sessions-grid');
|
||
var section = document.getElementById('active-sessions');
|
||
var total = activeSessions.length + dormantContainers.length;
|
||
|
||
if (total === 0) {
|
||
section.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
section.style.display = '';
|
||
document.getElementById('active-sessions-count').textContent = '(' + total + ')';
|
||
grid.innerHTML = '';
|
||
|
||
// Render active sessions
|
||
activeSessions.forEach(function(s) {
|
||
var card = document.createElement('div');
|
||
card.className = 'session-card';
|
||
card.title = 'Click to reconnect';
|
||
var thumbUrl = s.thumbnail_url || '';
|
||
var name = s.entry_display_name || s.hostname || s.url || '?';
|
||
var typeBadge = '<span class="type-badge type-' + escapeHtml(s.session_type) + '">' + escapeHtml(s.session_type) + '</span>';
|
||
var shareUrl = s.share_url ? (window.location.origin + s.share_url) : '';
|
||
var shareUrlRo = s.share_url_readonly ? (window.location.origin + s.share_url_readonly) : '';
|
||
var actionsHtml = '<div class="card-actions">';
|
||
if (shareUrl && shareUrlRo) {
|
||
actionsHtml += '<button type="button" class="btn-add" data-share-url="' + escapeAttr(shareUrl) + '" data-share-url-ro="' + escapeAttr(shareUrlRo) + '" title="Get a link to let someone join this session">Share</button>';
|
||
}
|
||
actionsHtml += '<button type="button" class="btn-add btn-danger" data-terminate="' + escapeAttr(s.session_id) + '" data-name="' + escapeAttr(name) + '" title="End this session now">Terminate</button>';
|
||
actionsHtml += '</div>';
|
||
card.innerHTML = '<img class="session-card-thumb" src="' + escapeHtml(thumbUrl) + (thumbUrl ? '?t=' + Date.now() : '') + '" onerror="this.style.display=\'none\'" alt="">'
|
||
+ '<div class="session-card-info"><div class="card-name">' + escapeHtml(name) + '</div>'
|
||
+ '<div class="card-meta">' + typeBadge + ' <span>' + escapeHtml(s.username || '') + '</span></div></div>'
|
||
+ actionsHtml;
|
||
card.title = 'Click to open this session';
|
||
var shareBtn = card.querySelector('[data-share-url]');
|
||
if (shareBtn) {
|
||
shareBtn.addEventListener('click', function(ev) {
|
||
ev.stopPropagation();
|
||
openShareModal(shareBtn.getAttribute('data-share-url'), shareBtn.getAttribute('data-share-url-ro'));
|
||
});
|
||
}
|
||
var termBtn = card.querySelector('[data-terminate]');
|
||
if (termBtn) {
|
||
termBtn.addEventListener('click', function(ev) {
|
||
ev.stopPropagation();
|
||
var sid = termBtn.getAttribute('data-terminate');
|
||
var label = termBtn.getAttribute('data-name') || sid;
|
||
rgConfirm('Terminate session "' + label + '"? Any unsaved work will be lost.', { danger: true, confirmText: 'Terminate' }).then(function(ok) {
|
||
if (!ok) return;
|
||
termBtn.disabled = true;
|
||
termBtn.textContent = 'Ending...';
|
||
fetch('/api/sessions/' + sid, {
|
||
method: 'DELETE',
|
||
headers: apiHeaders(),
|
||
credentials: 'same-origin'
|
||
}).then(function(res) {
|
||
if (!res.ok) return res.text().then(function(t) { throw new Error(t || 'terminate failed'); });
|
||
loadActiveSessions();
|
||
}).catch(function(err) {
|
||
termBtn.disabled = false;
|
||
termBtn.textContent = 'Terminate';
|
||
showError('Terminate failed: ' + err.message);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
card.addEventListener('click', function() {
|
||
// Active session: open the existing session's client URL.
|
||
// We used to POST to /connect here which minted a fresh
|
||
// session every click — caused duplicates when users
|
||
// clicked back to an existing tab. Dormant VDI containers
|
||
// below still need /connect since there's no live
|
||
// session to attach to.
|
||
if (s.client_url) {
|
||
window.open(s.client_url, '_blank');
|
||
}
|
||
});
|
||
grid.appendChild(card);
|
||
});
|
||
|
||
// Render dormant VDI containers
|
||
dormantContainers.forEach(function(c) {
|
||
var card = document.createElement('div');
|
||
card.className = 'session-card dormant';
|
||
card.title = 'Dormant desktop — click to reconnect';
|
||
var thumbUrl = c.thumbnail_url || '';
|
||
var name = c.image || c.container_name;
|
||
card.innerHTML = '<img class="session-card-thumb" src="' + escapeHtml(thumbUrl) + (thumbUrl ? '?t=' + Date.now() : '') + '" onerror="this.style.display=\'none\'" alt="">'
|
||
+ '<div class="session-card-info"><div class="card-name">' + escapeHtml(name) + '</div>'
|
||
+ '<div class="card-meta"><span class="type-badge type-vdi">vdi</span> <span>dormant</span></div></div>';
|
||
if (c.entry_key) {
|
||
card.addEventListener('click', function() {
|
||
// Reconnect via connections connect
|
||
var parts = c.entry_key.split('/');
|
||
if (parts.length === 3) {
|
||
var scope = parts[0], folder = parts[1], entry = parts[2];
|
||
fetch('/api/addressbook/folders/' + encodeURIComponent(scope) + '/' + encodeURIComponent(folder) + '/entries/' + encodeURIComponent(entry) + '/connect', {
|
||
method: 'POST',
|
||
headers: Object.assign({'Content-Type': 'application/json'}, apiHeaders()),
|
||
credentials: 'same-origin',
|
||
body: JSON.stringify({ width: window.innerWidth, height: window.innerHeight, screen_width: screen.width, screen_height: screen.height, keyboard_layout: rgKeyboardLayout() })
|
||
}).then(function(res) { return res.json(); }).then(function(data) {
|
||
if (data.session_id) window.open('/client/' + data.session_id, '_blank');
|
||
else showError(data.error || 'Failed to reconnect');
|
||
}).catch(function(e) { showError('Reconnect failed: ' + e.message); });
|
||
}
|
||
});
|
||
}
|
||
grid.appendChild(card);
|
||
});
|
||
});
|
||
}
|
||
|
||
// `done` runs once the folder list (and any restored selection) is
|
||
// in place, for the URL hash actions that need it.
|
||
function loadFolders(done) {
|
||
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(done);
|
||
}, 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 || [];
|
||
unavailableScopes = data.unavailable_scopes || [];
|
||
// Populate folder list and entry cache from batch response
|
||
folders = allFolders.map(function(f) {
|
||
return {
|
||
name: f.name,
|
||
scope: f.scope,
|
||
description: f.description || '',
|
||
display_name: f.display_name || '',
|
||
path: f.path || f.name,
|
||
has_children: !!f.has_children
|
||
};
|
||
});
|
||
folderEntries = {};
|
||
folderEntryCounts = {};
|
||
allFolders.forEach(function(f) {
|
||
var key = folderKey(f.scope, f.path || f.name);
|
||
// A path-only folder is listed because something below it
|
||
// is shared with you; its own entries are not. Leave it
|
||
// uncached so opening it asks the server, which says so.
|
||
if (f.path_only) return;
|
||
folderEntries[key] = f.entries || [];
|
||
folderEntryCounts[key] = (f.entries || []).length;
|
||
});
|
||
if (folders.length === 0 && unavailableScopes.length === 0 && isAdmin) {
|
||
document.getElementById('empty-state').style.display = '';
|
||
document.getElementById('main-content').style.display = 'none';
|
||
} else if (folders.length === 0) {
|
||
document.getElementById('main-content').style.display = '';
|
||
renderFolders();
|
||
} else {
|
||
document.getElementById('empty-state').style.display = 'none';
|
||
document.getElementById('main-content').style.display = '';
|
||
renderFolders();
|
||
// Restore: expanded tree first (populates subfolderCache so
|
||
// deep selections can resolve via findFolder), then the
|
||
// selected folder, then fall back to folders[0] if nothing
|
||
// else picked up.
|
||
restoreExpandedTree(function() {
|
||
renderFolders();
|
||
if (!selectedFolder) {
|
||
var saved = loadSelectedFolder();
|
||
if (saved) {
|
||
var f = findFolder(saved.scope, saved.path);
|
||
if (f) {
|
||
selectedFolder = f;
|
||
renderFolders();
|
||
loadEntries(f.scope, folderPath(f));
|
||
maybeAutoOpenSingleton();
|
||
if (done) done();
|
||
return;
|
||
}
|
||
}
|
||
// Nothing restored; pick the first folder.
|
||
selectedFolder = folders[0];
|
||
saveSelectedFolder();
|
||
renderFolders();
|
||
loadEntries(folders[0].scope, folderPath(folders[0]));
|
||
}
|
||
maybeAutoOpenSingleton();
|
||
if (done) done();
|
||
});
|
||
return;
|
||
}
|
||
maybeAutoOpenSingleton();
|
||
if (done) done();
|
||
})
|
||
.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 = '';
|
||
// Greyed placeholders for scopes whose Vault backend is unreachable
|
||
// (multi-Vault DR). Empty in a healthy single-Vault deployment, so
|
||
// this adds nothing to the quick-start experience.
|
||
(unavailableScopes || []).forEach(function(scope) {
|
||
ul.appendChild(buildUnavailableScopeNode(scope));
|
||
});
|
||
if (folders.length === 0) {
|
||
if (!unavailableScopes || unavailableScopes.length === 0) {
|
||
var empty = document.createElement('li');
|
||
empty.style.cssText = 'color:#666;cursor:default;padding:1em 0.8em';
|
||
empty.textContent = 'No folders visible to your account.';
|
||
ul.appendChild(empty);
|
||
}
|
||
return;
|
||
}
|
||
folders.forEach(function(f) {
|
||
ul.appendChild(buildFolderNode(f, true));
|
||
});
|
||
}
|
||
|
||
// A non-interactive, greyed tree node shown when a scope's Vault backend
|
||
// is down, so the outage is visible instead of the scope silently
|
||
// vanishing. The page's /api/me poll re-runs init() every 15s, which
|
||
// refreshes this state.
|
||
function buildUnavailableScopeNode(scope) {
|
||
var label = scope === 'instance'
|
||
? 'Local'
|
||
: (scope.charAt(0).toUpperCase() + scope.slice(1));
|
||
var li = document.createElement('li');
|
||
li.className = 'folder-unavailable';
|
||
li.title = 'The Vault serving ' + label.toLowerCase()
|
||
+ ' connections is unreachable. Retrying automatically.';
|
||
var row = document.createElement('div');
|
||
row.className = 'folder-row';
|
||
row.style.cssText = 'opacity:0.55;cursor:default';
|
||
var icon = document.createElement('span');
|
||
icon.className = 'tree-chevron';
|
||
icon.style.color = 'var(--status-error)';
|
||
icon.textContent = '⚠'; // warning sign
|
||
row.appendChild(icon);
|
||
var name = document.createElement('span');
|
||
name.className = 'folder-name';
|
||
name.textContent = label + ' connections unavailable';
|
||
row.appendChild(name);
|
||
li.appendChild(row);
|
||
return li;
|
||
}
|
||
|
||
function buildFolderNode(f, showScope) {
|
||
var path = folderPath(f);
|
||
var key = folderKey(f.scope, path);
|
||
var li = document.createElement('li');
|
||
li.setAttribute('data-path', path);
|
||
li.setAttribute('data-scope', f.scope);
|
||
if (selectedFolder && selectedFolder.scope === f.scope && folderPath(selectedFolder) === path) {
|
||
li.className = 'selected';
|
||
}
|
||
|
||
var row = document.createElement('div');
|
||
row.className = 'folder-row';
|
||
|
||
var isExpanded = !!expandedPaths[key];
|
||
if (f.has_children) {
|
||
// Chevron column exists only for folders that can expand; leaves
|
||
// render no placeholder so their names start flush left (#254).
|
||
// Full-size triangles (U+25BC/U+25B6), matching every other
|
||
// collapsible on the page; the small variants (U+25BE/U+25B8)
|
||
// rendered as faint, hard-to-see ink.
|
||
var chevron = document.createElement('span');
|
||
chevron.className = 'tree-chevron';
|
||
chevron.textContent = isExpanded ? '\u25BC' : '\u25B6';
|
||
chevron.title = isExpanded ? 'Collapse' : 'Expand';
|
||
if (isExpanded) chevron.classList.add('expanded');
|
||
chevron.addEventListener('click', function(ev) {
|
||
ev.stopPropagation();
|
||
toggleFolder(f);
|
||
});
|
||
row.appendChild(chevron);
|
||
}
|
||
|
||
var main = document.createElement('div');
|
||
main.className = 'folder-row-main';
|
||
var nameRow = document.createElement('div');
|
||
nameRow.className = 'folder-name-row';
|
||
var nameSpan = document.createElement('span');
|
||
nameSpan.className = 'folder-name';
|
||
// Title is the display name when one is set (e.g. the NetBox tenant
|
||
// name); the folder key then goes on the subline. Without one the
|
||
// key is the title and the description the subline.
|
||
nameSpan.textContent = f.display_name || f.name;
|
||
nameSpan.title = f.display_name ? f.name + (f.description ? ' \u2014 ' + f.description : '') : (f.description || f.name);
|
||
nameRow.appendChild(nameSpan);
|
||
if (folderEntryCounts[key] !== undefined) {
|
||
// Entry count as a small right-aligned pill on the name line.
|
||
var count = folderEntryCounts[key];
|
||
var countSpan = document.createElement('span');
|
||
countSpan.className = 'folder-count';
|
||
countSpan.setAttribute('data-count', count);
|
||
countSpan.textContent = count;
|
||
countSpan.title = count + (count === 1 ? ' entry' : ' entries');
|
||
nameRow.appendChild(countSpan);
|
||
}
|
||
if (showScope) {
|
||
var scopeBadge = document.createElement('span');
|
||
scopeBadge.className = 'folder-scope';
|
||
scopeBadge.setAttribute('data-scope', f.scope);
|
||
scopeBadge.title = f.scope === 'shared'
|
||
? 'Shared — visible across all rustguac instances'
|
||
: 'Instance — this server only';
|
||
nameRow.appendChild(scopeBadge);
|
||
}
|
||
main.appendChild(nameRow);
|
||
if (f.display_name || f.description) {
|
||
var desc = document.createElement('span');
|
||
desc.className = 'folder-desc' + (f.display_name ? ' folder-key' : '');
|
||
desc.textContent = f.display_name ? f.name : f.description;
|
||
main.appendChild(desc);
|
||
}
|
||
row.appendChild(main);
|
||
|
||
row.addEventListener('click', function() {
|
||
selectedFolder = f;
|
||
saveSelectedFolder();
|
||
renderFolders();
|
||
loadEntries(f.scope, path);
|
||
});
|
||
li.appendChild(row);
|
||
|
||
if (f.has_children && isExpanded) {
|
||
var childrenUl = document.createElement('ul');
|
||
childrenUl.className = 'tree-children';
|
||
var kids = subfolderCache[key];
|
||
if (!kids) {
|
||
var loadingLi = document.createElement('li');
|
||
loadingLi.style.color = 'var(--text-dim)';
|
||
loadingLi.style.cursor = 'default';
|
||
loadingLi.style.padding = '0.3em 0.5em';
|
||
loadingLi.textContent = 'Loading...';
|
||
childrenUl.appendChild(loadingLi);
|
||
} else if (kids.length === 0) {
|
||
var emptyLi = document.createElement('li');
|
||
emptyLi.style.color = 'var(--text-dim)';
|
||
emptyLi.style.cursor = 'default';
|
||
emptyLi.style.padding = '0.3em 0.5em';
|
||
emptyLi.style.fontSize = '0.85em';
|
||
emptyLi.textContent = '(empty)';
|
||
childrenUl.appendChild(emptyLi);
|
||
} else {
|
||
kids.forEach(function(child) {
|
||
childrenUl.appendChild(buildFolderNode(child, false));
|
||
});
|
||
}
|
||
li.appendChild(childrenUl);
|
||
}
|
||
|
||
return li;
|
||
}
|
||
|
||
function toggleFolder(f) {
|
||
var path = folderPath(f);
|
||
var key = folderKey(f.scope, path);
|
||
if (expandedPaths[key]) {
|
||
delete expandedPaths[key];
|
||
saveExpandedPaths();
|
||
renderFolders();
|
||
} else {
|
||
expandedPaths[key] = true;
|
||
saveExpandedPaths();
|
||
renderFolders();
|
||
if (!subfolderCache[key]) {
|
||
loadSubfolders(f.scope, path, function() { renderFolders(); });
|
||
}
|
||
}
|
||
}
|
||
|
||
function loadSubfolders(scope, path, cb) {
|
||
var key = folderKey(scope, path);
|
||
fetch('/api/addressbook/folders/' + encodeURIComponent(scope) + '/' + encodeURIComponent(path) + '/subfolders', {
|
||
headers: apiHeaders(),
|
||
credentials: 'same-origin'
|
||
})
|
||
.then(function(res) {
|
||
if (!res.ok) return res.text().then(function(t) { throw new Error(t); });
|
||
return res.json();
|
||
})
|
||
.then(function(data) {
|
||
subfolderCache[key] = (data || []).map(function(sf) {
|
||
return {
|
||
name: sf.name,
|
||
scope: sf.scope,
|
||
description: sf.description || '',
|
||
display_name: sf.display_name || '',
|
||
path: sf.path || (path + '/' + sf.name),
|
||
has_children: !!sf.has_children
|
||
};
|
||
});
|
||
if (cb) cb();
|
||
})
|
||
.catch(function(err) {
|
||
subfolderCache[key] = [];
|
||
showError('Failed to load subfolders: ' + err.message);
|
||
if (cb) cb();
|
||
});
|
||
}
|
||
|
||
function findFolder(scope, path) {
|
||
// Search top-level + subfolder cache for a folder object by scope/path.
|
||
for (var i = 0; i < folders.length; i++) {
|
||
if (folders[i].scope === scope && folderPath(folders[i]) === path) return folders[i];
|
||
}
|
||
for (var k in subfolderCache) {
|
||
var arr = subfolderCache[k];
|
||
for (var j = 0; j < arr.length; j++) {
|
||
if (arr[j].scope === scope && folderPath(arr[j]) === path) return arr[j];
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ── Live refresh ──
|
||
// GET /api/addressbook/version?since=N answers from memory with what
|
||
// rustguac changed in the address book after version N: which folders,
|
||
// and whether it was their entries or the folder itself. It is polled
|
||
// every few seconds while the tab is visible and no dialog is open,
|
||
// and a tab acts only on what it shows:
|
||
// entries changed in the open folder -> that folder is re-read
|
||
// entries changed elsewhere -> the cached copy is forgotten;
|
||
// nothing is read until opened
|
||
// a folder created, deleted or changed -> the folder list is re-read
|
||
// (no entries), plus the open
|
||
// folder if it was in that subtree
|
||
// the search index -> marked stale and rebuilt the
|
||
// next time the box is used;
|
||
// while results are showing, only
|
||
// the changed folder's rows are
|
||
// re-read and patched in
|
||
// So one change costs a tab at most one folder's entries plus one
|
||
// folder list, never the whole book, and a poll costs no Vault read.
|
||
// `changes: null` (first poll, or the tab fell further behind than
|
||
// the server's log reaches, or the server restarted) refreshes what
|
||
// is shown, once. Nothing is reloaded on a timer. A hidden tab does
|
||
// not poll and catches up the moment it is shown; a tab with a
|
||
// dialog open does not poll either, so a change waits for the dialog
|
||
// instead of being missed. A user's own credential variables are not
|
||
// address-book changes and never arrive here: the tab that saved them
|
||
// reloads its own status (saveMyCredentials).
|
||
var LIVE_POLL_MS = 3000;
|
||
var liveVersion = null; // the version this tab's view reflects
|
||
var liveEpoch = null; // which server process that version came from
|
||
var liveTimer = null;
|
||
var liveBusy = false;
|
||
|
||
|
||
|
||
// ── Live refresh ──
|
||
// GET /api/addressbook/version?since=N answers from memory with what
|
||
// rustguac changed in the address book after version N: which folders,
|
||
// and whether it was their entries or the folder itself. It is polled
|
||
// every few seconds while the tab is visible and no dialog is open,
|
||
// and a tab acts only on what it shows:
|
||
// entries changed in the open folder -> that folder is re-read
|
||
// entries changed elsewhere -> the cached copy is forgotten;
|
||
// nothing is read until opened
|
||
// a folder created, deleted or changed -> the folder list is re-read
|
||
// (no entries), plus the open
|
||
// folder if it was in that subtree
|
||
// the search index (search.js) -> told to check the version
|
||
// now rather than at its next poll
|
||
// So one change costs a tab at most one folder's entries plus one
|
||
// folder list, never the whole book, and a poll costs no Vault read.
|
||
// `changes: null` (first poll, or the tab fell further behind than
|
||
// the server's log reaches, or the server restarted) refreshes what
|
||
// is shown, once. Nothing is reloaded on a timer. A hidden tab does
|
||
// not poll and catches up the moment it is shown; a tab with a
|
||
// dialog open does not poll either, so a change waits for the dialog
|
||
// instead of being missed. A user's own credential variables are not
|
||
// address-book changes and never arrive here: the tab that saved them
|
||
// reloads its own status (saveMyCredentials).
|
||
var LIVE_POLL_MS = 3000;
|
||
var liveVersion = null; // the version this tab's view reflects
|
||
var liveEpoch = null; // which server process that version came from
|
||
var liveTimer = null;
|
||
var liveBusy = false;
|
||
|
||
// What a batch of changes means for this tab. Pure, so it is tested
|
||
// without a browser (tests/connections-live.mjs).
|
||
// changes: [{scope, folder, kind: 'entries'|'tree'|'all'}], or null
|
||
// to refresh everything shown
|
||
// view: { selectedKey, searchActive, indexLoaded, cachedKeys }
|
||
// Returns { tree, treeAll, treeKeys, fetch: [key], drop: [key],
|
||
// index: 'keep'|'stale'|'rebuild' }: `fetch` is re-read now
|
||
// (the open folder, or a folder whose index rows to patch),
|
||
// `drop` is forgotten until opened.
|
||
function livePlan(changes, view) {
|
||
var plan = { tree: false, treeAll: false, treeKeys: [], fetch: [], drop: [], index: 'keep' };
|
||
var all = !changes;
|
||
var stale = false;
|
||
var touched = {};
|
||
var under = function(k, key) { return k === key || k.indexOf(key + '/') === 0; };
|
||
// A tree row without a folder means a folder changed somewhere
|
||
// this user may not be told about (the server never names a
|
||
// folder they cannot open): refresh what is shown, as for `all`.
|
||
(changes || []).forEach(function(c) {
|
||
if (c.kind === 'all' || (c.kind === 'tree' && !c.folder)) all = true;
|
||
});
|
||
if (!all) changes.forEach(function(c) {
|
||
var key = c.scope + '|' + c.folder;
|
||
if (c.kind === 'tree') {
|
||
plan.tree = true;
|
||
plan.treeKeys.push(key);
|
||
stale = true; // paths or visibility moved: only a walk knows
|
||
// Whatever was cached below the folder went with it.
|
||
view.cachedKeys.forEach(function(k) { if (under(k, key)) touched[k] = 'drop'; });
|
||
if (view.selectedKey && under(view.selectedKey, key)) touched[view.selectedKey] = 'fetch';
|
||
} else if (key === view.selectedKey) {
|
||
touched[key] = 'fetch';
|
||
} else if (view.searchActive && view.indexLoaded) {
|
||
touched[key] = touched[key] || 'patch';
|
||
} else {
|
||
touched[key] = touched[key] || 'drop';
|
||
stale = true;
|
||
}
|
||
});
|
||
if (all) {
|
||
plan.tree = true;
|
||
plan.treeAll = true;
|
||
stale = true;
|
||
view.cachedKeys.forEach(function(k) { touched[k] = 'drop'; });
|
||
if (view.selectedKey) touched[view.selectedKey] = 'fetch';
|
||
}
|
||
if (stale) plan.index = (view.searchActive && view.indexLoaded) ? 'rebuild' : 'stale';
|
||
Object.keys(touched).forEach(function(k) {
|
||
var what = touched[k];
|
||
// While searching the open folder is not on screen: patch the
|
||
// index from it, or just forget it when the index is being
|
||
// rebuilt anyway.
|
||
if (what === 'fetch' && view.searchActive) what = 'patch';
|
||
if (what === 'patch' && plan.index === 'rebuild') what = 'drop';
|
||
if (what === 'drop') plan.drop.push(k); else plan.fetch.push(k);
|
||
});
|
||
return plan;
|
||
}
|
||
|
||
// What a poll answer means, given the version and epoch this tab
|
||
// last saw. Pure, tested in tests/connections-live.mjs. Returns
|
||
// undefined when there is nothing to do, null to refresh what is
|
||
// shown, or the list of changes. A different epoch is a restarted
|
||
// server: its version counter started again, so its changes are not
|
||
// the ones after ours, whatever the numbers say.
|
||
function liveChanges(data, version, epoch) {
|
||
if (data.epoch !== epoch) return null;
|
||
if (data.version === version && data.changes) return undefined;
|
||
return data.changes;
|
||
}
|
||
|
||
function liveView() {
|
||
return {
|
||
selectedKey: selectedFolder ? folderKey(selectedFolder.scope, folderPath(selectedFolder)) : null,
|
||
// Search is an overlay from search.js over an untouched folder
|
||
// view, so the open folder is always the one on screen.
|
||
searchActive: false,
|
||
indexLoaded: false,
|
||
cachedKeys: Object.keys(folderEntries)
|
||
};
|
||
}
|
||
|
||
function liveApply(plan) {
|
||
plan.drop.forEach(function(k) { delete folderEntries[k]; delete folderEntryCounts[k]; });
|
||
if (plan.index !== 'keep' && typeof window.rgSearchRecheck === 'function') window.rgSearchRecheck();
|
||
var fetchFolders = function() {
|
||
plan.fetch.forEach(function(k) {
|
||
var sep = k.indexOf('|');
|
||
var scope = k.slice(0, sep), path = k.slice(sep + 1);
|
||
if (liveView().selectedKey === k) {
|
||
loadEntries(scope, path, true);
|
||
} else {
|
||
refetchFolderEntries(scope, path);
|
||
}
|
||
});
|
||
};
|
||
if (plan.tree) reloadFolderList(plan, fetchFolders); else fetchFolders();
|
||
}
|
||
|
||
// Re-read one folder's entries into the cache without showing it.
|
||
function refetchFolderEntries(scope, path) {
|
||
var key = folderKey(scope, path);
|
||
fetch('/api/addressbook/folders/' + encodeURIComponent(scope) + '/' + encodeURIComponent(path) + '/entries', {
|
||
headers: apiHeaders(),
|
||
credentials: 'same-origin'
|
||
})
|
||
.then(function(res) { return res.ok ? res.json() : []; })
|
||
.then(function(entries) {
|
||
if (Array.isArray(entries) && entries.length) {
|
||
folderEntries[key] = entries;
|
||
folderEntryCounts[key] = entries.length;
|
||
} else {
|
||
delete folderEntries[key];
|
||
delete folderEntryCounts[key];
|
||
entries = [];
|
||
}
|
||
renderFolders();
|
||
})
|
||
.catch(function() {});
|
||
}
|
||
|
||
// Re-read the folder list alone (no entries) after a folder came,
|
||
// went or changed, then the subfolder listings that change made
|
||
// stale: the changed folder's parent (it came or went there), its
|
||
// own, and its subtree's. The open folder is re-resolved; if it is
|
||
// gone the first folder is shown, as after deleting one here.
|
||
function reloadFolderList(plan, cb) {
|
||
fetch('/api/addressbook/folders', { 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(list) {
|
||
folders = (list || []).map(function(f) {
|
||
return {
|
||
name: f.name,
|
||
scope: f.scope,
|
||
description: f.description || '',
|
||
path: f.path || f.name,
|
||
has_children: !!f.has_children
|
||
};
|
||
});
|
||
var parentOf = function(key) { var i = key.lastIndexOf('/'); return i < 0 ? null : key.slice(0, i); };
|
||
Object.keys(subfolderCache).forEach(function(k) {
|
||
var stale = plan.treeAll || plan.treeKeys.some(function(key) {
|
||
return k === key || k === parentOf(key) || k.indexOf(key + '/') === 0;
|
||
});
|
||
if (stale) delete subfolderCache[k];
|
||
});
|
||
var emptyEl = document.getElementById('empty-state');
|
||
var mainEl = document.getElementById('main-content');
|
||
if (folders.length === 0 && unavailableScopes.length === 0 && isAdmin) {
|
||
emptyEl.style.display = '';
|
||
mainEl.style.display = 'none';
|
||
} else {
|
||
emptyEl.style.display = 'none';
|
||
mainEl.style.display = '';
|
||
}
|
||
restoreExpandedTree(function() {
|
||
resolveSelectedFolder(function(sel) {
|
||
if (selectedFolder && !sel) {
|
||
var goneKey = folderKey(selectedFolder.scope, folderPath(selectedFolder));
|
||
plan.fetch = plan.fetch.filter(function(k) { return k !== goneKey; });
|
||
selectedFolder = folders[0] || null;
|
||
saveSelectedFolder();
|
||
if (selectedFolder) {
|
||
loadEntries(selectedFolder.scope, folderPath(selectedFolder));
|
||
} else if (!selectedFolder) {
|
||
document.getElementById('entries-header').style.display = 'none';
|
||
document.getElementById('entries-content').innerHTML = '<p class="empty">Select a folder to view entries.</p>';
|
||
}
|
||
} else if (sel) {
|
||
selectedFolder = sel;
|
||
}
|
||
renderFolders();
|
||
if (cb) cb();
|
||
});
|
||
});
|
||
})
|
||
.catch(function(err) {
|
||
showError('Failed to refresh folders: ' + err.message);
|
||
if (cb) cb();
|
||
});
|
||
}
|
||
|
||
// The open folder's object in the rebuilt tree, loading the listings
|
||
// of collapsed ancestors it hides behind; null if it no longer exists.
|
||
function resolveSelectedFolder(cb) {
|
||
if (!selectedFolder) { cb(null); return; }
|
||
var scope = selectedFolder.scope, path = folderPath(selectedFolder);
|
||
var parts = path.split('/');
|
||
var ancestors = [];
|
||
for (var i = 1; i < parts.length; i++) ancestors.push(parts.slice(0, i).join('/'));
|
||
var idx = 0;
|
||
(function next() {
|
||
if (idx >= ancestors.length) { cb(findFolder(scope, path)); return; }
|
||
var p = ancestors[idx++];
|
||
if (!findFolder(scope, p)) { cb(null); return; }
|
||
if (subfolderCache[folderKey(scope, p)]) next(); else loadSubfolders(scope, p, next);
|
||
})();
|
||
}
|
||
|
||
function livePoll() {
|
||
if (liveBusy) return;
|
||
// The baseline is taken whatever the tab's state, or a page loaded
|
||
// in a background tab would miss what changed before it was shown.
|
||
// After that a hidden tab does not poll, and a dialog may be
|
||
// editing what just changed: the poll waits for it, and the change
|
||
// is picked up once it closes.
|
||
if (liveVersion !== null && (document.hidden || document.querySelector('.modal-overlay.active'))) return;
|
||
liveBusy = true;
|
||
var url = '/api/addressbook/version' + (liveVersion === null ? '' : '?since=' + liveVersion);
|
||
fetch(url, { headers: apiHeaders(), credentials: 'same-origin' })
|
||
.then(function(res) { return res.ok ? res.json() : null; })
|
||
.then(function(data) {
|
||
if (!data || typeof data.version !== 'number') return;
|
||
if (liveVersion === null) {
|
||
// Baseline: the page is loading this version now.
|
||
liveVersion = data.version;
|
||
liveEpoch = data.epoch;
|
||
return;
|
||
}
|
||
var changes = liveChanges(data, liveVersion, liveEpoch);
|
||
liveVersion = data.version;
|
||
liveEpoch = data.epoch;
|
||
if (changes === undefined) return;
|
||
liveApply(livePlan(changes, liveView()));
|
||
})
|
||
.catch(function() {})
|
||
.then(function() { liveBusy = false; });
|
||
}
|
||
function startLiveRefresh() {
|
||
if (liveTimer) return;
|
||
livePoll();
|
||
liveTimer = setInterval(livePoll, LIVE_POLL_MS);
|
||
document.addEventListener('visibilitychange', function() { if (!document.hidden) livePoll(); });
|
||
window.addEventListener('focus', livePoll);
|
||
}
|
||
|
||
function highlight(text, tokens) {
|
||
var safe = escapeHtml(text || '');
|
||
if (!tokens || !tokens.length) return safe;
|
||
var escaped = tokens
|
||
.filter(function(t) { return t.length > 0; })
|
||
.map(function(t) { return t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); });
|
||
if (!escaped.length) return safe;
|
||
var re = new RegExp('(' + escaped.join('|') + ')', 'gi');
|
||
return safe.replace(re, '<mark>$1</mark>');
|
||
}
|
||
|
||
// Open a folder requested via the URL hash (#open=scope/path), used
|
||
// when the global search on another page links here.
|
||
function rgHashOpen() {
|
||
var m = /^#open=(.+)$/.exec(location.hash || '');
|
||
if (!m) return;
|
||
var decoded = decodeURIComponent(m[1]);
|
||
var slash = decoded.indexOf('/');
|
||
if (slash < 0) return;
|
||
var scope = decoded.slice(0, slash), path = decoded.slice(slash + 1);
|
||
try { history.replaceState(null, '', location.pathname + location.search); } catch (e) {}
|
||
window.rgSelectFolder(scope, path);
|
||
}
|
||
|
||
// Select a folder in the sidebar: expand its ancestors, highlight it and
|
||
// load its entries. Exposed for the global search (search.js) and the
|
||
// #open=scope/path URL hash, so a folder can be opened from any page.
|
||
window.rgSelectFolder = function(scope, targetPath) {
|
||
var parts = targetPath.split('/');
|
||
var ancestors = [];
|
||
for (var i = 1; i < parts.length; i++) {
|
||
ancestors.push(parts.slice(0, i).join('/'));
|
||
}
|
||
ancestors.forEach(function(p) {
|
||
expandedPaths[folderKey(scope, p)] = true;
|
||
});
|
||
saveExpandedPaths();
|
||
|
||
var idx = 0;
|
||
function loadNext() {
|
||
if (idx >= ancestors.length) {
|
||
var target = findFolder(scope, targetPath);
|
||
if (target) {
|
||
selectedFolder = target;
|
||
saveSelectedFolder();
|
||
renderFolders();
|
||
loadEntries(scope, targetPath);
|
||
setTimeout(function() {
|
||
try {
|
||
var sel = '.folder-list li[data-scope="' + scope + '"][data-path="' + targetPath.replace(/"/g, '\\"') + '"]';
|
||
var row = document.querySelector(sel);
|
||
if (row && row.scrollIntoView) row.scrollIntoView({ block: 'nearest' });
|
||
} catch (_) { /* selector edge case — non-fatal */ }
|
||
}, 80);
|
||
} else {
|
||
renderFolders();
|
||
}
|
||
return;
|
||
}
|
||
var p = ancestors[idx++];
|
||
var key = folderKey(scope, p);
|
||
if (subfolderCache[key]) {
|
||
loadNext();
|
||
} else {
|
||
loadSubfolders(scope, p, loadNext);
|
||
}
|
||
}
|
||
loadNext();
|
||
};
|
||
|
||
function loadEntries(scope, folderPath, forceRefresh) {
|
||
var header = document.getElementById('entries-header');
|
||
header.style.display = '';
|
||
var f = findFolder(scope, folderPath);
|
||
var dn = f && f.display_name ? f.display_name : '';
|
||
document.getElementById('entries-title').textContent = dn || folderPath;
|
||
document.getElementById('entries-key').textContent = dn ? folderPath : '';
|
||
document.getElementById('entries-desc').textContent = f && f.description ? f.description : '';
|
||
|
||
var cacheKey = folderKey(scope, folderPath);
|
||
var cached = folderEntries[cacheKey];
|
||
// UX (#254): jump to the entries header and flash the title, but
|
||
// only when the shown folder actually changed (not on initial
|
||
// load, live refresh, forceRefresh after a mutation, or search
|
||
// clear re-rendering the same folder).
|
||
announceFolderShown(cacheKey, header);
|
||
|
||
// Use cache if available and not forcing refresh (e.g. after mutation)
|
||
if (cached && !forceRefresh) {
|
||
currentEntries = cached;
|
||
folderEntryCounts[cacheKey] = cached.length;
|
||
updateFolderCount(scope, folderPath, cached.length);
|
||
renderEntries(cached, scope, folderPath);
|
||
return;
|
||
}
|
||
|
||
var content = document.getElementById('entries-content');
|
||
content.innerHTML = entriesSkeletonHtml();
|
||
|
||
fetch('/api/addressbook/folders/' + encodeURIComponent(scope) + '/' + encodeURIComponent(folderPath) + '/entries', {
|
||
headers: apiHeaders(),
|
||
credentials: 'same-origin'
|
||
})
|
||
.then(function(res) {
|
||
// A folder can be in the tree only because something below
|
||
// it is shared with you; its own entries are not. Say so
|
||
// rather than showing the raw refusal.
|
||
if (res.status === 403) return null;
|
||
if (!res.ok) return res.text().then(function(t) { throw new Error(t); });
|
||
return res.json();
|
||
})
|
||
.then(function(entries) {
|
||
if (entries === null) {
|
||
currentEntries = [];
|
||
bulkSyncSelection([], scope, folderPath);
|
||
updateBulkBar();
|
||
content.innerHTML = '<p class="empty">You don\'t have access to this folder itself. Open a folder inside it to see what has been shared with you.</p>';
|
||
return;
|
||
}
|
||
currentEntries = entries;
|
||
folderEntries[cacheKey] = entries;
|
||
folderEntryCounts[cacheKey] = entries.length;
|
||
updateFolderCount(scope, folderPath, entries.length); // in-place badge update
|
||
renderEntries(entries, scope, folderPath);
|
||
})
|
||
.catch(function(err) {
|
||
content.innerHTML = '<p class="empty">' + escapeHtml(err.message) + '</p>';
|
||
});
|
||
}
|
||
|
||
// "host (Tenant)" display names, as the NetBox sync writes them, read as
|
||
// the host over a quieter tenant line. `tokens` highlights search hits.
|
||
function entryNameHtml(label, tokens) {
|
||
var esc = tokens ? function(t) { return highlight(t, tokens); } : escapeHtml;
|
||
var m = /^(.*\S)\s+\(([^()]+)\)$/.exec(label || '');
|
||
if (!m) return esc(label || '');
|
||
return '<span class="entry-name">' + esc(m[1]) + '</span><span class="entry-sub">' + esc(m[2]) + '</span>';
|
||
}
|
||
|
||
// Credential variables an entry references that the user hasn't set
|
||
// yet. Empty until the cred status has loaded (setCredVars !== null).
|
||
function entryUnsetVars(e) {
|
||
if (!setCredVars || !e || !e.credential_variables) return [];
|
||
return e.credential_variables.filter(function(v) { return !setCredVars.has(v); });
|
||
}
|
||
|
||
// Fetch which credential variables the user has set, then refresh the
|
||
// proactive banner and re-render the entry list so pills appear.
|
||
function loadCredStatus() {
|
||
fetch('/api/me/credentials', { headers: apiHeaders(), credentials: 'same-origin' })
|
||
.then(function(r) { return r.ok ? r.json() : null; })
|
||
.then(function(data) {
|
||
if (!data || !data.credentials) return;
|
||
setCredVars = new Set();
|
||
Object.keys(data.credentials).forEach(function(k) {
|
||
if (data.credentials[k] && data.credentials[k].set) setCredVars.add(k);
|
||
});
|
||
updateCredBanner();
|
||
refreshEntriesView();
|
||
})
|
||
.catch(function() {});
|
||
}
|
||
|
||
// Distinct credential variables referenced by any visible entry that the
|
||
// user hasn't set. Prefer the (comprehensive) search index; fall back to
|
||
// whatever folder entries are cached.
|
||
// localStorage remembers which unset credentials the user dismissed, so
|
||
// the banner stays gone across refreshes. It only re-appears if a
|
||
// genuinely NEW unset credential shows up (not merely because the set
|
||
// shrank after a partial save) — dismissing silences today's set, not
|
||
// future needs.
|
||
var CREDS_DISMISS_KEY = 'rustguac_creds_banner_dismissed';
|
||
var currentUnsetCreds = []; // names behind the banner right now (for dismiss)
|
||
function getDismissedCreds() {
|
||
try {
|
||
return new Set((localStorage.getItem(CREDS_DISMISS_KEY) || '').split(',').filter(Boolean));
|
||
} catch (e) { return new Set(); }
|
||
}
|
||
|
||
function updateCredBanner() {
|
||
var banner = document.getElementById('creds-banner');
|
||
if (!banner) return;
|
||
if (!setCredVars) { banner.style.display = 'none'; return; }
|
||
var entries = [];
|
||
if (window.rgSearchIndex) {
|
||
entries = window.rgSearchIndex.map(function(r) { return r.entry; });
|
||
} else {
|
||
Object.keys(folderEntries).forEach(function(k) {
|
||
(folderEntries[k] || []).forEach(function(e) { entries.push(e); });
|
||
});
|
||
}
|
||
var unset = {};
|
||
entries.forEach(function(e) {
|
||
entryUnsetVars(e).forEach(function(v) { unset[v] = true; });
|
||
});
|
||
var names = Object.keys(unset);
|
||
currentUnsetCreds = names;
|
||
if (names.length === 0) { banner.style.display = 'none'; return; }
|
||
// Stay hidden only if every currently-unset credential was already
|
||
// dismissed; a new one re-alerts.
|
||
var dismissed = getDismissedCreds();
|
||
if (names.every(function(n) { return dismissed.has(n); })) {
|
||
banner.style.display = 'none';
|
||
return;
|
||
}
|
||
document.getElementById('creds-banner-text').textContent =
|
||
'You have ' + names.length + ' credential' + (names.length === 1 ? '' : 's')
|
||
+ ' to set up before some connections will work.';
|
||
banner.style.display = 'flex';
|
||
}
|
||
|
||
function refreshEntriesView() {
|
||
if (lastEntriesRender) {
|
||
renderEntries(lastEntriesRender.entries, lastEntriesRender.scope, lastEntriesRender.folder);
|
||
}
|
||
}
|
||
|
||
// Shared facet filters (filters.js) above the entries table: Type in a
|
||
// folder, Type and Folder across search results. Selections persist in
|
||
// sessionStorage for this tab; the Folder facet only applies while
|
||
// searching.
|
||
// Folder-view filter (Type), shown in the folder header.
|
||
var typeFacet = { key: 'type', label: 'Type',
|
||
value: function(x) { var e = x.entry || x; return e.session_type; },
|
||
render: function(v) { return '<span class="type-badge type-' + escapeAttr(v) + '">' + escapeHtml(v) + '</span>'; } };
|
||
var entryFilters = rgFilters.create({
|
||
mount: document.getElementById('entries-filters'),
|
||
id: 'connections',
|
||
facets: [ typeFacet ],
|
||
onChange: function() {
|
||
if (lastEntriesRender) renderEntries(lastEntriesRender.entries, lastEntriesRender.scope, lastEntriesRender.folder);
|
||
}
|
||
});
|
||
|
||
function renderEntries(allEntries, scope, folder) {
|
||
lastEntriesRender = { entries: allEntries, scope: scope, folder: folder };
|
||
bulkSyncSelection(allEntries, scope, folder);
|
||
var content = document.getElementById('entries-content');
|
||
entryFilters.update(allEntries);
|
||
var entries = entryFilters.apply(allEntries);
|
||
if (entries.length === 0 && allEntries.length > 0) {
|
||
updateBulkBar();
|
||
content.innerHTML = '<p class="empty">No entries match the current filters.</p>';
|
||
return;
|
||
}
|
||
if (entries.length === 0) {
|
||
updateBulkBar();
|
||
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>';
|
||
if (isAdmin) html += '<th class="sel-cell"><input type="checkbox" id="sel-all" title="Select all"></th>';
|
||
html += '<th>Name</th><th>Type</th><th>Host</th><th>User</th><th></th>';
|
||
if (isAdmin) html += '<th></th>';
|
||
html += '</tr></thead><tbody>';
|
||
|
||
entries.forEach(function(e, idx) {
|
||
var label = e.display_name || e.name;
|
||
var hostBase = e.session_type === 'web' ? (e.url || '')
|
||
: e.session_type === 'vdi' ? (e.container_image || '')
|
||
: e.session_type === 'proxmox' ? ((e.proxmox_url || '') + (e.proxmox_vmid ? ' · VM ' + e.proxmox_vmid : ''))
|
||
: (e.hostname || '');
|
||
if (e.port && e.session_type !== 'web' && e.session_type !== 'vdi') hostBase += ':' + e.port;
|
||
var hostSuffix = '';
|
||
if (e.jump_hosts && e.jump_hosts.length > 0) {
|
||
var hopNames = e.jump_hosts.map(function(h) { return h.hostname; }).join(' \u2192 ');
|
||
hostSuffix = ' <span style="color:#888;font-size:0.8em" title="via ' + escapeAttr(hopNames) + '">via ' + escapeHtml(hopNames) + '</span>';
|
||
}
|
||
var typeCls = 'type-' + e.session_type;
|
||
|
||
var isSel = isAdmin && bulkSel.names.has(e.name);
|
||
html += isSel ? '<tr class="selected">' : '<tr>';
|
||
if (isAdmin) {
|
||
html += '<td class="sel-cell"><input type="checkbox" class="sel-entry" data-sel-index="' + idx + '" data-sel-name="' + escapeAttr(e.name) + '"' + (isSel ? ' checked' : '') + ' title="Select"></td>';
|
||
}
|
||
var unsetVars = entryUnsetVars(e);
|
||
var credPill = unsetVars.length > 0
|
||
? ' <span title="Set these first: ' + escapeAttr(unsetVars.join(', ')) + '" style="background:var(--status-pending);color:var(--bg);border-radius:8px;padding:0.05em 0.5em;font-size:0.72em;font-weight:bold;white-space:nowrap;vertical-align:middle">credentials needed</span>'
|
||
: '';
|
||
html += '<td>' + entryNameHtml(label) + credPill + '</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.session_type === 'proxmox'
|
||
? escapeHtml(e.proxmox_token_id || '')
|
||
: (e.username ? escapeHtml(e.username) : '');
|
||
var needsPrompt = (e.prompt_credentials || !e.has_credentials) && e.session_type !== 'web' && e.session_type !== 'proxmox';
|
||
if (needsPrompt) userCol += ' <span style="color:#888;font-size:0.8em" title="Credentials will be prompted at connect time">[prompt]</span>';
|
||
if (e.inherited_credentials) userCol += ' <span style="color:#888;font-size:0.8em" title="Uses the folder\'s default credentials">[folder]</span>';
|
||
html += '<td>' + userCol + '</td>';
|
||
var connectLabel = needsPrompt ? 'Login...' : 'Connect';
|
||
html += '<td class="connect-cell"><button class="btn-connect" data-connect="' + escapeAttr(e.name) + '" data-scope="' + escapeAttr(scope) + '" data-folder="' + escapeAttr(folder) + '">' + connectLabel + '</button>'
|
||
+ '<button class="btn-connect btn-connect-win" title="Open in a new window" data-connect-win="' + escapeAttr(e.name) + '" data-scope="' + escapeAttr(scope) + '" data-folder="' + escapeAttr(folder) + '">⧉</button></td>';
|
||
if (isAdmin) {
|
||
// One cell for the three admin actions: three cells cost
|
||
// two extra runs of cell padding on every row, which is
|
||
// what pushed "delete" off the right edge at 1710px wide.
|
||
html += '<td class="actions-cell">'
|
||
+ '<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>'
|
||
+ '<button class="btn-small" data-clone-entry="' + escapeAttr(e.name) + '" data-scope="' + escapeAttr(scope) + '" data-folder="' + escapeAttr(folder) + '">clone</button>'
|
||
+ '<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;
|
||
updateBulkBar();
|
||
}
|
||
|
||
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, '>');
|
||
}
|
||
|
||
// ── Bulk selection: move / copy / delete many entries ───────
|
||
//
|
||
// Selection is per folder: changing folder clears it. The work is
|
||
// done by POST /api/addressbook/bulk, which copies each entry as
|
||
// stored (credentials included, never overwriting) and reports a
|
||
// result per entry, so this page only chooses and reports.
|
||
function bulkSyncSelection(entries, scope, folder) {
|
||
var key = scope + '|' + folder;
|
||
if (bulkSel.key !== key) {
|
||
bulkSel.key = key;
|
||
bulkSel.names.clear();
|
||
bulkLastIndex = null;
|
||
return;
|
||
}
|
||
// Same folder re-rendered: drop names that are no longer there.
|
||
var present = new Set(entries.map(function(e) { return e.name; }));
|
||
Array.from(bulkSel.names).forEach(function(n) {
|
||
if (!present.has(n)) bulkSel.names.delete(n);
|
||
});
|
||
}
|
||
|
||
function updateBulkBar() {
|
||
var n = bulkSel.names.size;
|
||
var bar = document.getElementById('bulk-bar');
|
||
bar.classList.toggle('active', isAdmin && n > 0);
|
||
document.getElementById('bulk-count').textContent = n + (n === 1 ? ' entry selected' : ' entries selected');
|
||
var all = document.getElementById('sel-all');
|
||
if (all) {
|
||
var boxes = document.querySelectorAll('#entries-content .sel-entry');
|
||
all.checked = boxes.length > 0 && n === boxes.length;
|
||
all.indeterminate = n > 0 && n < boxes.length;
|
||
}
|
||
}
|
||
|
||
function bulkSetBox(box, checked) {
|
||
box.checked = checked;
|
||
var name = box.getAttribute('data-sel-name');
|
||
if (checked) bulkSel.names.add(name); else bulkSel.names.delete(name);
|
||
var tr = box.closest('tr');
|
||
if (tr) tr.classList.toggle('selected', checked);
|
||
}
|
||
|
||
document.getElementById('entries-content').addEventListener('click', function(e) {
|
||
var t = e.target;
|
||
if (t.id === 'sel-all') {
|
||
document.querySelectorAll('#entries-content .sel-entry').forEach(function(b) {
|
||
bulkSetBox(b, t.checked);
|
||
});
|
||
bulkLastIndex = null;
|
||
updateBulkBar();
|
||
return;
|
||
}
|
||
if (!t.classList || !t.classList.contains('sel-entry')) return;
|
||
var idx = parseInt(t.getAttribute('data-sel-index'), 10);
|
||
if (e.shiftKey && bulkLastIndex !== null) {
|
||
// Range: everything between the last click and this one takes
|
||
// this box's new state.
|
||
var lo = Math.min(bulkLastIndex, idx), hi = Math.max(bulkLastIndex, idx);
|
||
document.querySelectorAll('#entries-content .sel-entry').forEach(function(b) {
|
||
var i = parseInt(b.getAttribute('data-sel-index'), 10);
|
||
if (i >= lo && i <= hi) bulkSetBox(b, t.checked);
|
||
});
|
||
} else {
|
||
bulkSetBox(t, t.checked);
|
||
}
|
||
bulkLastIndex = idx;
|
||
updateBulkBar();
|
||
});
|
||
|
||
document.getElementById('bulk-clear').addEventListener('click', function() {
|
||
bulkSel.names.clear();
|
||
document.querySelectorAll('#entries-content .sel-entry').forEach(function(b) { bulkSetBox(b, false); });
|
||
bulkLastIndex = null;
|
||
updateBulkBar();
|
||
});
|
||
document.getElementById('bulk-move').addEventListener('click', function() { openBulk('move'); });
|
||
document.getElementById('bulk-copy').addEventListener('click', function() { openBulk('copy'); });
|
||
document.getElementById('bulk-delete').addEventListener('click', function() { openBulk('delete'); });
|
||
|
||
function bulkNoun(n) { return n + (n === 1 ? ' entry' : ' entries'); }
|
||
|
||
function bulkNameList(names, max) {
|
||
var shown = names.slice(0, max).join(', ');
|
||
return names.length > max ? shown + ' and ' + (names.length - max) + ' more' : shown;
|
||
}
|
||
|
||
function openBulk(action) {
|
||
if (!selectedFolder || bulkSel.names.size === 0) return;
|
||
var source = { scope: selectedFolder.scope, folder: folderPath(selectedFolder) };
|
||
var names = Array.from(bulkSel.names).sort();
|
||
bulkState = {
|
||
action: action,
|
||
source: source,
|
||
items: names.map(function(n) { return { scope: source.scope, folder: source.folder, name: n }; }),
|
||
target: null,
|
||
folders: null
|
||
};
|
||
var verb = { move: 'Move', copy: 'Copy', 'delete': 'Delete' }[action];
|
||
document.getElementById('bulk-title').textContent = verb + ' ' + bulkNoun(names.length);
|
||
document.getElementById('bulk-error').textContent = '';
|
||
document.getElementById('bulk-results').style.display = 'none';
|
||
document.getElementById('bulk-conflict').style.display = 'none';
|
||
document.querySelector('input[name="bulk-conflict"][value="skip"]').checked = true;
|
||
var confirmBtn = document.getElementById('bulk-confirm');
|
||
confirmBtn.style.display = '';
|
||
confirmBtn.textContent = verb;
|
||
confirmBtn.classList.toggle('bulk-danger', action === 'delete');
|
||
document.getElementById('bulk-cancel').textContent = 'Cancel';
|
||
var preview = document.getElementById('bulk-preview');
|
||
|
||
if (action === 'delete') {
|
||
document.getElementById('bulk-pick').style.display = 'none';
|
||
preview.innerHTML = '';
|
||
var p = document.createElement('p');
|
||
p.textContent = 'Permanently delete ' + bulkNoun(names.length) + ' from ' + source.folder + ': ' + bulkNameList(names, 15) + '.';
|
||
preview.appendChild(p);
|
||
var w = document.createElement('p');
|
||
w.className = 'bulk-warn';
|
||
w.textContent = 'Their stored credentials are deleted with them. This cannot be undone.';
|
||
preview.appendChild(w);
|
||
confirmBtn.disabled = false;
|
||
} else {
|
||
document.getElementById('bulk-pick').style.display = '';
|
||
document.getElementById('bulk-folder-filter').value = '';
|
||
preview.textContent = '';
|
||
confirmBtn.disabled = true;
|
||
var list = document.getElementById('bulk-folder-list');
|
||
list.innerHTML = '<p class="empty" style="padding:0.5em">Loading folders...</p>';
|
||
fetch('/api/addressbook/folder-paths', { headers: apiHeaders(), credentials: 'same-origin' })
|
||
.then(function(res) {
|
||
if (!res.ok) return res.text().then(function(t) { throw new Error(t); });
|
||
return res.json();
|
||
})
|
||
.then(function(data) {
|
||
if (!bulkState) return;
|
||
bulkState.folders = data.folders || [];
|
||
renderBulkFolders();
|
||
})
|
||
.catch(function(err) {
|
||
list.innerHTML = '';
|
||
document.getElementById('bulk-error').textContent = 'Could not load folders: ' + err.message;
|
||
});
|
||
}
|
||
document.getElementById('bulk-modal').classList.add('active');
|
||
}
|
||
|
||
function renderBulkFolders() {
|
||
var list = document.getElementById('bulk-folder-list');
|
||
var q = document.getElementById('bulk-folder-filter').value.trim().toLowerCase();
|
||
list.innerHTML = '';
|
||
var shown = 0;
|
||
bulkState.folders.forEach(function(f, i) {
|
||
if (q && f.path.toLowerCase().indexOf(q) < 0) return;
|
||
var isSource = f.scope === bulkState.source.scope && f.path === bulkState.source.folder;
|
||
// Moving into the folder it is already in does nothing.
|
||
if (isSource && bulkState.action === 'move') return;
|
||
var label = document.createElement('label');
|
||
var radio = document.createElement('input');
|
||
radio.type = 'radio';
|
||
radio.name = 'bulk-target';
|
||
radio.value = String(i);
|
||
if (bulkState.target === f) radio.checked = true;
|
||
radio.addEventListener('change', function() { chooseBulkTarget(f); });
|
||
label.appendChild(radio);
|
||
var name = document.createElement('span');
|
||
name.textContent = f.path;
|
||
if (isSource) name.className = 'bulk-current';
|
||
label.appendChild(name);
|
||
var tag = document.createElement('span');
|
||
tag.className = 'scope-tag';
|
||
tag.textContent = (isSource ? 'this folder, ' : '') + f.scope;
|
||
label.appendChild(tag);
|
||
list.appendChild(label);
|
||
shown++;
|
||
});
|
||
if (shown === 0) list.innerHTML = '<p class="empty" style="padding:0.5em">No matching folders.</p>';
|
||
}
|
||
document.getElementById('bulk-folder-filter').addEventListener('input', function() {
|
||
if (bulkState && bulkState.folders) renderBulkFolders();
|
||
});
|
||
|
||
function chooseBulkTarget(f) {
|
||
bulkState.target = f;
|
||
var confirmBtn = document.getElementById('bulk-confirm');
|
||
confirmBtn.disabled = true;
|
||
var preview = document.getElementById('bulk-preview');
|
||
preview.textContent = 'Checking ' + f.path + '...';
|
||
var base = '/api/addressbook/folders/';
|
||
var get = function(url) {
|
||
return fetch(url, { headers: apiHeaders(), credentials: 'same-origin' }).then(function(res) {
|
||
if (res.status === 404) return null;
|
||
if (!res.ok) return res.text().then(function(t) { throw new Error(t); });
|
||
return res.json();
|
||
});
|
||
};
|
||
Promise.all([
|
||
get(base + encodeURIComponent(f.scope) + '/' + encodeURIComponent(f.path) + '/entries'),
|
||
get(base + encodeURIComponent(f.scope) + '/' + encodeURIComponent(f.path) + '/config'),
|
||
get(base + encodeURIComponent(bulkState.source.scope) + '/' + encodeURIComponent(bulkState.source.folder) + '/config')
|
||
]).then(function(r) {
|
||
if (!bulkState || bulkState.target !== f) return; // a later choice won
|
||
var existing = new Set((r[0] || []).map(function(e) { return e.name; }));
|
||
var clashes = bulkState.items.filter(function(i) { return existing.has(i.name); }).map(function(i) { return i.name; });
|
||
var verb = bulkState.action === 'move' ? 'moved' : 'copied';
|
||
preview.innerHTML = '';
|
||
var p = document.createElement('p');
|
||
p.textContent = bulkNoun(bulkState.items.length) + ' will be ' + verb + ' to ' + f.path + ' (' + f.scope + '). Stored credentials go with them.';
|
||
preview.appendChild(p);
|
||
if (clashes.length) {
|
||
var c = document.createElement('p');
|
||
c.className = 'bulk-warn';
|
||
c.textContent = clashes.length + ' already ' + (clashes.length === 1 ? 'exists' : 'exist') + ' there: ' + bulkNameList(clashes, 10) + '. Existing entries are never overwritten.';
|
||
preview.appendChild(c);
|
||
}
|
||
document.getElementById('bulk-conflict').style.display = clashes.length ? '' : 'none';
|
||
if (f.scope !== bulkState.source.scope) {
|
||
var s = document.createElement('p');
|
||
s.className = 'bulk-warn';
|
||
s.textContent = 'This crosses from ' + bulkState.source.scope + ' to ' + f.scope + ', which changes which rustguac instances can see these entries.';
|
||
preview.appendChild(s);
|
||
}
|
||
// Groups match case-sensitively, emails do not.
|
||
var norm = function(cfg, key) {
|
||
var list = (cfg && cfg[key]) || [];
|
||
if (key === 'allowed_users') list = list.map(function(x) { return x.toLowerCase(); });
|
||
return list.slice().sort().join(', ');
|
||
};
|
||
var srcGroups = norm(r[2], 'allowed_groups'), dstGroups = norm(r[1], 'allowed_groups');
|
||
var srcUsers = norm(r[2], 'allowed_users'), dstUsers = norm(r[1], 'allowed_users');
|
||
if (srcGroups !== dstGroups || srcUsers !== dstUsers) {
|
||
var g = document.createElement('p');
|
||
g.className = 'bulk-warn';
|
||
var desc = function(groups, users) {
|
||
var parts = [];
|
||
parts.push('groups: ' + (groups || 'none'));
|
||
if (users) parts.push('users: ' + users);
|
||
return parts.join('; ');
|
||
};
|
||
g.textContent = 'The target folder grants access differently (here: ' + desc(srcGroups, srcUsers) + '. There: ' + desc(dstGroups, dstUsers) + '), so who can see and connect to these entries will change.';
|
||
preview.appendChild(g);
|
||
}
|
||
confirmBtn.disabled = false;
|
||
}).catch(function(err) {
|
||
preview.textContent = '';
|
||
document.getElementById('bulk-error').textContent = 'Could not check the target folder: ' + err.message;
|
||
});
|
||
}
|
||
|
||
document.getElementById('bulk-confirm').addEventListener('click', function() {
|
||
if (!bulkState) return;
|
||
var confirmBtn = this;
|
||
var body = { action: bulkState.action, items: bulkState.items };
|
||
if (bulkState.action !== 'delete') {
|
||
if (!bulkState.target) return;
|
||
body.target = { scope: bulkState.target.scope, folder: bulkState.target.path };
|
||
body.on_conflict = document.querySelector('input[name="bulk-conflict"]:checked').value;
|
||
}
|
||
confirmBtn.disabled = true;
|
||
confirmBtn.textContent = 'Working...';
|
||
document.getElementById('bulk-error').textContent = '';
|
||
fetch('/api/addressbook/bulk', {
|
||
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) { showBulkResults(data); })
|
||
.catch(function(err) {
|
||
document.getElementById('bulk-error').textContent = err.message;
|
||
confirmBtn.disabled = false;
|
||
confirmBtn.textContent = 'Retry';
|
||
});
|
||
});
|
||
|
||
function showBulkResults(data) {
|
||
var labels = {
|
||
moved: 'moved', copied: 'copied', deleted: 'deleted', skipped: 'skipped',
|
||
failed: 'failed', copied_not_removed: 'copied but not removed from here'
|
||
};
|
||
var counts = data.summary || {};
|
||
var parts = Object.keys(counts).map(function(k) { return counts[k] + ' ' + (labels[k] || k); });
|
||
var box = document.getElementById('bulk-results');
|
||
box.innerHTML = '';
|
||
var p = document.createElement('p');
|
||
p.textContent = parts.join(', ') + '.';
|
||
box.appendChild(p);
|
||
var notable = (data.results || []).filter(function(r) {
|
||
var plain = r.status === 'moved' || r.status === 'copied' || r.status === 'deleted';
|
||
return !plain || !!r.new_name;
|
||
});
|
||
if (notable.length) {
|
||
var ul = document.createElement('ul');
|
||
notable.forEach(function(r) {
|
||
var li = document.createElement('li');
|
||
var bad = r.status === 'failed' || r.status === 'copied_not_removed';
|
||
if (bad) li.className = 'bulk-bad';
|
||
li.textContent = r.name + ': ' + (r.new_name ? 'saved as ' + r.new_name : (r.error || labels[r.status] || r.status));
|
||
ul.appendChild(li);
|
||
});
|
||
box.appendChild(ul);
|
||
}
|
||
box.style.display = '';
|
||
document.getElementById('bulk-pick').style.display = 'none';
|
||
document.getElementById('bulk-conflict').style.display = 'none';
|
||
document.getElementById('bulk-preview').innerHTML = '';
|
||
document.getElementById('bulk-confirm').style.display = 'none';
|
||
document.getElementById('bulk-cancel').textContent = 'Close';
|
||
bulkState.done = true;
|
||
}
|
||
|
||
function closeBulk() {
|
||
var st = bulkState;
|
||
bulkState = null;
|
||
document.getElementById('bulk-modal').classList.remove('active');
|
||
if (!st || !st.done) return;
|
||
// Refresh what changed: the source always, the target if cached.
|
||
bulkSel.names.clear();
|
||
if (st.target) {
|
||
var tk = folderKey(st.target.scope, st.target.path);
|
||
delete folderEntries[tk];
|
||
delete folderEntryCounts[tk];
|
||
}
|
||
loadEntries(st.source.scope, st.source.folder, true);
|
||
}
|
||
document.getElementById('bulk-cancel').addEventListener('click', closeBulk);
|
||
document.getElementById('bulk-modal').addEventListener('click', function(ev) {
|
||
if (ev.target === this) closeBulk();
|
||
});
|
||
|
||
// Connect to an entry (with optional credential prompt)
|
||
var pendingConnect = null; // { scope, folder, name, btn }
|
||
|
||
function doConnect(scope, folder, name, btn, extraBody, newWindow) {
|
||
var isWinBtn = !!(btn.getAttribute && btn.getAttribute('data-connect-win'));
|
||
btn.disabled = true;
|
||
if (!isWinBtn) btn.classList.add('loading');
|
||
clearError();
|
||
// Open the target window synchronously now, while we are still
|
||
// inside the click gesture: a window opened after the async fetch
|
||
// is popup-blocked, and — the reason it matters for a
|
||
// fullscreen-on-connect entry — a window opened within the gesture
|
||
// carries the user activation, so the client can enter fullscreen
|
||
// on load with no second click. The ⧉ button gets a sized popup;
|
||
// a plain Connect gets a tab.
|
||
var targetWin = null, popup = null;
|
||
if (newWindow) {
|
||
// Measured popup (search.js rgOpenSessionWindow): the splash
|
||
// page reports its laid-out viewport, and the session is
|
||
// created at exactly that size so nothing resizes on load.
|
||
if (typeof window.rgOpenSessionWindow === 'function') {
|
||
popup = window.rgOpenSessionWindow(name);
|
||
targetWin = popup.win;
|
||
} else {
|
||
targetWin = window.open('', '_blank',
|
||
'width=1280,height=800,menubar=no,toolbar=no,location=no,status=no');
|
||
}
|
||
} else {
|
||
targetWin = window.open('', '_blank');
|
||
}
|
||
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);
|
||
if (!body.keyboard_layout) body.keyboard_layout = rgKeyboardLayout();
|
||
(popup ? popup.size : Promise.resolve(null)).then(function(sz) {
|
||
if (sz) { body.width = sz.width; body.height = sz.height; }
|
||
return 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) {
|
||
// Carry the fullscreen-on-connect flag so the client can go
|
||
// fullscreen immediately, using the activation from this click.
|
||
var _e0 = null;
|
||
for (var _k = 0; _k < currentEntries.length; _k++) { if (currentEntries[_k].name === name) { _e0 = currentEntries[_k]; break; } }
|
||
// A new window (⧉) is explicitly windowed: fs=0 tells the
|
||
// client not to fullscreen-on-connect even if the entry asks.
|
||
var url = data.client_url + '?name=' + encodeURIComponent(name)
|
||
+ (newWindow ? '&fs=0' : (_e0 && _e0.fullscreen_on_connect ? '&fs=1' : ''));
|
||
if (targetWin && !targetWin.closed) targetWin.location.href = url;
|
||
else window.open(url, '_blank');
|
||
})
|
||
.catch(function(err) {
|
||
if (targetWin && !targetWin.closed) targetWin.close();
|
||
if (err.message === 'missing_credentials') {
|
||
// Guided, resumable flow: prompt only the missing variables,
|
||
// then re-run this exact connect on save instead of
|
||
// dead-ending the user in the full credential editor.
|
||
openMyCredentials({
|
||
onlyVars: err.missing || [],
|
||
heading: 'Enter the credentials needed to connect to "' + name + '".',
|
||
onSaved: function() {
|
||
doConnect(scope, folder, name, btn, extraBody, newWindow);
|
||
}
|
||
});
|
||
} else {
|
||
showError(err.message);
|
||
}
|
||
})
|
||
.finally(function() {
|
||
btn.disabled = false;
|
||
btn.classList.remove('loading');
|
||
});
|
||
}
|
||
|
||
function showCredPrompt(entry, scope, folder, btn, newWindow) {
|
||
pendingConnect = { scope: scope, folder: folder, name: entry.name, btn: btn, newWindow: !!newWindow };
|
||
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';
|
||
// Pre-fill username if the entry has one stored (optional for VNC)
|
||
document.getElementById('cred-username').value = 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 optional (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.classList.remove('loading');
|
||
}
|
||
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.newWindow);
|
||
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();
|
||
});
|
||
|
||
// Connect / clone / delete / edit actions for the folder entries table.
|
||
function handleEntryActionsClick(e) {
|
||
var btn = e.target;
|
||
|
||
if (btn.getAttribute('data-connect') || btn.getAttribute('data-connect-win')) {
|
||
var newWindow = !!btn.getAttribute('data-connect-win');
|
||
var name = btn.getAttribute('data-connect') || btn.getAttribute('data-connect-win');
|
||
var scope = btn.getAttribute('data-scope');
|
||
var folder = btn.getAttribute('data-folder');
|
||
clearError();
|
||
|
||
// Find the entry in the current folder's entries.
|
||
var entry = null;
|
||
for (var i = 0; i < currentEntries.length; i++) {
|
||
if (currentEntries[i].name === name) { entry = currentEntries[i]; break; }
|
||
}
|
||
|
||
// Prompt for credentials if prompt_credentials is set or no stored creds
|
||
// (RDP and SSH entries without passwords/keys)
|
||
if (entry && (entry.prompt_credentials || !entry.has_credentials) && entry.session_type !== 'web' && entry.session_type !== 'vdi' && entry.session_type !== 'proxmox') {
|
||
showCredPrompt(entry, scope, folder, btn, newWindow);
|
||
} else if (entry && entry.fullscreen_on_connect && !newWindow && typeof window.rgLaunchFullscreen === 'function') {
|
||
// Zero-click fullscreen: fullscreen this tab on the click and
|
||
// load the session in a full-viewport iframe (search.js).
|
||
window.rgLaunchFullscreen(scope, folder, name, btn);
|
||
} else {
|
||
doConnect(scope, folder, name, btn, undefined, newWindow);
|
||
}
|
||
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');
|
||
rgConfirm('Delete entry "' + name + '"?', { danger: true, confirmText: 'Delete' }).then(function(ok) {
|
||
if (!ok) 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;
|
||
cloneSource = { scope: scope, folder: folder, name: name };
|
||
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')
|
||
);
|
||
}
|
||
}
|
||
document.getElementById('entries-content').addEventListener('click', handleEntryActionsClick);
|
||
|
||
// ── Folder modal ──
|
||
|
||
var folderEditMode = null; // null = create, {scope, name, path} = edit
|
||
var fmUsersLoaded = false; // edit mode: has the folder's allowed_users arrived yet?
|
||
var subfolderParent = null; // null, or { scope, path } when creating a subfolder
|
||
|
||
function openNewFolder() {
|
||
folderEditMode = null;
|
||
subfolderParent = null;
|
||
document.getElementById('folder-modal-title').textContent = 'New Folder';
|
||
document.getElementById('fm-name').value = '';
|
||
document.getElementById('fm-name').disabled = false;
|
||
document.getElementById('fm-desc').value = '';
|
||
document.getElementById('fm-display').value = '';
|
||
document.getElementById('fm-scope').value = 'shared';
|
||
document.getElementById('fm-scope').disabled = false;
|
||
document.getElementById('fm-error').textContent = '';
|
||
document.getElementById('fm-groups-input').value = '';
|
||
document.getElementById('fm-inherit').checked = false;
|
||
document.getElementById('fm-users').value = '';
|
||
fmUsersLoaded = true;
|
||
fmGroups = [];
|
||
renderFmGroupChips();
|
||
renderFmDefaults({});
|
||
loadKnownGroups(populateFmGroupOptions);
|
||
document.getElementById('folder-modal').classList.add('active');
|
||
}
|
||
|
||
function openNewSubfolder() {
|
||
if (!selectedFolder) return;
|
||
folderEditMode = null;
|
||
subfolderParent = { scope: selectedFolder.scope, path: folderPath(selectedFolder) };
|
||
document.getElementById('folder-modal-title').textContent = 'New Subfolder in ' + subfolderParent.path;
|
||
document.getElementById('fm-name').value = '';
|
||
document.getElementById('fm-name').disabled = false;
|
||
document.getElementById('fm-desc').value = '';
|
||
document.getElementById('fm-display').value = '';
|
||
document.getElementById('fm-scope').value = subfolderParent.scope;
|
||
document.getElementById('fm-scope').disabled = true;
|
||
document.getElementById('fm-error').textContent = '';
|
||
document.getElementById('fm-groups-input').value = '';
|
||
document.getElementById('fm-inherit').checked = true;
|
||
document.getElementById('fm-users').value = '';
|
||
fmUsersLoaded = true;
|
||
fmGroups = [];
|
||
renderFmGroupChips();
|
||
loadKnownGroups(populateFmGroupOptions);
|
||
document.getElementById('folder-modal').classList.add('active');
|
||
}
|
||
|
||
document.getElementById('btn-new-folder').addEventListener('click', openNewFolder);
|
||
document.getElementById('btn-empty-create-folder').addEventListener('click', openNewFolder);
|
||
document.getElementById('btn-new-subfolder').addEventListener('click', openNewSubfolder);
|
||
|
||
document.getElementById('btn-edit-folder').addEventListener('click', function() {
|
||
if (!selectedFolder) return;
|
||
var selPath = folderPath(selectedFolder);
|
||
folderEditMode = { scope: selectedFolder.scope, name: selectedFolder.name, path: selPath };
|
||
document.getElementById('folder-modal-title').textContent = 'Edit Folder: ' + selPath;
|
||
document.getElementById('fm-name').value = selectedFolder.name;
|
||
document.getElementById('fm-name').disabled = true;
|
||
document.getElementById('fm-desc').value = selectedFolder.description || '';
|
||
document.getElementById('fm-display').value = selectedFolder.display_name || '';
|
||
document.getElementById('fm-scope').value = selectedFolder.scope;
|
||
document.getElementById('fm-scope').disabled = true;
|
||
document.getElementById('fm-error').textContent = '';
|
||
|
||
// Reset chip state and kick off known-groups load, then fetch current config.
|
||
document.getElementById('fm-groups-input').value = '';
|
||
document.getElementById('fm-users').value = '';
|
||
fmUsersLoaded = false;
|
||
fmGroups = [];
|
||
renderFmGroupChips();
|
||
loadKnownGroups(populateFmGroupOptions);
|
||
fetch('/api/addressbook/folders/' + encodeURIComponent(selectedFolder.scope) + '/' + encodeURIComponent(selPath) + '/config', {
|
||
headers: apiHeaders(),
|
||
credentials: 'same-origin'
|
||
}).then(function(res) {
|
||
if (res.ok) return res.json();
|
||
return null;
|
||
}).then(function(data) {
|
||
if (data && Array.isArray(data.allowed_groups)) {
|
||
fmGroups = data.allowed_groups.slice();
|
||
renderFmGroupChips();
|
||
populateFmGroupOptions();
|
||
}
|
||
document.getElementById('fm-inherit').checked = !!(data && data.inherit_from_parent);
|
||
if (data && Array.isArray(data.allowed_users)) {
|
||
document.getElementById('fm-users').value = data.allowed_users.join('\n');
|
||
fmUsersLoaded = true;
|
||
}
|
||
}).catch(function() {});
|
||
renderFmDefaults({});
|
||
fetch('/api/addressbook/folders/' + encodeURIComponent(selectedFolder.scope) + '/' + encodeURIComponent(selPath) + '/defaults', {
|
||
headers: apiHeaders(),
|
||
credentials: 'same-origin'
|
||
}).then(function(res) { return res.ok ? res.json() : null; })
|
||
.then(function(data) { if (data && data.defaults) renderFmDefaults(data.defaults); })
|
||
.catch(function() {});
|
||
|
||
document.getElementById('folder-modal').classList.add('active');
|
||
});
|
||
|
||
document.getElementById('fm-cancel').addEventListener('click', function() {
|
||
document.getElementById('folder-modal').classList.remove('active');
|
||
});
|
||
|
||
document.getElementById('fm-groups-add').addEventListener('click', addFmGroupFromInput);
|
||
(function wireFmCombo() {
|
||
var inp = document.getElementById('fm-groups-input');
|
||
inp.addEventListener('focus', openFmCombo);
|
||
inp.addEventListener('input', populateFmGroupOptions);
|
||
inp.addEventListener('keydown', function(ev) {
|
||
var opts = fmGroupOptionsFiltered();
|
||
if (ev.key === 'ArrowDown') {
|
||
ev.preventDefault();
|
||
openFmCombo();
|
||
if (fmComboActive < opts.length - 1) fmComboActive++;
|
||
updateFmComboActive();
|
||
} else if (ev.key === 'ArrowUp') {
|
||
ev.preventDefault();
|
||
if (fmComboActive > 0) fmComboActive--;
|
||
updateFmComboActive();
|
||
} else if (ev.key === 'Enter') {
|
||
ev.preventDefault();
|
||
if (inp.value.trim()) {
|
||
addFmGroupFromInput();
|
||
} else {
|
||
pickFmGroupFromCombo();
|
||
}
|
||
} else if (ev.key === 'Escape') {
|
||
closeFmCombo();
|
||
inp.blur();
|
||
}
|
||
});
|
||
document.addEventListener('mousedown', function(ev) {
|
||
var combo = document.getElementById('fm-groups-combo');
|
||
if (combo && !combo.contains(ev.target)) closeFmCombo();
|
||
});
|
||
})();
|
||
|
||
// ── Folder default credentials (admin) ──
|
||
var FM_DEFAULT_TYPES = ['ssh', 'rdp', 'vnc', 'web'];
|
||
var fmDefaultsLoaded = {}; // type -> {username, domain, has_password, has_private_key}
|
||
function renderFmDefaults(summary) {
|
||
fmDefaultsLoaded = summary || {};
|
||
var rows = document.getElementById('fm-defaults-rows');
|
||
rows.innerHTML = '';
|
||
var any = false;
|
||
FM_DEFAULT_TYPES.forEach(function(t) {
|
||
var d = fmDefaultsLoaded[t];
|
||
if (d) any = true;
|
||
rows.insertAdjacentHTML('beforeend',
|
||
'<tr data-type="' + t + '"><th>' + t + '</th>' +
|
||
'<td><input class="fmd-user" placeholder="username" autocomplete="off" value="' + escapeAttr((d && d.username) || '') + '"></td>' +
|
||
'<td><input type="password" class="fmd-pass" autocomplete="new-password" placeholder="' + (d && d.has_password ? '(stored)' : 'password') + '"></td>' +
|
||
'<td>' + (t === 'ssh'
|
||
? '<textarea class="fmd-key" rows="1" spellcheck="false" placeholder="' + (d && d.has_private_key ? '(stored)' : 'private key') + '"></textarea>'
|
||
: t === 'rdp'
|
||
? '<input class="fmd-domain" placeholder="domain" autocomplete="off" value="' + escapeAttr((d && d.domain) || '') + '">'
|
||
: '') + '</td>' +
|
||
'<td><label class="fmd-clear-label"><input type="checkbox" class="fmd-clear"> clear</label></td></tr>');
|
||
});
|
||
document.getElementById('fm-defaults').open = any;
|
||
}
|
||
// The PUT body: only what changed. A ticked clear sends null for the
|
||
// type; a typed secret sets it; a changed username sets it (empty
|
||
// clears). Nothing typed means no request at all.
|
||
function fmDefaultsPatch() {
|
||
var patch = {};
|
||
document.querySelectorAll('#fm-defaults-rows tr').forEach(function(tr) {
|
||
var t = tr.getAttribute('data-type');
|
||
var d = fmDefaultsLoaded[t];
|
||
if (tr.querySelector('.fmd-clear').checked) {
|
||
if (d) patch[t] = null;
|
||
return;
|
||
}
|
||
var part = {};
|
||
var user = tr.querySelector('.fmd-user').value.trim();
|
||
if (user !== ((d && d.username) || '')) part.username = user;
|
||
var domEl = tr.querySelector('.fmd-domain');
|
||
if (domEl && domEl.value.trim() !== ((d && d.domain) || '')) part.domain = domEl.value.trim();
|
||
var pass = tr.querySelector('.fmd-pass').value;
|
||
if (pass) part.password = pass;
|
||
var keyEl = tr.querySelector('.fmd-key');
|
||
if (keyEl && keyEl.value.trim()) part.private_key = keyEl.value.trim();
|
||
if (Object.keys(part).length) patch[t] = part;
|
||
});
|
||
return patch;
|
||
}
|
||
function saveFmDefaults(scope, path) {
|
||
var patch = fmDefaultsPatch();
|
||
if (!Object.keys(patch).length) return Promise.resolve();
|
||
return fetch('/api/addressbook/folders/' + encodeURIComponent(scope) + '/' + encodeURIComponent(path) + '/defaults', {
|
||
method: 'PUT',
|
||
headers: apiHeaders({ 'Content-Type': 'application/json' }),
|
||
credentials: 'same-origin',
|
||
body: JSON.stringify(patch)
|
||
}).then(function(res) {
|
||
if (!res.ok) return res.text().then(function(t) { throw new Error('Default credentials: ' + t); });
|
||
});
|
||
}
|
||
|
||
document.getElementById('fm-save').addEventListener('click', function() {
|
||
var name = document.getElementById('fm-name').value.trim();
|
||
var desc = document.getElementById('fm-desc').value.trim();
|
||
var displayName = document.getElementById('fm-display').value.trim();
|
||
// Include anything still typed in the group input but not chipped yet
|
||
addFmGroupFromInput();
|
||
var groups = fmGroups.slice();
|
||
var users = document.getElementById('fm-users').value.split(/[\s,;]+/).filter(function(u) { return u; });
|
||
var scope = document.getElementById('fm-scope').value;
|
||
var errEl = document.getElementById('fm-error');
|
||
|
||
if (!name || !/^[a-zA-Z0-9_.-]+$/.test(name)) {
|
||
errEl.textContent = 'Name must be alphanumeric, hyphens, underscores, dots only.';
|
||
return;
|
||
}
|
||
|
||
errEl.textContent = '';
|
||
var saveBtn = document.getElementById('fm-save');
|
||
saveBtn.disabled = true;
|
||
saveBtn.textContent = 'Saving...';
|
||
|
||
if (folderEditMode) {
|
||
fetch('/api/addressbook/folders/' + encodeURIComponent(folderEditMode.scope) + '/' + encodeURIComponent(folderEditMode.path || folderEditMode.name), {
|
||
method: 'PUT',
|
||
headers: apiHeaders({ 'Content-Type': 'application/json' }),
|
||
credentials: 'same-origin',
|
||
body: JSON.stringify(Object.assign(
|
||
{ allowed_groups: groups, description: desc, display_name: displayName, inherit_from_parent: document.getElementById('fm-inherit').checked },
|
||
// Only send the list once it has loaded: an omitted
|
||
// field keeps the stored list, an empty one clears it.
|
||
fmUsersLoaded ? { allowed_users: users } : {}
|
||
))
|
||
})
|
||
.then(function(res) {
|
||
if (!res.ok) return res.text().then(function(t) { throw new Error(t); });
|
||
return saveFmDefaults(folderEditMode.scope, folderEditMode.path || folderEditMode.name);
|
||
})
|
||
.then(function() {
|
||
document.getElementById('folder-modal').classList.remove('active');
|
||
// Update selected folder description
|
||
if (selectedFolder) { selectedFolder.description = desc; selectedFolder.display_name = displayName; }
|
||
loadFolders();
|
||
})
|
||
.catch(function(err) { errEl.textContent = err.message; })
|
||
.finally(function() { saveBtn.disabled = false; saveBtn.textContent = 'Save'; });
|
||
} else {
|
||
var fullPath = subfolderParent ? subfolderParent.path + '/' + name : name;
|
||
var postScope = subfolderParent ? subfolderParent.scope : scope;
|
||
fetch('/api/addressbook/folders', {
|
||
method: 'POST',
|
||
headers: apiHeaders({ 'Content-Type': 'application/json' }),
|
||
credentials: 'same-origin',
|
||
body: JSON.stringify({ name: fullPath, allowed_groups: groups, allowed_users: users, description: desc, display_name: displayName, scope: postScope, inherit_from_parent: document.getElementById('fm-inherit').checked })
|
||
})
|
||
.then(function(res) {
|
||
if (!res.ok) return res.text().then(function(t) { throw new Error(t); });
|
||
return saveFmDefaults(postScope, fullPath);
|
||
})
|
||
.then(function() {
|
||
document.getElementById('folder-modal').classList.remove('active');
|
||
if (subfolderParent) {
|
||
// Expand parent so the new child shows, then invalidate cache and reload.
|
||
var parentScope = subfolderParent.scope;
|
||
var parentPath = subfolderParent.path;
|
||
var parentKey = folderKey(parentScope, parentPath);
|
||
expandedPaths[parentKey] = true;
|
||
saveExpandedPaths();
|
||
delete subfolderCache[parentKey];
|
||
var parent = findFolder(parentScope, parentPath);
|
||
if (parent) parent.has_children = true;
|
||
selectedFolder = { name: name, scope: postScope, description: desc, display_name: displayName, path: fullPath, has_children: false };
|
||
saveSelectedFolder();
|
||
subfolderParent = null;
|
||
loadSubfolders(parentScope, parentPath, function() {
|
||
renderFolders();
|
||
loadEntries(selectedFolder.scope, fullPath, true);
|
||
});
|
||
} else {
|
||
selectedFolder = { name: name, scope: scope, description: desc, display_name: displayName, path: name, has_children: false };
|
||
saveSelectedFolder();
|
||
loadFolders();
|
||
}
|
||
})
|
||
.catch(function(err) { errEl.textContent = err.message; })
|
||
.finally(function() { saveBtn.disabled = false; saveBtn.textContent = 'Save'; });
|
||
}
|
||
});
|
||
|
||
document.getElementById('btn-delete-folder').addEventListener('click', function() {
|
||
if (!selectedFolder) return;
|
||
var selPath = folderPath(selectedFolder);
|
||
var hasSubtree = !!(selectedFolder.has_children);
|
||
var warning = hasSubtree
|
||
? 'Delete folder "' + selPath + '" AND all its subfolders and entries?\n\nThis recursively removes the entire subtree and cannot be undone.'
|
||
: 'Delete folder "' + selPath + '" and all its entries?\n\nThis cannot be undone.';
|
||
rgConfirm(warning, { title: 'Delete folder', danger: true, confirmText: 'Delete' }).then(function(ok) {
|
||
if (!ok) return;
|
||
fetch('/api/addressbook/folders/' + encodeURIComponent(selectedFolder.scope) + '/' + encodeURIComponent(selPath), {
|
||
method: 'DELETE',
|
||
headers: apiHeaders(),
|
||
credentials: 'same-origin'
|
||
}).then(function(res) {
|
||
if (!res.ok) return res.text().then(function(t) { showError(t); });
|
||
return res.json().catch(function() { return null; });
|
||
}).then(function(data) {
|
||
if (data && (data.subfolders_deleted || data.entries_deleted)) {
|
||
var msg = 'Deleted ' + (data.entries_deleted || 0) + ' entr' + ((data.entries_deleted === 1) ? 'y' : 'ies');
|
||
if (data.subfolders_deleted) {
|
||
msg += ' across ' + data.subfolders_deleted + ' subfolder' + ((data.subfolders_deleted === 1) ? '' : 's');
|
||
}
|
||
msg += '.';
|
||
showInfo(msg);
|
||
}
|
||
selectedFolder = null;
|
||
saveSelectedFolder();
|
||
subfolderCache = {}; // drop all cached subfolder listings; they may reference the deleted path
|
||
document.getElementById('entries-header').style.display = 'none';
|
||
document.getElementById('entries-content').innerHTML = '<p class="empty">Select a folder to view entries.</p>';
|
||
loadFolders();
|
||
});
|
||
});
|
||
});
|
||
|
||
// ── Entry modal ──
|
||
|
||
var entryEditMode = null; // null = create, {scope, folder, name} = edit
|
||
var cloneSource = null; // {scope, folder, name} while the create form is a clone
|
||
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-spice-fields').style.display = 'none';
|
||
document.getElementById('em-proxmox-fields').style.display = 'none';
|
||
document.getElementById('em-web-fields').style.display = 'none';
|
||
document.getElementById('em-vdi-fields').style.display = 'none';
|
||
var val = entryTypeSelect.value;
|
||
if (val === 'ssh') document.getElementById('em-ssh-fields').style.display = '';
|
||
else if (val === 'rdp') document.getElementById('em-rdp-fields').style.display = '';
|
||
else if (val === 'vnc') document.getElementById('em-vnc-fields').style.display = '';
|
||
else if (val === 'spice') document.getElementById('em-spice-fields').style.display = '';
|
||
else if (val === 'proxmox') document.getElementById('em-proxmox-fields').style.display = '';
|
||
else if (val === 'web') {
|
||
document.getElementById('em-web-fields').style.display = '';
|
||
loadLoginScripts(function() { populateLoginScriptDropdown(''); });
|
||
}
|
||
else if (val === 'vdi') document.getElementById('em-vdi-fields').style.display = '';
|
||
// Show drive option for SSH and RDP only
|
||
document.getElementById('em-drive-section').style.display = (val === 'ssh' || val === 'rdp') ? '' : 'none';
|
||
// Show RemoteApp for RDP only
|
||
document.getElementById('em-remoteapp-section').style.display = val === 'rdp' ? '' : 'none';
|
||
// Show Video Performance for RDP only
|
||
document.getElementById('em-video-perf-section').style.display = val === 'rdp' ? '' : 'none';
|
||
// Show tunnel option for VDI excluded (local containers)
|
||
document.getElementById('em-tunnel-section').style.display = val === 'vdi' ? 'none' : '';
|
||
// Show recording settings for all types
|
||
document.getElementById('em-recording-section').style.display = '';
|
||
// Typescript recording is SSH-only (#159)
|
||
document.getElementById('em-typescript-row').style.display = val === 'ssh' ? '' : 'none';
|
||
// SSH terminal font size is SSH-only
|
||
document.getElementById('em-ssh-font-size').parentElement.style.display = val === 'ssh' ? '' : 'none';
|
||
// Wake-on-LAN applies to SSH/RDP/VNC (guacd passes the magic packet)
|
||
document.getElementById('em-wol-section').style.display = (val === 'ssh' || val === 'rdp' || val === 'vnc') ? '' : 'none';
|
||
});
|
||
|
||
// Toggle Wake-on-LAN fields visibility
|
||
document.getElementById('em-wol-toggle').addEventListener('click', function() {
|
||
var fields = document.getElementById('em-wol-fields');
|
||
var arrow = document.getElementById('em-wol-arrow');
|
||
if (fields.style.display === 'none') {
|
||
fields.style.display = '';
|
||
arrow.innerHTML = '▼';
|
||
} else {
|
||
fields.style.display = 'none';
|
||
arrow.innerHTML = '▶';
|
||
}
|
||
});
|
||
|
||
// Toggle tunnel fields visibility
|
||
document.getElementById('em-tunnel-toggle').addEventListener('click', function() {
|
||
var fields = document.getElementById('em-tunnel-fields');
|
||
var arrow = document.getElementById('em-tunnel-arrow');
|
||
if (fields.style.display === 'none') {
|
||
fields.style.display = '';
|
||
arrow.innerHTML = '▼';
|
||
} else {
|
||
fields.style.display = 'none';
|
||
arrow.innerHTML = '▶';
|
||
}
|
||
});
|
||
|
||
// Toggle RemoteApp fields visibility
|
||
document.getElementById('em-remoteapp-toggle').addEventListener('click', function() {
|
||
var fields = document.getElementById('em-remoteapp-fields');
|
||
var arrow = document.getElementById('em-remoteapp-arrow');
|
||
if (fields.style.display === 'none') {
|
||
fields.style.display = '';
|
||
arrow.innerHTML = '▼';
|
||
} else {
|
||
fields.style.display = 'none';
|
||
arrow.innerHTML = '▶';
|
||
}
|
||
});
|
||
|
||
// Toggle Recording Settings fields visibility
|
||
document.getElementById('em-recording-toggle').addEventListener('click', function() {
|
||
var fields = document.getElementById('em-recording-fields');
|
||
var arrow = document.getElementById('em-recording-arrow');
|
||
if (fields.style.display === 'none') {
|
||
fields.style.display = '';
|
||
arrow.innerHTML = '▼';
|
||
} else {
|
||
fields.style.display = 'none';
|
||
arrow.innerHTML = '▶';
|
||
}
|
||
});
|
||
|
||
// Toggle recording sub-fields when override checkbox changes
|
||
document.getElementById('em-override-recording').addEventListener('change', function() {
|
||
document.getElementById('em-recording-sub').style.display = this.checked ? '' : 'none';
|
||
});
|
||
|
||
// Toggle Video Performance fields visibility
|
||
document.getElementById('em-video-perf-toggle').addEventListener('click', function() {
|
||
var fields = document.getElementById('em-video-perf-fields');
|
||
var arrow = document.getElementById('em-video-perf-arrow');
|
||
if (fields.style.display === 'none') {
|
||
fields.style.display = '';
|
||
arrow.innerHTML = '▼';
|
||
} else {
|
||
fields.style.display = 'none';
|
||
arrow.innerHTML = '▶';
|
||
}
|
||
});
|
||
|
||
// Toggle Autofill fields visibility
|
||
document.getElementById('em-enable-drive').addEventListener('change', function() {
|
||
var warn = document.getElementById('em-drive-warning');
|
||
warn.style.display = (this.checked && !_driveConfigured) ? '' : 'none';
|
||
});
|
||
document.getElementById('em-automation-toggle').addEventListener('click', function() {
|
||
var fields = document.getElementById('em-automation-fields');
|
||
var arrow = document.getElementById('em-automation-arrow');
|
||
if (fields.style.display === 'none') {
|
||
fields.style.display = '';
|
||
arrow.innerHTML = '▼';
|
||
} else {
|
||
fields.style.display = 'none';
|
||
arrow.innerHTML = '▶';
|
||
}
|
||
});
|
||
|
||
document.getElementById('em-autofill-toggle').addEventListener('click', function() {
|
||
var fields = document.getElementById('em-autofill-fields');
|
||
var arrow = document.getElementById('em-autofill-arrow');
|
||
if (fields.style.display === 'none') {
|
||
fields.style.display = '';
|
||
arrow.innerHTML = '▼';
|
||
} else {
|
||
fields.style.display = 'none';
|
||
arrow.innerHTML = '▶';
|
||
}
|
||
});
|
||
|
||
// ── Autofill row management ──
|
||
var autofillRows = []; // Array of {url, username, password}
|
||
|
||
function renderAutofillRows() {
|
||
var container = document.getElementById('em-autofill-rows');
|
||
container.innerHTML = '';
|
||
autofillRows.forEach(function(row, i) {
|
||
var div = document.createElement('div');
|
||
div.style.cssText = 'display:flex;gap:0.4em;align-items:center;margin-bottom:0.3em';
|
||
div.innerHTML = '<input type="text" placeholder="https://example.com" value="' + escapeHtml(row.url) + '" style="flex:2;padding:0.3em 0.5em;font-size:0.9em;background:var(--input);color:var(--text);border:1px solid var(--border);border-radius:4px" data-af-idx="' + i + '" data-af-field="url">'
|
||
+ '<input type="text" placeholder="$USERNAME" value="' + escapeHtml(row.username) + '" style="flex:1;padding:0.3em 0.5em;font-size:0.9em;background:var(--input);color:var(--text);border:1px solid var(--border);border-radius:4px" data-af-idx="' + i + '" data-af-field="username">'
|
||
+ '<input type="text" placeholder="$PASSWORD" value="' + escapeHtml(row.password) + '" style="flex:1;padding:0.3em 0.5em;font-size:0.9em;background:var(--input);color:var(--text);border:1px solid var(--border);border-radius:4px" data-af-idx="' + i + '" data-af-field="password">'
|
||
+ '<button type="button" style="background:none;border:none;color:var(--primary);cursor:pointer;font-size:1.1em;padding:0 0.3em" data-af-remove="' + i + '" title="Remove">×</button>';
|
||
container.appendChild(div);
|
||
});
|
||
// Bind input change events
|
||
container.querySelectorAll('[data-af-field]').forEach(function(input) {
|
||
input.addEventListener('input', function() {
|
||
var idx = parseInt(this.getAttribute('data-af-idx'));
|
||
var field = this.getAttribute('data-af-field');
|
||
autofillRows[idx][field] = this.value;
|
||
});
|
||
});
|
||
container.querySelectorAll('[data-af-remove]').forEach(function(btn) {
|
||
btn.addEventListener('click', function() {
|
||
var idx = parseInt(this.getAttribute('data-af-remove'));
|
||
autofillRows.splice(idx, 1);
|
||
renderAutofillRows();
|
||
});
|
||
});
|
||
}
|
||
|
||
document.getElementById('em-autofill-add').addEventListener('click', function() {
|
||
var urlVal = document.getElementById('em-url').value.trim();
|
||
autofillRows.push({ url: urlVal || 'https://', username: '$USERNAME', password: '$PASSWORD' });
|
||
renderAutofillRows();
|
||
// Auto-expand the section
|
||
document.getElementById('em-autofill-fields').style.display = '';
|
||
document.getElementById('em-autofill-arrow').innerHTML = '▼';
|
||
});
|
||
|
||
// Toggle Allowed Domains fields visibility
|
||
document.getElementById('em-allowed-domains-toggle').addEventListener('click', function() {
|
||
var fields = document.getElementById('em-allowed-domains-fields');
|
||
var arrow = document.getElementById('em-allowed-domains-arrow');
|
||
if (fields.style.display === 'none') {
|
||
fields.style.display = '';
|
||
arrow.innerHTML = '▼';
|
||
} else {
|
||
fields.style.display = 'none';
|
||
arrow.innerHTML = '▶';
|
||
}
|
||
});
|
||
|
||
// ── Allowed Domains management ──
|
||
var allowedDomains = []; // Array of strings
|
||
|
||
function renderAllowedDomains() {
|
||
var container = document.getElementById('em-allowed-domains-list');
|
||
container.innerHTML = '';
|
||
allowedDomains.forEach(function(domain, i) {
|
||
var div = document.createElement('div');
|
||
div.style.cssText = 'display:flex;gap:0.4em;align-items:center;margin-bottom:0.3em';
|
||
div.innerHTML = '<input type="text" placeholder="example.com" value="' + escapeHtml(domain) + '" style="flex:1;padding:0.3em 0.5em;font-size:0.9em;background:var(--input);color:var(--text);border:1px solid var(--border);border-radius:4px" data-ad-idx="' + i + '">'
|
||
+ '<button type="button" style="background:none;border:none;color:var(--primary);cursor:pointer;font-size:1.1em;padding:0 0.3em" data-ad-remove="' + i + '" title="Remove">×</button>';
|
||
container.appendChild(div);
|
||
});
|
||
container.querySelectorAll('[data-ad-idx]').forEach(function(input) {
|
||
input.addEventListener('input', function() {
|
||
var idx = parseInt(this.getAttribute('data-ad-idx'));
|
||
allowedDomains[idx] = this.value;
|
||
});
|
||
});
|
||
container.querySelectorAll('[data-ad-remove]').forEach(function(btn) {
|
||
btn.addEventListener('click', function() {
|
||
var idx = parseInt(this.getAttribute('data-ad-remove'));
|
||
allowedDomains.splice(idx, 1);
|
||
renderAllowedDomains();
|
||
});
|
||
});
|
||
}
|
||
|
||
document.getElementById('em-allowed-domains-add').addEventListener('click', function() {
|
||
var urlVal = document.getElementById('em-url').value.trim();
|
||
var defaultDomain = '';
|
||
try { defaultDomain = new URL(urlVal).hostname; } catch(e) {}
|
||
allowedDomains.push(defaultDomain || '');
|
||
renderAllowedDomains();
|
||
document.getElementById('em-allowed-domains-fields').style.display = '';
|
||
document.getElementById('em-allowed-domains-arrow').innerHTML = '▼';
|
||
});
|
||
|
||
// ── Multi-hop management ──
|
||
var hopList = []; // Array of {hostname, port, username, password, private_key, expanded}
|
||
|
||
function renderHops() {
|
||
var container = document.getElementById('em-hops-list');
|
||
container.innerHTML = '';
|
||
hopList.forEach(function(hop, i) {
|
||
var card = document.createElement('div');
|
||
card.className = 'hop-card';
|
||
|
||
// Header
|
||
var header = document.createElement('div');
|
||
header.className = 'hop-card-header';
|
||
|
||
var num = document.createElement('span');
|
||
num.className = 'hop-num';
|
||
num.textContent = '#' + (i + 1);
|
||
header.appendChild(num);
|
||
|
||
var hostWrap = document.createElement('span');
|
||
hostWrap.className = 'hop-hostname';
|
||
var hostInput = document.createElement('input');
|
||
hostInput.type = 'text';
|
||
hostInput.placeholder = 'bastion.example.com';
|
||
hostInput.value = hop.hostname || '';
|
||
hostInput.setAttribute('data-hop', i);
|
||
hostInput.setAttribute('data-field', 'hostname');
|
||
hostInput.addEventListener('input', onHopFieldChange);
|
||
hostInput.addEventListener('click', function(e) { e.stopPropagation(); });
|
||
hostWrap.appendChild(hostInput);
|
||
header.appendChild(hostWrap);
|
||
|
||
var toggle = document.createElement('button');
|
||
toggle.type = 'button';
|
||
toggle.className = 'hop-remove';
|
||
toggle.textContent = hop.expanded ? 'collapse' : 'expand';
|
||
toggle.setAttribute('data-hop', i);
|
||
toggle.addEventListener('click', function(e) {
|
||
e.stopPropagation();
|
||
hopList[i].expanded = !hopList[i].expanded;
|
||
renderHops();
|
||
});
|
||
header.appendChild(toggle);
|
||
|
||
var removeBtn = document.createElement('button');
|
||
removeBtn.type = 'button';
|
||
removeBtn.className = 'hop-remove';
|
||
removeBtn.textContent = 'remove';
|
||
removeBtn.setAttribute('data-hop', i);
|
||
removeBtn.addEventListener('click', function(e) {
|
||
e.stopPropagation();
|
||
hopList.splice(i, 1);
|
||
renderHops();
|
||
updateFlowDiagram();
|
||
});
|
||
header.appendChild(removeBtn);
|
||
|
||
card.appendChild(header);
|
||
|
||
// Body (collapsible)
|
||
var body = document.createElement('div');
|
||
body.className = 'hop-card-body' + (hop.expanded ? ' expanded' : '');
|
||
|
||
var fpDisplay = hop.host_key_fingerprint || (hop.host_key ? 'pinned' : '');
|
||
var fpColor = hop.host_key ? 'color:var(--accent,#0a0)' : 'color:var(--text-muted,#888)';
|
||
var fpLabel = hop.host_key ? fpDisplay : 'not pinned';
|
||
var verifyLabel = hop.host_key ? 'Re-verify' : 'Verify Host Key';
|
||
|
||
body.innerHTML =
|
||
'<label>SSH Port<input type="number" data-hop="' + i + '" data-field="port" value="' + (hop.port || 22) + '"></label>' +
|
||
'<label>Username<input type="text" data-hop="' + i + '" data-field="username" value="' + escapeAttr(hop.username || '') + '"></label>' +
|
||
'<label>Password<input type="password" data-hop="' + i + '" data-field="password" value="' + escapeAttr(hop.password || '') + '"></label>' +
|
||
'<label>Private key (PEM)<textarea data-hop="' + i + '" data-field="private_key" rows="2" style="resize:vertical;font-size:0.85em" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----">' + escapeHtml(hop.private_key || '') + '</textarea></label>' +
|
||
'<div style="margin-top:0.5em;display:flex;align-items:center;gap:0.6em">' +
|
||
'<button type="button" class="hop-verify-btn" data-hop="' + i + '" style="padding:0.3em 0.7em;font-size:0.85em;font-family:monospace;cursor:pointer;background:var(--input,#222);color:var(--accent,#0f0);border:1px solid var(--border,#333);border-radius:3px">' + verifyLabel + '</button>' +
|
||
'<span class="hop-fp" data-hop="' + i + '" style="font-size:0.8em;' + fpColor + '">' + escapeHtml(fpLabel) + '</span>' +
|
||
'</div>';
|
||
|
||
var inputs = body.querySelectorAll('input, textarea');
|
||
for (var j = 0; j < inputs.length; j++) {
|
||
inputs[j].addEventListener('input', onHopFieldChange);
|
||
}
|
||
|
||
var verifyBtn = body.querySelector('.hop-verify-btn');
|
||
verifyBtn.addEventListener('click', function(e) {
|
||
e.stopPropagation();
|
||
var idx = parseInt(this.getAttribute('data-hop'));
|
||
probeHostKey(idx);
|
||
});
|
||
|
||
card.appendChild(body);
|
||
container.appendChild(card);
|
||
});
|
||
updateFlowDiagram();
|
||
}
|
||
|
||
function probeHostKey(hopIndex) {
|
||
var hop = hopList[hopIndex];
|
||
if (!hop || !hop.hostname || !hop.hostname.trim()) {
|
||
rgAlert('Enter a hostname first.');
|
||
return;
|
||
}
|
||
var btn = document.querySelector('.hop-verify-btn[data-hop="' + hopIndex + '"]');
|
||
var fpSpan = document.querySelector('.hop-fp[data-hop="' + hopIndex + '"]');
|
||
if (btn) { btn.disabled = true; btn.textContent = 'Probing...'; }
|
||
|
||
fetch('/api/ssh/probe-host-key', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({ hostname: hop.hostname.trim(), port: hop.port || 22 })
|
||
}).then(function(r) { return r.json().then(function(d) { return {ok: r.ok, data: d}; }); })
|
||
.then(function(res) {
|
||
if (!res.ok) {
|
||
rgAlert('Probe failed: ' + (res.data.error || 'unknown error'));
|
||
if (btn) { btn.disabled = false; btn.textContent = hop.host_key ? 'Re-verify' : 'Verify Host Key'; }
|
||
return;
|
||
}
|
||
var msg = hop.hostname.trim() + ':' + (hop.port || 22) + '\n\n' +
|
||
res.data.fingerprint + ' (' + res.data.algorithm + ')';
|
||
rgConfirm(msg, { title: 'Trust host key?', confirmText: 'Trust', safe: true }).then(function(ok) {
|
||
if (ok) {
|
||
hopList[hopIndex].host_key = res.data.host_key;
|
||
hopList[hopIndex].host_key_fingerprint = res.data.fingerprint;
|
||
renderHops();
|
||
} else {
|
||
if (btn) { btn.disabled = false; btn.textContent = hop.host_key ? 'Re-verify' : 'Verify Host Key'; }
|
||
}
|
||
});
|
||
}).catch(function(e) {
|
||
rgAlert('Probe failed: ' + e.message);
|
||
if (btn) { btn.disabled = false; btn.textContent = hop.host_key ? 'Re-verify' : 'Verify Host Key'; }
|
||
});
|
||
}
|
||
|
||
function onHopFieldChange(e) {
|
||
var idx = parseInt(e.target.getAttribute('data-hop'));
|
||
var field = e.target.getAttribute('data-field');
|
||
if (field === 'port') {
|
||
hopList[idx][field] = parseInt(e.target.value) || 22;
|
||
} else {
|
||
hopList[idx][field] = e.target.value;
|
||
}
|
||
if (field === 'hostname') updateFlowDiagram();
|
||
}
|
||
|
||
function updateFlowDiagram() {
|
||
var diag = document.getElementById('em-flow-diagram');
|
||
if (!diag) return;
|
||
var html = '<span class="flow-node flow-node-you">You</span>';
|
||
hopList.forEach(function(hop) {
|
||
var label = hop.hostname || '???';
|
||
html += '<span class="flow-arrow">→</span><span class="flow-node flow-node-hop">' + escapeHtml(label) + '</span>';
|
||
});
|
||
// Add target
|
||
var type = entryTypeSelect.value;
|
||
var targetHost = '';
|
||
var targetPort = '';
|
||
var typeLabel = type.toUpperCase();
|
||
if (type === 'ssh') {
|
||
targetHost = document.getElementById('em-hostname').value || '???';
|
||
targetPort = document.getElementById('em-port').value || '22';
|
||
} else if (type === 'rdp') {
|
||
targetHost = document.getElementById('em-rdp-hostname').value || '???';
|
||
targetPort = document.getElementById('em-rdp-port').value || '3389';
|
||
} else if (type === 'vnc') {
|
||
targetHost = document.getElementById('em-vnc-hostname').value || '???';
|
||
targetPort = document.getElementById('em-vnc-port').value || '5900';
|
||
} else if (type === 'spice') {
|
||
targetHost = document.getElementById('em-spice-hostname').value || '???';
|
||
targetPort = document.getElementById('em-spice-port').value || '5900';
|
||
} else if (type === 'proxmox') {
|
||
targetHost = document.getElementById('em-proxmox-node').value || '???';
|
||
targetPort = document.getElementById('em-proxmox-vmid').value || '???';
|
||
} 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;
|
||
setAdhocMode(false);
|
||
entryEditMode = null;
|
||
cloneSource = null;
|
||
document.getElementById('entry-modal-title').textContent = 'New Entry in ' + folderPath(selectedFolder);
|
||
document.getElementById('em-name').value = '';
|
||
document.getElementById('em-name').disabled = false;
|
||
document.getElementById('em-display-name').value = '';
|
||
entryTypeSelect.value = 'ssh';
|
||
entryTypeSelect.disabled = false;
|
||
entryTypeSelect.dispatchEvent(new Event('change'));
|
||
clearEntryFields();
|
||
document.getElementById('em-move-section').style.display = 'none';
|
||
document.getElementById('em-error').textContent = '';
|
||
document.getElementById('entry-modal').classList.add('active');
|
||
}
|
||
|
||
document.getElementById('btn-new-entry').addEventListener('click', openNewEntry);
|
||
|
||
function clearEntryFields() {
|
||
document.getElementById('em-hostname').value = '';
|
||
document.getElementById('em-port').value = '22';
|
||
document.getElementById('em-username').value = '';
|
||
document.getElementById('em-password').value = '';
|
||
document.getElementById('em-private-key').value = '';
|
||
document.getElementById('em-rdp-hostname').value = '';
|
||
document.getElementById('em-rdp-port').value = '3389';
|
||
document.getElementById('em-rdp-username').value = '';
|
||
document.getElementById('em-rdp-password').value = '';
|
||
document.getElementById('em-rdp-domain').value = '';
|
||
document.getElementById('em-rdp-security').value = '';
|
||
document.getElementById('em-rdp-layout').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-username').value = '';
|
||
document.getElementById('em-vnc-password').value = '';
|
||
document.getElementById('em-vnc-color-depth').value = '';
|
||
document.getElementById('em-vnc-resize').checked = false;
|
||
document.getElementById('em-vnc-network').value = '';
|
||
document.getElementById('em-vnc-prompt-creds').checked = false;
|
||
document.getElementById('em-spice-hostname').value = '';
|
||
document.getElementById('em-spice-port').value = '5900';
|
||
document.getElementById('em-spice-password').value = '';
|
||
document.getElementById('em-spice-color-depth').value = '';
|
||
document.getElementById('em-spice-tls').checked = false;
|
||
document.getElementById('em-spice-tls-port').value = '';
|
||
document.getElementById('em-spice-ignore-cert').checked = false;
|
||
document.getElementById('em-spice-ca-cert').value = '';
|
||
document.getElementById('em-spice-cert-subject').value = '';
|
||
document.getElementById('em-spice-proxy').value = '';
|
||
document.getElementById('em-spice-prompt-creds').checked = false;
|
||
document.getElementById('em-proxmox-url').value = '';
|
||
document.getElementById('em-proxmox-node').value = '';
|
||
document.getElementById('em-proxmox-vmid').value = '';
|
||
document.getElementById('em-proxmox-token-id').value = '';
|
||
document.getElementById('em-proxmox-token-secret').value = '';
|
||
document.getElementById('em-proxmox-token-secret').placeholder = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx';
|
||
document.getElementById('em-proxmox-color-depth').value = '';
|
||
document.getElementById('em-proxmox-monitors').value = '1';
|
||
document.getElementById('em-proxmox-verify-tls').checked = false;
|
||
document.getElementById('em-ssh-font-size').value = '';
|
||
document.getElementById('em-wol-enabled').checked = false;
|
||
document.getElementById('em-wol-mac').value = '';
|
||
document.getElementById('em-wol-broadcast').value = '';
|
||
document.getElementById('em-wol-port').value = '';
|
||
document.getElementById('em-wol-wait').value = '';
|
||
document.getElementById('em-wol-fields').style.display = 'none';
|
||
document.getElementById('em-wol-arrow').innerHTML = '▶';
|
||
document.getElementById('em-url').value = '';
|
||
document.getElementById('em-banner').value = '';
|
||
document.getElementById('em-web-username').value = '';
|
||
document.getElementById('em-web-password').value = '';
|
||
document.getElementById('em-login-script').value = '';
|
||
document.getElementById('em-automation-fields').style.display = 'none';
|
||
document.getElementById('em-automation-arrow').innerHTML = '▶';
|
||
autofillRows = [];
|
||
renderAutofillRows();
|
||
document.getElementById('em-autofill-fields').style.display = 'none';
|
||
document.getElementById('em-autofill-arrow').innerHTML = '▶';
|
||
allowedDomains = [];
|
||
renderAllowedDomains();
|
||
document.getElementById('em-allowed-domains-fields').style.display = 'none';
|
||
document.getElementById('em-allowed-domains-arrow').innerHTML = '▶';
|
||
document.getElementById('em-enable-gfx').checked = false;
|
||
document.getElementById('em-enable-desktop-comp').checked = false;
|
||
document.getElementById('em-force-lossless').checked = false;
|
||
document.getElementById('em-enable-h264').checked = false;
|
||
document.getElementById('em-enable-wallpaper').checked = false;
|
||
document.getElementById('em-enable-theming').checked = false;
|
||
document.getElementById('em-enable-full-window-drag').checked = false;
|
||
document.getElementById('em-video-perf-fields').style.display = 'none';
|
||
document.getElementById('em-video-perf-arrow').innerHTML = '▶';
|
||
document.getElementById('em-disable-copy').checked = false;
|
||
document.getElementById('em-disable-paste').checked = false;
|
||
document.getElementById('em-allow-sharing').checked = false;
|
||
document.getElementById('em-auto-open-singleton').checked = false;
|
||
document.getElementById('em-fullscreen-on-connect').checked = false;
|
||
document.getElementById('em-autohide-side-tabs').checked = false;
|
||
document.getElementById('em-enable-drive').checked = false;
|
||
document.getElementById('em-drive-warning').style.display = 'none';
|
||
document.getElementById('em-remote-app').value = '';
|
||
document.getElementById('em-remote-app-dir').value = '';
|
||
document.getElementById('em-remote-app-args').value = '';
|
||
document.getElementById('em-remoteapp-fields').style.display = 'none';
|
||
document.getElementById('em-remoteapp-arrow').innerHTML = '▶';
|
||
document.getElementById('em-vdi-image').value = '';
|
||
document.getElementById('em-vdi-cpu').value = '';
|
||
document.getElementById('em-vdi-memory').value = '';
|
||
document.getElementById('em-vdi-env').value = '';
|
||
document.getElementById('em-vdi-container-username').value = '';
|
||
document.getElementById('em-vdi-container-password').value = '';
|
||
document.getElementById('em-vdi-container-password').placeholder = 'Leave blank to generate per-connect';
|
||
document.getElementById('em-vdi-idle-timeout').value = '';
|
||
document.getElementById('em-vdi-banner').value = '';
|
||
document.getElementById('em-override-recording').checked = false;
|
||
document.getElementById('em-enable-recording').checked = true;
|
||
document.getElementById('em-max-recordings').value = '0';
|
||
document.getElementById('em-recording-sub').style.display = 'none';
|
||
document.getElementById('em-record-typescript').checked = false;
|
||
document.getElementById('em-recording-fields').style.display = 'none';
|
||
document.getElementById('em-recording-arrow').innerHTML = '▶';
|
||
hopList = [];
|
||
renderHops();
|
||
document.getElementById('em-tunnel-fields').style.display = 'none';
|
||
document.getElementById('em-tunnel-arrow').innerHTML = '▶';
|
||
}
|
||
|
||
function openEditEntry(name, scope, folder, type) {
|
||
setAdhocMode(false);
|
||
entryEditMode = { scope: scope, folder: folder, name: name };
|
||
cloneSource = null;
|
||
document.getElementById('entry-modal-title').textContent = 'Edit: ' + name;
|
||
document.getElementById('em-name').value = name;
|
||
document.getElementById('em-name').disabled = true;
|
||
document.getElementById('em-display-name').value = '';
|
||
document.getElementById('em-error').textContent = '';
|
||
clearEntryFields();
|
||
|
||
// Pre-select the type from the table data
|
||
if (type) {
|
||
entryTypeSelect.value = type;
|
||
entryTypeSelect.disabled = true;
|
||
entryTypeSelect.dispatchEvent(new Event('change'));
|
||
}
|
||
|
||
// Populate move-to-folder dropdown (includes top-level + any loaded subfolders)
|
||
var moveSelect = document.getElementById('em-move-target');
|
||
moveSelect.innerHTML = '<option value="">(keep in current folder)</option>';
|
||
var moveCandidates = [];
|
||
folders.forEach(function(f) { moveCandidates.push(f); });
|
||
for (var ck in subfolderCache) {
|
||
subfolderCache[ck].forEach(function(f) { moveCandidates.push(f); });
|
||
}
|
||
moveCandidates.forEach(function(f) {
|
||
var p = folderPath(f);
|
||
if (p === folder && f.scope === scope) return;
|
||
var opt = document.createElement('option');
|
||
opt.value = f.scope + '|' + p;
|
||
opt.textContent = p + ' (' + f.scope + ')';
|
||
moveSelect.appendChild(opt);
|
||
});
|
||
document.getElementById('em-move-section').style.display = moveCandidates.length > 1 ? '' : 'none';
|
||
|
||
// Pre-fill with existing entry data from the loaded entries
|
||
var entryData = null;
|
||
for (var i = 0; i < currentEntries.length; i++) {
|
||
if (currentEntries[i].name === name) {
|
||
entryData = currentEntries[i];
|
||
break;
|
||
}
|
||
}
|
||
if (entryData) {
|
||
if (entryData.display_name && entryData.display_name !== name) {
|
||
document.getElementById('em-display-name').value = entryData.display_name;
|
||
}
|
||
if (type === 'ssh') {
|
||
document.getElementById('em-hostname').value = entryData.hostname || '';
|
||
document.getElementById('em-port').value = entryData.port || '22';
|
||
document.getElementById('em-username').value = entryData.username || '';
|
||
document.getElementById('em-ssh-font-size').value = entryData.ssh_font_size || '';
|
||
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-layout').value = entryData.server_layout || '';
|
||
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-monitors').value = entryData.max_monitors || 1;
|
||
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-username').value = entryData.username || '';
|
||
document.getElementById('em-vnc-color-depth').value = entryData.color_depth ? String(entryData.color_depth) : '';
|
||
document.getElementById('em-vnc-resize').checked = !!entryData.vnc_resize;
|
||
document.getElementById('em-vnc-network').value = entryData.vnc_network || '';
|
||
document.getElementById('em-vnc-prompt-creds').checked = !!entryData.prompt_credentials;
|
||
} else if (type === 'spice') {
|
||
document.getElementById('em-spice-hostname').value = entryData.hostname || '';
|
||
document.getElementById('em-spice-port').value = entryData.port || '5900';
|
||
document.getElementById('em-spice-color-depth').value = entryData.color_depth ? String(entryData.color_depth) : '';
|
||
document.getElementById('em-spice-tls').checked = !!entryData.spice_tls;
|
||
document.getElementById('em-spice-tls-port').value = entryData.spice_tls_port || '';
|
||
document.getElementById('em-spice-ignore-cert').checked = !!entryData.ignore_cert;
|
||
document.getElementById('em-spice-ca-cert').value = entryData.spice_ca_cert || '';
|
||
document.getElementById('em-spice-cert-subject').value = entryData.spice_cert_subject || '';
|
||
document.getElementById('em-spice-proxy').value = entryData.spice_proxy || '';
|
||
document.getElementById('em-spice-prompt-creds').checked = !!entryData.prompt_credentials;
|
||
} else if (type === 'proxmox') {
|
||
document.getElementById('em-proxmox-url').value = entryData.proxmox_url || '';
|
||
document.getElementById('em-proxmox-node').value = entryData.proxmox_node || '';
|
||
document.getElementById('em-proxmox-vmid').value = entryData.proxmox_vmid || '';
|
||
document.getElementById('em-proxmox-color-depth').value = entryData.color_depth ? String(entryData.color_depth) : '';
|
||
document.getElementById('em-proxmox-monitors').value = entryData.max_monitors || 1;
|
||
document.getElementById('em-proxmox-verify-tls').checked = !!entryData.proxmox_verify_tls;
|
||
document.getElementById('em-proxmox-token-id').value = entryData.proxmox_token_id || '';
|
||
// Secret is never returned; show a hint if one is stored.
|
||
var secEl = document.getElementById('em-proxmox-token-secret');
|
||
secEl.value = '';
|
||
secEl.placeholder = entryData.has_proxmox_token_secret ? '(leave blank to keep existing)' : 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx';
|
||
} else if (type === 'web') {
|
||
document.getElementById('em-url').value = entryData.url || '';
|
||
document.getElementById('em-banner').value = entryData.banner || '';
|
||
document.getElementById('em-web-username').value = entryData.username || '';
|
||
loadLoginScripts(function() { populateLoginScriptDropdown(entryData.login_script || ''); });
|
||
// Auto-expand automation if any automation fields are set
|
||
if (entryData.username || entryData.login_script || entryData.autofill) {
|
||
document.getElementById('em-automation-fields').style.display = '';
|
||
document.getElementById('em-automation-arrow').innerHTML = '▼';
|
||
}
|
||
// Populate autofill rows
|
||
if (entryData.autofill) {
|
||
try {
|
||
var afData = typeof entryData.autofill === 'string' ? JSON.parse(entryData.autofill) : entryData.autofill;
|
||
if (Array.isArray(afData) && afData.length > 0) {
|
||
autofillRows = afData.map(function(r) {
|
||
return { url: r.url || '', username: r.username || '$USERNAME', password: r.password || '$PASSWORD' };
|
||
});
|
||
renderAutofillRows();
|
||
document.getElementById('em-autofill-fields').style.display = '';
|
||
document.getElementById('em-autofill-arrow').innerHTML = '▼';
|
||
}
|
||
} catch(e) { /* ignore bad JSON */ }
|
||
}
|
||
// Populate allowed domains
|
||
if (entryData.allowed_domains && Array.isArray(entryData.allowed_domains) && entryData.allowed_domains.length > 0) {
|
||
allowedDomains = entryData.allowed_domains.slice();
|
||
renderAllowedDomains();
|
||
document.getElementById('em-allowed-domains-fields').style.display = '';
|
||
document.getElementById('em-allowed-domains-arrow').innerHTML = '▼';
|
||
}
|
||
} else if (type === 'vdi') {
|
||
document.getElementById('em-vdi-image').value = entryData.container_image || '';
|
||
document.getElementById('em-vdi-cpu').value = entryData.container_cpu_limit || '';
|
||
document.getElementById('em-vdi-memory').value = entryData.container_memory_limit || '';
|
||
if (entryData.container_env) {
|
||
var envLines = Object.keys(entryData.container_env).map(function(k) {
|
||
return k + '=' + entryData.container_env[k];
|
||
}).join('\n');
|
||
document.getElementById('em-vdi-env').value = envLines;
|
||
}
|
||
document.getElementById('em-vdi-container-username').value = entryData.container_username || '';
|
||
// Password field is left blank by design; the entry retains the stored value
|
||
// unless a new one is typed. has_container_password shows whether one is set.
|
||
var cpw = document.getElementById('em-vdi-container-password');
|
||
cpw.value = '';
|
||
cpw.placeholder = entryData.has_container_password
|
||
? '(stored password unchanged unless you type a new one)'
|
||
: 'Leave blank to generate per-connect';
|
||
document.getElementById('em-vdi-idle-timeout').value = entryData.container_idle_timeout_mins || '';
|
||
document.getElementById('em-vdi-banner').value = entryData.banner || '';
|
||
}
|
||
if (type === 'ssh' || type === 'rdp') {
|
||
document.getElementById('em-enable-drive').checked = !!entryData.enable_drive;
|
||
document.getElementById('em-drive-warning').style.display = (entryData.enable_drive && !_driveConfigured) ? '' : 'none';
|
||
}
|
||
// Populate RemoteApp fields (RDP)
|
||
if (type === 'rdp' && (entryData.remote_app || entryData.remote_app_dir || entryData.remote_app_args)) {
|
||
document.getElementById('em-remote-app').value = entryData.remote_app || '';
|
||
document.getElementById('em-remote-app-dir').value = entryData.remote_app_dir || '';
|
||
document.getElementById('em-remote-app-args').value = entryData.remote_app_args || '';
|
||
document.getElementById('em-remoteapp-fields').style.display = '';
|
||
document.getElementById('em-remoteapp-arrow').innerHTML = '▼';
|
||
}
|
||
// Populate recording override fields
|
||
if (entryData.enable_recording !== null && entryData.enable_recording !== undefined) {
|
||
document.getElementById('em-override-recording').checked = true;
|
||
document.getElementById('em-recording-sub').style.display = '';
|
||
document.getElementById('em-enable-recording').checked = entryData.enable_recording !== false;
|
||
document.getElementById('em-max-recordings').value = entryData.max_recordings || '0';
|
||
document.getElementById('em-recording-fields').style.display = '';
|
||
document.getElementById('em-recording-arrow').innerHTML = '▼';
|
||
} else if (entryData.max_recordings) {
|
||
document.getElementById('em-override-recording').checked = true;
|
||
document.getElementById('em-recording-sub').style.display = '';
|
||
document.getElementById('em-enable-recording').checked = true;
|
||
document.getElementById('em-max-recordings').value = entryData.max_recordings;
|
||
document.getElementById('em-recording-fields').style.display = '';
|
||
document.getElementById('em-recording-arrow').innerHTML = '▼';
|
||
}
|
||
// Typescript recording flag (SSH only; #159)
|
||
document.getElementById('em-typescript-row').style.display = type === 'ssh' ? '' : 'none';
|
||
document.getElementById('em-record-typescript').checked = !!entryData.record_typescript;
|
||
if (entryData.record_typescript) {
|
||
document.getElementById('em-recording-fields').style.display = '';
|
||
document.getElementById('em-recording-arrow').innerHTML = '▼';
|
||
}
|
||
// Populate video performance settings (RDP only)
|
||
if (type === 'rdp') {
|
||
document.getElementById('em-enable-gfx').checked = !!entryData.enable_gfx;
|
||
document.getElementById('em-enable-desktop-comp').checked = !!entryData.enable_desktop_composition;
|
||
document.getElementById('em-force-lossless').checked = !!entryData.force_lossless;
|
||
document.getElementById('em-enable-h264').checked = !!entryData.enable_h264;
|
||
document.getElementById('em-enable-wallpaper').checked = !!entryData.enable_wallpaper;
|
||
document.getElementById('em-enable-theming').checked = !!entryData.enable_theming;
|
||
document.getElementById('em-enable-full-window-drag').checked = !!entryData.enable_full_window_drag;
|
||
if (entryData.enable_gfx || entryData.enable_desktop_composition || entryData.force_lossless || entryData.enable_h264 || entryData.enable_wallpaper || entryData.enable_theming || entryData.enable_full_window_drag) {
|
||
document.getElementById('em-video-perf-fields').style.display = '';
|
||
document.getElementById('em-video-perf-arrow').innerHTML = '▼';
|
||
}
|
||
}
|
||
// Populate clipboard settings
|
||
document.getElementById('em-disable-copy').checked = !!entryData.disable_copy;
|
||
document.getElementById('em-disable-paste').checked = !!entryData.disable_paste;
|
||
// Populate per-entry sharing flag
|
||
document.getElementById('em-allow-sharing').checked = !!entryData.allow_sharing;
|
||
// Populate auto-open singleton flag
|
||
document.getElementById('em-auto-open-singleton').checked = !!entryData.auto_open_if_singleton;
|
||
// Populate fullscreen-on-connect flag
|
||
document.getElementById('em-fullscreen-on-connect').checked = !!entryData.fullscreen_on_connect;
|
||
// Populate autohide-side-tabs flag
|
||
document.getElementById('em-autohide-side-tabs').checked = !!entryData.autohide_side_tabs;
|
||
// Populate Wake-on-LAN fields (SSH/RDP/VNC)
|
||
if (type === 'ssh' || type === 'rdp' || type === 'vnc') {
|
||
document.getElementById('em-wol-enabled').checked = !!entryData.wol_send_packet;
|
||
document.getElementById('em-wol-mac').value = entryData.wol_mac_addr || '';
|
||
document.getElementById('em-wol-broadcast').value = entryData.wol_broadcast_addr || '';
|
||
document.getElementById('em-wol-port').value = entryData.wol_udp_port || '';
|
||
document.getElementById('em-wol-wait').value = entryData.wol_wait_time || '';
|
||
if (entryData.wol_send_packet || entryData.wol_mac_addr) {
|
||
document.getElementById('em-wol-fields').style.display = '';
|
||
document.getElementById('em-wol-arrow').innerHTML = '▼';
|
||
}
|
||
}
|
||
// Populate hop list from jump_hosts array
|
||
if (entryData.jump_hosts && entryData.jump_hosts.length > 0) {
|
||
hopList = entryData.jump_hosts.map(function(h) {
|
||
return { hostname: h.hostname || '', port: h.port || 22, username: h.username || '', password: '', private_key: '', host_key: h.host_key || '', host_key_fingerprint: h.host_key_fingerprint || '', expanded: false };
|
||
});
|
||
renderHops();
|
||
// Auto-expand the tunnel section
|
||
document.getElementById('em-tunnel-fields').style.display = '';
|
||
document.getElementById('em-tunnel-arrow').innerHTML = '▼';
|
||
}
|
||
}
|
||
|
||
document.getElementById('entry-modal').classList.add('active');
|
||
}
|
||
|
||
document.getElementById('em-cancel').addEventListener('click', function() {
|
||
document.getElementById('entry-modal').classList.remove('active');
|
||
entryTypeSelect.disabled = false;
|
||
});
|
||
|
||
// The entry the editor describes, in the form the entries API takes
|
||
// and the ad-hoc connect endpoint reads. Shared by Save, Connect and
|
||
// Save as entry, so every option works in all three.
|
||
function buildEntryFromForm() {
|
||
var displayName = document.getElementById('em-display-name').value.trim();
|
||
var type = entryTypeSelect.value;
|
||
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;
|
||
var fs = parseInt(document.getElementById('em-ssh-font-size').value);
|
||
if (fs > 0) entry.ssh_font_size = fs;
|
||
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;
|
||
var layout = document.getElementById('em-rdp-layout').value;
|
||
if (layout) entry.server_layout = layout;
|
||
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;
|
||
var rmon = parseInt(document.getElementById('em-rdp-monitors').value);
|
||
if (rmon > 1) entry.max_monitors = rmon;
|
||
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 u = document.getElementById('em-vnc-username').value.trim();
|
||
if (u) entry.username = u;
|
||
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-resize').checked) entry.vnc_resize = true;
|
||
var net = document.getElementById('em-vnc-network').value;
|
||
if (net) entry.vnc_network = net;
|
||
if (document.getElementById('em-vnc-prompt-creds').checked) entry.prompt_credentials = true;
|
||
} else if (type === 'spice') {
|
||
var h = document.getElementById('em-spice-hostname').value.trim();
|
||
if (h) entry.hostname = h;
|
||
var p = parseInt(document.getElementById('em-spice-port').value);
|
||
if (p) entry.port = p;
|
||
var pw = document.getElementById('em-spice-password').value;
|
||
if (pw) entry.password = pw;
|
||
var cd = document.getElementById('em-spice-color-depth').value;
|
||
if (cd) entry.color_depth = parseInt(cd);
|
||
entry.spice_tls = document.getElementById('em-spice-tls').checked;
|
||
var tp = parseInt(document.getElementById('em-spice-tls-port').value);
|
||
if (tp) entry.spice_tls_port = tp;
|
||
entry.ignore_cert = document.getElementById('em-spice-ignore-cert').checked;
|
||
var ca = document.getElementById('em-spice-ca-cert').value.trim();
|
||
if (ca) entry.spice_ca_cert = ca;
|
||
var cs = document.getElementById('em-spice-cert-subject').value.trim();
|
||
if (cs) entry.spice_cert_subject = cs;
|
||
var px = document.getElementById('em-spice-proxy').value.trim();
|
||
if (px) entry.spice_proxy = px;
|
||
if (document.getElementById('em-spice-prompt-creds').checked) entry.prompt_credentials = true;
|
||
} else if (type === 'proxmox') {
|
||
var purl = document.getElementById('em-proxmox-url').value.trim();
|
||
if (purl) entry.proxmox_url = purl;
|
||
var pnode = document.getElementById('em-proxmox-node').value.trim();
|
||
if (pnode) entry.proxmox_node = pnode;
|
||
var pvmid = parseInt(document.getElementById('em-proxmox-vmid').value);
|
||
if (pvmid) entry.proxmox_vmid = pvmid;
|
||
var ptid = document.getElementById('em-proxmox-token-id').value.trim();
|
||
if (ptid) entry.proxmox_token_id = ptid;
|
||
// Only send the secret if the user entered one (blank = keep existing).
|
||
var psec = document.getElementById('em-proxmox-token-secret').value.trim();
|
||
if (psec) entry.proxmox_token_secret = psec;
|
||
var pcd = document.getElementById('em-proxmox-color-depth').value;
|
||
if (pcd) entry.color_depth = parseInt(pcd);
|
||
var pmon = parseInt(document.getElementById('em-proxmox-monitors').value);
|
||
if (pmon > 1) entry.max_monitors = pmon;
|
||
entry.proxmox_verify_tls = document.getElementById('em-proxmox-verify-tls').checked;
|
||
} else if (type === 'web') {
|
||
var url = document.getElementById('em-url').value.trim();
|
||
if (url) entry.url = url;
|
||
var bn = document.getElementById('em-banner').value.trim();
|
||
if (bn) entry.banner = bn;
|
||
var wu = document.getElementById('em-web-username').value.trim();
|
||
if (wu) entry.username = wu;
|
||
var wpw = document.getElementById('em-web-password').value;
|
||
if (wpw) entry.password = wpw;
|
||
var ls = document.getElementById('em-login-script').value.trim();
|
||
if (ls) entry.login_script = ls;
|
||
// Serialize autofill rows
|
||
var afValid = autofillRows.filter(function(r) { return r.url && r.url.trim(); });
|
||
if (afValid.length > 0) {
|
||
entry.autofill = JSON.stringify(afValid);
|
||
}
|
||
// Serialize allowed domains
|
||
var adValid = allowedDomains.filter(function(d) { return d && d.trim(); });
|
||
if (adValid.length > 0) {
|
||
entry.allowed_domains = adValid;
|
||
}
|
||
} else if (type === 'vdi') {
|
||
var img = document.getElementById('em-vdi-image').value.trim();
|
||
if (img) entry.container_image = img;
|
||
var cpu = parseFloat(document.getElementById('em-vdi-cpu').value);
|
||
if (cpu > 0) entry.container_cpu_limit = cpu;
|
||
var mem = parseInt(document.getElementById('em-vdi-memory').value);
|
||
if (mem > 0) entry.container_memory_limit = mem;
|
||
var envText = document.getElementById('em-vdi-env').value.trim();
|
||
if (envText) {
|
||
var envMap = {};
|
||
envText.split('\n').forEach(function(line) {
|
||
var eq = line.indexOf('=');
|
||
if (eq > 0) envMap[line.substring(0, eq).trim()] = line.substring(eq + 1);
|
||
});
|
||
if (Object.keys(envMap).length > 0) entry.container_env = envMap;
|
||
}
|
||
var cu = document.getElementById('em-vdi-container-username').value.trim();
|
||
if (cu) entry.container_username = cu;
|
||
// Password: only send when user typed something. Empty preserves the stored value
|
||
// server-side (see ab_update_entry in api.rs).
|
||
var cp = document.getElementById('em-vdi-container-password').value;
|
||
if (cp) entry.container_password = cp;
|
||
var idleT = parseInt(document.getElementById('em-vdi-idle-timeout').value);
|
||
if (idleT > 0) entry.container_idle_timeout_mins = idleT;
|
||
var vbn = document.getElementById('em-vdi-banner').value.trim();
|
||
if (vbn) entry.banner = vbn;
|
||
}
|
||
|
||
// Drive setting for SSH and RDP
|
||
if (type === 'ssh' || type === 'rdp') {
|
||
entry.enable_drive = document.getElementById('em-enable-drive').checked;
|
||
}
|
||
|
||
// RemoteApp (RDP only)
|
||
if (type === 'rdp') {
|
||
var ra = document.getElementById('em-remote-app').value.trim();
|
||
var raDir = document.getElementById('em-remote-app-dir').value.trim();
|
||
var raArgs = document.getElementById('em-remote-app-args').value.trim();
|
||
if (ra) entry.remote_app = ra;
|
||
if (raDir) entry.remote_app_dir = raDir;
|
||
if (raArgs) entry.remote_app_args = raArgs;
|
||
}
|
||
|
||
// Recording overrides
|
||
if (document.getElementById('em-override-recording').checked) {
|
||
entry.enable_recording = document.getElementById('em-enable-recording').checked;
|
||
var maxRec = parseInt(document.getElementById('em-max-recordings').value) || 0;
|
||
if (maxRec > 0) entry.max_recordings = maxRec;
|
||
}
|
||
// Typescript recording (SSH only; #159). Off by default.
|
||
if (type === 'ssh' && document.getElementById('em-record-typescript').checked) {
|
||
entry.record_typescript = true;
|
||
}
|
||
|
||
// Video performance settings (RDP only)
|
||
if (type === 'rdp') {
|
||
if (document.getElementById('em-enable-gfx').checked) entry.enable_gfx = true;
|
||
if (document.getElementById('em-enable-desktop-comp').checked) entry.enable_desktop_composition = true;
|
||
if (document.getElementById('em-force-lossless').checked) entry.force_lossless = true;
|
||
if (document.getElementById('em-enable-h264').checked) entry.enable_h264 = true;
|
||
if (document.getElementById('em-enable-wallpaper').checked) entry.enable_wallpaper = true;
|
||
if (document.getElementById('em-enable-theming').checked) entry.enable_theming = true;
|
||
if (document.getElementById('em-enable-full-window-drag').checked) entry.enable_full_window_drag = true;
|
||
}
|
||
|
||
// Clipboard settings (all types)
|
||
if (document.getElementById('em-disable-copy').checked) entry.disable_copy = true;
|
||
if (document.getElementById('em-disable-paste').checked) entry.disable_paste = true;
|
||
|
||
// Per-entry sharing (admin opt-in; gates the Share button on
|
||
// Connections active-session cards)
|
||
if (document.getElementById('em-allow-sharing').checked) entry.allow_sharing = true;
|
||
// Auto-open if this is the user's only visible entry (#103)
|
||
if (document.getElementById('em-auto-open-singleton').checked) entry.auto_open_if_singleton = true;
|
||
// Open in fullscreen on connect (#154)
|
||
if (document.getElementById('em-fullscreen-on-connect').checked) entry.fullscreen_on_connect = true;
|
||
// Auto-hide the clipboard/files side tabs when idle
|
||
if (document.getElementById('em-autohide-side-tabs').checked) entry.autohide_side_tabs = true;
|
||
|
||
// Wake-on-LAN (SSH/RDP/VNC)
|
||
if (type === 'ssh' || type === 'rdp' || type === 'vnc') {
|
||
if (document.getElementById('em-wol-enabled').checked) entry.wol_send_packet = true;
|
||
var wolMac = document.getElementById('em-wol-mac').value.trim();
|
||
if (wolMac) entry.wol_mac_addr = wolMac;
|
||
var wolBc = document.getElementById('em-wol-broadcast').value.trim();
|
||
if (wolBc) entry.wol_broadcast_addr = wolBc;
|
||
var wolPort = parseInt(document.getElementById('em-wol-port').value);
|
||
if (wolPort > 0) entry.wol_udp_port = wolPort;
|
||
var wolWait = parseInt(document.getElementById('em-wol-wait').value);
|
||
if (wolWait > 0) entry.wol_wait_time = wolWait;
|
||
}
|
||
|
||
// Tunnel / jump host settings — multi-hop (all types)
|
||
if (type === 'ssh' || type === 'rdp' || type === 'vnc' || type === 'web') {
|
||
var hops = hopList.filter(function(h) { return h.hostname && h.hostname.trim(); });
|
||
if (hops.length > 0) {
|
||
entry.jump_hosts = hops.map(function(h) {
|
||
var hop = { hostname: h.hostname.trim(), port: h.port || 22, username: h.username || '' };
|
||
if (h.password) hop.password = h.password;
|
||
if (h.private_key) hop.private_key = h.private_key;
|
||
if (h.host_key) hop.host_key = h.host_key;
|
||
return hop;
|
||
});
|
||
}
|
||
}
|
||
return entry;
|
||
}
|
||
|
||
document.getElementById('em-save').addEventListener('click', function() {
|
||
var name = document.getElementById('em-name').value.trim();
|
||
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 = buildEntryFromForm();
|
||
|
||
errEl.textContent = '';
|
||
var scope = selectedFolder.scope;
|
||
var folder = folderPath(selectedFolder);
|
||
|
||
var saveBtn = document.getElementById('em-save');
|
||
saveBtn.disabled = true;
|
||
saveBtn.textContent = 'Saving...';
|
||
|
||
// Check if we need to move the entry
|
||
var moveTarget = document.getElementById('em-move-target').value;
|
||
|
||
if (entryEditMode) {
|
||
// Update existing entry
|
||
var updatePromise = fetch('/api/addressbook/folders/' + encodeURIComponent(entryEditMode.scope) + '/' + encodeURIComponent(entryEditMode.folder) + '/entries/' + encodeURIComponent(entryEditMode.name), {
|
||
method: 'PUT',
|
||
headers: apiHeaders({ 'Content-Type': 'application/json' }),
|
||
credentials: 'same-origin',
|
||
body: JSON.stringify(entry)
|
||
})
|
||
.then(function(res) {
|
||
if (!res.ok) return res.text().then(function(t) { throw new Error(t); });
|
||
return res.json();
|
||
});
|
||
|
||
updatePromise.then(function() {
|
||
if (moveTarget) {
|
||
// The server moves the entry as stored, secrets
|
||
// included. This page never holds them, so building
|
||
// the copy here dropped every password, key and
|
||
// $variable reference (#232).
|
||
var sep = moveTarget.indexOf('|');
|
||
var targetScope = sep >= 0 ? moveTarget.slice(0, sep) : moveTarget;
|
||
var targetFolder = sep >= 0 ? moveTarget.slice(sep + 1) : '';
|
||
|
||
return fetch('/api/addressbook/folders/' + encodeURIComponent(entryEditMode.scope) + '/' + encodeURIComponent(entryEditMode.folder) + '/entries/' + encodeURIComponent(entryEditMode.name) + '/move', {
|
||
method: 'POST',
|
||
headers: apiHeaders({ 'Content-Type': 'application/json' }),
|
||
credentials: 'same-origin',
|
||
body: JSON.stringify({ target_scope: targetScope, target_folder: targetFolder })
|
||
}).then(function(res) {
|
||
if (!res.ok) return res.text().then(function(t) { throw new Error('Move failed: ' + t); });
|
||
}).then(function() {
|
||
// Switch to the target folder
|
||
selectedFolder = findFolder(targetScope, targetFolder) || selectedFolder;
|
||
saveSelectedFolder();
|
||
});
|
||
}
|
||
})
|
||
.then(function() {
|
||
document.getElementById('entry-modal').classList.remove('active');
|
||
entryTypeSelect.disabled = false;
|
||
loadEntries(selectedFolder.scope, folderPath(selectedFolder), true);
|
||
})
|
||
.catch(function(err) { errEl.textContent = err.message; })
|
||
.finally(function() { saveBtn.disabled = false; saveBtn.textContent = 'Save'; });
|
||
} else if (cloneSource) {
|
||
// Clone: the server copies the source entry as stored, secrets
|
||
// included (this page never holds them), and the form's edits
|
||
// are then applied as an ordinary update, which keeps any
|
||
// stored secret it is not given (#232).
|
||
var src = cloneSource;
|
||
var entryUrl = '/api/addressbook/folders/' + encodeURIComponent(scope) + '/' + encodeURIComponent(folder) + '/entries/' + encodeURIComponent(name);
|
||
fetch('/api/addressbook/folders/' + encodeURIComponent(src.scope) + '/' + encodeURIComponent(src.folder) + '/entries/' + encodeURIComponent(src.name) + '/copy', {
|
||
method: 'POST',
|
||
headers: apiHeaders({ 'Content-Type': 'application/json' }),
|
||
credentials: 'same-origin',
|
||
body: JSON.stringify({ target_scope: scope, target_folder: folder, new_name: name })
|
||
})
|
||
.then(function(res) {
|
||
if (!res.ok) return res.text().then(function(t) { throw new Error('Clone failed: ' + t); });
|
||
return fetch(entryUrl, {
|
||
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); });
|
||
cloneSource = null;
|
||
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'; });
|
||
} 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'; });
|
||
}
|
||
});
|
||
|
||
// ── Ad-hoc connections ("Connect to a host") ──
|
||
// The entry editor in ad-hoc mode: the same form, so every option an
|
||
// entry has works ad-hoc too, with Connect in place of Save and no
|
||
// name or folder. Poweruser and up. The server applies the ad-hoc
|
||
// policy (network fences, site recording and drive settings, VDI
|
||
// limits) whatever the form sends; for non-admins the options only a
|
||
// saved entry may set are hidden here as well.
|
||
var canAdhoc = false; // set by init() from the caller's role
|
||
var vaultReady = false; // set by init(): Save as entry and variables need Vault
|
||
// Credential fields that offer the caller's own variables: text
|
||
// inputs get a datalist, the rest (passwords, keys) a picker.
|
||
var ADHOC_VAR_LIST_FIELDS = ['em-username', 'em-rdp-username', 'em-rdp-domain', 'em-vnc-username', 'em-web-username'];
|
||
var ADHOC_VAR_PICK_FIELDS = ['em-password', 'em-private-key', 'em-rdp-password', 'em-vnc-password', 'em-spice-password', 'em-web-password'];
|
||
|
||
function setAdhocMode(on) {
|
||
var modal = document.getElementById('entry-modal');
|
||
modal.classList.toggle('adhoc', !!on);
|
||
modal.classList.toggle('adhoc-restricted', !!on && !isAdmin);
|
||
ADHOC_VAR_LIST_FIELDS.forEach(function(id) {
|
||
var el = document.getElementById(id);
|
||
if (on) el.setAttribute('list', 'em-cred-vars'); else el.removeAttribute('list');
|
||
});
|
||
}
|
||
|
||
function renderAdhocVars(names) {
|
||
var dl = document.getElementById('em-cred-vars');
|
||
dl.innerHTML = '';
|
||
names.forEach(function(n) {
|
||
var o = document.createElement('option');
|
||
o.value = '$' + n;
|
||
dl.appendChild(o);
|
||
});
|
||
ADHOC_VAR_PICK_FIELDS.forEach(function(id) {
|
||
var field = document.getElementById(id);
|
||
var host = field.closest('label') || field;
|
||
var pick = host.nextElementSibling;
|
||
if (!pick || !pick.classList.contains('var-pick')) {
|
||
pick = document.createElement('select');
|
||
pick.className = 'var-pick adhoc-only';
|
||
pick.setAttribute('data-for', id);
|
||
pick.setAttribute('aria-label', 'Use one of your credential variables');
|
||
pick.addEventListener('change', function() {
|
||
if (!pick.value) return;
|
||
field.value = '$' + pick.value;
|
||
field.dispatchEvent(new Event('input', { bubbles: true }));
|
||
pick.value = '';
|
||
});
|
||
host.parentNode.insertBefore(pick, host.nextSibling);
|
||
}
|
||
pick.innerHTML = '<option value="">Use a credential variable…</option>';
|
||
names.forEach(function(n) {
|
||
var o = document.createElement('option');
|
||
o.value = n;
|
||
o.textContent = '$' + n;
|
||
pick.appendChild(o);
|
||
});
|
||
pick.style.display = names.length ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
// Only the names: the values never reach the browser and are
|
||
// resolved by the server when connecting.
|
||
function loadAdhocVars() {
|
||
renderAdhocVars([]);
|
||
if (!vaultReady || apiKey) return;
|
||
fetch('/api/me/credentials', { headers: apiHeaders(), credentials: 'same-origin' })
|
||
.then(function(r) { return r.ok ? r.json() : null; })
|
||
.then(function(d) {
|
||
if (d && d.credentials) renderAdhocVars(Object.keys(d.credentials).sort());
|
||
})
|
||
.catch(function() {});
|
||
}
|
||
|
||
function syncAdhocKeypair() {
|
||
var gen = document.getElementById('em-ssh-generate-keypair').checked;
|
||
['em-password', 'em-private-key'].forEach(function(id) {
|
||
var el = document.getElementById(id);
|
||
el.disabled = gen;
|
||
if (gen) el.value = '';
|
||
});
|
||
}
|
||
document.getElementById('em-ssh-generate-keypair').addEventListener('change', syncAdhocKeypair);
|
||
|
||
function adhocSaveAsTargets() {
|
||
var out = [];
|
||
(folders || []).forEach(function(f) { out.push(f); });
|
||
for (var ck in subfolderCache) {
|
||
subfolderCache[ck].forEach(function(f) { out.push(f); });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function openAdhocEntry() {
|
||
if (!canAdhoc) return;
|
||
entryEditMode = null;
|
||
cloneSource = null;
|
||
setAdhocMode(true);
|
||
document.getElementById('entry-modal-title').textContent = 'Connect to a host';
|
||
document.getElementById('em-name').value = '';
|
||
document.getElementById('em-display-name').value = '';
|
||
entryTypeSelect.value = 'ssh';
|
||
entryTypeSelect.disabled = false;
|
||
entryTypeSelect.dispatchEvent(new Event('change'));
|
||
clearEntryFields();
|
||
document.getElementById('em-ssh-generate-keypair').checked = false;
|
||
syncAdhocKeypair();
|
||
document.getElementById('em-error').textContent = '';
|
||
|
||
// Admins can keep what they filled in as a saved entry.
|
||
var targets = isAdmin && vaultReady ? adhocSaveAsTargets() : [];
|
||
var sel = document.getElementById('em-saveas-folder');
|
||
sel.innerHTML = '';
|
||
targets.forEach(function(f) {
|
||
var p = folderPath(f);
|
||
var opt = document.createElement('option');
|
||
opt.value = f.scope + '|' + p;
|
||
opt.textContent = p + ' (' + f.scope + ')';
|
||
if (selectedFolder && selectedFolder.scope === f.scope && folderPath(selectedFolder) === p) opt.selected = true;
|
||
sel.appendChild(opt);
|
||
});
|
||
document.getElementById('em-saveas-name').value = '';
|
||
var canSave = targets.length > 0;
|
||
document.getElementById('em-saveas-section').style.display = canSave ? '' : 'none';
|
||
document.getElementById('em-saveas').style.display = canSave ? '' : 'none';
|
||
|
||
loadAdhocVars();
|
||
document.getElementById('entry-modal').classList.add('active');
|
||
try { document.getElementById('em-hostname').focus(); } catch (e) {}
|
||
}
|
||
// The global "Connect to a host" button (search.js) opens the editor
|
||
// in place here; every other page links to /connections.html#adhoc.
|
||
// A click before this page knows the caller's role waits for it.
|
||
var adhocRequested = false;
|
||
window.rgOpenAdhoc = function() {
|
||
if (canAdhoc) openAdhocEntry(); else adhocRequested = true;
|
||
};
|
||
function maybeOpenAdhocFromHash() {
|
||
var fromHash = location.hash === '#adhoc';
|
||
if (!(fromHash || adhocRequested) || !canAdhoc) return;
|
||
adhocRequested = false;
|
||
if (fromHash) {
|
||
try { history.replaceState(null, '', location.pathname + location.search); } catch (e) {}
|
||
}
|
||
openAdhocEntry();
|
||
}
|
||
|
||
function apiErrorText(t) {
|
||
try { var j = JSON.parse(t); if (j && j.error) return j.error; } catch (e) {}
|
||
return t;
|
||
}
|
||
|
||
function doAdhocConnect() {
|
||
var btn = document.getElementById('em-connect');
|
||
var errEl = document.getElementById('em-error');
|
||
var entry = buildEntryFromForm();
|
||
delete entry.display_name;
|
||
var body = { entry: entry };
|
||
if (entry.type === 'ssh' && document.getElementById('em-ssh-generate-keypair').checked) {
|
||
body.generate_keypair = true;
|
||
delete entry.password;
|
||
delete entry.private_key;
|
||
}
|
||
var bw = window.innerWidth, bh = window.innerHeight;
|
||
if (bw > 0 && bh > 0) { body.width = bw; body.height = bh; }
|
||
if (window.screen && screen.width > 0 && screen.height > 0) {
|
||
body.screen_width = screen.width;
|
||
body.screen_height = screen.height;
|
||
}
|
||
body.dpi = Math.round((window.devicePixelRatio || 1) * 96);
|
||
body.keyboard_layout = rgKeyboardLayout();
|
||
// Opened inside the click so it is not popup-blocked (see doConnect).
|
||
var targetWin = window.open('', '_blank');
|
||
errEl.textContent = '';
|
||
btn.disabled = true;
|
||
btn.textContent = 'Connecting...';
|
||
fetch('/api/addressbook/adhoc/connect', {
|
||
method: 'POST',
|
||
headers: apiHeaders({ 'Content-Type': 'application/json' }),
|
||
credentials: 'same-origin',
|
||
body: JSON.stringify(body)
|
||
})
|
||
.then(function(res) {
|
||
if (res.status === 412) {
|
||
return res.json().then(function(j) {
|
||
var names = (j.missing_variables || []).map(function(n) { return '$' + n; });
|
||
throw new Error('You have no credential variable ' + names.join(', ')
|
||
+ '. Pick one of yours from the list, or set it in My Credentials.');
|
||
});
|
||
}
|
||
if (!res.ok) return res.text().then(function(t) { throw new Error(apiErrorText(t)); });
|
||
return res.json();
|
||
})
|
||
.then(function(data) {
|
||
var url = data.client_url + (entry.fullscreen_on_connect ? '?fs=1' : '');
|
||
if (targetWin && !targetWin.closed) targetWin.location.href = url;
|
||
else window.open(url, '_blank');
|
||
document.getElementById('entry-modal').classList.remove('active');
|
||
if (vaultReady) loadActiveSessions();
|
||
})
|
||
.catch(function(err) {
|
||
if (targetWin && !targetWin.closed) targetWin.close();
|
||
errEl.textContent = err.message;
|
||
})
|
||
.finally(function() {
|
||
btn.disabled = false;
|
||
btn.textContent = 'Connect';
|
||
});
|
||
}
|
||
document.getElementById('em-connect').addEventListener('click', doAdhocConnect);
|
||
|
||
// Admin: keep the ad-hoc connection as an entry. Credential variables
|
||
// are stored as the references typed, never as values.
|
||
document.getElementById('em-saveas').addEventListener('click', function() {
|
||
var btn = this;
|
||
var errEl = document.getElementById('em-error');
|
||
var name = document.getElementById('em-saveas-name').value.trim();
|
||
var target = document.getElementById('em-saveas-folder').value;
|
||
if (!target) { errEl.textContent = 'Choose a folder to save the entry in.'; return; }
|
||
if (!name || !/^[a-zA-Z0-9_.-]+$/.test(name)) {
|
||
errEl.textContent = 'Name must be alphanumeric, hyphens, underscores, dots only.';
|
||
return;
|
||
}
|
||
var sep = target.indexOf('|');
|
||
var scope = target.slice(0, sep), folder = target.slice(sep + 1);
|
||
var entry = buildEntryFromForm();
|
||
delete entry.display_name;
|
||
var body = { name: name };
|
||
for (var k in entry) body[k] = entry[k];
|
||
errEl.textContent = '';
|
||
btn.disabled = true;
|
||
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(apiErrorText(t)); });
|
||
document.getElementById('entry-modal').classList.remove('active');
|
||
showInfo('Saved "' + name + '" in ' + folder + '.');
|
||
window.rgSelectFolder(scope, folder);
|
||
})
|
||
.catch(function(err) { errEl.textContent = err.message; })
|
||
.finally(function() { btn.disabled = false; });
|
||
});
|
||
|
||
// 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 ──
|
||
// Callback to run after a successful credential save (used by the
|
||
// resumable connect flow); cleared whenever the modal opens/closes.
|
||
var credsSavedCallback = null;
|
||
// When set, restrict the editor to just these variable names (focused
|
||
// "credentials needed to connect" mode).
|
||
var credsOnlyVars = null;
|
||
|
||
document.getElementById('my-creds-item').addEventListener('click', function() {
|
||
document.getElementById('user-menu').style.display = 'none';
|
||
openMyCredentials();
|
||
});
|
||
var _mcNav = document.getElementById('my-creds-nav');
|
||
if (_mcNav) _mcNav.addEventListener('click', function(e) {
|
||
e.preventDefault();
|
||
openMyCredentials();
|
||
});
|
||
var _cbSetup = document.getElementById('creds-banner-setup');
|
||
if (_cbSetup) _cbSetup.addEventListener('click', function() { openMyCredentials(); });
|
||
var _cbDismiss = document.getElementById('creds-banner-dismiss');
|
||
if (_cbDismiss) _cbDismiss.addEventListener('click', function() {
|
||
// Persist the dismissal so it survives refresh; remember exactly
|
||
// which credentials were silenced so a new one can still re-alert.
|
||
try { localStorage.setItem(CREDS_DISMISS_KEY, currentUnsetCreds.join(',')); } catch (e) {}
|
||
document.getElementById('creds-banner').style.display = 'none';
|
||
});
|
||
document.getElementById('my-creds-close').addEventListener('click', function() {
|
||
document.getElementById('my-creds-modal').classList.remove('active');
|
||
credsSavedCallback = null;
|
||
credsOnlyVars = null;
|
||
});
|
||
document.getElementById('my-creds-save').addEventListener('click', saveMyCredentials);
|
||
var _mcSearch = document.getElementById('my-creds-search');
|
||
if (_mcSearch) _mcSearch.addEventListener('input', function() { filterMyCreds(this.value); });
|
||
|
||
// Live-filter the credential list by variable name / domain, hiding
|
||
// empty groups and force-expanding matching ones while a query is active.
|
||
function filterMyCreds(q) {
|
||
q = (q || '').trim().toLowerCase();
|
||
document.querySelectorAll('#my-creds-list .cred-group').forEach(function(group) {
|
||
var anyVisible = false;
|
||
group.querySelectorAll('.cred-item').forEach(function(item) {
|
||
var match = !q || item.getAttribute('data-cred-search').indexOf(q) !== -1;
|
||
item.style.display = match ? '' : 'none';
|
||
if (match) anyVisible = true;
|
||
});
|
||
group.style.display = anyVisible ? '' : 'none';
|
||
if (q) group.classList.remove('collapsed');
|
||
});
|
||
}
|
||
|
||
// opts (all optional): { onlyVars: [names], heading: "...", onSaved: fn }
|
||
// onlyVars focuses the editor on specific variables; onSaved runs after
|
||
// a successful save (the resumable connect flow).
|
||
function openMyCredentials(opts) {
|
||
opts = opts || {};
|
||
credsSavedCallback = opts.onSaved || null;
|
||
credsOnlyVars = opts.onlyVars || null;
|
||
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');
|
||
var descEl = document.getElementById('my-creds-desc');
|
||
if (descEl) {
|
||
descEl.textContent = opts.heading
|
||
|| 'Store your credentials securely in Vault. Connections entries that use credential variables will auto-fill from these values.';
|
||
}
|
||
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 || {};
|
||
// Multi-Vault: when creds_split, each credential can be stored
|
||
// shared (fleet-wide) or local (this instance). Single-Vault
|
||
// deployments report creds_split=false and get no toggle.
|
||
var credsSplit = !!credsData.creds_split;
|
||
var credDefaultScope = credsData.default_scope || 'local';
|
||
var varNames = Object.keys(allVars).sort();
|
||
|
||
if (varNames.length === 0) {
|
||
empty.style.display = '';
|
||
return;
|
||
}
|
||
|
||
// Group by domain prefix. Each domain is a collapsible group and
|
||
// each credential a .cred-item carrying search text, so the
|
||
// filter box and group folds keep long lists navigable.
|
||
var domainKeys = Object.keys(domains).sort();
|
||
var totalVars = 0;
|
||
domainKeys.forEach(function(domain) {
|
||
// In focused mode, keep only the requested variables and skip
|
||
// domains that end up empty.
|
||
var vars = domains[domain].filter(function(v) {
|
||
return !credsOnlyVars || credsOnlyVars.indexOf(v.name) !== -1;
|
||
});
|
||
if (vars.length === 0) return;
|
||
totalVars += vars.length;
|
||
|
||
vars.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);
|
||
});
|
||
|
||
var group = document.createElement('div');
|
||
group.className = 'cred-group';
|
||
|
||
var head = document.createElement('div');
|
||
head.className = 'cred-group-head';
|
||
var caret = document.createElement('span');
|
||
caret.className = 'cred-group-caret';
|
||
caret.textContent = '▼'; // full-size triangle (U+25BC); rotates to ▶ when collapsed
|
||
head.appendChild(caret);
|
||
var headText = document.createElement('span');
|
||
headText.textContent = domain;
|
||
head.appendChild(headText);
|
||
var headCount = document.createElement('span');
|
||
headCount.className = 'cred-group-count';
|
||
headCount.textContent = vars.length;
|
||
head.appendChild(headCount);
|
||
head.addEventListener('click', function() {
|
||
group.classList.toggle('collapsed');
|
||
});
|
||
group.appendChild(head);
|
||
|
||
var body = document.createElement('div');
|
||
body.className = 'cred-group-body';
|
||
group.appendChild(body);
|
||
|
||
vars.forEach(function(v) {
|
||
var name = v.name;
|
||
var isSecret = name.endsWith('_password') || name.endsWith('_key');
|
||
var displayName = name.replace(domain + '_', '');
|
||
|
||
var item = document.createElement('div');
|
||
item.className = 'cred-item';
|
||
item.setAttribute('data-cred-search',
|
||
(name + ' ' + displayName + ' ' + domain).toLowerCase());
|
||
|
||
var row = document.createElement('label');
|
||
row.style.cssText = 'display:block;margin-top:0.5em;color:var(--text-muted);font-size:0.9em';
|
||
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);
|
||
item.appendChild(row);
|
||
|
||
// Per-credential shared/local toggle (multi-Vault only).
|
||
if (credsSplit) {
|
||
var origScope = (sv && sv.scope) ? sv.scope : credDefaultScope;
|
||
var scopeRow = document.createElement('div');
|
||
scopeRow.style.cssText = 'margin:0.2em 0 0.4em 0.2em;font-size:0.8em;color:var(--text-dim)';
|
||
var scopeLabel = document.createElement('label');
|
||
scopeLabel.style.cssText = 'display:inline-flex;align-items:center;gap:0.35em;cursor:pointer';
|
||
var cb = document.createElement('input');
|
||
cb.type = 'checkbox';
|
||
cb.className = 'my-cred-scope';
|
||
cb.setAttribute('data-var', name);
|
||
cb.setAttribute('data-scope-orig', origScope);
|
||
cb.checked = (origScope === 'shared');
|
||
cb.style.cssText = 'width:auto;margin:0';
|
||
scopeLabel.appendChild(cb);
|
||
var scopeTxt = document.createElement('span');
|
||
scopeTxt.textContent = 'Shared across sites';
|
||
scopeTxt.title = 'Store this credential in the shared Vault so it propagates to every site. Unchecked keeps it local to this instance (survives a central-Vault outage).';
|
||
scopeLabel.appendChild(scopeTxt);
|
||
scopeRow.appendChild(scopeLabel);
|
||
item.appendChild(scopeRow);
|
||
}
|
||
|
||
body.appendChild(item);
|
||
});
|
||
|
||
list.appendChild(group);
|
||
});
|
||
|
||
// Reveal the filter box once the list is long enough to warrant
|
||
// it (never in focused single-connect mode).
|
||
var searchEl = document.getElementById('my-creds-search');
|
||
if (searchEl) {
|
||
searchEl.value = '';
|
||
searchEl.style.display = (!credsOnlyVars && totalVars > 6) ? '' : 'none';
|
||
}
|
||
}).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 scopes = {};
|
||
// Current toggle state per variable (present only in multi-Vault).
|
||
var scopeState = {}; // name -> 'shared' | 'local'
|
||
var scopeMoved = {}; // name -> true if toggled from its original
|
||
document.querySelectorAll('.my-cred-scope').forEach(function(cb) {
|
||
var n = cb.getAttribute('data-var');
|
||
var cur = cb.checked ? 'shared' : 'local';
|
||
scopeState[n] = cur;
|
||
scopeMoved[n] = (cur !== cb.getAttribute('data-scope-orig'));
|
||
});
|
||
var hasChanges = false;
|
||
inputs.forEach(function(el) {
|
||
var name = el.getAttribute('data-var');
|
||
var val = el.value.trim();
|
||
// Send when the value was entered OR only the scope moved (a
|
||
// blank value tells the backend to keep the existing value and
|
||
// just move it to the new scope).
|
||
if (val || scopeMoved[name]) {
|
||
creds[name] = val;
|
||
if (name in scopeState) scopes[name] = scopeState[name];
|
||
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, scopes: scopes })
|
||
})
|
||
.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');
|
||
// Refresh the "credentials to set up" banner and per-entry pills.
|
||
loadCredStatus();
|
||
// Resume whatever was blocked on credentials (e.g. a connect).
|
||
var cb = credsSavedCallback;
|
||
credsSavedCallback = null;
|
||
credsOnlyVars = null;
|
||
if (cb) cb();
|
||
})
|
||
.catch(function(err) {
|
||
errEl.textContent = err.message;
|
||
})
|
||
.finally(function() {
|
||
btn.disabled = false;
|
||
btn.textContent = 'Save';
|
||
});
|
||
}
|
||
|
||
// ── Onboarding Wizard ──
|
||
var onboardSteps = [
|
||
{ title: 'Welcome to rustguac', minRole: 1, body:
|
||
'This is your remote access gateway. Browse connection entries organised by folder, then click <span class="onboard-hl">Connect</span> to launch a session in a new browser tab.' +
|
||
'<br><br>This quick tour covers the essentials — you can dismiss it at any time.'
|
||
},
|
||
{ title: 'Navigation', minRole: 1, body:
|
||
'The nav bar at the top links to:' +
|
||
'<br>• <span class="onboard-hl">Connections</span> — your connection entries' +
|
||
'<br>• <span class="onboard-hl">Sessions</span> — active and recent sessions' +
|
||
'<br>• <span class="onboard-hl">Recordings</span> — session playback' +
|
||
'<br>• <span class="onboard-hl">Docs</span> — built-in documentation' +
|
||
'<br><br>The <span class="onboard-hl">Settings</span> menu (top right) has theme selection, credential management, and this tour.'
|
||
},
|
||
{ title: 'My Credentials', minRole: 2, body:
|
||
'Some entries use credential variables like <code>$domain_username</code>. Before connecting, set your personal credentials:' +
|
||
'<br><br>Click <span class="onboard-hl">Settings</span> → <span class="onboard-hl">My Credentials</span> to fill in your values. They are stored securely in Vault and applied automatically when you connect.'
|
||
},
|
||
{ title: 'Connect vs Login', minRole: 2, body:
|
||
'Connections entries have two connection modes:' +
|
||
'<br><br>• <span class="onboard-hl">Connect</span> — launches the session directly using the saved settings.' +
|
||
'<br>• <span class="onboard-hl">Login</span> — runs an automated login script after connecting (e.g. filling in a web login form). If an entry shows Login, the admin has configured an auto-login workflow for it.'
|
||
},
|
||
{ title: 'Clipboard', minRole: 2, body:
|
||
'<span class="onboard-key">Ctrl</span>+<span class="onboard-key">V</span> pastes from your browser clipboard into the remote session automatically.' +
|
||
'<br><br>For advanced use (copy from remote, send arbitrary text), press <span class="onboard-key">Ctrl</span>+<span class="onboard-key">Alt</span>+<span class="onboard-key">Shift</span> or click the <span class="onboard-hl">Clipboard</span> tab on the left edge of the session window.'
|
||
},
|
||
{ title: 'File Transfer', minRole: 2, body:
|
||
'RDP sessions with drive redirection enabled show a <span class="onboard-hl">Files</span> tab on the right edge of the session window.' +
|
||
'<br><br>Use it to upload and download files via a shared virtual drive. SSH sessions support SFTP file transfer instead.' +
|
||
'<br><br>Toggle the file panel with <span class="onboard-key">Ctrl</span>+<span class="onboard-key">Alt</span>+<span class="onboard-key">F</span>.'
|
||
},
|
||
{ title: 'Connect to a host', minRole: 3, body:
|
||
'As a power user, you can connect to a host that isn\'t a saved entry with <span class="onboard-hl">Connect to a host</span>, next to the search box on every page.' +
|
||
'<br><br>It opens the entry editor with every option an entry has. Nothing is saved, and credential fields accept your own credential variables, such as <code>$my_ssh_key</code>.'
|
||
},
|
||
{ title: 'Managing the Connections', minRole: 4, body:
|
||
'As an admin, you can:' +
|
||
'<br>• Create and edit <span class="onboard-hl">folders</span> with OIDC group permissions' +
|
||
'<br>• Add, edit, clone, and delete <span class="onboard-hl">entries</span>' +
|
||
'<br>• Configure credential variables, login scripts, and recording overrides per entry' +
|
||
'<br>• Manage users and API tokens from the <span class="onboard-hl">Admin</span> page'
|
||
}
|
||
];
|
||
var onboardCurrent = 0;
|
||
var onboardFiltered = [];
|
||
|
||
function startOnboarding(role) {
|
||
if (localStorage.getItem('rustguac_onboarding_dismissed')) return;
|
||
var level = roleLevel[role] || 1;
|
||
onboardFiltered = onboardSteps.filter(function(s) { return s.minRole <= level; });
|
||
if (onboardFiltered.length === 0) return;
|
||
onboardCurrent = 0;
|
||
renderOnboardStep();
|
||
document.getElementById('onboard-dismiss').checked = false;
|
||
document.getElementById('onboarding-modal').classList.add('active');
|
||
}
|
||
|
||
function renderOnboardStep() {
|
||
var step = onboardFiltered[onboardCurrent];
|
||
document.getElementById('onboard-title').textContent = step.title;
|
||
document.getElementById('onboard-body').innerHTML = step.body;
|
||
document.getElementById('onboard-step-indicator').textContent = (onboardCurrent + 1) + ' / ' + onboardFiltered.length;
|
||
document.getElementById('onboard-back').style.display = onboardCurrent === 0 ? 'none' : '';
|
||
var nextBtn = document.getElementById('onboard-next');
|
||
nextBtn.textContent = (onboardCurrent === onboardFiltered.length - 1) ? 'Done' : 'Next';
|
||
}
|
||
|
||
function closeOnboarding() {
|
||
document.getElementById('onboarding-modal').classList.remove('active');
|
||
if (document.getElementById('onboard-dismiss').checked) {
|
||
localStorage.setItem('rustguac_onboarding_dismissed', '1');
|
||
}
|
||
}
|
||
|
||
document.getElementById('onboard-next').addEventListener('click', function() {
|
||
if (onboardCurrent < onboardFiltered.length - 1) {
|
||
onboardCurrent++;
|
||
renderOnboardStep();
|
||
} else {
|
||
localStorage.setItem('rustguac_onboarding_dismissed', '1');
|
||
closeOnboarding();
|
||
}
|
||
});
|
||
document.getElementById('onboard-back').addEventListener('click', function() {
|
||
if (onboardCurrent > 0) {
|
||
onboardCurrent--;
|
||
renderOnboardStep();
|
||
}
|
||
});
|
||
document.getElementById('onboard-close').addEventListener('click', function() {
|
||
// Corner "×" skips the intro entirely and won't show again until
|
||
// re-triggered via Settings → Welcome Tour.
|
||
localStorage.setItem('rustguac_onboarding_dismissed', '1');
|
||
closeOnboarding();
|
||
});
|
||
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);
|
||
});
|
||
|
||
// ── UX (#254): selection feedback & sidebar render hygiene ──────────
|
||
// Everything here wraps existing functions or post-processes the DOM;
|
||
// renderFolders() itself is untouched.
|
||
|
||
var prefersReducedMotion = (function() {
|
||
try { return window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
|
||
catch (e) { return false; }
|
||
})();
|
||
var lastShownFolderKey = null; // "scope|path" last passed to loadEntries()
|
||
var sidebarRevealPending = false; // set by loadFolders(); consumed by the renderFolders wrapper
|
||
|
||
// Nearest scrollable ancestor of el, stopping at (and including) the
|
||
// sidebar. null when the sidebar is not its own scroll container,
|
||
// in which case the page scroll already handles visibility.
|
||
function sidebarScrollContainer(el) {
|
||
var sidebar = document.querySelector('.sidebar');
|
||
var node = el ? el.parentElement : null;
|
||
while (node) {
|
||
var oy = getComputedStyle(node).overflowY;
|
||
if ((oy === 'auto' || oy === 'scroll') && node.scrollHeight > node.clientHeight) return node;
|
||
if (node === sidebar) break;
|
||
node = node.parentElement;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// scrollIntoView({ block: 'nearest' }) confined to the sidebar's own
|
||
// scroll container: moves container.scrollTop only, never the page.
|
||
function revealSidebarRow(li) {
|
||
if (!li) return;
|
||
var box = sidebarScrollContainer(li);
|
||
if (!box) return;
|
||
var row = li.querySelector('.folder-row') || li;
|
||
var cr = box.getBoundingClientRect();
|
||
var lr = row.getBoundingClientRect();
|
||
if (lr.top < cr.top) box.scrollTop -= (cr.top - lr.top);
|
||
else if (lr.bottom > cr.bottom) box.scrollTop += Math.min(lr.bottom - cr.bottom, lr.top - cr.top);
|
||
}
|
||
|
||
function selectedFolderLi() {
|
||
return document.querySelector('#folder-list li.selected');
|
||
}
|
||
|
||
// Bring the entries header to the top of the viewport when it is not
|
||
// comfortably in view (above the viewport after scrolling a long
|
||
// sidebar, or pushed into the lower half by the strips above it).
|
||
function jumpToEntriesHeader(header) {
|
||
if (!header) return;
|
||
// Scroll the entries pane only. scrollIntoView() would also scroll
|
||
// every scrollable ancestor, and a body with overflow hidden can
|
||
// still be scrolled by script: that pushed the brand row and the
|
||
// nav off the top of the page.
|
||
var pane = header.closest('.main');
|
||
if (pane && pane.scrollHeight > pane.clientHeight) {
|
||
try {
|
||
pane.scrollTo({ top: 0, behavior: prefersReducedMotion ? 'auto' : 'smooth' });
|
||
} catch (e) {
|
||
pane.scrollTop = 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Replay the .changed animation on the entries header.
|
||
function flashEntriesHeader(header) {
|
||
if (!header) return;
|
||
header.classList.remove('changed');
|
||
void header.offsetWidth; // restart keyframes
|
||
header.classList.add('changed');
|
||
clearTimeout(header._rgChangedTimer);
|
||
// Fallback clear: with reduced motion the animation is disabled so
|
||
// no animationend fires; otherwise animationend clears it earlier.
|
||
header._rgChangedTimer = setTimeout(function() { header.classList.remove('changed'); }, 900);
|
||
}
|
||
(function() {
|
||
var header = document.getElementById('entries-header');
|
||
if (!header) return;
|
||
header.addEventListener('animationend', function(e) {
|
||
// The title finishes first; clear when the (longer) border
|
||
// flash ends so both animations complete.
|
||
if (e.animationName === 'rg-header-flash') header.classList.remove('changed');
|
||
});
|
||
})();
|
||
|
||
// Called by loadEntries() for every folder it shows. Only a change of
|
||
// folder (after one has already been shown) gets the jump + flash.
|
||
function announceFolderShown(key, header) {
|
||
var prev = lastShownFolderKey;
|
||
lastShownFolderKey = key;
|
||
if (prev === null || prev === key) return;
|
||
flashEntriesHeader(header);
|
||
jumpToEntriesHeader(header);
|
||
revealSidebarRow(selectedFolderLi());
|
||
}
|
||
|
||
// Skeleton placeholder shown while entries are fetched (never for the
|
||
// cached path). Same table class and column count as renderEntries().
|
||
function entriesSkeletonHtml() {
|
||
var cols = ['Name', 'Type', 'Host', 'User', ''];
|
||
var html = '<table class="entries-table skeleton" aria-busy="true" aria-label="Loading entries"><thead><tr>';
|
||
if (isAdmin) html += '<th class="sel-cell"><span class="sk-bar sk-check"></span></th>';
|
||
cols.forEach(function(c) { html += '<th>' + c + '</th>'; });
|
||
if (isAdmin) html += '<th></th><th></th><th></th>';
|
||
html += '</tr></thead><tbody>';
|
||
for (var i = 0; i < 4; i++) {
|
||
html += '<tr>';
|
||
if (isAdmin) html += '<td class="sel-cell"><span class="sk-bar sk-check"></span></td>';
|
||
html += '<td><span class="sk-bar"></span></td>' +
|
||
'<td><span class="sk-bar sk-badge"></span></td>' +
|
||
'<td><span class="sk-bar"></span></td>' +
|
||
'<td><span class="sk-bar"></span></td>' +
|
||
'<td class="connect-cell"><span class="sk-bar sk-btn"></span></td>';
|
||
if (isAdmin) html += '<td></td><td></td><td></td>';
|
||
html += '</tr>';
|
||
}
|
||
return html + '</tbody></table>';
|
||
}
|
||
|
||
function cssAttrEscape(s) {
|
||
if (window.CSS && CSS.escape) return CSS.escape(s);
|
||
return String(s).replace(/["\\]/g, '\\$&');
|
||
}
|
||
|
||
// Update one folder's count badge in place instead of rebuilding the
|
||
// whole sidebar. Keeps whatever text format renderFolders() produced
|
||
// (only the number is swapped). Falls back to a full render when the
|
||
// row or its badge does not exist yet.
|
||
function updateFolderCount(scope, path, n) {
|
||
folderEntryCounts[folderKey(scope, path)] = n;
|
||
var li = document.querySelector('#folder-list li[data-scope="' + cssAttrEscape(scope) + '"][data-path="' + cssAttrEscape(path) + '"]');
|
||
var badge = null;
|
||
if (li) {
|
||
var all = li.querySelectorAll('.folder-count');
|
||
for (var i = 0; i < all.length; i++) {
|
||
if (all[i].closest('li') === li) { badge = all[i]; break; }
|
||
}
|
||
}
|
||
if (!badge || !/\d/.test(badge.textContent)) { renderFolders(); return; }
|
||
badge.textContent = badge.textContent.replace(/\d[\d,]*/, String(n));
|
||
badge.setAttribute('data-count', String(n));
|
||
badge.title = n + ' entr' + (n === 1 ? 'y' : 'ies');
|
||
}
|
||
|
||
// Wrap renderFolders(): preserve the sidebar's scroll position across
|
||
// a rebuild, and reveal the selected row (within the sidebar only)
|
||
// when the selection changed or a tree reload asked for it.
|
||
var __renderFolders = renderFolders;
|
||
var __lastRenderedSelKey = null;
|
||
renderFolders = function() {
|
||
var boxes = [];
|
||
var node = document.getElementById('folder-list');
|
||
while (node) {
|
||
if (node.scrollTop) boxes.push({ el: node, top: node.scrollTop });
|
||
if (node.classList && node.classList.contains('sidebar')) break;
|
||
node = node.parentElement;
|
||
}
|
||
var result = __renderFolders.apply(this, arguments);
|
||
boxes.forEach(function(b) { b.el.scrollTop = b.top; });
|
||
var selKey = selectedFolder ? folderKey(selectedFolder.scope, folderPath(selectedFolder)) : null;
|
||
var li = selectedFolderLi();
|
||
if (li && (sidebarRevealPending || selKey !== __lastRenderedSelKey)) {
|
||
sidebarRevealPending = false;
|
||
revealSidebarRow(li);
|
||
}
|
||
__lastRenderedSelKey = selKey;
|
||
return result;
|
||
};
|
||
|
||
// Wrap loadFolders(): after a tree (re)build the selected row should
|
||
// be visible in the sidebar. Arguments pass through untouched.
|
||
var __loadFolders = loadFolders;
|
||
loadFolders = function() {
|
||
sidebarRevealPending = true;
|
||
return __loadFolders.apply(this, arguments);
|
||
};
|
||
// ── end UX (#254) ────────────────────────────────────────────────────
|
||
|
||
// ── UX (#254): folder filter + keyboard navigation ──────────────────
|
||
// Post-processes the tree that renderFolders() builds (it never edits
|
||
// it): a live filter box mounted in #sidebar-tools, tree/treeitem ARIA
|
||
// roles with a roving tabindex, arrow-key navigation, and an `f`
|
||
// shortcut mirroring the `/` quick-find one above.
|
||
var FOLDER_FILTER_KEY = 'rustguac_folder_filter';
|
||
var _ffWrap = null, _ffInput = null, _ffCount = null;
|
||
|
||
(function mountFolderFilter() {
|
||
var tools = document.getElementById('sidebar-tools');
|
||
if (!tools) return;
|
||
_ffWrap = document.createElement('div');
|
||
_ffWrap.className = 'folder-filter-wrap';
|
||
_ffInput = document.createElement('input');
|
||
_ffInput.type = 'search';
|
||
_ffInput.id = 'folder-filter';
|
||
_ffInput.placeholder = 'Filter folders';
|
||
_ffInput.autocomplete = 'off';
|
||
_ffInput.spellcheck = false;
|
||
_ffInput.setAttribute('aria-label', 'Filter folders');
|
||
_ffInput.setAttribute('aria-controls', 'folder-list');
|
||
_ffInput.title = 'Filter folders (f). Arrow down to move into the list.';
|
||
_ffCount = document.createElement('span');
|
||
_ffCount.id = 'folder-filter-count';
|
||
_ffCount.setAttribute('aria-live', 'polite');
|
||
var clear = document.createElement('button');
|
||
clear.type = 'button';
|
||
clear.id = 'folder-filter-clear';
|
||
clear.title = 'Clear filter (Esc)';
|
||
clear.setAttribute('aria-label', 'Clear folder filter');
|
||
clear.textContent = '×';
|
||
clear.tabIndex = -1;
|
||
_ffWrap.appendChild(_ffInput);
|
||
_ffWrap.appendChild(_ffCount);
|
||
_ffWrap.appendChild(clear);
|
||
tools.appendChild(_ffWrap);
|
||
|
||
try { _ffInput.value = sessionStorage.getItem(FOLDER_FILTER_KEY) || ''; } catch (e) {}
|
||
|
||
_ffInput.addEventListener('input', function() {
|
||
try {
|
||
if (_ffInput.value) sessionStorage.setItem(FOLDER_FILTER_KEY, _ffInput.value);
|
||
else sessionStorage.removeItem(FOLDER_FILTER_KEY);
|
||
} catch (e) {}
|
||
applyFolderFilter();
|
||
});
|
||
clear.addEventListener('click', function() {
|
||
clearFolderFilter();
|
||
_ffInput.focus();
|
||
});
|
||
_ffInput.addEventListener('keydown', function(e) {
|
||
if (e.key === 'Escape') {
|
||
if (_ffInput.value) clearFolderFilter();
|
||
else _ffInput.blur();
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
} else if (e.key === 'ArrowDown') {
|
||
var rows = visibleFolderRows();
|
||
if (rows.length) { e.preventDefault(); focusFolderRow(rows[0]); }
|
||
} else if (e.key === 'Enter') {
|
||
// Exactly one match: open it. Otherwise step into the list.
|
||
var vis = visibleFolderRows();
|
||
if (!vis.length) return;
|
||
e.preventDefault();
|
||
focusFolderRow(vis[0]);
|
||
if (vis.length === 1) vis[0].click();
|
||
}
|
||
});
|
||
})();
|
||
|
||
function clearFolderFilter() {
|
||
if (!_ffInput) return;
|
||
_ffInput.value = '';
|
||
try { sessionStorage.removeItem(FOLDER_FILTER_KEY); } catch (e) {}
|
||
applyFolderFilter();
|
||
}
|
||
|
||
// The folder <li>s renderFolders() emits (placeholders carry no data-path).
|
||
function folderLis(container) {
|
||
var out = [];
|
||
for (var i = 0; i < container.children.length; i++) {
|
||
var li = container.children[i];
|
||
if (li.tagName === 'LI' && li.hasAttribute('data-path')) out.push(li);
|
||
}
|
||
return out;
|
||
}
|
||
function folderRowOf(li) {
|
||
var row = li.firstElementChild;
|
||
return row && row.classList.contains('folder-row') ? row : null;
|
||
}
|
||
function folderChildList(li) {
|
||
for (var i = 0; i < li.children.length; i++) {
|
||
if (li.children[i].tagName === 'UL') return li.children[i];
|
||
}
|
||
return null;
|
||
}
|
||
function folderLiHaystack(li) {
|
||
var parts = [li.getAttribute('data-path') || ''];
|
||
var row = folderRowOf(li);
|
||
if (row) {
|
||
var name = row.querySelector('.folder-name-row > span:first-child, .folder-name');
|
||
if (name) parts.push(name.textContent);
|
||
var desc = row.querySelector('.folder-desc');
|
||
if (desc) parts.push(desc.textContent);
|
||
}
|
||
return parts.join(' ').toLowerCase();
|
||
}
|
||
// Loaded-but-collapsed descendants (subfolderCache) count as matches
|
||
// too, so a parent stays visible when something under it matches.
|
||
function cachedDescendantMatches(scope, path, tokens, depth) {
|
||
var kids = subfolderCache[folderKey(scope, path)];
|
||
if (!kids || depth > 12) return false;
|
||
for (var i = 0; i < kids.length; i++) {
|
||
var k = kids[i];
|
||
var hay = (folderPath(k) + ' ' + k.name + ' ' + (k.description || '')).toLowerCase();
|
||
if (matchesTokens(hay, tokens)) return true;
|
||
if (k.has_children && cachedDescendantMatches(k.scope, folderPath(k), tokens, depth + 1)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
function matchesTokens(hay, tokens) {
|
||
for (var t = 0; t < tokens.length; t++) {
|
||
if (hay.indexOf(tokens[t]) === -1) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function applyFolderFilter() {
|
||
var ul = document.getElementById('folder-list');
|
||
if (!ul) return;
|
||
var q = _ffInput ? (_ffInput.value || '').trim().toLowerCase() : '';
|
||
var tokens = q ? q.split(/\s+/).filter(Boolean) : [];
|
||
var total = 0, shown = 0;
|
||
|
||
// Returns true if anything in this <ul> stays visible.
|
||
function walk(list, parentSelfMatch, depth) {
|
||
var anyVisible = false;
|
||
for (var i = 0; i < list.children.length; i++) {
|
||
var li = list.children[i];
|
||
if (li.tagName !== 'LI') continue;
|
||
if (!li.hasAttribute('data-path')) {
|
||
// Placeholders: "Loading...", "(empty)", unavailable scope, "No folders".
|
||
var keep = !tokens.length || depth === 0 || parentSelfMatch;
|
||
li.classList.toggle('filtered-out', !keep);
|
||
continue;
|
||
}
|
||
total++;
|
||
var selfMatch = !tokens.length || matchesTokens(folderLiHaystack(li), tokens);
|
||
var childUl = folderChildList(li);
|
||
var childVisible = childUl ? walk(childUl, selfMatch, depth + 1) : false;
|
||
var visible = selfMatch || childVisible;
|
||
if (!visible && tokens.length && !childUl) {
|
||
var row = folderRowOf(li);
|
||
var chev = row && row.querySelector('.tree-chevron');
|
||
if (chev && !chev.classList.contains('leaf')) {
|
||
visible = cachedDescendantMatches(li.getAttribute('data-scope'), li.getAttribute('data-path'), tokens, 0);
|
||
}
|
||
}
|
||
li.classList.toggle('filtered-out', !visible);
|
||
if (visible) { shown++; anyVisible = true; }
|
||
}
|
||
return anyVisible;
|
||
}
|
||
walk(ul, true, 0);
|
||
|
||
if (_ffWrap) {
|
||
// No folders at all (empty / no-vault states): nothing to filter.
|
||
_ffWrap.style.display = total === 0 && !tokens.length ? 'none' : '';
|
||
_ffWrap.classList.toggle('filtering', tokens.length > 0);
|
||
if (tokens.length) {
|
||
_ffCount.textContent = shown + ' of ' + total;
|
||
_ffCount.classList.toggle('no-match', shown === 0);
|
||
_ffInput.style.paddingRight = (_ffCount.offsetWidth + 34) + 'px';
|
||
} else {
|
||
_ffCount.textContent = '';
|
||
_ffCount.classList.remove('no-match');
|
||
_ffInput.style.paddingRight = '';
|
||
}
|
||
}
|
||
decorateFolderTree(ul);
|
||
}
|
||
|
||
// ARIA roles + roving tabindex. Cheap enough to redo on every render.
|
||
function decorateFolderTree(ul) {
|
||
ul.setAttribute('role', 'tree');
|
||
ul.setAttribute('aria-label', 'Folders');
|
||
var rows = [];
|
||
function visit(list, level) {
|
||
if (list !== ul) list.setAttribute('role', 'group');
|
||
folderLis(list).forEach(function(li) {
|
||
var row = folderRowOf(li);
|
||
if (!row) return;
|
||
row.setAttribute('role', 'treeitem');
|
||
row.setAttribute('aria-level', String(level));
|
||
row.setAttribute('aria-selected', li.classList.contains('selected') ? 'true' : 'false');
|
||
var chev = row.querySelector('.tree-chevron');
|
||
if (chev && !chev.classList.contains('leaf')) {
|
||
row.setAttribute('aria-expanded', chev.classList.contains('expanded') ? 'true' : 'false');
|
||
} else {
|
||
row.removeAttribute('aria-expanded');
|
||
}
|
||
row.tabIndex = -1;
|
||
rows.push(row);
|
||
var childUl = folderChildList(li);
|
||
if (childUl) visit(childUl, level + 1);
|
||
});
|
||
}
|
||
visit(ul, 1);
|
||
var vis = visibleFolderRows();
|
||
var tabStop = null;
|
||
for (var i = 0; i < vis.length; i++) {
|
||
if (vis[i].getAttribute('aria-selected') === 'true') { tabStop = vis[i]; break; }
|
||
}
|
||
if (!tabStop && vis.length) tabStop = vis[0];
|
||
if (tabStop) tabStop.tabIndex = 0;
|
||
}
|
||
|
||
function visibleFolderRows() {
|
||
var ul = document.getElementById('folder-list');
|
||
if (!ul) return [];
|
||
var out = [];
|
||
var all = ul.querySelectorAll('li[data-path] > .folder-row');
|
||
for (var i = 0; i < all.length; i++) {
|
||
if (!all[i].closest('.filtered-out')) out.push(all[i]);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function focusFolderRow(row) {
|
||
if (!row) return;
|
||
var ul = document.getElementById('folder-list');
|
||
if (ul) {
|
||
var rows = ul.querySelectorAll('li[data-path] > .folder-row');
|
||
for (var i = 0; i < rows.length; i++) rows[i].tabIndex = -1;
|
||
}
|
||
row.tabIndex = 0;
|
||
try { row.focus({ preventScroll: true }); } catch (e) { row.focus(); }
|
||
try { row.scrollIntoView({ block: 'nearest' }); } catch (e) {}
|
||
}
|
||
|
||
function folderRowKey(row) {
|
||
var li = row && row.parentElement;
|
||
if (!li || !li.hasAttribute('data-path')) return null;
|
||
return folderKey(li.getAttribute('data-scope'), li.getAttribute('data-path'));
|
||
}
|
||
function folderRowByKey(key) {
|
||
var ul = document.getElementById('folder-list');
|
||
if (!ul || !key) return null;
|
||
var rows = ul.querySelectorAll('li[data-path] > .folder-row');
|
||
for (var i = 0; i < rows.length; i++) {
|
||
if (folderRowKey(rows[i]) === key) return rows[i];
|
||
}
|
||
return null;
|
||
}
|
||
|
||
(function wireFolderTreeKeys() {
|
||
var ul = document.getElementById('folder-list');
|
||
if (!ul) return;
|
||
ul.addEventListener('focusin', function(e) {
|
||
var row = e.target.closest && e.target.closest('li[data-path] > .folder-row');
|
||
if (!row) return;
|
||
var rows = ul.querySelectorAll('li[data-path] > .folder-row');
|
||
for (var i = 0; i < rows.length; i++) rows[i].tabIndex = -1;
|
||
row.tabIndex = 0;
|
||
});
|
||
ul.addEventListener('keydown', function(e) {
|
||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||
var row = e.target.closest && e.target.closest('li[data-path] > .folder-row');
|
||
if (!row) return;
|
||
var rows = visibleFolderRows();
|
||
var idx = rows.indexOf(row);
|
||
var li = row.parentElement;
|
||
var chev = row.querySelector('.tree-chevron');
|
||
var expandable = chev && !chev.classList.contains('leaf');
|
||
var expanded = expandable && chev.classList.contains('expanded');
|
||
switch (e.key) {
|
||
case 'ArrowDown':
|
||
e.preventDefault();
|
||
if (idx < rows.length - 1) focusFolderRow(rows[idx + 1]);
|
||
break;
|
||
case 'ArrowUp':
|
||
e.preventDefault();
|
||
if (idx > 0) focusFolderRow(rows[idx - 1]);
|
||
else if (_ffInput && _ffWrap && _ffWrap.style.display !== 'none') { _ffInput.focus(); _ffInput.select(); }
|
||
break;
|
||
case 'Home':
|
||
e.preventDefault();
|
||
if (rows.length) focusFolderRow(rows[0]);
|
||
break;
|
||
case 'End':
|
||
e.preventDefault();
|
||
if (rows.length) focusFolderRow(rows[rows.length - 1]);
|
||
break;
|
||
case 'Enter':
|
||
case ' ':
|
||
e.preventDefault();
|
||
row.click();
|
||
break;
|
||
case 'ArrowRight':
|
||
e.preventDefault();
|
||
if (!expandable) break;
|
||
if (!expanded) { chev.click(); break; }
|
||
var childUl = folderChildList(li);
|
||
if (childUl) {
|
||
var kids = folderLis(childUl).map(folderRowOf).filter(function(r) {
|
||
return r && !r.closest('.filtered-out');
|
||
});
|
||
if (kids.length) focusFolderRow(kids[0]);
|
||
}
|
||
break;
|
||
case 'ArrowLeft':
|
||
e.preventDefault();
|
||
if (expanded) { chev.click(); break; }
|
||
var parentLi = li.parentElement && li.parentElement.closest('li[data-path]');
|
||
var parentRow = parentLi && folderRowOf(parentLi);
|
||
if (parentRow && !parentRow.closest('.filtered-out')) focusFolderRow(parentRow);
|
||
break;
|
||
case 'Escape':
|
||
if (_ffInput && _ffInput.value) { e.stopPropagation(); clearFolderFilter(); }
|
||
break;
|
||
}
|
||
});
|
||
})();
|
||
|
||
// Wrap renderFolders(): keep the filter and keyboard focus across the
|
||
// full rebuilds it does (callers resolve the binding at call time).
|
||
var __renderFoldersBase = renderFolders;
|
||
renderFolders = function() {
|
||
var ul = document.getElementById('folder-list');
|
||
var focusedKey = null;
|
||
if (ul && document.activeElement && ul.contains(document.activeElement)) {
|
||
var r = document.activeElement.closest('li[data-path] > .folder-row');
|
||
focusedKey = r ? folderRowKey(r) : null;
|
||
}
|
||
__renderFoldersBase();
|
||
applyFolderFilter();
|
||
if (focusedKey) {
|
||
var back = folderRowByKey(focusedKey);
|
||
if (back && !back.closest('.filtered-out')) focusFolderRow(back);
|
||
}
|
||
};
|
||
|
||
// `f` focuses the folder filter; same guards as the `/` quick-find above.
|
||
document.addEventListener('keydown', function(e) {
|
||
if (e.key !== 'f' || e.ctrlKey || e.metaKey || e.altKey) return;
|
||
var t = document.activeElement;
|
||
if (t) {
|
||
var tag = t.tagName;
|
||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable) return;
|
||
}
|
||
if (document.querySelector('.modal-overlay.active')) return;
|
||
if (!_ffInput || !_ffWrap || _ffWrap.style.display === 'none') return;
|
||
var main = document.getElementById('main-content');
|
||
if (main && main.style.display === 'none') return;
|
||
e.preventDefault();
|
||
_ffInput.focus();
|
||
_ffInput.select();
|
||
});
|
||
|
||
|
||
init();
|
||
// Other pages link here as /connections.html#my-credentials.
|
||
if (location.hash === '#my-credentials') {
|
||
history.replaceState(null, '', location.pathname + location.search);
|
||
setTimeout(function() { openMyCredentials(); }, 250);
|
||
}
|
||
</script>
|
||
<script src="/ui.js"></script>
|
||
<script src="/search.js"></script>
|
||
<script>
|
||
// The global search (search.js) loads the full address-book index; the
|
||
// credentials banner reads it (window.rgSearchIndex) for a complete
|
||
// view, so refresh it whenever the index is ready or changes.
|
||
document.addEventListener('rg-search-index-ready', function() {
|
||
try { if (typeof updateCredBanner === 'function') updateCredBanner(); } catch (e) {}
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|