Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions application/i18n/locales/en/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,9 @@ export const enTerminalMessages: Messages = {
'serial.field.configLabelPlaceholder': 'e.g. Arduino Uno',
'serial.connectAndSave': 'Connect & Save',
'serial.edit.title': 'Serial Port Settings',
'serial.field.username': 'Username',
'serial.field.password': 'Password',
'serial.field.autoLoginDesc': 'When set, Login/Password prompts on the serial console are answered automatically with these saved credentials.',

// Keyboard Interactive Authentication (2FA/MFA)
'keyboard.interactive.title': 'Authentication Required',
Expand Down
3 changes: 3 additions & 0 deletions application/i18n/locales/es/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,9 @@ export const esTerminalMessages: Messages = {
'serial.field.configLabelPlaceholder': 'p. ej., Arduino Uno',
'serial.connectAndSave': 'Conectar y guardar',
'serial.edit.title': 'Configuración del puerto serial',
'serial.field.username': 'Usuario',
'serial.field.password': 'Contraseña',
'serial.field.autoLoginDesc': 'Si se establecen, los mensajes Login/Password de la consola serial se responden automáticamente con estas credenciales guardadas.',

// Keyboard Interactive Authentication (2FA/MFA)
'keyboard.interactive.title': 'Se requiere autenticación',
Expand Down
3 changes: 3 additions & 0 deletions application/i18n/locales/ru/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -834,6 +834,9 @@ export const ruTerminalMessages: Messages = {
'serial.field.configLabelPlaceholder': 'например, Arduino Uno',
'serial.connectAndSave': 'Подключить и сохранить',
'serial.edit.title': 'Настройки последовательного порта',
'serial.field.username': 'Имя пользователя',
'serial.field.password': 'Пароль',
'serial.field.autoLoginDesc': 'Если задано, запросы Login/Password в последовательной консоли автоматически заполняются этими сохранёнными учётными данными.',

// Keyboard Interactive Authentication (2FA/MFA)
'keyboard.interactive.title': 'Требуется аутентификация',
Expand Down
3 changes: 3 additions & 0 deletions application/i18n/locales/zh-CN/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -918,6 +918,9 @@ export const zhCNTerminalMessages: Messages = {
'serial.field.configLabelPlaceholder': '例如 Arduino Uno',
'serial.connectAndSave': '连接并保存',
'serial.edit.title': '串口设置',
'serial.field.username': '用户名',
'serial.field.password': '密码',
'serial.field.autoLoginDesc': '设置后,串口终端出现 Login/Password 登录提示时,会自动填充此处保存的用户名和密码。',

// Keyboard Interactive Authentication (2FA/MFA)
'keyboard.interactive.title': '需要验证',
Expand Down
3 changes: 3 additions & 0 deletions application/i18n/locales/zh-TW/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -918,6 +918,9 @@ export const zhTWTerminalMessages: Messages = {
'serial.field.configLabelPlaceholder': '例如 Arduino Uno',
'serial.connectAndSave': '連線並儲存',
'serial.edit.title': '序列埠設定',
'serial.field.username': '使用者名稱',
'serial.field.password': '密碼',
'serial.field.autoLoginDesc': '設定後,序列埠終端出現 Login/Password 登入提示時,會自動填入此處儲存的使用者名稱和密碼。',

// Keyboard Interactive Authentication (2FA/MFA)
'keyboard.interactive.title': '需要驗證',
Expand Down
40 changes: 39 additions & 1 deletion components/SerialHostDetailsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Serial Host Details Panel
* A dedicated editor for serial port hosts (distinct from SSH HostDetailsPanel)
*/
import { ChevronDown, ChevronUp, Save, Tag, Usb } from 'lucide-react';
import { ChevronDown, ChevronUp, Eye, EyeOff, Save, Tag, Usb } from 'lucide-react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { useTerminalBackend } from '../application/state/useTerminalBackend';
Expand Down Expand Up @@ -75,9 +75,12 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
const [ports, setPorts] = useState<SerialPort[]>([]);
const [isLoadingPorts, setIsLoadingPorts] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const [showPassword, setShowPassword] = useState(false);

// Form state
const [label, setLabel] = useState(initialData.label);
const [username, setUsername] = useState(initialData.username || '');
const [password, setPassword] = useState(initialData.password || '');
const [selectedPort, setSelectedPort] = useState(initialData.hostname || initialData.serialConfig?.path || '');
const [baudRate, setBaudRate] = useState(initialData.serialConfig?.baudRate || initialData.port || 115200);
const [dataBits, setDataBits] = useState<5 | 6 | 7 | 8>(initialData.serialConfig?.dataBits || 8);
Expand Down Expand Up @@ -140,6 +143,8 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
label: label.trim() || `Serial: ${portName}`,
hostname: selectedPort,
port: baudRate,
username: username.trim() || undefined,
password: password || undefined,
Comment thread
binaricat marked this conversation as resolved.
Outdated
tags,
group,
charset,
Expand Down Expand Up @@ -276,6 +281,39 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
)}
</div>

{/* Login credentials (auto-login) */}
<div className="space-y-2">
<Label htmlFor="serial-username">{t('serial.field.username')}</Label>
<Input
id="serial-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t('serial.field.username')}
autoComplete="off"
/>
<div className="relative">
<Input
id="serial-password"
value={password}
type={showPassword ? 'text' : 'password'}
onChange={(e) => setPassword(e.target.value)}
placeholder={t('serial.field.password')}
autoComplete="off"
className="pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
<p className="text-xs text-muted-foreground">
{t('serial.field.autoLoginDesc')}
</p>
</div>

{/* Tags */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
import test from "node:test";
import assert from "node:assert/strict";

import { createTerminalSessionStarters } from "./createTerminalSessionStarters";

const noop = () => undefined;
const ENCRYPTED_CREDENTIAL_PLACEHOLDER = "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==";

const buildBackend = (overrides: Partial<Record<string, unknown>> = {}) => ({
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async () => "ssh-session",
startTelnetSession: async () => "telnet-session",
startMoshSession: async () => "mosh-session",
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
...overrides,
});

const buildCtx = (backend: unknown, extra: Record<string, unknown> = {}) => ({
host: {
id: "serial-1",
label: "Serial: ttyUSB0",
hostname: "/dev/ttyUSB0",
protocol: "serial",
charset: "UTF-8",
},
keys: [],
identities: [],
sessionId: "session-1",
serialConfig: {
path: "/dev/ttyUSB0",
baudRate: 115200,
dataBits: 8,
stopBits: 1,
parity: "none",
flowControl: "none",
},
terminalSettings: {},
terminalBackend: backend,
sessionRef: { current: null },
hasConnectedRef: { current: false },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
bootEpochRef: { current: 0 },
updateStatus: noop,
setStatus: noop,
setError: noop,
setNeedsAuth: noop,
setAuthRetryMessage: noop,
setAuthPassword: noop,
setProgressLogs: noop,
setProgressValue: noop,
...extra,
});

const term = {
cols: 120,
rows: 32,
write: noop,
writeln: noop,
scrollToBottom: noop,
};

test("startSerial passes saved host credentials for auto-login", async () => {
let capturedOptions: Record<string, unknown> | null = null;
const backend = buildBackend({
startSerialSession: async (options: Record<string, unknown>) => {
capturedOptions = options;
return "serial-session";
},
});

await createTerminalSessionStarters(buildCtx(backend, {
host: {
id: "serial-1",
label: "Serial: ttyUSB0",
hostname: "/dev/ttyUSB0",
protocol: "serial",
username: "admin",
password: "secret",
},
}) as never).startSerial(term as never);

assert.ok(capturedOptions);
assert.equal(capturedOptions.username, "admin");
assert.equal(capturedOptions.password, "secret");
});

test("startSerial omits credentials when none are saved", async () => {
let capturedOptions: Record<string, unknown> | null = null;
const backend = buildBackend({
startSerialSession: async (options: Record<string, unknown>) => {
capturedOptions = options;
return "serial-session";
},
});

await createTerminalSessionStarters(buildCtx(backend) as never).startSerial(term as never);

assert.ok(capturedOptions);
assert.equal(capturedOptions.username, undefined);
assert.equal("password" in capturedOptions, false);
});

test("startSerial skips an undecryptable saved password", async () => {
let capturedOptions: Record<string, unknown> | null = null;
const backend = buildBackend({
startSerialSession: async (options: Record<string, unknown>) => {
capturedOptions = options;
return "serial-session";
},
});

await createTerminalSessionStarters(buildCtx(backend, {
host: {
id: "serial-1",
hostname: "/dev/ttyUSB0",
protocol: "serial",
username: "",
password: ENCRYPTED_CREDENTIAL_PLACEHOLDER,
},
}) as never).startSerial(term as never);

assert.ok(capturedOptions);
assert.equal("username" in capturedOptions, false);
assert.equal("password" in capturedOptions, false);
});

test("startSerial waits for auto-login before running the startup command", async () => {
const writtenCommands: string[] = [];
const executedCommands: string[] = [];
let autoLoginComplete: ((evt: { sessionId: string }) => void) | null = null;
let resolveCommand: (() => void) | null = null;
const commandWritten = new Promise<void>((resolve) => {
resolveCommand = resolve;
});

const backend = buildBackend({
startSerialSession: async () => "serial-session",
onTelnetAutoLoginComplete: (
_sessionId: string,
cb: (evt: { sessionId: string }) => void,
) => {
autoLoginComplete = cb;
return noop;
},
onTelnetAutoLoginCancelled: () => noop,
writeToSession: (_sessionId: string, data: string) => {
writtenCommands.push(data);
resolveCommand?.();
},
});

const ctx = buildCtx(backend, {
host: {
id: "serial-1",
hostname: "/dev/ttyUSB0",
protocol: "serial",
username: "admin",
password: "secret",
startupCommand: "show version",
},
onCommandExecuted: (command: string) => {
executedCommands.push(command);
},
});

await createTerminalSessionStarters(ctx as never).startSerial(term as never);
assert.ok(autoLoginComplete);

// The startup command must not fire on the default 600ms delay while the
// main-process auto-login is still answering prompts.
await new Promise((resolve) => setTimeout(resolve, 700));
assert.deepEqual(writtenCommands, []);
assert.deepEqual(executedCommands, []);

autoLoginComplete?.({ sessionId: "session-1" });

await Promise.race([
commandWritten,
new Promise((_, reject) =>
setTimeout(() => reject(new Error("Timed out waiting for startup command")), 1000),
),
]);
assert.deepEqual(writtenCommands, ["show version\r"]);
assert.deepEqual(executedCommands, ["show version"]);
});

test("startSerial runs the startup command without waiting when no credentials are saved", async () => {
const writtenCommands: string[] = [];
const backend = buildBackend({
startSerialSession: async () => "serial-session",
writeToSession: (_sessionId: string, data: string) => {
writtenCommands.push(data);
},
});

const ctx = buildCtx(backend, {
host: {
id: "serial-1",
hostname: "/dev/ttyUSB0",
protocol: "serial",
startupCommand: "show version",
},
});

await createTerminalSessionStarters(ctx as never).startSerial(term as never);

await Promise.race([
new Promise<void>((resolve) => {
const tick = () => {
if (writtenCommands.length > 0) {
resolve();
return;
}
setTimeout(tick, 20);
};
tick();
}),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("Timed out waiting for startup command")), 2000),
),
]);
assert.deepEqual(writtenCommands, ["show version\r"]);
});
Loading
Loading