1610 lines
65 KiB
JavaScript
1610 lines
65 KiB
JavaScript
(function() {
|
|
/**
|
|
* Nexus Terminal — connection profile editor.
|
|
* Renders a slide-out drawer for creating and editing connection profiles.
|
|
* Dynamically switches form sections based on the selected protocol.
|
|
*/
|
|
|
|
const { createElement, escapeHtml, generateId, $ } = window.NexusTerminal;
|
|
|
|
/**
|
|
* Default values mirroring the Go backend defaults.
|
|
*/
|
|
const DEFAULTS = {
|
|
ssh: {
|
|
port: 22,
|
|
protocol: 'ssh',
|
|
ssh: {
|
|
authMethods: ['publickey', 'password', 'keyboard-interactive'],
|
|
secretMode: 'saved',
|
|
keySecretMode: 'saved',
|
|
hostKeyPolicy: 'ask',
|
|
terminalType: 'xterm-256color',
|
|
keepaliveInterval: 30,
|
|
keepaliveCountMax: 3,
|
|
compression: false,
|
|
jumpHosts: [],
|
|
portForwards: [],
|
|
startupCommand: '',
|
|
envVars: {},
|
|
loginScripts: [],
|
|
},
|
|
},
|
|
telnet: {
|
|
port: 23,
|
|
protocol: 'telnet',
|
|
telnet: {
|
|
localEcho: false,
|
|
lineEnding: 'crlf',
|
|
terminalType: 'xterm-256color',
|
|
encoding: 'utf-8',
|
|
negotiationDebug: false,
|
|
lineMode: false,
|
|
loginScripts: [],
|
|
},
|
|
},
|
|
raw: {
|
|
port: 23,
|
|
protocol: 'raw',
|
|
raw: {
|
|
autoReconnect: false,
|
|
lineEnding: 'crlf',
|
|
encoding: 'utf-8',
|
|
showTimestamps: false,
|
|
showDirection: false,
|
|
payloadPresets: [],
|
|
},
|
|
},
|
|
github: {
|
|
port: 443,
|
|
protocol: 'github',
|
|
host: 'github.com',
|
|
github: {
|
|
baseURL: 'https://api.github.com',
|
|
secretMode: 'saved',
|
|
refreshIntervalSecs: 300,
|
|
},
|
|
},
|
|
};
|
|
|
|
class ProfileEditor {
|
|
/**
|
|
* @param {App} app
|
|
*/
|
|
constructor(app) {
|
|
this.app = app;
|
|
|
|
/** @type {HTMLElement|null} */
|
|
this.drawer = null;
|
|
|
|
/** @type {Object|null} The profile currently being edited. */
|
|
this._profile = null;
|
|
|
|
/** @type {boolean} */
|
|
this._isNew = false;
|
|
}
|
|
|
|
// ── Public API ───────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Opens the editor with a blank profile of the given protocol.
|
|
* @param {string} [protocol='ssh']
|
|
*/
|
|
openNew(protocol = 'ssh') {
|
|
const profile = this._defaultProfile(protocol);
|
|
this._isNew = true;
|
|
this._profile = profile;
|
|
this.render(profile, true);
|
|
}
|
|
|
|
/**
|
|
* Opens the editor pre-populated with an existing profile.
|
|
* @param {string} profileId
|
|
*/
|
|
openEdit(profileId) {
|
|
const profile = this.app.state.getProfile(profileId);
|
|
if (!profile) {
|
|
this.app.notifications.error('Profile not found');
|
|
return;
|
|
}
|
|
this._isNew = false;
|
|
this._profile = JSON.parse(JSON.stringify(profile)); // deep clone
|
|
this.render(this._profile, false);
|
|
}
|
|
|
|
/**
|
|
* Renders (or re-renders) the editor drawer.
|
|
* @param {Object} profile
|
|
* @param {boolean} isNew
|
|
*/
|
|
render(profile, isNew) {
|
|
this.close();
|
|
|
|
const overlay = createElement('div', 'drawer-backdrop');
|
|
const drawer = createElement('div', 'drawer');
|
|
|
|
// ── Header ──────────────────────────────────────────────
|
|
const header = createElement('div', 'drawer-header');
|
|
header.innerHTML = `
|
|
<h2 class="drawer-title">${isNew ? 'New Connection' : 'Edit Connection'}</h2>
|
|
<button class="drawer-close" aria-label="Close">×</button>
|
|
`;
|
|
drawer.appendChild(header);
|
|
|
|
// ── Protocol selector tabs ──────────────────────────────
|
|
const protocols = ['ssh', 'telnet', 'raw', 'github'];
|
|
const tabBar = createElement('div', 'drawer-protocol-tabs');
|
|
for (const proto of protocols) {
|
|
const label = proto === 'github' ? 'GitHub' : proto.toUpperCase();
|
|
const btn = createElement('button', `drawer-proto-tab ${profile.protocol === proto ? 'active' : ''}`, {
|
|
textContent: label,
|
|
dataset: { proto },
|
|
});
|
|
btn.addEventListener('click', () => this.handleProtocolChange(proto));
|
|
tabBar.appendChild(btn);
|
|
}
|
|
drawer.appendChild(tabBar);
|
|
|
|
// ── Scrollable body ─────────────────────────────────────
|
|
const body = createElement('div', 'drawer-body');
|
|
|
|
// General section (common to all protocols)
|
|
body.appendChild(this._renderGeneralSection(profile));
|
|
|
|
// Protocol-specific sections
|
|
switch (profile.protocol) {
|
|
case 'ssh':
|
|
this._renderSSHSections(profile).forEach((s) => body.appendChild(s));
|
|
break;
|
|
case 'telnet':
|
|
this._renderTelnetSections(profile).forEach((s) => body.appendChild(s));
|
|
break;
|
|
case 'raw':
|
|
this._renderRAWSections(profile).forEach((s) => body.appendChild(s));
|
|
break;
|
|
case 'github':
|
|
this._renderGitHubSections(profile).forEach((s) => body.appendChild(s));
|
|
break;
|
|
}
|
|
|
|
// Terminal section (SSH and Telnet only)
|
|
if (profile.protocol !== 'raw' && profile.protocol !== 'github') {
|
|
body.appendChild(this._renderTerminalSection(profile));
|
|
}
|
|
|
|
// Notes & Tags section (common)
|
|
body.appendChild(this._renderNotesSection(profile));
|
|
|
|
drawer.appendChild(body);
|
|
|
|
// ── Footer ──────────────────────────────────────────────
|
|
const footer = createElement('div', 'drawer-footer');
|
|
const cancelBtn = createElement('button', 'btn btn-secondary', { textContent: 'Cancel' });
|
|
const testBtn = createElement('button', 'btn btn-outline', { textContent: 'Test' });
|
|
const saveBtn = createElement('button', 'btn btn-primary', { textContent: isNew ? 'Create' : 'Save' });
|
|
const connectBtn = createElement('button', 'btn btn-accent', { textContent: isNew ? 'Create & Connect' : 'Save & Connect' });
|
|
const deleteBtn = !isNew
|
|
? createElement('button', 'btn btn-danger', { textContent: 'Delete' })
|
|
: null;
|
|
|
|
cancelBtn.addEventListener('click', () => this.close());
|
|
testBtn.addEventListener('click', () => this._testConnection());
|
|
saveBtn.addEventListener('click', () => this._handleSave(false));
|
|
connectBtn.addEventListener('click', () => this._handleSave(true));
|
|
if (deleteBtn) {
|
|
deleteBtn.addEventListener('click', () => this._handleDelete());
|
|
}
|
|
|
|
if (deleteBtn) footer.appendChild(deleteBtn);
|
|
footer.appendChild(cancelBtn);
|
|
footer.appendChild(testBtn);
|
|
footer.appendChild(saveBtn);
|
|
footer.appendChild(connectBtn);
|
|
drawer.appendChild(footer);
|
|
|
|
// ── Events ──────────────────────────────────────────────
|
|
header.querySelector('.drawer-close').addEventListener('click', () => this.close());
|
|
overlay.addEventListener('click', () => this.close());
|
|
|
|
overlay.appendChild(drawer);
|
|
document.body.appendChild(overlay);
|
|
this.drawer = overlay;
|
|
|
|
// Trigger visibility after append so CSS transitions animate
|
|
requestAnimationFrame(() => {
|
|
overlay.classList.add('is-visible');
|
|
drawer.classList.add('is-visible');
|
|
});
|
|
|
|
// Prevent overlay click from reaching through the drawer
|
|
drawer.addEventListener('click', (e) => e.stopPropagation());
|
|
|
|
// Focus the first input
|
|
const firstInput = drawer.querySelector('input, select, textarea');
|
|
if (firstInput) firstInput.focus();
|
|
}
|
|
|
|
/**
|
|
* Closes the editor drawer.
|
|
*/
|
|
close() {
|
|
if (this.drawer) {
|
|
this.drawer.remove();
|
|
this.drawer = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles protocol change from the tab bar.
|
|
* @param {string} protocol
|
|
*/
|
|
handleProtocolChange(protocol) {
|
|
if (this._profile.protocol === protocol) return;
|
|
|
|
// Preserve common fields, reset protocol-specific ones
|
|
this._collectFormValues();
|
|
const base = this._defaultProfile(protocol);
|
|
base.id = this._profile.id;
|
|
base.displayName = this._profile.displayName;
|
|
base.description = this._profile.description;
|
|
base.host = this._profile.host;
|
|
base.username = this._profile.username;
|
|
base.tags = this._profile.tags;
|
|
base.notes = this._profile.notes;
|
|
base.favourite = this._profile.favourite;
|
|
|
|
this._profile = base;
|
|
this.render(this._profile, this._isNew);
|
|
}
|
|
|
|
// ── Section renderers ────────────────────────────────────────────
|
|
|
|
/** @private */
|
|
_renderGeneralSection(profile) {
|
|
const fields = [
|
|
this._field('text', 'displayName', 'Display Name', profile.displayName, { placeholder: 'My Server' }),
|
|
this._field('text', 'host', 'Host', profile.host, {
|
|
placeholder: profile.protocol === 'github' ? 'github.com' : '192.168.1.1 or example.com',
|
|
required: true,
|
|
}),
|
|
];
|
|
|
|
// Port and username not relevant for GitHub connections
|
|
if (profile.protocol !== 'github') {
|
|
fields.push(this._field('number', 'port', 'Port', profile.port, { min: 1, max: 65535, required: true }));
|
|
fields.push(this._field('text', 'username', 'Username', profile.username, { placeholder: 'root' }));
|
|
}
|
|
|
|
fields.push(this._renderGroupField(profile));
|
|
fields.push(this._field('text', 'description', 'Description', profile.description, { placeholder: 'Optional description' }));
|
|
|
|
return this._renderSection('General', fields);
|
|
}
|
|
|
|
/**
|
|
* Renders a combo-box for the Group field — a text input backed by a
|
|
* datalist of existing group names. Typing a new name creates a group.
|
|
* @param {Object} profile
|
|
* @returns {HTMLElement}
|
|
* @private
|
|
*/
|
|
_renderGroupField(profile) {
|
|
const group = createElement('div', 'form-group');
|
|
const label = createElement('label', 'form-label', {
|
|
textContent: 'Group',
|
|
for: 'field-folder',
|
|
});
|
|
group.appendChild(label);
|
|
|
|
// Collect distinct group names from all profiles
|
|
const existingGroups = [...new Set(
|
|
this.app.state.profiles
|
|
.map((p) => (p.folder || '').trim())
|
|
.filter(Boolean)
|
|
)].sort((a, b) => a.localeCompare(b));
|
|
|
|
// Create the datalist so the browser offers autocomplete
|
|
const listId = 'folder-suggestions';
|
|
const datalist = document.createElement('datalist');
|
|
datalist.id = listId;
|
|
for (const g of existingGroups) {
|
|
const opt = document.createElement('option');
|
|
opt.value = g;
|
|
datalist.appendChild(opt);
|
|
}
|
|
group.appendChild(datalist);
|
|
|
|
const input = createElement('input', 'form-input', {
|
|
type: 'text',
|
|
id: 'field-folder',
|
|
name: 'folder',
|
|
value: profile.folder || '',
|
|
placeholder: 'e.g. Production, Staging, Home Lab',
|
|
});
|
|
input.setAttribute('list', listId);
|
|
input.setAttribute('autocomplete', 'off');
|
|
group.appendChild(input);
|
|
|
|
const hint = createElement('span', 'form-hint', {
|
|
textContent: 'Choose an existing group or type a new name to create one',
|
|
});
|
|
group.appendChild(hint);
|
|
|
|
return group;
|
|
}
|
|
|
|
/** @private */
|
|
_renderSSHSections(profile) {
|
|
const ssh = profile.ssh || {};
|
|
const sections = [];
|
|
|
|
// Authentication
|
|
sections.push(this._renderSection('Authentication', [
|
|
this._field('select', 'ssh.secretMode', 'Password Mode', ssh.secretMode, {
|
|
options: [
|
|
{ value: 'saved', label: 'Saved in vault' },
|
|
{ value: 'session-only', label: 'Session only' },
|
|
{ value: 'ask-every-time', label: 'Ask every time' },
|
|
],
|
|
}),
|
|
this._field('tags', 'ssh.authMethods', 'Auth Methods', (ssh.authMethods || []).join(', '), {
|
|
help: 'Comma-separated: publickey, password, keyboard-interactive',
|
|
}),
|
|
]));
|
|
|
|
// Key & Passphrase
|
|
sections.push(this._renderSection('Key & Passphrase', [
|
|
this._field('text', 'ssh.privateKeyPath', 'Private Key Path', ssh.privateKeyPath, {
|
|
placeholder: '~/.ssh/id_ed25519',
|
|
}),
|
|
this._field('select', 'ssh.keySecretMode', 'Passphrase Mode', ssh.keySecretMode, {
|
|
options: [
|
|
{ value: 'saved', label: 'Saved in vault' },
|
|
{ value: 'session-only', label: 'Session only' },
|
|
{ value: 'ask-every-time', label: 'Ask every time' },
|
|
],
|
|
}),
|
|
]));
|
|
|
|
// Host Verification
|
|
sections.push(this._renderSection('Host Verification', [
|
|
this._field('select', 'ssh.hostKeyPolicy', 'Host Key Policy', ssh.hostKeyPolicy, {
|
|
options: [
|
|
{ value: 'strict', label: 'Strict (reject unknown)' },
|
|
{ value: 'ask', label: 'Ask (prompt on unknown)' },
|
|
{ value: 'accept-new', label: 'Accept new keys' },
|
|
{ value: 'insecure', label: 'Insecure (accept all)' },
|
|
],
|
|
}),
|
|
]));
|
|
|
|
// Jump Hosts
|
|
sections.push(this._renderSection('Jump Hosts', [
|
|
this._renderJumpHostsList(ssh.jumpHosts || []),
|
|
], true));
|
|
|
|
// Port Forwarding
|
|
sections.push(this._renderSection('Port Forwarding', [
|
|
this._renderPortForwardsList(ssh.portForwards || []),
|
|
], true));
|
|
|
|
// Startup
|
|
sections.push(this._renderSection('Startup', [
|
|
this._field('text', 'ssh.startupCommand', 'Startup Command', ssh.startupCommand, {
|
|
placeholder: 'Optional command to run after connecting',
|
|
}),
|
|
this._field('textarea', 'ssh.envVarsText', 'Environment Variables', this._envVarsToText(ssh.envVars), {
|
|
placeholder: 'KEY=VALUE (one per line)',
|
|
rows: 3,
|
|
}),
|
|
]));
|
|
|
|
// Login Scripts (expect-style)
|
|
sections.push(this._renderLoginScriptSection('ssh', ssh.loginScripts || []));
|
|
|
|
// SSH Agent
|
|
sections.push(this._renderSection('SSH Agent', [
|
|
this._field('toggle', 'ssh.agentAuth', 'Use SSH Agent for Authentication', ssh.agentAuth, {
|
|
help: 'Authenticate using keys from the local SSH agent (ssh-agent)',
|
|
}),
|
|
this._field('toggle', 'ssh.agentForwarding', 'Forward Agent to Remote', ssh.agentForwarding, {
|
|
help: 'Allow the remote host to use your local SSH agent keys (equivalent to ssh -A)',
|
|
}),
|
|
], true));
|
|
|
|
// TOTP / 2FA
|
|
sections.push(this._renderTOTPSection(profile));
|
|
|
|
// Proxy
|
|
sections.push(this._renderSection('Proxy', [
|
|
this._field('select', 'ssh.proxyType', 'Proxy Type', ssh.proxyType || 'none', {
|
|
options: [
|
|
{ value: 'none', label: 'None (direct connection)' },
|
|
{ value: 'socks5', label: 'SOCKS5' },
|
|
{ value: 'http', label: 'HTTP CONNECT' },
|
|
],
|
|
}),
|
|
this._field('text', 'ssh.proxyHost', 'Proxy Host', ssh.proxyHost || '', {
|
|
placeholder: 'proxy.example.com',
|
|
}),
|
|
this._field('number', 'ssh.proxyPort', 'Proxy Port', ssh.proxyPort || 1080, { min: 1, max: 65535 }),
|
|
this._field('text', 'ssh.proxyUsername', 'Proxy Username', ssh.proxyUsername || '', {
|
|
placeholder: 'Optional',
|
|
}),
|
|
this._field('text', 'ssh.proxyPassword', 'Proxy Password', ssh.proxyPassword || '', {
|
|
placeholder: 'Optional',
|
|
}),
|
|
], true));
|
|
|
|
// Advanced
|
|
sections.push(this._renderSection('Advanced', [
|
|
this._field('text', 'ssh.terminalType', 'Terminal Type', ssh.terminalType, { placeholder: 'xterm-256color' }),
|
|
this._field('number', 'ssh.keepaliveInterval', 'Keepalive Interval (s)', ssh.keepaliveInterval, { min: 0 }),
|
|
this._field('number', 'ssh.keepaliveCountMax', 'Max Missed Keepalives', ssh.keepaliveCountMax, { min: 0 }),
|
|
this._field('toggle', 'ssh.compression', 'Compression', ssh.compression),
|
|
this._field('toggle', 'ssh.autoReconnect', 'Auto-Reconnect', ssh.autoReconnect, {
|
|
help: 'Automatically reconnect with exponential backoff when the connection drops',
|
|
}),
|
|
this._field('number', 'ssh.maxReconnectTries', 'Max Reconnect Attempts', ssh.maxReconnectTries || 5, { min: 1, max: 20 }),
|
|
], true));
|
|
|
|
return sections;
|
|
}
|
|
|
|
/** @private */
|
|
_renderTelnetSections(profile) {
|
|
const tel = profile.telnet || {};
|
|
const sections = [];
|
|
|
|
sections.push(this._renderSection('Login', [
|
|
this._field('toggle', 'telnet.localEcho', 'Local Echo', tel.localEcho),
|
|
this._field('toggle', 'telnet.lineMode', 'Line Mode', tel.lineMode),
|
|
]));
|
|
|
|
sections.push(this._renderSection('Terminal & Encoding', [
|
|
this._field('text', 'telnet.terminalType', 'Terminal Type', tel.terminalType, { placeholder: 'xterm-256color' }),
|
|
this._field('select', 'telnet.encoding', 'Encoding', tel.encoding, {
|
|
options: [
|
|
{ value: 'utf-8', label: 'UTF-8' },
|
|
{ value: 'ascii', label: 'ASCII' },
|
|
{ value: 'iso-8859-1', label: 'ISO 8859-1' },
|
|
],
|
|
}),
|
|
]));
|
|
|
|
sections.push(this._renderSection('Echo & Line Endings', [
|
|
this._field('select', 'telnet.lineEnding', 'Line Ending', tel.lineEnding, {
|
|
options: [
|
|
{ value: 'crlf', label: 'CR+LF (\\r\\n)' },
|
|
{ value: 'lf', label: 'LF (\\n)' },
|
|
{ value: 'cr', label: 'CR (\\r)' },
|
|
{ value: 'none', label: 'None' },
|
|
],
|
|
}),
|
|
]));
|
|
|
|
sections.push(this._renderSection('Negotiation', [
|
|
this._field('toggle', 'telnet.negotiationDebug', 'Debug Negotiation', tel.negotiationDebug),
|
|
], true));
|
|
|
|
// Login Scripts (expect-style)
|
|
sections.push(this._renderLoginScriptSection('telnet', tel.loginScripts || []));
|
|
|
|
sections.push(this._renderSection('Reconnection', [
|
|
this._field('toggle', 'telnet.autoReconnect', 'Auto-Reconnect', tel.autoReconnect, {
|
|
help: 'Automatically reconnect with exponential backoff when the connection drops',
|
|
}),
|
|
this._field('number', 'telnet.maxReconnectTries', 'Max Reconnect Attempts', tel.maxReconnectTries || 5, { min: 1, max: 20 }),
|
|
], true));
|
|
|
|
return sections;
|
|
}
|
|
|
|
/** @private */
|
|
_renderRAWSections(profile) {
|
|
const raw = profile.raw || {};
|
|
const sections = [];
|
|
|
|
sections.push(this._renderSection('Encoding & Framing', [
|
|
this._field('select', 'raw.encoding', 'Encoding', raw.encoding, {
|
|
options: [
|
|
{ value: 'utf-8', label: 'UTF-8' },
|
|
{ value: 'ascii', label: 'ASCII' },
|
|
{ value: 'hex', label: 'Hex' },
|
|
],
|
|
}),
|
|
this._field('select', 'raw.lineEnding', 'Line Ending', raw.lineEnding, {
|
|
options: [
|
|
{ value: 'crlf', label: 'CR+LF (\\r\\n)' },
|
|
{ value: 'lf', label: 'LF (\\n)' },
|
|
{ value: 'cr', label: 'CR (\\r)' },
|
|
{ value: 'none', label: 'None' },
|
|
],
|
|
}),
|
|
]));
|
|
|
|
sections.push(this._renderSection('Display', [
|
|
this._field('toggle', 'raw.showTimestamps', 'Show Timestamps', raw.showTimestamps),
|
|
this._field('toggle', 'raw.showDirection', 'Show Direction', raw.showDirection),
|
|
]));
|
|
|
|
sections.push(this._renderSection('Payload Presets', [
|
|
this._renderPayloadPresetsList(raw.payloadPresets || []),
|
|
], true));
|
|
|
|
sections.push(this._renderSection('Timeouts & Reconnect', [
|
|
this._field('number', 'connectTimeout', 'Connect Timeout (s)', profile.connectTimeout, { min: 0 }),
|
|
this._field('number', 'readTimeout', 'Read Timeout (s)', profile.readTimeout, { min: 0 }),
|
|
this._field('toggle', 'raw.autoReconnect', 'Auto-reconnect', raw.autoReconnect),
|
|
]));
|
|
|
|
return sections;
|
|
}
|
|
|
|
/** @private */
|
|
_renderGitHubSections(profile) {
|
|
const sections = [];
|
|
const gh = profile.github || DEFAULTS.github.github;
|
|
|
|
sections.push(this._renderSection('API', [
|
|
this._field('text', 'github.baseURL', 'API Base URL', gh.baseURL, {
|
|
placeholder: 'https://api.github.com',
|
|
}),
|
|
this._field('select', 'github.secretMode', 'Token Storage', gh.secretMode, {
|
|
options: [
|
|
{ value: 'saved', label: 'Save to keychain' },
|
|
{ value: 'session-only', label: 'Remember for session' },
|
|
{ value: 'ask-every-time', label: 'Ask every time' },
|
|
],
|
|
}),
|
|
]));
|
|
|
|
sections.push(this._renderSection('Refresh', [
|
|
this._field('number', 'github.refreshIntervalSecs', 'Refresh Interval (seconds)', gh.refreshIntervalSecs, {
|
|
min: 60, max: 3600,
|
|
}),
|
|
]));
|
|
|
|
return sections;
|
|
}
|
|
|
|
/** @private */
|
|
_renderTerminalSection(profile) {
|
|
return this._renderSection('Terminal', [
|
|
this._field('text', 'terminalFontFamily', 'Font Family', profile.terminalFontFamily, {
|
|
placeholder: 'JetBrains Mono, Menlo, Monaco, Consolas, monospace',
|
|
}),
|
|
this._field('number', 'terminalFontSize', 'Font Size', profile.terminalFontSize, { min: 8, max: 36 }),
|
|
this._field('number', 'terminalScrollback', 'Scrollback Lines', profile.terminalScrollback, { min: 0, max: 100000 }),
|
|
this._field('select', 'terminalCursorStyle', 'Cursor Style', profile.terminalCursorStyle, {
|
|
options: [
|
|
{ value: 'block', label: 'Block' },
|
|
{ value: 'underline', label: 'Underline' },
|
|
{ value: 'bar', label: 'Bar' },
|
|
],
|
|
}),
|
|
]);
|
|
}
|
|
|
|
/** @private */
|
|
_renderNotesSection(profile) {
|
|
return this._renderSection('Notes & Tags', [
|
|
this._field('tags', 'tags', 'Tags', (profile.tags || []).join(', '), {
|
|
placeholder: 'production, london, vpn',
|
|
help: 'Comma-separated tags for filtering',
|
|
}),
|
|
this._field('textarea', 'notes', 'Notes', profile.notes, {
|
|
placeholder: 'Free-form notes…',
|
|
rows: 4,
|
|
}),
|
|
]);
|
|
}
|
|
|
|
// ── Form helpers ─────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Renders a collapsible form section.
|
|
* @param {string} title
|
|
* @param {HTMLElement[]} fields
|
|
* @param {boolean} [startCollapsed=false]
|
|
* @returns {HTMLElement}
|
|
* @private
|
|
*/
|
|
/**
|
|
* Renders the TOTP/2FA section with secret management and test button.
|
|
* @param {Object} profile
|
|
* @returns {HTMLElement}
|
|
* @private
|
|
*/
|
|
_renderTOTPSection(profile) {
|
|
const section = createElement('div', 'form-section');
|
|
const header = createElement('div', 'form-section-header');
|
|
let collapsed = true;
|
|
|
|
header.innerHTML = `
|
|
<span class="form-section-chevron">▸</span>
|
|
<span class="form-section-title">TOTP / 2FA</span>
|
|
`;
|
|
|
|
const content = createElement('div', 'form-section-content');
|
|
content.style.display = 'none';
|
|
|
|
const helpText = createElement('div', 'form-help', {
|
|
textContent: 'Configure a TOTP secret to auto-fill 2FA codes during SSH keyboard-interactive authentication. The secret is stored encrypted.',
|
|
});
|
|
content.appendChild(helpText);
|
|
|
|
// TOTP secret input
|
|
const group = createElement('div', 'form-group');
|
|
const label = createElement('label', 'form-label', { textContent: 'TOTP Secret' });
|
|
group.appendChild(label);
|
|
|
|
const row = createElement('div', 'totp-input-row');
|
|
|
|
const secretInput = createElement('input', 'form-input', {
|
|
type: 'password',
|
|
placeholder: 'Base32-encoded secret (e.g. from authenticator app setup)',
|
|
id: 'field-ssh.totpSecret',
|
|
dataset: { field: 'ssh.totpSecret' },
|
|
autocapitalize: 'off',
|
|
autocorrect: 'off',
|
|
spellcheck: false,
|
|
});
|
|
|
|
// Show/hide toggle
|
|
const toggleBtn = createElement('button', 'btn btn-small btn-secondary totp-toggle', {
|
|
textContent: '👁',
|
|
title: 'Show/hide secret',
|
|
type: 'button',
|
|
});
|
|
toggleBtn.addEventListener('mousedown', e => e.preventDefault());
|
|
toggleBtn.addEventListener('click', () => {
|
|
secretInput.type = secretInput.type === 'password' ? 'text' : 'password';
|
|
});
|
|
|
|
// Test button — generates a live code
|
|
const testBtn = createElement('button', 'btn btn-small btn-accent totp-test', {
|
|
textContent: 'Test',
|
|
title: 'Generate a code with this secret',
|
|
type: 'button',
|
|
});
|
|
testBtn.addEventListener('mousedown', e => e.preventDefault());
|
|
testBtn.addEventListener('click', async () => {
|
|
const profileId = profile.id;
|
|
if (profileId) {
|
|
try {
|
|
const code = await window.go.api.Service.GetTOTPCode(profileId);
|
|
testBtn.textContent = code;
|
|
setTimeout(() => { testBtn.textContent = 'Test'; }, 5000);
|
|
} catch (err) {
|
|
// If no saved secret yet, try computing locally using the input value
|
|
testBtn.textContent = 'Save first';
|
|
setTimeout(() => { testBtn.textContent = 'Test'; }, 2000);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Save button
|
|
const saveBtn = createElement('button', 'btn btn-small btn-primary totp-save', {
|
|
textContent: 'Save',
|
|
title: 'Encrypt and save the TOTP secret',
|
|
type: 'button',
|
|
});
|
|
saveBtn.addEventListener('mousedown', e => e.preventDefault());
|
|
saveBtn.addEventListener('click', async () => {
|
|
const profileId = profile.id;
|
|
const secret = secretInput.value.trim();
|
|
if (!profileId) {
|
|
NexusTerminal.Notifications.show('Save the profile first, then set the TOTP secret.', 'warning');
|
|
return;
|
|
}
|
|
if (!secret) {
|
|
// Delete existing secret
|
|
try {
|
|
await window.go.api.Service.DeleteTOTPSecret(profileId);
|
|
NexusTerminal.Notifications.show('TOTP secret removed.', 'info');
|
|
} catch (err) {
|
|
NexusTerminal.Notifications.show('Failed to remove TOTP secret.', 'error');
|
|
}
|
|
return;
|
|
}
|
|
try {
|
|
await window.go.api.Service.SetTOTPSecret(profileId, secret);
|
|
NexusTerminal.Notifications.show('TOTP secret saved securely.', 'success');
|
|
secretInput.value = '';
|
|
secretInput.placeholder = '••••••••• (secret saved)';
|
|
} catch (err) {
|
|
NexusTerminal.Notifications.show('Failed to save TOTP secret: ' + err, 'error');
|
|
}
|
|
});
|
|
|
|
// Delete button
|
|
const deleteBtn = createElement('button', 'btn btn-small btn-danger totp-delete', {
|
|
textContent: 'Delete',
|
|
title: 'Remove the TOTP secret',
|
|
type: 'button',
|
|
});
|
|
deleteBtn.addEventListener('mousedown', e => e.preventDefault());
|
|
deleteBtn.addEventListener('click', async () => {
|
|
const profileId = profile.id;
|
|
if (!profileId) return;
|
|
try {
|
|
await window.go.api.Service.DeleteTOTPSecret(profileId);
|
|
NexusTerminal.Notifications.show('TOTP secret removed.', 'info');
|
|
secretInput.placeholder = 'Base32-encoded secret (e.g. from authenticator app setup)';
|
|
} catch (err) {
|
|
NexusTerminal.Notifications.show('Failed to remove TOTP secret.', 'error');
|
|
}
|
|
});
|
|
|
|
row.appendChild(secretInput);
|
|
row.appendChild(toggleBtn);
|
|
row.appendChild(testBtn);
|
|
row.appendChild(saveBtn);
|
|
row.appendChild(deleteBtn);
|
|
group.appendChild(row);
|
|
content.appendChild(group);
|
|
|
|
// Check if secret is already saved and show placeholder
|
|
if (profile.id) {
|
|
(async () => {
|
|
try {
|
|
const has = await window.go.api.Service.HasTOTPSecret(profile.id);
|
|
if (has) {
|
|
secretInput.placeholder = '••••••••• (secret saved)';
|
|
}
|
|
} catch (_) {
|
|
// Ignore — might not be unlocked.
|
|
}
|
|
})();
|
|
}
|
|
|
|
header.addEventListener('mousedown', (e) => e.preventDefault());
|
|
header.addEventListener('click', () => {
|
|
collapsed = !collapsed;
|
|
content.style.display = collapsed ? 'none' : 'block';
|
|
header.querySelector('.form-section-chevron').textContent = collapsed ? '▸' : '▾';
|
|
});
|
|
|
|
section.appendChild(header);
|
|
section.appendChild(content);
|
|
return section;
|
|
}
|
|
|
|
/**
|
|
* Renders the login script section (expect-style send/wait steps).
|
|
* @param {string} prefix - 'ssh' or 'telnet'
|
|
* @param {Array} steps - Array of {type, value, timeout}
|
|
* @returns {HTMLElement}
|
|
* @private
|
|
*/
|
|
_renderLoginScriptSection(prefix, steps) {
|
|
const section = createElement('div', 'form-section');
|
|
const header = createElement('div', 'form-section-header');
|
|
let collapsed = true;
|
|
|
|
header.innerHTML = `
|
|
<span class="form-section-chevron">▸</span>
|
|
<span class="form-section-title">Login Script</span>
|
|
`;
|
|
|
|
const content = createElement('div', 'form-section-content');
|
|
content.style.display = 'none';
|
|
|
|
const helpText = createElement('div', 'form-help', {
|
|
textContent: 'Automate login sequences: wait for text from the server, then send a response. Steps execute in order after connection.',
|
|
});
|
|
content.appendChild(helpText);
|
|
|
|
// Hidden input to hold JSON
|
|
const hiddenInput = createElement('input', '', {
|
|
type: 'hidden',
|
|
id: `field-${prefix}.loginScripts`,
|
|
dataset: { field: `${prefix}.loginScripts` },
|
|
});
|
|
content.appendChild(hiddenInput);
|
|
|
|
// List container
|
|
const listContainer = createElement('div', 'login-script-list');
|
|
content.appendChild(listContainer);
|
|
|
|
// Working copy
|
|
let currentSteps = JSON.parse(JSON.stringify(steps || []));
|
|
|
|
const renderSteps = () => {
|
|
listContainer.innerHTML = '';
|
|
hiddenInput.value = JSON.stringify(currentSteps);
|
|
|
|
if (currentSteps.length === 0) {
|
|
const empty = createElement('div', 'login-script-empty', {
|
|
textContent: 'No login script steps configured.',
|
|
});
|
|
listContainer.appendChild(empty);
|
|
}
|
|
|
|
currentSteps.forEach((step, idx) => {
|
|
const row = createElement('div', 'login-script-row');
|
|
|
|
const typeSelect = createElement('select', 'form-input login-script-type');
|
|
['wait', 'send'].forEach(t => {
|
|
const opt = createElement('option', '', { value: t, textContent: t === 'wait' ? 'Wait for' : 'Send' });
|
|
if (t === step.type) opt.selected = true;
|
|
typeSelect.appendChild(opt);
|
|
});
|
|
typeSelect.addEventListener('change', () => {
|
|
currentSteps[idx].type = typeSelect.value;
|
|
hiddenInput.value = JSON.stringify(currentSteps);
|
|
renderSteps();
|
|
});
|
|
|
|
const valueInput = createElement('input', 'form-input login-script-value', {
|
|
type: 'text',
|
|
value: step.value || '',
|
|
placeholder: step.type === 'wait' ? 'Text to match...' : 'Text to send...',
|
|
autocapitalize: 'off',
|
|
autocorrect: 'off',
|
|
spellcheck: false,
|
|
});
|
|
valueInput.addEventListener('input', () => {
|
|
currentSteps[idx].value = valueInput.value;
|
|
hiddenInput.value = JSON.stringify(currentSteps);
|
|
});
|
|
|
|
row.appendChild(typeSelect);
|
|
row.appendChild(valueInput);
|
|
|
|
// Timeout field for wait steps
|
|
if (step.type === 'wait') {
|
|
const timeoutInput = createElement('input', 'form-input login-script-timeout', {
|
|
type: 'number',
|
|
value: step.timeout || 10,
|
|
min: 1,
|
|
max: 120,
|
|
title: 'Timeout (seconds)',
|
|
});
|
|
timeoutInput.style.width = '60px';
|
|
timeoutInput.addEventListener('input', () => {
|
|
currentSteps[idx].timeout = parseInt(timeoutInput.value, 10) || 10;
|
|
hiddenInput.value = JSON.stringify(currentSteps);
|
|
});
|
|
const timeoutLabel = createElement('span', 'login-script-timeout-label', { textContent: 's' });
|
|
row.appendChild(timeoutInput);
|
|
row.appendChild(timeoutLabel);
|
|
}
|
|
|
|
// Move up/down/remove buttons
|
|
const actions = createElement('div', 'login-script-actions');
|
|
|
|
if (idx > 0) {
|
|
const upBtn = createElement('button', 'login-script-btn', { textContent: '▲', title: 'Move up', type: 'button' });
|
|
upBtn.addEventListener('mousedown', e => e.preventDefault());
|
|
upBtn.addEventListener('click', () => {
|
|
[currentSteps[idx - 1], currentSteps[idx]] = [currentSteps[idx], currentSteps[idx - 1]];
|
|
renderSteps();
|
|
});
|
|
actions.appendChild(upBtn);
|
|
}
|
|
|
|
if (idx < currentSteps.length - 1) {
|
|
const downBtn = createElement('button', 'login-script-btn', { textContent: '▼', title: 'Move down', type: 'button' });
|
|
downBtn.addEventListener('mousedown', e => e.preventDefault());
|
|
downBtn.addEventListener('click', () => {
|
|
[currentSteps[idx], currentSteps[idx + 1]] = [currentSteps[idx + 1], currentSteps[idx]];
|
|
renderSteps();
|
|
});
|
|
actions.appendChild(downBtn);
|
|
}
|
|
|
|
const removeBtn = createElement('button', 'login-script-btn login-script-btn-remove', { textContent: '✕', title: 'Remove', type: 'button' });
|
|
removeBtn.addEventListener('mousedown', e => e.preventDefault());
|
|
removeBtn.addEventListener('click', () => {
|
|
currentSteps.splice(idx, 1);
|
|
renderSteps();
|
|
});
|
|
actions.appendChild(removeBtn);
|
|
|
|
row.appendChild(actions);
|
|
listContainer.appendChild(row);
|
|
});
|
|
};
|
|
|
|
renderSteps();
|
|
|
|
// Add step buttons
|
|
const addRow = createElement('div', 'login-script-add-row');
|
|
const addWaitBtn = createElement('button', 'btn btn-small btn-secondary', { textContent: '+ Wait', type: 'button' });
|
|
addWaitBtn.addEventListener('mousedown', e => e.preventDefault());
|
|
addWaitBtn.addEventListener('click', () => {
|
|
currentSteps.push({ type: 'wait', value: '', timeout: 10 });
|
|
renderSteps();
|
|
});
|
|
const addSendBtn = createElement('button', 'btn btn-small btn-secondary', { textContent: '+ Send', type: 'button' });
|
|
addSendBtn.addEventListener('mousedown', e => e.preventDefault());
|
|
addSendBtn.addEventListener('click', () => {
|
|
currentSteps.push({ type: 'send', value: '', timeout: 0 });
|
|
renderSteps();
|
|
});
|
|
addRow.appendChild(addWaitBtn);
|
|
addRow.appendChild(addSendBtn);
|
|
content.appendChild(addRow);
|
|
|
|
header.addEventListener('mousedown', (e) => e.preventDefault());
|
|
header.addEventListener('click', () => {
|
|
collapsed = !collapsed;
|
|
content.style.display = collapsed ? 'none' : 'block';
|
|
header.querySelector('.form-section-chevron').textContent = collapsed ? '▸' : '▾';
|
|
});
|
|
|
|
section.appendChild(header);
|
|
section.appendChild(content);
|
|
return section;
|
|
}
|
|
|
|
_renderSection(title, fields, startCollapsed = false) {
|
|
const section = createElement('div', 'form-section');
|
|
const header = createElement('div', 'form-section-header');
|
|
let collapsed = startCollapsed;
|
|
|
|
header.innerHTML = `
|
|
<span class="form-section-chevron">${collapsed ? '▸' : '▾'}</span>
|
|
<span class="form-section-title">${escapeHtml(title)}</span>
|
|
`;
|
|
|
|
const content = createElement('div', 'form-section-content');
|
|
if (collapsed) content.style.display = 'none';
|
|
|
|
for (const field of fields) {
|
|
if (field instanceof HTMLElement) {
|
|
content.appendChild(field);
|
|
}
|
|
}
|
|
|
|
// Prevent mousedown from starting a text selection gesture in WKWebView,
|
|
// which would swallow the first click and require a double-click.
|
|
header.addEventListener('mousedown', (e) => e.preventDefault());
|
|
header.addEventListener('click', () => {
|
|
collapsed = !collapsed;
|
|
content.style.display = collapsed ? 'none' : 'block';
|
|
header.querySelector('.form-section-chevron').textContent = collapsed ? '▸' : '▾';
|
|
});
|
|
|
|
section.appendChild(header);
|
|
section.appendChild(content);
|
|
return section;
|
|
}
|
|
|
|
/**
|
|
* Renders a single form field.
|
|
* @param {string} type - text | number | select | toggle | textarea | tags | file
|
|
* @param {string} name - Dot-notated field name, e.g. "ssh.secretMode".
|
|
* @param {string} label
|
|
* @param {*} value
|
|
* @param {Object} [opts]
|
|
* @returns {HTMLElement}
|
|
* @private
|
|
*/
|
|
_field(type, name, label, value, opts = {}) {
|
|
const group = createElement('div', 'form-group');
|
|
|
|
// Label
|
|
const labelEl = createElement('label', 'form-label', {
|
|
textContent: label,
|
|
for: `field-${name}`,
|
|
});
|
|
group.appendChild(labelEl);
|
|
|
|
// Input element based on type
|
|
let input;
|
|
|
|
switch (type) {
|
|
case 'text':
|
|
case 'file':
|
|
input = createElement('input', 'form-input', {
|
|
type: type === 'file' ? 'text' : 'text',
|
|
id: `field-${name}`,
|
|
name,
|
|
value: value || '',
|
|
placeholder: opts.placeholder || '',
|
|
});
|
|
if (opts.required) input.required = true;
|
|
break;
|
|
|
|
case 'number':
|
|
input = createElement('input', 'form-input', {
|
|
type: 'number',
|
|
id: `field-${name}`,
|
|
name,
|
|
value: value != null ? String(value) : '',
|
|
});
|
|
if (opts.min != null) input.min = opts.min;
|
|
if (opts.max != null) input.max = opts.max;
|
|
if (opts.required) input.required = true;
|
|
break;
|
|
|
|
case 'select': {
|
|
input = createElement('select', 'form-select', {
|
|
id: `field-${name}`,
|
|
name,
|
|
});
|
|
for (const opt of (opts.options || [])) {
|
|
const option = createElement('option', '', {
|
|
value: opt.value,
|
|
textContent: opt.label,
|
|
});
|
|
if (opt.value === value) option.selected = true;
|
|
input.appendChild(option);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'toggle': {
|
|
const wrapper = createElement('div', 'form-toggle-wrapper');
|
|
input = createElement('input', 'form-toggle', {
|
|
type: 'checkbox',
|
|
id: `field-${name}`,
|
|
name,
|
|
});
|
|
input.checked = !!value;
|
|
const slider = createElement('label', 'form-toggle-label', { for: `field-${name}` });
|
|
wrapper.appendChild(input);
|
|
wrapper.appendChild(slider);
|
|
group.appendChild(wrapper);
|
|
// Help text
|
|
if (opts.help) {
|
|
group.appendChild(createElement('span', 'form-hint', { textContent: opts.help }));
|
|
}
|
|
return group;
|
|
}
|
|
|
|
case 'textarea':
|
|
input = createElement('textarea', 'form-textarea', {
|
|
id: `field-${name}`,
|
|
name,
|
|
placeholder: opts.placeholder || '',
|
|
});
|
|
input.rows = opts.rows || 3;
|
|
input.textContent = value || '';
|
|
break;
|
|
|
|
case 'tags':
|
|
input = createElement('input', 'form-input', {
|
|
type: 'text',
|
|
id: `field-${name}`,
|
|
name,
|
|
value: value || '',
|
|
placeholder: opts.placeholder || '',
|
|
});
|
|
break;
|
|
|
|
default:
|
|
input = createElement('input', 'form-input', {
|
|
type: 'text',
|
|
id: `field-${name}`,
|
|
name,
|
|
value: value || '',
|
|
});
|
|
}
|
|
|
|
group.appendChild(input);
|
|
|
|
// Help text
|
|
if (opts.help) {
|
|
group.appendChild(createElement('span', 'form-hint', { textContent: opts.help }));
|
|
}
|
|
|
|
return group;
|
|
}
|
|
|
|
/**
|
|
* Renders the jump-hosts list editor.
|
|
* @param {Array<Object>} jumpHosts
|
|
* @returns {HTMLElement}
|
|
* @private
|
|
*/
|
|
_renderJumpHostsList(jumpHosts) {
|
|
const wrapper = createElement('div', 'jump-hosts-list');
|
|
|
|
for (let i = 0; i < jumpHosts.length; i++) {
|
|
const jh = jumpHosts[i];
|
|
const row = createElement('div', 'jump-host-row');
|
|
row.innerHTML = `
|
|
<div class="jump-host-fields">
|
|
<input class="form-input form-input-sm" name="jumpHost-${i}-host" value="${escapeHtml(jh.host || '')}" placeholder="Host" />
|
|
<input class="form-input form-input-sm" name="jumpHost-${i}-port" type="number" value="${jh.port || 22}" min="1" max="65535" style="width:80px" />
|
|
<input class="form-input form-input-sm" name="jumpHost-${i}-username" value="${escapeHtml(jh.username || '')}" placeholder="User" />
|
|
</div>
|
|
<div class="jump-host-fields">
|
|
<input class="form-input form-input-sm" name="jumpHost-${i}-privateKeyPath" value="${escapeHtml(jh.privateKeyPath || '')}" placeholder="Private key path (optional)" style="flex:1" />
|
|
<button class="btn btn-icon btn-danger jump-host-remove" data-index="${i}" title="Remove">×</button>
|
|
</div>
|
|
`;
|
|
row.querySelector('.jump-host-remove').addEventListener('click', () => {
|
|
jumpHosts.splice(i, 1);
|
|
this.render(this._profile, this._isNew);
|
|
});
|
|
wrapper.appendChild(row);
|
|
}
|
|
|
|
const addBtn = createElement('button', 'btn btn-sm btn-outline', { textContent: '+ Add Jump Host' });
|
|
addBtn.addEventListener('click', () => {
|
|
jumpHosts.push({ host: '', port: 22, username: '', authMethod: 'publickey', secretMode: 'ask-every-time', privateKeyPath: '' });
|
|
this.render(this._profile, this._isNew);
|
|
});
|
|
wrapper.appendChild(addBtn);
|
|
|
|
return wrapper;
|
|
}
|
|
|
|
/**
|
|
* Renders the port-forwarding rules list editor.
|
|
* @param {Array<Object>} portForwards
|
|
* @returns {HTMLElement}
|
|
* @private
|
|
*/
|
|
_renderPortForwardsList(portForwards) {
|
|
const wrapper = createElement('div', 'port-forwards-list');
|
|
|
|
for (let i = 0; i < portForwards.length; i++) {
|
|
const pf = portForwards[i];
|
|
const row = createElement('div', 'port-forward-row');
|
|
row.innerHTML = `
|
|
<div class="port-forward-fields">
|
|
<select class="form-select form-select-sm" name="portFwd-${i}-direction" style="width:110px">
|
|
<option value="local" ${pf.direction === 'local' ? 'selected' : ''}>Local →</option>
|
|
<option value="remote" ${pf.direction === 'remote' ? 'selected' : ''}>← Remote</option>
|
|
</select>
|
|
<input class="form-input form-input-sm" name="portFwd-${i}-bindHost" value="${escapeHtml(pf.bindHost || '127.0.0.1')}" placeholder="Bind host" style="width:110px" />
|
|
<input class="form-input form-input-sm" name="portFwd-${i}-bindPort" type="number" value="${pf.bindPort || 0}" min="1" max="65535" placeholder="Bind port" style="width:80px" />
|
|
<span class="port-forward-arrow">⟶</span>
|
|
<input class="form-input form-input-sm" name="portFwd-${i}-destHost" value="${escapeHtml(pf.destHost || '')}" placeholder="Dest host" style="width:110px" />
|
|
<input class="form-input form-input-sm" name="portFwd-${i}-destPort" type="number" value="${pf.destPort || 0}" min="1" max="65535" placeholder="Dest port" style="width:80px" />
|
|
</div>
|
|
<div class="port-forward-fields">
|
|
<input class="form-input form-input-sm" name="portFwd-${i}-label" value="${escapeHtml(pf.label || '')}" placeholder="Label (optional)" style="flex:1" />
|
|
<label class="port-forward-enabled-label" title="Enable on connect">
|
|
<input type="checkbox" name="portFwd-${i}-enabled" ${pf.enabled !== false ? 'checked' : ''} />
|
|
<span>Active</span>
|
|
</label>
|
|
<button class="btn btn-icon btn-danger port-forward-remove" data-index="${i}" title="Remove">×</button>
|
|
</div>
|
|
`;
|
|
row.querySelector('.port-forward-remove').addEventListener('click', () => {
|
|
portForwards.splice(i, 1);
|
|
this.render(this._profile, this._isNew);
|
|
});
|
|
wrapper.appendChild(row);
|
|
}
|
|
|
|
const addBtn = createElement('button', 'btn btn-sm btn-outline', { textContent: '+ Add Port Forward' });
|
|
addBtn.addEventListener('click', () => {
|
|
portForwards.push({
|
|
id: generateId(),
|
|
direction: 'local',
|
|
bindHost: '127.0.0.1',
|
|
bindPort: 0,
|
|
destHost: '',
|
|
destPort: 0,
|
|
enabled: true,
|
|
label: '',
|
|
});
|
|
this.render(this._profile, this._isNew);
|
|
});
|
|
wrapper.appendChild(addBtn);
|
|
|
|
return wrapper;
|
|
}
|
|
|
|
/**
|
|
* Renders the payload-presets list editor.
|
|
* @param {Array<Object>} presets
|
|
* @returns {HTMLElement}
|
|
* @private
|
|
*/
|
|
_renderPayloadPresetsList(presets) {
|
|
const wrapper = createElement('div', 'payload-presets-list');
|
|
|
|
for (let i = 0; i < presets.length; i++) {
|
|
const ps = presets[i];
|
|
const row = createElement('div', 'preset-row');
|
|
row.innerHTML = `
|
|
<input class="form-input form-input-sm" name="preset-${i}-name" value="${escapeHtml(ps.name || '')}" placeholder="Name" />
|
|
<input class="form-input form-input-sm" name="preset-${i}-payload" value="${escapeHtml(ps.payload || '')}" placeholder="Payload" />
|
|
<select class="form-select form-select-sm" name="preset-${i}-format">
|
|
<option value="text" ${ps.format === 'text' ? 'selected' : ''}>Text</option>
|
|
<option value="hex" ${ps.format === 'hex' ? 'selected' : ''}>Hex</option>
|
|
<option value="escaped" ${ps.format === 'escaped' ? 'selected' : ''}>Escaped</option>
|
|
</select>
|
|
<button class="btn btn-icon btn-danger preset-remove" data-index="${i}" title="Remove">×</button>
|
|
`;
|
|
row.querySelector('.preset-remove').addEventListener('click', () => {
|
|
presets.splice(i, 1);
|
|
this.render(this._profile, this._isNew);
|
|
});
|
|
wrapper.appendChild(row);
|
|
}
|
|
|
|
const addBtn = createElement('button', 'btn btn-sm btn-outline', { textContent: '+ Add Preset' });
|
|
addBtn.addEventListener('click', () => {
|
|
presets.push({ id: generateId(), name: '', payload: '', format: 'text' });
|
|
this.render(this._profile, this._isNew);
|
|
});
|
|
wrapper.appendChild(addBtn);
|
|
|
|
return wrapper;
|
|
}
|
|
|
|
// ── Form data collection ─────────────────────────────────────────
|
|
|
|
/**
|
|
* Reads current form values into this._profile.
|
|
* @private
|
|
*/
|
|
_collectFormValues() {
|
|
if (!this.drawer) return;
|
|
|
|
const val = (name) => {
|
|
const el = this.drawer.querySelector(`[name="${name}"]`);
|
|
if (!el) return undefined;
|
|
if (el.type === 'checkbox') return el.checked;
|
|
if (el.type === 'number') return el.value !== '' ? Number(el.value) : 0;
|
|
return el.value;
|
|
};
|
|
|
|
const p = this._profile;
|
|
|
|
// General
|
|
p.displayName = val('displayName') || '';
|
|
p.host = val('host') || '';
|
|
p.port = val('port') || p.port;
|
|
p.username = val('username') || '';
|
|
p.description = val('description') || '';
|
|
p.folder = (val('folder') || '').trim();
|
|
p.terminalFontFamily = val('terminalFontFamily') || '';
|
|
p.terminalFontSize = val('terminalFontSize') || 14;
|
|
p.terminalScrollback = val('terminalScrollback') || 10000;
|
|
p.terminalCursorStyle = val('terminalCursorStyle') || 'block';
|
|
p.connectTimeout = val('connectTimeout') || 30;
|
|
p.readTimeout = val('readTimeout') || 0;
|
|
|
|
// Tags
|
|
const tagsStr = val('tags');
|
|
if (tagsStr !== undefined) {
|
|
p.tags = tagsStr.split(',').map((t) => t.trim()).filter(Boolean);
|
|
}
|
|
|
|
// Notes
|
|
const notes = val('notes');
|
|
if (notes !== undefined) p.notes = notes;
|
|
|
|
// SSH-specific
|
|
if (p.protocol === 'ssh' && p.ssh) {
|
|
p.ssh.secretMode = val('ssh.secretMode') || p.ssh.secretMode;
|
|
p.ssh.privateKeyPath = val('ssh.privateKeyPath') || '';
|
|
p.ssh.keySecretMode = val('ssh.keySecretMode') || p.ssh.keySecretMode;
|
|
p.ssh.hostKeyPolicy = val('ssh.hostKeyPolicy') || p.ssh.hostKeyPolicy;
|
|
p.ssh.terminalType = val('ssh.terminalType') || 'xterm-256color';
|
|
p.ssh.keepaliveInterval = val('ssh.keepaliveInterval') ?? 30;
|
|
p.ssh.keepaliveCountMax = val('ssh.keepaliveCountMax') ?? 3;
|
|
p.ssh.compression = val('ssh.compression') || false;
|
|
p.ssh.agentAuth = val('ssh.agentAuth') || false;
|
|
p.ssh.agentForwarding = val('ssh.agentForwarding') || false;
|
|
p.ssh.autoReconnect = val('ssh.autoReconnect') || false;
|
|
p.ssh.maxReconnectTries = val('ssh.maxReconnectTries') ?? 5;
|
|
p.ssh.startupCommand = val('ssh.startupCommand') || '';
|
|
|
|
// Proxy settings
|
|
p.ssh.proxyType = val('ssh.proxyType') || 'none';
|
|
p.ssh.proxyHost = val('ssh.proxyHost') || '';
|
|
p.ssh.proxyPort = val('ssh.proxyPort') || 1080;
|
|
p.ssh.proxyUsername = val('ssh.proxyUsername') || '';
|
|
p.ssh.proxyPassword = val('ssh.proxyPassword') || '';
|
|
|
|
const authStr = val('ssh.authMethods');
|
|
if (authStr !== undefined) {
|
|
p.ssh.authMethods = authStr.split(',').map((s) => s.trim()).filter(Boolean);
|
|
}
|
|
|
|
// Env vars from textarea
|
|
const envText = val('ssh.envVarsText');
|
|
if (envText !== undefined) {
|
|
p.ssh.envVars = {};
|
|
envText.split('\n').forEach((line) => {
|
|
const eq = line.indexOf('=');
|
|
if (eq > 0) {
|
|
p.ssh.envVars[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Jump hosts
|
|
const jh = [];
|
|
let ji = 0;
|
|
while (this.drawer.querySelector(`[name="jumpHost-${ji}-host"]`)) {
|
|
jh.push({
|
|
host: val(`jumpHost-${ji}-host`) || '',
|
|
port: val(`jumpHost-${ji}-port`) || 22,
|
|
username: val(`jumpHost-${ji}-username`) || '',
|
|
authMethod: 'publickey',
|
|
secretMode: 'ask-every-time',
|
|
privateKeyPath: val(`jumpHost-${ji}-privateKeyPath`) || '',
|
|
});
|
|
ji++;
|
|
}
|
|
p.ssh.jumpHosts = jh;
|
|
|
|
// Port forwards
|
|
const pfs = [];
|
|
let fi = 0;
|
|
while (this.drawer.querySelector(`[name="portFwd-${fi}-direction"]`)) {
|
|
pfs.push({
|
|
id: (p.ssh.portForwards && p.ssh.portForwards[fi]?.id) || generateId(),
|
|
direction: val(`portFwd-${fi}-direction`) || 'local',
|
|
bindHost: val(`portFwd-${fi}-bindHost`) || '127.0.0.1',
|
|
bindPort: val(`portFwd-${fi}-bindPort`) || 0,
|
|
destHost: val(`portFwd-${fi}-destHost`) || '',
|
|
destPort: val(`portFwd-${fi}-destPort`) || 0,
|
|
enabled: val(`portFwd-${fi}-enabled`) !== false,
|
|
label: val(`portFwd-${fi}-label`) || '',
|
|
});
|
|
fi++;
|
|
}
|
|
p.ssh.portForwards = pfs;
|
|
|
|
// Login scripts
|
|
const loginScriptsRaw = val('ssh.loginScripts');
|
|
if (loginScriptsRaw) {
|
|
try {
|
|
p.ssh.loginScripts = JSON.parse(loginScriptsRaw);
|
|
} catch (_) {
|
|
p.ssh.loginScripts = [];
|
|
}
|
|
} else {
|
|
p.ssh.loginScripts = [];
|
|
}
|
|
}
|
|
|
|
// Telnet-specific
|
|
if (p.protocol === 'telnet' && p.telnet) {
|
|
p.telnet.localEcho = val('telnet.localEcho') || false;
|
|
p.telnet.lineMode = val('telnet.lineMode') || false;
|
|
p.telnet.terminalType = val('telnet.terminalType') || 'xterm-256color';
|
|
p.telnet.encoding = val('telnet.encoding') || 'utf-8';
|
|
p.telnet.lineEnding = val('telnet.lineEnding') || 'crlf';
|
|
p.telnet.negotiationDebug = val('telnet.negotiationDebug') || false;
|
|
p.telnet.autoReconnect = val('telnet.autoReconnect') || false;
|
|
p.telnet.maxReconnectTries = val('telnet.maxReconnectTries') ?? 5;
|
|
|
|
// Login scripts
|
|
const telLoginScriptsRaw = val('telnet.loginScripts');
|
|
if (telLoginScriptsRaw) {
|
|
try {
|
|
p.telnet.loginScripts = JSON.parse(telLoginScriptsRaw);
|
|
} catch (_) {
|
|
p.telnet.loginScripts = [];
|
|
}
|
|
} else {
|
|
p.telnet.loginScripts = [];
|
|
}
|
|
}
|
|
|
|
// RAW-specific
|
|
if (p.protocol === 'raw' && p.raw) {
|
|
p.raw.encoding = val('raw.encoding') || 'utf-8';
|
|
p.raw.lineEnding = val('raw.lineEnding') || 'crlf';
|
|
p.raw.showTimestamps = val('raw.showTimestamps') || false;
|
|
p.raw.showDirection = val('raw.showDirection') || false;
|
|
p.raw.autoReconnect = val('raw.autoReconnect') || false;
|
|
|
|
// Presets
|
|
const presets = [];
|
|
let pi = 0;
|
|
while (this.drawer.querySelector(`[name="preset-${pi}-name"]`)) {
|
|
presets.push({
|
|
id: (p.raw.payloadPresets && p.raw.payloadPresets[pi]?.id) || generateId(),
|
|
name: val(`preset-${pi}-name`) || '',
|
|
payload: val(`preset-${pi}-payload`) || '',
|
|
format: val(`preset-${pi}-format`) || 'text',
|
|
});
|
|
pi++;
|
|
}
|
|
p.raw.payloadPresets = presets;
|
|
}
|
|
|
|
// GitHub-specific
|
|
if (p.protocol === 'github') {
|
|
if (!p.github) p.github = {};
|
|
p.github.baseURL = val('github.baseURL') || 'https://api.github.com';
|
|
p.github.secretMode = val('github.secretMode') || 'saved';
|
|
p.github.refreshIntervalSecs = val('github.refreshIntervalSecs') || 300;
|
|
}
|
|
}
|
|
|
|
// ── Validation ───────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Validates the collected profile. Returns an array of error strings (empty = valid).
|
|
* @param {Object} profile
|
|
* @returns {string[]}
|
|
* @private
|
|
*/
|
|
_validate(profile) {
|
|
const errors = [];
|
|
if (!profile.host || !profile.host.trim()) {
|
|
errors.push('Host is required.');
|
|
}
|
|
if (!profile.port || profile.port < 1 || profile.port > 65535) {
|
|
errors.push('Port must be between 1 and 65535.');
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
// ── Actions ──────────────────────────────────────────────────────
|
|
|
|
/** @private */
|
|
async _handleSave(andConnect = false) {
|
|
this._collectFormValues();
|
|
const errors = this._validate(this._profile);
|
|
if (errors.length > 0) {
|
|
this.app.notifications.warning(errors.join(' '));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
let saved;
|
|
if (this._isNew) {
|
|
saved = await window.go.api.Service.CreateProfile(this._profile);
|
|
this.app.state.addProfile(saved);
|
|
this.app.notifications.success('Profile created');
|
|
} else {
|
|
saved = await window.go.api.Service.UpdateProfile(this._profile);
|
|
this.app.state.updateProfile(saved);
|
|
this.app.notifications.success('Profile updated');
|
|
}
|
|
this.app.sidebar.render();
|
|
this.close();
|
|
|
|
if (andConnect && saved && saved.id) {
|
|
this.app.connectProfile(saved.id);
|
|
}
|
|
} catch (err) {
|
|
this.app.notifications.error('Save failed: ' + err);
|
|
}
|
|
}
|
|
|
|
/** @private */
|
|
async _handleDelete() {
|
|
const ok = await this._confirmDelete();
|
|
if (!ok) return;
|
|
|
|
try {
|
|
await window.go.api.Service.DeleteProfile(this._profile.id);
|
|
this.app.state.removeProfile(this._profile.id);
|
|
this.app.sidebar.render();
|
|
this.app.notifications.success('Profile deleted');
|
|
this.close();
|
|
} catch (err) {
|
|
this.app.notifications.error('Failed to delete profile: ' + err);
|
|
}
|
|
}
|
|
|
|
/** @private */
|
|
async _testConnection() {
|
|
this._collectFormValues();
|
|
const errors = this._validate(this._profile);
|
|
if (errors.length > 0) {
|
|
this.app.notifications.warning(errors.join(' '));
|
|
return;
|
|
}
|
|
|
|
const host = this._profile.host;
|
|
const port = this._profile.port;
|
|
this.app.notifications.info(`Testing connection to ${host}:${port}…`);
|
|
|
|
try {
|
|
const result = await window.go.api.Service.TestConnection(host, port);
|
|
this.app.notifications.success(`Reachable — ${result.latencyMs}ms latency`);
|
|
} catch (err) {
|
|
this.app.notifications.error('Test failed: ' + err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Shows a confirmation modal for profile deletion.
|
|
* @returns {Promise<boolean>}
|
|
* @private
|
|
*/
|
|
_confirmDelete() {
|
|
return new Promise((resolve) => {
|
|
const backdrop = createElement('div', 'modal-backdrop');
|
|
const modal = createElement('div', 'modal modal-sm');
|
|
const label = escapeHtml(this._profile.displayName || this._profile.host || 'Untitled');
|
|
modal.innerHTML = `
|
|
<div class="modal-header">
|
|
<h3 class="modal-title">Delete Profile</h3>
|
|
<button class="btn btn-icon btn-xs modal-close">×</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p style="font-size:var(--text-sm);color:var(--text-secondary)">
|
|
Delete <strong>${label}</strong>? This cannot be undone.
|
|
</p>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button class="btn btn-sm confirm-cancel">Cancel</button>
|
|
<button class="btn btn-sm btn-danger confirm-ok">Delete</button>
|
|
</div>
|
|
`;
|
|
backdrop.appendChild(modal);
|
|
document.body.appendChild(backdrop);
|
|
requestAnimationFrame(() => {
|
|
backdrop.classList.add('is-visible');
|
|
modal.classList.add('is-visible');
|
|
});
|
|
|
|
const close = (val) => {
|
|
backdrop.classList.remove('is-visible');
|
|
modal.classList.remove('is-visible');
|
|
setTimeout(() => backdrop.remove(), 200);
|
|
resolve(val);
|
|
};
|
|
modal.querySelector('.modal-close').addEventListener('click', () => close(false));
|
|
modal.querySelector('.confirm-cancel').addEventListener('click', () => close(false));
|
|
modal.querySelector('.confirm-ok').addEventListener('click', () => close(true));
|
|
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(false); });
|
|
});
|
|
}
|
|
|
|
// ── Utilities ────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Returns a default profile object for the given protocol.
|
|
* @param {string} protocol
|
|
* @returns {Object}
|
|
* @private
|
|
*/
|
|
_defaultProfile(protocol) {
|
|
const base = {
|
|
id: generateId(),
|
|
displayName: '',
|
|
description: '',
|
|
protocol,
|
|
host: '',
|
|
port: 0,
|
|
username: '',
|
|
tags: [],
|
|
folder: '',
|
|
environment: '',
|
|
notes: '',
|
|
favourite: false,
|
|
lastConnected: null,
|
|
connectTimeout: 30,
|
|
readTimeout: 0,
|
|
terminalFontFamily: 'JetBrains Mono, Menlo, Monaco, Consolas, monospace',
|
|
terminalFontSize: 14,
|
|
terminalScrollback: 10000,
|
|
terminalCursorStyle: 'block',
|
|
ssh: null,
|
|
telnet: null,
|
|
raw: null,
|
|
};
|
|
|
|
const defaults = DEFAULTS[protocol] || {};
|
|
return { ...base, ...defaults };
|
|
}
|
|
|
|
/**
|
|
* Converts an envVars map into "KEY=VALUE\n" text.
|
|
* @param {Object} envVars
|
|
* @returns {string}
|
|
* @private
|
|
*/
|
|
_envVarsToText(envVars) {
|
|
if (!envVars) return '';
|
|
return Object.entries(envVars).map(([k, v]) => `${k}=${v}`).join('\n');
|
|
}
|
|
}
|
|
|
|
window.NexusTerminal = window.NexusTerminal || {};
|
|
window.NexusTerminal.ProfileEditor = ProfileEditor;
|
|
|
|
})();
|