mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 14:30:13 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7ad0207c6 | ||
|
|
90e0de1e6b | ||
|
|
27a54d9780 | ||
|
|
6bf0d2ff15 | ||
|
|
56b185a80b | ||
|
|
c62d375d2a | ||
|
|
80263c4647 | ||
|
|
20debb5c5a | ||
|
|
d70386f74c | ||
|
|
bfa1a9c635 | ||
|
|
892777fbf2 | ||
|
|
09a9b5ece8 |
No files matched your search
@@ -188,6 +188,7 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.auth.retryMessage': 'Authentication failed. Please check your credentials and try again.',
|
||||
'terminal.auth.retryLog': 'Authentication failed. Please try again.',
|
||||
'terminal.auth.noKeysHint': 'No keys available. Add keys in Keychain.',
|
||||
'terminal.auth.selectIdentity': 'Use saved identity',
|
||||
'terminal.auth.continueSave': 'Continue & Save',
|
||||
'terminal.auth.credentialsUnavailable': 'Saved credentials cannot be decrypted on this device. Please re-enter and save them again.',
|
||||
'terminal.auth.jumpCredentialsUnavailable': 'A jump host has saved credentials that cannot be decrypted on this device. Open host settings and re-enter them.',
|
||||
|
||||
@@ -188,6 +188,7 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.auth.retryMessage': 'Falló la autenticación. Verifica tus credenciales e inténtalo de nuevo.',
|
||||
'terminal.auth.retryLog': 'Falló la autenticación. Inténtalo de nuevo.',
|
||||
'terminal.auth.noKeysHint': 'No hay claves disponibles. Agrega claves en Keychain.',
|
||||
'terminal.auth.selectIdentity': 'Usar identidad guardada',
|
||||
'terminal.auth.continueSave': 'Continuar y guardar',
|
||||
'terminal.auth.credentialsUnavailable': 'Las credenciales guardadas no se pueden descifrar en este dispositivo. Vuelve a ingresarlas y guárdalas de nuevo.',
|
||||
'terminal.auth.jumpCredentialsUnavailable': 'Un host de salto tiene credenciales guardadas que no se pueden descifrar en este dispositivo. Abre la configuración del host y vuelve a ingresarlas.',
|
||||
|
||||
@@ -209,6 +209,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.auth.retryMessage': 'Ошибка аутентификации. Проверьте учётные данные и повторите попытку.',
|
||||
'terminal.auth.retryLog': 'Ошибка аутентификации. Повторите попытку.',
|
||||
'terminal.auth.noKeysHint': 'Нет доступных ключей. Добавьте ключи в связке ключей.',
|
||||
'terminal.auth.selectIdentity': 'Использовать сохранённый профиль',
|
||||
'terminal.auth.continueSave': 'Продолжить и сохранить',
|
||||
'terminal.auth.credentialsUnavailable': 'Сохранённые учётные данные не могут быть расшифрованы на этом устройстве. Пожалуйста, введите и сохраните их заново.',
|
||||
'terminal.auth.jumpCredentialsUnavailable': 'У jump-хоста сохранены учётные данные, которые нельзя расшифровать на этом устройстве. Откройте настройки хоста и введите их заново.',
|
||||
|
||||
@@ -579,6 +579,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.auth.retryMessage': '身份验证失败。请检查凭据后重试。',
|
||||
'terminal.auth.retryLog': '身份验证失败,请重试。',
|
||||
'terminal.auth.noKeysHint': '暂无密钥,请先在钥匙串中添加。',
|
||||
'terminal.auth.selectIdentity': '使用已保存的身份',
|
||||
'terminal.auth.continueSave': '继续并保存',
|
||||
'terminal.auth.credentialsUnavailable': '当前设备无法解密已保存凭据,请重新输入并再次保存。',
|
||||
'terminal.auth.jumpCredentialsUnavailable': '某个跳板机的已保存凭据无法在当前设备解密,请到主机设置中重新填写。',
|
||||
|
||||
@@ -579,6 +579,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.auth.retryMessage': '驗證失敗。請檢查憑證後再試一次。',
|
||||
'terminal.auth.retryLog': '驗證失敗,請再試一次。',
|
||||
'terminal.auth.noKeysHint': '暫無金鑰,請先在鑰匙串中新增。',
|
||||
'terminal.auth.selectIdentity': '使用已儲存的身分',
|
||||
'terminal.auth.continueSave': '繼續並儲存',
|
||||
'terminal.auth.credentialsUnavailable': '目前裝置無法解密已儲存憑證,請重新輸入並再次儲存。',
|
||||
'terminal.auth.jumpCredentialsUnavailable': '某個跳板機的已儲存憑證無法在目前裝置解密,請到主機設定中重新填寫。',
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
buildSftpHostCredentials,
|
||||
} from "./useSftpHostCredentials.ts";
|
||||
import type { Host, Identity, KnownHost, SSHKey } from "../../../domain/models.ts";
|
||||
import { resolvePasswordAuthSftpHost } from "../../../domain/authIdentityPicker.ts";
|
||||
|
||||
const host = (overrides: Partial<Host> = {}): Host => ({
|
||||
id: "host-1",
|
||||
@@ -16,6 +17,22 @@ const host = (overrides: Partial<Host> = {}): Host => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("SFTP credentials follow the temporary deploy identity on a root profile", () => {
|
||||
const identities = [
|
||||
{ id: "root-id", label: "root", username: "root", authMethod: "password", password: "bad-root", created: 0 },
|
||||
{ id: "deploy-id", label: "deploy", username: "deploy", authMethod: "password", password: "good-deploy", created: 0 },
|
||||
] as Identity[];
|
||||
const savedHost = host({ identityId: "root-id" });
|
||||
const sftpHost = resolvePasswordAuthSftpHost(savedHost, identities, {
|
||||
authMethod: "password", username: "deploy", password: "good-deploy", savedToHost: false,
|
||||
});
|
||||
const credentials = buildSftpHostCredentials({ host: sftpHost, hosts: [savedHost], keys: [], identities });
|
||||
assert.equal(credentials.username, "deploy");
|
||||
assert.equal(credentials.password, "good-deploy");
|
||||
assert.equal(credentials.hostId, savedHost.id);
|
||||
assert.equal(savedHost.identityId, "root-id");
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials forwards system agent settings for target and jump hosts", () => {
|
||||
const jump = host({
|
||||
id: "jump-1",
|
||||
|
||||
+23
-8
@@ -82,6 +82,7 @@ import { usePluginTerminalProviders } from "../application/state/usePluginTermin
|
||||
import type { PluginTerminalDecorationRule } from "../domain/pluginTerminalProviders";
|
||||
import { terminalReconnectRegistry } from "../application/state/terminalReconnectRegistry";
|
||||
import { resolveSftpReuseSourceSessionId } from "../application/state/terminalConnectionReuse";
|
||||
import { resolvePasswordAuthSftpHost } from "../domain/authIdentityPicker";
|
||||
// SFTPModal removed - SFTP is now handled by SftpSidePanel in TerminalLayer
|
||||
import { Button } from "./ui/button";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
|
||||
@@ -1238,6 +1239,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
const auth = useTerminalAuthState({
|
||||
host,
|
||||
identities,
|
||||
pendingAuthRef,
|
||||
termRef,
|
||||
onUpdateHost: handleUpdateHostFromTerminal,
|
||||
@@ -1438,8 +1440,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
hasEverConnectedRef.current = true;
|
||||
clearAutoReconnect();
|
||||
}
|
||||
onStatusChange?.(sessionId, next);
|
||||
}, [clearAutoReconnect, onStatusChange, sessionId]);
|
||||
const sftpHost = next === "connected"
|
||||
? resolvePasswordAuthSftpHost(host, identities, pendingAuthRef.current)
|
||||
: host;
|
||||
onStatusChange?.(sessionId, next, sftpHost === host ? undefined : sftpHost);
|
||||
}, [clearAutoReconnect, host, identities, onStatusChange, sessionId]);
|
||||
const updateStatusRef = useRef(updateStatus);
|
||||
updateStatusRef.current = updateStatus;
|
||||
|
||||
@@ -3467,8 +3472,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (onOpenSftp) {
|
||||
// Delegate to parent (TerminalLayer) for shared SFTP side panel
|
||||
const initialPath = await resolveSftpInitialPath();
|
||||
const sftpHost = statusRef.current === 'connected'
|
||||
? resolvePasswordAuthSftpHost(host, identities, pendingAuthRef.current)
|
||||
: host;
|
||||
onOpenSftp(
|
||||
host,
|
||||
sftpHost,
|
||||
initialPath,
|
||||
undefined,
|
||||
sessionId,
|
||||
@@ -3483,7 +3491,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return;
|
||||
}
|
||||
setShowSFTP(true);
|
||||
}, [host, onOpenSftp, resolveSftpInitialPath, sessionId, showSFTP]);
|
||||
}, [host, identities, onOpenSftp, resolveSftpInitialPath, sessionId, showSFTP]);
|
||||
|
||||
const handleSendYmodem = useCallback(async () => {
|
||||
if (!isSerialConnection || statusRef.current !== "connected") return;
|
||||
@@ -3961,6 +3969,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
requiresUserInput: auth.needsAuth || needsHostKeyVerification || isConnectionAwaitingUserInput,
|
||||
});
|
||||
|
||||
const dragDropSftpHost = status === 'connected'
|
||||
? resolvePasswordAuthSftpHost(host, identities, pendingAuthRef.current)
|
||||
: host;
|
||||
const dragDropSudoPassword = dragDropSftpHost === host
|
||||
? resolvedSudoAutofillPassword
|
||||
: identities.find(identity => identity.id === dragDropSftpHost.identityId)?.password;
|
||||
|
||||
const {
|
||||
handleDragEnter,
|
||||
handleDragLeave,
|
||||
@@ -3968,9 +3983,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
handleDrop,
|
||||
isDraggingOver,
|
||||
} = useTerminalDragDrop({
|
||||
host,
|
||||
resolvedLoginUsername,
|
||||
resolvedSudoPassword: resolvedSudoAutofillPassword,
|
||||
host: dragDropSftpHost,
|
||||
resolvedLoginUsername: dragDropSftpHost === host ? resolvedLoginUsername : dragDropSftpHost.username,
|
||||
resolvedSudoPassword: dragDropSudoPassword,
|
||||
isLocalConnection,
|
||||
isNetworkDevice,
|
||||
onOpenSftp,
|
||||
@@ -4545,7 +4560,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, RefreshCcw, Sparkles, SquareArrowOutUpRight, Unplug, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost: !!onUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI: onAddSelectionToAI ? handleAddSelectionToAI : undefined, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect: (attachExistingSession || compactToolbar) ? undefined : handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
|
||||
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, RefreshCcw, Sparkles, SquareArrowOutUpRight, Unplug, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost: !!onUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI: onAddSelectionToAI ? handleAddSelectionToAI : undefined, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect: (attachExistingSession || compactToolbar) ? undefined : handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, identities, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
|
||||
<ScriptExecutionOverlay
|
||||
run={activeScriptRun}
|
||||
onPause={() => { void pauseScriptRun(activeScriptRun.runId); }}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '../application/state/codingCliSessionSignalController';
|
||||
import { collectSessionIds } from '../domain/workspace';
|
||||
import { isPluginHostProtocol } from '../domain/pluginConnection';
|
||||
import { isSameSftpHostSelection } from '../domain/sftpTerminalIdentity';
|
||||
|
||||
import { cn, normalizeLineEndings } from '../lib/utils';
|
||||
import { detectLocalOs } from '../lib/localShell';
|
||||
@@ -468,7 +469,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const { resolveDefaultLayoutForSession, saveWorkspaceLayoutAsDefault: persistWorkspaceLayoutAsDefault } =
|
||||
useWorkspaceLayoutPresetState();
|
||||
|
||||
const applyWorkspaceLayoutPresetForSession = useCallback((session: TerminalSession, tabId: string) => {
|
||||
const applyWorkspaceLayoutPresetForSession = useCallback((session: TerminalSession, tabId: string, sftpHostOverride?: Host) => {
|
||||
// Preset persistence and resolution live in the application layer; the
|
||||
// component only mounts the resolved panes into its UI state.
|
||||
const resolved = resolveDefaultLayoutForSession(session);
|
||||
@@ -501,6 +502,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sftpOpeningTabIdsRef.current.add(tabId);
|
||||
sftpRetainedAfterCloseTabIdsRef.current.delete(tabId);
|
||||
sftpPaneClosedTabIdsRef.current.delete(tabId);
|
||||
setSftpHostSourceSessionForTab(prev => new Map(prev).set(tabId, session.id));
|
||||
const host = hostsRef.current.find(h => h.id === session.hostId);
|
||||
const hostWithOverrides: Host = host
|
||||
? {
|
||||
@@ -520,7 +522,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
} as Host;
|
||||
setSftpHostForTab(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(tabId, hostWithOverrides);
|
||||
next.set(tabId, sftpHostOverride ?? hostWithOverrides);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
@@ -549,11 +551,18 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
return true;
|
||||
}, [persistWorkspaceLayoutAsDefault, t]);
|
||||
|
||||
const handleStatusChange = useCallback((sessionId: string, status: TerminalSession['status']) => {
|
||||
const handleStatusChange = useCallback((sessionId: string, status: TerminalSession['status'], sftpHostOverride?: Host) => {
|
||||
onUpdateSessionStatus(sessionId, status);
|
||||
|
||||
if (status !== 'connected') return;
|
||||
|
||||
setSftpAuthHostBySessionId(prev => {
|
||||
const next = new Map(prev);
|
||||
if (sftpHostOverride) next.set(sessionId, sftpHostOverride);
|
||||
else next.delete(sessionId);
|
||||
return next;
|
||||
});
|
||||
|
||||
const session = sessionsRef.current.find(s => s.id === sessionId);
|
||||
if (!session) return;
|
||||
const workspaceId = session.workspaceId;
|
||||
@@ -573,6 +582,19 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}
|
||||
const proto = presetSession.protocol ?? 'ssh';
|
||||
|
||||
if (presetSession.id === sessionId && (sftpHostOverride || sftpHostSourceSessionForTabRef.current.get(tabId) === sessionId)) {
|
||||
const connectedHost = sftpHostOverride ?? sessionHostsMapRef.current.get(sessionId);
|
||||
if (connectedHost) {
|
||||
setSftpHostForTab(prev => new Map(prev).set(tabId, connectedHost));
|
||||
}
|
||||
setSftpHostSourceSessionForTab(prev => {
|
||||
if (!sftpHostOverride) return prev;
|
||||
const next = new Map(prev);
|
||||
next.set(tabId, sessionId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
if (sidePanelOpenTabsRef.current.has(tabId)) return;
|
||||
|
||||
const targetPanel = resolveSessionSidePanelAutoOpen({
|
||||
@@ -588,7 +610,11 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
// saved default that may omit file transfer — only when SFTP is actually
|
||||
// available. Ordinary auto-open still yields to the saved default.
|
||||
if (targetPanel !== "sftp") {
|
||||
if (applyWorkspaceLayoutPresetForSession(presetSession, tabId)) return;
|
||||
if (applyWorkspaceLayoutPresetForSession(
|
||||
presetSession,
|
||||
tabId,
|
||||
presetSession.id === sessionId ? sftpHostOverride : undefined,
|
||||
)) return;
|
||||
if (!targetPanel) return;
|
||||
}
|
||||
|
||||
@@ -596,6 +622,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
|
||||
if (targetPanel === 'sftp') {
|
||||
sftpPaneClosedTabIdsRef.current.delete(tabId);
|
||||
setSftpHostSourceSessionForTab(prev => new Map(prev).set(tabId, presetSession.id));
|
||||
const host = hostsRef.current.find(h => h.id === presetSession.hostId);
|
||||
const hostWithOverrides: Host = host
|
||||
? {
|
||||
@@ -616,7 +643,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
|
||||
setSftpHostForTab(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(tabId, hostWithOverrides);
|
||||
next.set(tabId, presetSession.id === sessionId && sftpHostOverride ? sftpHostOverride : hostWithOverrides);
|
||||
return next;
|
||||
});
|
||||
} else if (targetPanel === 'ai') {
|
||||
@@ -753,6 +780,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
|
||||
// The host to pass to the SFTP panel - stored when the user opens SFTP
|
||||
const [sftpHostForTab, setSftpHostForTab] = useState<Map<string, Host>>(new Map());
|
||||
const [sftpHostSourceSessionForTab, setSftpHostSourceSessionForTab] = useState<Map<string, string>>(new Map());
|
||||
const [sftpAuthHostBySessionId, setSftpAuthHostBySessionId] = useState<Map<string, Host>>(new Map());
|
||||
useEffect(() => {
|
||||
setSftpAuthHostBySessionId(prev => {
|
||||
const liveIds = new Set(sessions.map(session => session.id));
|
||||
const next = new Map([...prev].filter(([id]) => liveIds.has(id)));
|
||||
return next.size === prev.size ? prev : next;
|
||||
});
|
||||
}, [sessions]);
|
||||
const [sftpInitialLocationForTab, setSftpInitialLocationForTab] = useState<
|
||||
Map<string, { hostId: string; path: string }>
|
||||
>(new Map());
|
||||
@@ -765,6 +801,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const notesOpenRequestIdRef = useRef(0);
|
||||
const sftpHostForTabRef = useRef(sftpHostForTab);
|
||||
sftpHostForTabRef.current = sftpHostForTab;
|
||||
const sftpHostSourceSessionForTabRef = useRef(sftpHostSourceSessionForTab);
|
||||
sftpHostSourceSessionForTabRef.current = sftpHostSourceSessionForTab;
|
||||
const sftpActiveTransfersByTabRef = useRef<Map<string, number>>(new Map());
|
||||
const sftpActiveExternalEditsByTabRef = useRef<Map<string, number>>(new Map());
|
||||
const sftpRetainedAfterCloseTabIdsRef = useRef<Set<string>>(new Set());
|
||||
@@ -812,6 +850,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
next.delete(tabId);
|
||||
return next;
|
||||
});
|
||||
setSftpHostSourceSessionForTab(prev => {
|
||||
if (!prev.has(tabId)) return prev;
|
||||
const next = new Map(prev);
|
||||
next.delete(tabId);
|
||||
return next;
|
||||
});
|
||||
setSftpPendingUploadsForTab(prev => {
|
||||
if (!prev.has(tabId)) return prev;
|
||||
const next = new Map(prev);
|
||||
@@ -997,14 +1041,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
// Compare full endpoint identity so that session-time overrides
|
||||
// (different port/protocol for the same host ID) trigger a switch
|
||||
// instead of toggling the panel closed.
|
||||
const isSameEndpoint = currentHost
|
||||
&& currentHost.id === host.id
|
||||
&& currentHost.hostname === host.hostname
|
||||
&& currentHost.port === host.port
|
||||
&& currentHost.protocol === host.protocol
|
||||
&& currentHost.username === host.username
|
||||
&& currentHost.sftpSudo === host.sftpSudo
|
||||
&& (currentHost.sftpFileProtocol || "auto") === (host.sftpFileProtocol || "auto");
|
||||
const isSameEndpoint = isSameSftpHostSelection(currentHost, host);
|
||||
|
||||
const currentLayout = sidePanelLayoutsRef.current.get(tabId);
|
||||
const paneCount = currentLayout
|
||||
@@ -1041,6 +1078,18 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
next.set(tabId, host);
|
||||
return next;
|
||||
});
|
||||
setSftpHostSourceSessionForTab(prev => {
|
||||
const next = new Map(prev);
|
||||
if (originSessionId) next.set(tabId, originSessionId);
|
||||
else next.delete(tabId);
|
||||
return next;
|
||||
});
|
||||
if (originSessionId) {
|
||||
const sessionHost = sessionHostsMapRef.current.get(originSessionId);
|
||||
if (sessionHost && (host.username !== sessionHost.username || host.identityId !== sessionHost.identityId)) {
|
||||
setSftpAuthHostBySessionId(prev => new Map(prev).set(originSessionId, host));
|
||||
}
|
||||
}
|
||||
|
||||
const { connectionKey, effectiveInitialPath } = resolveSftpOpenTarget({
|
||||
tabId,
|
||||
@@ -1575,16 +1624,35 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sftpPaneClosedTabIdsRef.current.delete(tabId);
|
||||
}
|
||||
|
||||
if (tab === 'sftp') {
|
||||
const sourceSessionId = getActiveTerminalSessionId();
|
||||
const sessionHost = sourceSessionId ? sessionHostsMapRef.current.get(sourceSessionId) : undefined;
|
||||
const authHost = sourceSessionId ? sftpAuthHostBySessionId.get(sourceSessionId) : undefined;
|
||||
const storedHost = sftpHostForTabRef.current.get(tabId);
|
||||
if (sourceSessionId && sessionHost && authHost
|
||||
&& (!storedHost || sftpHostSourceSessionForTabRef.current.has(tabId)
|
||||
|| (storedHost.id === sessionHost.id
|
||||
&& storedHost.hostname === sessionHost.hostname
|
||||
&& (storedHost.port || 22) === (sessionHost.port || 22)))) {
|
||||
setSftpHostForTab(prev => new Map(prev).set(tabId, authHost));
|
||||
setSftpHostSourceSessionForTab(prev => new Map(prev).set(tabId, sourceSessionId));
|
||||
}
|
||||
}
|
||||
|
||||
// If switching to SFTP and no host is stored yet, resolve it
|
||||
if (tab === 'sftp' && !sftpHostForTabRef.current.has(tabId)) {
|
||||
const host = resolveSftpHostForTab(tabId);
|
||||
const sourceSessionId = getActiveTerminalSessionId();
|
||||
const host = (sourceSessionId && sftpAuthHostBySessionId.get(sourceSessionId))
|
||||
?? resolveSftpHostForTab(tabId);
|
||||
if (!host) return;
|
||||
if (sourceSessionId) {
|
||||
setSftpHostSourceSessionForTab(prev => new Map(prev).set(tabId, sourceSessionId));
|
||||
}
|
||||
setSftpHostForTab(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(tabId, host);
|
||||
return next;
|
||||
});
|
||||
const sourceSessionId = getActiveTerminalSessionId();
|
||||
const { effectiveInitialPath } = resolveSftpOpenTarget({
|
||||
tabId,
|
||||
host,
|
||||
@@ -1606,7 +1674,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
// explicitly closes the now-idle panel, preserving the completed history.
|
||||
markSidePanelSubTabOpened(tabId, tab);
|
||||
return true;
|
||||
}, [getActiveTerminalSessionId, markSidePanelSubTabOpened, resolveSftpHostForTab, resolveSftpOpenTarget]);
|
||||
}, [getActiveTerminalSessionId, markSidePanelSubTabOpened, resolveSftpHostForTab, resolveSftpOpenTarget, sftpAuthHostBySessionId]);
|
||||
|
||||
// Switch only the focused pane. If the tool is already open elsewhere in
|
||||
// the tree, the domain helper focuses that pane instead of duplicating it.
|
||||
@@ -2544,6 +2612,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
setSidePanelHeight,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpHostForTab,
|
||||
setSftpHostSourceSessionForTab,
|
||||
setSftpInitialLocationForTab,
|
||||
onSaveWorkspaceLayoutAsDefault: handleSaveWorkspaceLayoutAsDefault,
|
||||
setSftpPendingUploadsForTab,
|
||||
@@ -2558,6 +2627,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sftpDoubleClickBehavior,
|
||||
sftpFollowTerminalCwd,
|
||||
sftpHostForTab,
|
||||
sftpHostSourceSessionForTab,
|
||||
sftpAuthHostBySessionId,
|
||||
sftpInitialLocationForTab,
|
||||
sftpPendingUploadsForTab,
|
||||
sftpPaneClosedTabIdsRef,
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
import type { Identity } from '../../types';
|
||||
import type { Host } from '../../types';
|
||||
import type { PendingAuth } from './runtime/createTerminalSessionStarters';
|
||||
|
||||
test('saved password identity retries with its username and supports both submit paths', async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
|
||||
pretendToBeVisual: true,
|
||||
url: 'http://localhost',
|
||||
});
|
||||
const { window } = dom;
|
||||
const previousGlobals = new Map<string, PropertyDescriptor | undefined>();
|
||||
const installGlobal = (key: string, value: unknown) => {
|
||||
previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
};
|
||||
class ResizeObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
for (const key of [
|
||||
'window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement',
|
||||
'HTMLTextAreaElement', 'Element', 'SVGElement', 'Node', 'NodeFilter',
|
||||
'MutationObserver', 'CustomEvent', 'DOMRect', 'Event', 'KeyboardEvent',
|
||||
'MouseEvent', 'PointerEvent',
|
||||
]) {
|
||||
installGlobal(key, key === 'window' ? window : window[key as keyof typeof window]);
|
||||
}
|
||||
installGlobal('getComputedStyle', window.getComputedStyle.bind(window));
|
||||
installGlobal('requestAnimationFrame', window.requestAnimationFrame.bind(window));
|
||||
installGlobal('cancelAnimationFrame', window.cancelAnimationFrame.bind(window));
|
||||
installGlobal('ResizeObserver', ResizeObserverStub);
|
||||
installGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
|
||||
const { default: React, act } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../../application/i18n/I18nProvider.tsx');
|
||||
const { TerminalAuthDialog } = await import('./TerminalAuthDialog.tsx');
|
||||
const { useTerminalAuthState } = await import('./hooks/useTerminalAuthState.ts');
|
||||
const identities: Identity[] = [
|
||||
{ id: 'saved', label: 'Password B', username: 'deploy', authMethod: 'password', password: 'saved-secret', created: 0 },
|
||||
{ id: 'unusable', label: 'Unreadable', username: 'root', authMethod: 'password', password: 'enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==', created: 1 },
|
||||
];
|
||||
const host: Host = {
|
||||
id: 'host', label: 'Host', hostname: 'localhost', port: 22, username: 'root',
|
||||
identityId: 'old', authMethod: 'password', password: 'wrong', tags: [], os: 'linux',
|
||||
};
|
||||
const submissions: Array<{ username?: string; password?: string; save?: boolean }> = [];
|
||||
const savedHosts: Host[] = [];
|
||||
const logs: string[][] = [];
|
||||
const Form = () => {
|
||||
const pendingAuthRef = React.useRef<PendingAuth>(null);
|
||||
const termRef = React.useRef({ clear() {} } as never);
|
||||
const auth = useTerminalAuthState({
|
||||
host, identities, pendingAuthRef, termRef,
|
||||
onUpdateHost: (updated) => savedHosts.push(updated),
|
||||
onStartSession: () => submissions.push({
|
||||
username: pendingAuthRef.current?.username,
|
||||
password: pendingAuthRef.current?.password,
|
||||
save: pendingAuthRef.current?.savedToHost,
|
||||
}),
|
||||
setStatus: () => {}, setProgressLogs: (next) => {
|
||||
logs.push(typeof next === 'function' ? next([]) : next);
|
||||
},
|
||||
});
|
||||
return <TerminalAuthDialog
|
||||
authMethod={auth.authMethod} setAuthMethod={auth.setAuthMethod}
|
||||
authUsername={auth.authUsername} setAuthUsername={auth.setAuthUsername}
|
||||
authPassword={auth.authPassword} setAuthPassword={auth.setAuthPassword}
|
||||
selectedIdentityId={auth.selectedIdentityId} onSelectIdentity={auth.selectIdentity}
|
||||
authKeyId={auth.authKeyId} setAuthKeyId={auth.setAuthKeyId}
|
||||
authPassphrase={auth.authPassphrase} setAuthPassphrase={auth.setAuthPassphrase}
|
||||
showAuthPassphrase={auth.showAuthPassphrase} setShowAuthPassphrase={auth.setShowAuthPassphrase}
|
||||
showAuthPassword={auth.showAuthPassword} setShowAuthPassword={auth.setShowAuthPassword}
|
||||
authRetryMessage="Authentication failed" keys={[]} identities={identities}
|
||||
onSubmit={() => auth.submit({ saveToHost: true })}
|
||||
onSubmitWithoutSave={() => auth.submit({ saveToHost: false })}
|
||||
onCancel={() => {}} isValid={auth.isValid}
|
||||
/>;
|
||||
};
|
||||
const root = createRoot(window.document.getElementById('root')!);
|
||||
const button = (label: string) => (Array.from(window.document.querySelectorAll('button')) as HTMLButtonElement[])
|
||||
.find(item => item.textContent?.includes(label));
|
||||
try {
|
||||
await act(async () => root.render(<I18nProvider locale="en"><Form /></I18nProvider>));
|
||||
assert.ok(button('Use saved identity'));
|
||||
await act(async () => button('Use saved identity')!.click());
|
||||
assert.ok(button('Password B'));
|
||||
assert.equal(button('Unreadable'), undefined);
|
||||
await act(async () => button('Password B')!.click());
|
||||
assert.equal((window.document.getElementById('auth-username') as HTMLInputElement).value, 'deploy');
|
||||
assert.equal((window.document.getElementById('auth-password') as HTMLInputElement).value, '');
|
||||
assert.equal(window.document.body.textContent?.includes('saved-secret'), false);
|
||||
assert.equal(window.document.querySelector<HTMLInputElement>('#auth-password')?.outerHTML.includes('saved-secret'), false);
|
||||
assert.equal((window.document.querySelector('#auth-password + button') as HTMLButtonElement).disabled, true);
|
||||
await act(async () => button('Continue')!.click());
|
||||
assert.deepEqual(submissions.at(-1), { username: 'deploy', password: 'saved-secret', save: false });
|
||||
assert.equal(savedHosts.length, 0);
|
||||
await act(async () => window.document.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]')!.click());
|
||||
await act(async () => button('Continue & Save')!.click());
|
||||
assert.deepEqual(submissions.at(-1), { username: 'deploy', password: 'saved-secret', save: true });
|
||||
assert.equal(savedHosts.at(-1)?.identityId, 'saved');
|
||||
assert.equal(savedHosts.at(-1)?.password, undefined);
|
||||
assert.equal(savedHosts.at(-1)?.savePassword, true);
|
||||
assert.equal(logs.flat().some((line) => line.includes('saved-secret')), false);
|
||||
const passwordInput = window.document.getElementById('auth-password') as HTMLInputElement;
|
||||
const setInputValue = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
|
||||
assert.ok(setInputValue);
|
||||
await act(async () => {
|
||||
setInputValue.call(passwordInput, 'manual-secret');
|
||||
passwordInput.dispatchEvent(new window.Event('input', { bubbles: true }));
|
||||
});
|
||||
assert.ok(button('Use saved identity'), 'manual edit must clear the saved identity label');
|
||||
await act(async () => button('Continue')!.click());
|
||||
assert.deepEqual(submissions.at(-1), { username: 'deploy', password: 'manual-secret', save: false });
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
window.close();
|
||||
for (const [key, descriptor] of previousGlobals) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -2,11 +2,12 @@
|
||||
* Terminal Authentication Dialog
|
||||
* Displays auth form with password/key selection for SSH connection
|
||||
*/
|
||||
import { BadgeCheck, ChevronDown, Eye, EyeOff, Key, Lock, Unplug } from 'lucide-react';
|
||||
import { BadgeCheck, ChevronDown, Eye, EyeOff, Key, Lock, Unplug, UserRound } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { SSHKey } from '../../types';
|
||||
import { Identity, SSHKey } from '../../types';
|
||||
import { listPasswordAuthIdentities } from '../../domain/authIdentityPicker';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
@@ -32,6 +33,10 @@ export interface TerminalAuthDialogProps {
|
||||
setShowAuthPassword: (show: boolean) => void;
|
||||
authRetryMessage: string | null;
|
||||
keys: SSHKey[];
|
||||
/** Keychain password identities offered for reuse on re-authentication (#3475). */
|
||||
identities?: Identity[];
|
||||
selectedIdentityId?: string | null;
|
||||
onSelectIdentity?: (identityId: string) => void;
|
||||
onSubmit: () => void;
|
||||
onSubmitWithoutSave?: () => void;
|
||||
onCancel: () => void;
|
||||
@@ -55,6 +60,9 @@ export const TerminalAuthDialog: React.FC<TerminalAuthDialogProps> = ({
|
||||
setShowAuthPassword,
|
||||
authRetryMessage,
|
||||
keys,
|
||||
identities,
|
||||
selectedIdentityId,
|
||||
onSelectIdentity,
|
||||
onSubmit,
|
||||
onSubmitWithoutSave,
|
||||
onCancel,
|
||||
@@ -76,9 +84,25 @@ export const TerminalAuthDialog: React.FC<TerminalAuthDialogProps> = ({
|
||||
|
||||
const [keyDropdownOpen, setKeyDropdownOpen] = React.useState(false);
|
||||
const [submitOptionsOpen, setSubmitOptionsOpen] = React.useState(false);
|
||||
const [identityDropdownOpen, setIdentityDropdownOpen] = React.useState(false);
|
||||
|
||||
const selectedKey = authKeyId ? keys.find((k) => k.id === authKeyId) : null;
|
||||
|
||||
// Password identities reusable on re-authentication (#3475). Only rendered
|
||||
// when at least one usable saved password exists; manual entry stays.
|
||||
const passwordIdentities = React.useMemo(
|
||||
() => listPasswordAuthIdentities(identities),
|
||||
[identities],
|
||||
);
|
||||
const selectedIdentity = selectedIdentityId
|
||||
? passwordIdentities.find((identity) => identity.id === selectedIdentityId && identity.username === authUsername)
|
||||
: null;
|
||||
|
||||
const handleSelectIdentity = (identity: Identity) => {
|
||||
onSelectIdentity?.(identity.id);
|
||||
setIdentityDropdownOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Auth method tabs */}
|
||||
@@ -139,9 +163,9 @@ export const TerminalAuthDialog: React.FC<TerminalAuthDialogProps> = ({
|
||||
<Input
|
||||
id="auth-password"
|
||||
type={showAuthPassword ? 'text' : 'password'}
|
||||
value={authPassword}
|
||||
value={selectedIdentity ? '' : authPassword}
|
||||
onChange={(e) => setAuthPassword(e.target.value)}
|
||||
placeholder={t("terminal.auth.password.placeholder")}
|
||||
placeholder={selectedIdentity ? t("terminal.auth.selectIdentity") : t("terminal.auth.password.placeholder")}
|
||||
className={cn("pr-10", authRetryMessage && "border-destructive/50")}
|
||||
autoFocus={!!authRetryMessage}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -150,10 +174,73 @@ export const TerminalAuthDialog: React.FC<TerminalAuthDialogProps> = ({
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowAuthPassword(!showAuthPassword)}
|
||||
disabled={Boolean(selectedIdentity)}
|
||||
>
|
||||
{showAuthPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
{onSelectIdentity && passwordIdentities.length > 0 && (
|
||||
<Popover open={identityDropdownOpen} onOpenChange={setIdentityDropdownOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2.5 px-3 py-2 rounded-lg border transition-colors text-left",
|
||||
selectedIdentity
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border/50 hover:bg-secondary/50"
|
||||
)}
|
||||
>
|
||||
<div className="h-6 w-6 rounded-md flex items-center justify-center shrink-0 bg-primary/20 text-primary">
|
||||
<UserRound size={12} />
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"flex-1 min-w-0 truncate text-sm",
|
||||
selectedIdentity ? "font-medium" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{selectedIdentity
|
||||
? selectedIdentity.label
|
||||
: t("terminal.auth.selectIdentity")}
|
||||
</span>
|
||||
{selectedIdentity?.username && (
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||
{selectedIdentity.username}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown size={14} className="text-muted-foreground shrink-0" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-1" align="start" style={{ width: 'var(--radix-popover-trigger-width)' }}>
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{passwordIdentities.map((identity) => (
|
||||
<button
|
||||
key={identity.id}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-3 py-2 rounded-md transition-colors text-left",
|
||||
selectedIdentity?.id === identity.id
|
||||
? "bg-primary/10 text-primary"
|
||||
: "hover:bg-secondary/80"
|
||||
)}
|
||||
onClick={() => handleSelectIdentity(identity)}
|
||||
>
|
||||
<div className="h-7 w-7 rounded-md flex items-center justify-center shrink-0 bg-primary/20 text-primary">
|
||||
<UserRound size={12} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{identity.label}</div>
|
||||
{identity.username && (
|
||||
<div className="text-xs text-muted-foreground truncate">{identity.username}</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">{'\u2022'.repeat(8)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -259,3 +259,57 @@ test("disconnected observer surfaces do not advertise a reconnect they cannot pe
|
||||
assert.equal(markup.includes("Start over"), false);
|
||||
assert.match(markup, /Close session/);
|
||||
});
|
||||
|
||||
test("re-auth dialog offers saved password identities from the vault (#3475)", () => {
|
||||
const baseAuthProps = {
|
||||
authMethod: "password" as const,
|
||||
setAuthMethod: () => {},
|
||||
authUsername: "root",
|
||||
setAuthUsername: () => {},
|
||||
authPassword: "",
|
||||
setAuthPassword: () => {},
|
||||
selectedIdentityId: null,
|
||||
onSelectIdentity: () => {},
|
||||
authKeyId: null,
|
||||
setAuthKeyId: () => {},
|
||||
authPassphrase: "",
|
||||
setAuthPassphrase: () => {},
|
||||
showAuthPassphrase: false,
|
||||
setShowAuthPassphrase: () => {},
|
||||
showAuthPassword: false,
|
||||
setShowAuthPassword: () => {},
|
||||
authRetryMessage: "Authentication failed. Please try again.",
|
||||
onSubmit: () => {},
|
||||
onCancel: () => {},
|
||||
isValid: true,
|
||||
};
|
||||
|
||||
const withIdentities = renderDialog({
|
||||
needsAuth: true,
|
||||
authProps: {
|
||||
...baseAuthProps,
|
||||
identities: [
|
||||
{
|
||||
id: "identity-1",
|
||||
label: "Password B",
|
||||
username: "deploy",
|
||||
authMethod: "password",
|
||||
password: "secret",
|
||||
created: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
assert.match(withIdentities, /Use saved identity/);
|
||||
assert.match(withIdentities, /Authentication failed\. Please try again\./);
|
||||
// Stored passwords are never rendered, only masked.
|
||||
assert.equal(withIdentities.includes("secret"), false);
|
||||
|
||||
const withoutIdentities = renderDialog({
|
||||
needsAuth: true,
|
||||
authProps: { ...baseAuthProps },
|
||||
});
|
||||
|
||||
assert.equal(withoutIdentities.includes("Use saved identity"), false);
|
||||
});
|
||||
@@ -391,7 +391,7 @@ function terminalViewCtxEqual(
|
||||
}
|
||||
|
||||
function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalViewContext; isPaneMagnified?: boolean }) {
|
||||
const { Activity, Button, Clock3, Copy, Maximize2, Radio, RefreshCcw, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, Unplug, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTerminalProtocol, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onCloseSession, onDetach, onDetachPointerDown, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scriptExecutionOverlay, searchMatchCount, searchFocusToken, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showDisconnectedTerminalNotice, showConnectionControls, showLogs, showSelectionAIAction, isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, terminalReconnectAvailable, reconnectNoticeMessage, timeLeft, toast, zmodem } = ctx;
|
||||
const { Activity, Button, Clock3, Copy, Maximize2, Radio, RefreshCcw, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, Unplug, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTerminalProtocol, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, identities, knownCwdRef, needsHostKeyVerification, onCloseSession, onDetach, onDetachPointerDown, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scriptExecutionOverlay, searchMatchCount, searchFocusToken, sessionDisplayName, sessionId, workspaceId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showDisconnectedTerminalNotice, showConnectionControls, showLogs, showSelectionAIAction, isRestoringSelectionRef, snippets, status, sudoHintRef, sudoHintText, passwordPickerState, onPasswordPickerSelect, passwordPickerTitle, passwordPickerEmptyText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, terminalReconnectAvailable, reconnectNoticeMessage, timeLeft, toast, zmodem } = ctx;
|
||||
// Context menu only needs a snapshot at open; avoid selection state lifting into Terminal.
|
||||
const [contextMenuHasSelection, setContextMenuHasSelection] = useState(false);
|
||||
const isNetworkDevice = host.deviceType === 'network'
|
||||
@@ -1262,6 +1262,9 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
|
||||
showAuthPassword: auth.showAuthPassword,
|
||||
setShowAuthPassword: auth.setShowAuthPassword,
|
||||
authRetryMessage: auth.authRetryMessage,
|
||||
identities,
|
||||
selectedIdentityId: auth.selectedIdentityId,
|
||||
onSelectIdentity: auth.selectIdentity,
|
||||
onSubmit: () => auth.submit(),
|
||||
onSubmitWithoutSave: () => auth.submit({ saveToHost: false }),
|
||||
onCancel: handleCancelConnect,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import type { Host, TerminalSession } from "../../../types";
|
||||
import type { Host, Identity, TerminalSession } from "../../../types";
|
||||
import { listPasswordAuthIdentities } from "../../../domain/authIdentityPicker";
|
||||
import type { PendingAuth } from "../runtime/createTerminalSessionStarters";
|
||||
import type { TerminalAuthMethod } from "../TerminalAuthDialog";
|
||||
import { logger } from "../../../lib/logger";
|
||||
@@ -21,27 +22,27 @@ export const buildSavedAuthHostUpdate = (
|
||||
username: string;
|
||||
password: string;
|
||||
keyId: string | null;
|
||||
identityId?: string | null;
|
||||
},
|
||||
): Host => ({
|
||||
...host,
|
||||
username: auth.username,
|
||||
authMethod: auth.authMethod,
|
||||
password: auth.authMethod === "password" ? auth.password : undefined,
|
||||
password: auth.identityId ? undefined : auth.authMethod === "password" ? auth.password : undefined,
|
||||
savePassword: auth.authMethod === "password" ? true : host.savePassword,
|
||||
identityFileId:
|
||||
auth.authMethod === "key" || auth.authMethod === "certificate"
|
||||
? (auth.keyId ?? undefined)
|
||||
: undefined,
|
||||
// Detach stale Keychain identity on explicit credential save (#1956):
|
||||
// resolveHostAuth prefers identity credentials over host fields.
|
||||
// Empty string (not undefined) so applyGroupDefaults treats this as an explicit
|
||||
// host-level override and does not re-inherit a group-level identity; consumers
|
||||
// check host.identityId truthiness so "" behaves as "no identity".
|
||||
identityId: "",
|
||||
// An explicitly chosen saved identity remains linked to the host. Manual
|
||||
// credentials detach the stale identity; empty string prevents inheriting
|
||||
// an unrelated group identity on the next connection.
|
||||
identityId: auth.identityId || "",
|
||||
});
|
||||
|
||||
export const useTerminalAuthState = ({
|
||||
host,
|
||||
identities,
|
||||
pendingAuthRef,
|
||||
termRef,
|
||||
onUpdateHost,
|
||||
@@ -50,6 +51,7 @@ export const useTerminalAuthState = ({
|
||||
setProgressLogs,
|
||||
}: {
|
||||
host: Host;
|
||||
identities: Identity[];
|
||||
pendingAuthRef: RefObject<PendingAuth>;
|
||||
termRef: RefObject<XTerm | null>;
|
||||
onUpdateHost?: (host: Host) => void;
|
||||
@@ -62,6 +64,7 @@ export const useTerminalAuthState = ({
|
||||
const [authUsername, setAuthUsername] = useState(host.username || "root");
|
||||
const [authMethod, setAuthMethod] = useState<TerminalAuthMethod>("password");
|
||||
const [authPassword, setAuthPassword] = useState("");
|
||||
const [selectedIdentityId, setSelectedIdentityId] = useState<string | null>(null);
|
||||
const [authKeyId, setAuthKeyId] = useState<string | null>(null);
|
||||
const [authPassphrase, setAuthPassphrase] = useState("");
|
||||
const [showAuthPassword, setShowAuthPassword] = useState(false);
|
||||
@@ -73,6 +76,7 @@ export const useTerminalAuthState = ({
|
||||
setAuthRetryMessage(null);
|
||||
setAuthUsername(host.username || "root");
|
||||
setAuthPassword("");
|
||||
setSelectedIdentityId(null);
|
||||
setAuthKeyId(null);
|
||||
setAuthPassphrase("");
|
||||
setShowAuthPassword(false);
|
||||
@@ -80,12 +84,38 @@ export const useTerminalAuthState = ({
|
||||
setSaveCredentials(true);
|
||||
}, [host.id, host.username]);
|
||||
|
||||
const selectedIdentity = useMemo(() =>
|
||||
selectedIdentityId
|
||||
? listPasswordAuthIdentities(identities).find((identity) => identity.id === selectedIdentityId && identity.username === authUsername)
|
||||
: undefined,
|
||||
[authUsername, identities, selectedIdentityId]);
|
||||
|
||||
const isValid = useMemo(() => {
|
||||
if (!authUsername.trim()) return false;
|
||||
if (authMethod === "password") return isAuthPasswordProvided(authPassword);
|
||||
if (authMethod === "password") return Boolean(selectedIdentity) || isAuthPasswordProvided(authPassword);
|
||||
if (authMethod === "key" || authMethod === "certificate") return !!authKeyId;
|
||||
return false;
|
||||
}, [authKeyId, authMethod, authPassword, authUsername]);
|
||||
}, [authKeyId, authMethod, authPassword, authUsername, selectedIdentity]);
|
||||
|
||||
const updateAuthPassword = useCallback((password: string) => {
|
||||
setSelectedIdentityId(null);
|
||||
setAuthPassword(password);
|
||||
}, []);
|
||||
|
||||
const updateAuthUsername = useCallback((username: string) => {
|
||||
setSelectedIdentityId(null);
|
||||
setAuthUsername(username);
|
||||
}, []);
|
||||
|
||||
const selectIdentity = useCallback((identityId: string) => {
|
||||
const identity = listPasswordAuthIdentities(identities).find((item) => item.id === identityId);
|
||||
if (!identity) return;
|
||||
setAuthMethod("password");
|
||||
setAuthUsername(identity.username);
|
||||
setAuthPassword("");
|
||||
setShowAuthPassword(false);
|
||||
setSelectedIdentityId(identity.id);
|
||||
}, [identities]);
|
||||
|
||||
const resetForRetry = useCallback(() => {
|
||||
setNeedsAuth(false);
|
||||
@@ -98,10 +128,11 @@ export const useTerminalAuthState = ({
|
||||
if (!isValid) return;
|
||||
|
||||
const shouldSave = opts?.saveToHost ?? saveCredentials;
|
||||
const password = selectedIdentity?.password ?? authPassword;
|
||||
pendingAuthRef.current = {
|
||||
authMethod,
|
||||
username: authUsername,
|
||||
password: authMethod === "password" ? authPassword : undefined,
|
||||
password: authMethod === "password" ? password : undefined,
|
||||
keyId:
|
||||
authMethod === "key" || authMethod === "certificate"
|
||||
? (authKeyId ?? undefined)
|
||||
@@ -118,8 +149,9 @@ export const useTerminalAuthState = ({
|
||||
buildSavedAuthHostUpdate(host, {
|
||||
authMethod,
|
||||
username: authUsername,
|
||||
password: authPassword,
|
||||
password,
|
||||
keyId: authKeyId,
|
||||
identityId: authMethod === "password" ? selectedIdentity?.id : undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -152,6 +184,7 @@ export const useTerminalAuthState = ({
|
||||
onUpdateHost,
|
||||
pendingAuthRef,
|
||||
saveCredentials,
|
||||
selectedIdentity,
|
||||
setProgressLogs,
|
||||
setStatus,
|
||||
termRef,
|
||||
@@ -164,11 +197,13 @@ export const useTerminalAuthState = ({
|
||||
authRetryMessage,
|
||||
setAuthRetryMessage,
|
||||
authUsername,
|
||||
setAuthUsername,
|
||||
setAuthUsername: updateAuthUsername,
|
||||
authMethod,
|
||||
setAuthMethod,
|
||||
authPassword,
|
||||
setAuthPassword,
|
||||
setAuthPassword: updateAuthPassword,
|
||||
selectedIdentityId,
|
||||
selectIdentity,
|
||||
authKeyId,
|
||||
setAuthKeyId,
|
||||
authPassphrase,
|
||||
|
||||
@@ -2869,6 +2869,14 @@ test("startSSH does not use unsaved retry passwords for sudo autofill", async ()
|
||||
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
||||
assert.equal(captured?.password, "temporary-secret");
|
||||
assert.equal(captured?.sftpReuseOptions, undefined);
|
||||
|
||||
// A different login user must never alias the original profile to SFTP.
|
||||
Object.assign(ctx.host, { password: undefined });
|
||||
ctx.pendingAuthRef.current.username = "deploy";
|
||||
captured = undefined;
|
||||
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
||||
assert.equal(captured?.username, "deploy");
|
||||
assert.equal(captured?.sftpReuseOptions, undefined);
|
||||
});
|
||||
|
||||
test("startSSH uses pending saved auth for sudo autofill on the first saved connection", async () => {
|
||||
|
||||
@@ -675,7 +675,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
requiresMfa: !!ctx.host.requiresMfa,
|
||||
port: ctx.host.port || 22,
|
||||
password: attempt.password,
|
||||
sftpReuseOptions: sftpReuseOptions?.password ? undefined : sftpReuseOptions,
|
||||
sftpReuseOptions: sftpReuseOptions?.password || sftpReuseOptions?.username !== effectiveUsername
|
||||
? undefined
|
||||
: sftpReuseOptions,
|
||||
privateKey: attempt.key?.source === 'reference' ? undefined : (sanitizeCredentialValue(attempt.key?.privateKey) || undefined),
|
||||
certificate: attempt.key?.certificate,
|
||||
publicKey: attempt.key?.publicKey,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
resolveTerminalDropErrorMessage,
|
||||
} from "./hooks/useTerminalDragDrop";
|
||||
import { resolvePreferredTerminalCwd } from "./sftpCwd";
|
||||
import { resolvePasswordAuthSftpHost } from "../../domain/authIdentityPicker";
|
||||
|
||||
const host = {
|
||||
id: "host-1",
|
||||
@@ -610,6 +611,36 @@ test("remote SSH folder drop to /root uses a resolved identity password", async
|
||||
assert.equal(openedHost?.identityId, "id-1");
|
||||
});
|
||||
|
||||
test("folder drop after choosing deploy identity keeps its saved sudo password", async () => {
|
||||
const rootHost = { ...host, username: "root", identityId: "root-id" };
|
||||
const identity = {
|
||||
id: "deploy-id", label: "Deploy", username: "deploy", authMethod: "password" as const,
|
||||
password: "deploy-secret", created: 0,
|
||||
};
|
||||
const sftpHost = resolvePasswordAuthSftpHost(rootHost, [identity], {
|
||||
authMethod: "password", username: "deploy", password: "deploy-secret", savedToHost: false,
|
||||
});
|
||||
let openedHost: Host | undefined;
|
||||
await handleTerminalDropEntries({
|
||||
dropEntries: [{ file: null, relativePath: "docs", isDirectory: true }],
|
||||
host: sftpHost,
|
||||
resolvedLoginUsername: sftpHost.username,
|
||||
resolvedSudoPassword: identity.password,
|
||||
isLocalConnection: false,
|
||||
onOpenSftp: (nextHost) => { openedHost = nextHost; },
|
||||
resolveSftpInitialPath: async () => "/root",
|
||||
scrollToBottomAfterProgrammaticInput: () => {},
|
||||
sessionId: "session-1",
|
||||
sessionRef: { current: "session-1" },
|
||||
terminalBackend: { writeToSession: () => {} },
|
||||
termRef: { current: null },
|
||||
});
|
||||
assert.equal(openedHost?.username, "deploy");
|
||||
assert.equal(openedHost?.identityId, "deploy-id");
|
||||
assert.equal(openedHost?.sftpSudo, true);
|
||||
assert.equal(rootHost.identityId, "root-id");
|
||||
});
|
||||
|
||||
test("remote SSH folder drop to /root fails closed without a saved sudo password", async () => {
|
||||
let openedSftp = false;
|
||||
|
||||
|
||||
@@ -176,7 +176,7 @@ export interface TerminalProps {
|
||||
keyBindings?: KeyBinding[];
|
||||
onHotkeyAction?: (action: string, event: KeyboardEvent) => void;
|
||||
onTerminalFontSizeChange?: (fontSize: number) => void;
|
||||
onStatusChange?: (sessionId: string, status: TerminalSession["status"]) => void;
|
||||
onStatusChange?: (sessionId: string, status: TerminalSession["status"], sftpHost?: Host) => void;
|
||||
onSessionExit?: (sessionId: string, evt: TerminalSessionExitEvent) => void;
|
||||
onTerminalDataCapture?: (sessionId: string, data: string) => void;
|
||||
onOsDetected?: (hostId: string, distro: string) => void;
|
||||
|
||||
@@ -805,7 +805,7 @@ interface TerminalPaneProps {
|
||||
onOpenTheme: () => void;
|
||||
onOpenSystem?: () => void;
|
||||
onCloseSession: (sessionId: string) => void;
|
||||
onStatusChange: (sessionId: string, status: TerminalSession['status']) => void;
|
||||
onStatusChange: (sessionId: string, status: TerminalSession['status'], sftpHost?: Host) => void;
|
||||
onSessionExit: (sessionId: string, evt: TerminalSessionExitEvent) => void;
|
||||
onTerminalDataCapture?: (sessionId: string, data: string) => void;
|
||||
onOsDetected: (hostId: string, distro: string) => void;
|
||||
@@ -1707,7 +1707,7 @@ interface TerminalPanesHostProps {
|
||||
onOpenTheme: () => void;
|
||||
onOpenSystem?: () => void;
|
||||
onCloseSession: (sessionId: string) => void;
|
||||
onStatusChange: (sessionId: string, status: TerminalSession['status']) => void;
|
||||
onStatusChange: (sessionId: string, status: TerminalSession['status'], sftpHost?: Host) => void;
|
||||
onSessionExit: (sessionId: string, evt: TerminalSessionExitEvent) => void;
|
||||
onTerminalDataCapture?: (sessionId: string, data: string) => void;
|
||||
onOsDetected: (hostId: string, distro: string) => void;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useSystemCapabilitiesWarmup } from '../../application/state/useSystemMa
|
||||
import { cn } from '../../lib/utils';
|
||||
import type { Host, TerminalSession, Workspace } from '../../types';
|
||||
import { resolveTerminalHibernateEnabled } from '../../domain/terminalHibernate';
|
||||
import { resolveTerminalSftpHost } from '../../domain/sftpTerminalIdentity';
|
||||
import { shouldMeasureTerminalLayerLayout } from '../terminalPaneVisibility';
|
||||
import { TerminalLayerView } from './TerminalLayerView';
|
||||
import { useTerminalAiContexts } from '../../application/state/useTerminalAiContexts';
|
||||
@@ -47,6 +48,8 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
const sessions = s.sessions as TerminalSession[];
|
||||
const sessionHostsMap = s.sessionHostsMap as Map<string, Host>;
|
||||
const sftpHostForTab = s.sftpHostForTab as Map<string, Host>;
|
||||
const sftpHostSourceSessionForTab = s.sftpHostSourceSessionForTab as Map<string, string>;
|
||||
const sftpAuthHostBySessionId = s.sftpAuthHostBySessionId as Map<string, Host>;
|
||||
const sidePanelOpenTabs = s.sidePanelOpenTabs as Map<string, SidePanelTab>;
|
||||
const sidePanelLayouts = s.sidePanelLayouts as Map<string, SidePanelLayout>;
|
||||
const showHostTreeSidebar = s.showHostTreeSidebar as boolean | undefined;
|
||||
@@ -134,14 +137,19 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
|
||||
const sftpActiveHost = useMemo((): Host | null => {
|
||||
if (!isSftpOpenForCurrentTab || !activeTabId) return null;
|
||||
if (activeWorkspace && focusedSessionId) {
|
||||
return sessionHostsMap.get(focusedSessionId) ?? sftpHostForTab.get(activeTabId) ?? null;
|
||||
}
|
||||
if (activeSession) {
|
||||
return sessionHostsMap.get(activeSession.id) ?? sftpHostForTab.get(activeTabId) ?? null;
|
||||
}
|
||||
return sftpHostForTab.get(activeTabId) ?? null;
|
||||
}, [activeSession, activeTabId, activeWorkspace, focusedSessionId, isSftpOpenForCurrentTab, sessionHostsMap, sftpHostForTab]);
|
||||
const sessionId = activeWorkspace ? focusedSessionId : activeSession?.id;
|
||||
const sessionHost = sessionId ? sessionHostsMap.get(sessionId) : null;
|
||||
const stored = sftpHostForTab.get(activeTabId);
|
||||
const authHost = sessionId && sftpHostSourceSessionForTab.has(activeTabId)
|
||||
? sftpAuthHostBySessionId.get(sessionId)
|
||||
: undefined;
|
||||
return resolveTerminalSftpHost({
|
||||
sessionHost,
|
||||
storedHost: stored,
|
||||
authenticatedHost: authHost,
|
||||
followsTerminal: sftpHostSourceSessionForTab.has(activeTabId),
|
||||
});
|
||||
}, [activeSession?.id, activeTabId, activeWorkspace, focusedSessionId, isSftpOpenForCurrentTab, sessionHostsMap, sftpAuthHostBySessionId, sftpHostForTab, sftpHostSourceSessionForTab]);
|
||||
|
||||
// Keep the same-endpoint SSH session id across disconnected/connecting so
|
||||
// SftpSidePanel can observe status transitions and rebind after Start over.
|
||||
@@ -155,11 +163,14 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
const sessionHost = sessionHostsMap.get(session.id);
|
||||
if (!sessionHost) return null;
|
||||
const sameEndpoint =
|
||||
sessionHost.hostname === sftpActiveHost.hostname
|
||||
sessionHost.id === sftpActiveHost.id
|
||||
&& sessionHost.hostname === sftpActiveHost.hostname
|
||||
&& (sessionHost.port || 22) === (sftpActiveHost.port || 22)
|
||||
&& (sessionHost.username || 'root') === (sftpActiveHost.username || 'root');
|
||||
&& ((sessionHost.username || 'root') === (sftpActiveHost.username || 'root')
|
||||
|| (activeTabId && sftpHostSourceSessionForTab.has(activeTabId)
|
||||
&& sftpAuthHostBySessionId.get(session.id) === sftpActiveHost));
|
||||
return sameEndpoint ? session.id : null;
|
||||
}, [activeSession?.id, activeWorkspace, focusedSessionId, isSftpOpenForCurrentTab, sessions, sessionHostsMap, sftpActiveHost]);
|
||||
}, [activeSession?.id, activeTabId, activeWorkspace, focusedSessionId, isSftpOpenForCurrentTab, sessions, sessionHostsMap, sftpActiveHost, sftpAuthHostBySessionId, sftpHostSourceSessionForTab]);
|
||||
|
||||
const linkedTerminalSessionIdForSftp = useMemo((): string | null => {
|
||||
if (!isSftpOpenForCurrentTab) return null;
|
||||
@@ -375,6 +386,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
Set,
|
||||
setDropHint,
|
||||
setSftpHostForTab: s.setSftpHostForTab,
|
||||
setSftpHostSourceSessionForTab: s.setSftpHostSourceSessionForTab,
|
||||
setSftpInitialLocationForTab: s.setSftpInitialLocationForTab,
|
||||
setSftpPendingUploadsForTab: s.setSftpPendingUploadsForTab,
|
||||
setAiMountedTabIds: s.setAiMountedTabIds,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
resolveSftpTransferNavigationTarget,
|
||||
} from '../../domain/sftpTransferNavigation';
|
||||
import { collectSidePanelPanes, sidePanelLayoutHasTool } from '../../domain/sidePanelLayout';
|
||||
import { isSameSftpHostSelection } from '../../domain/sftpTerminalIdentity';
|
||||
import { collectSessionIds } from '../../domain/workspace';
|
||||
import {
|
||||
moveSidePanelTabMap,
|
||||
@@ -108,7 +109,7 @@ export function pruneTerminalTabMemoryState(
|
||||
|
||||
export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
|
||||
const { openPath } = useSftpBackend();
|
||||
const { activeSidePanelTab, activeSidePanelLayout, activeTabId, activeTabIdRef, activeWorkspace, activityTrackedSessions, cancelAnimationFrame, ChunkedEscapeFilter, clearTopTabsPreviewVars, document, dropHint, effectiveHosts, filterTabsMap, focusedSessionId, getSessionActivityIdsToClear, handleToggleAiFromTopBar, handleToggleScriptsSidePanel, handleToggleSidePanel, hasNotifiableTerminalOutput, isComposeBarOpen, isFocusMode, isTerminalLayerVisible, lastSidePanelTabRef, Map, onConnectToHost, onSessionData, onSplitSessionRef, onToggleBroadcastRef, onToggleWorkspaceViewModeRef, prevFocusedSessionIdRef, refocusActiveTerminalSession, requestAnimationFrame, ResizeObserver, sessionActivityStore, sessions, Set, setAiMountedTabIds, setDropHint, setNotesMountedTabIds, setScriptsMountedTabIds, setSystemMountedTabIds, setSftpHostForTab, setSftpInitialLocationForTab, setSftpPendingUploadsForTab, setSidePanelOpenTabs, setSidePanelLayouts, setThemeMountedTabIds, setWorkspaceArea, shouldMeasureTerminalLayerLayout, sidePanelPosition, sidePanelHeight, sidePanelWidth, sftpActiveHost, sftpHostForTab, sftpPaneClosedTabIdsRef, shouldMarkSessionActivity, sidePanelOpenTabs, splitHorizontalHandlersRef, splitVerticalHandlersRef, toggleScriptsSidePanelRef, toggleSidePanelRef, validAIScopeTargetIds, validSessionActivityIds, window, workspaceBroadcastHandlersRef, workspaceFocusHandlersRef, workspaceInnerRef, workspaces } = ctx;
|
||||
const { activeSidePanelTab, activeSidePanelLayout, activeTabId, activeTabIdRef, activeWorkspace, activityTrackedSessions, cancelAnimationFrame, ChunkedEscapeFilter, clearTopTabsPreviewVars, document, dropHint, effectiveHosts, filterTabsMap, focusedSessionId, getSessionActivityIdsToClear, handleToggleAiFromTopBar, handleToggleScriptsSidePanel, handleToggleSidePanel, hasNotifiableTerminalOutput, isComposeBarOpen, isFocusMode, isTerminalLayerVisible, lastSidePanelTabRef, Map, onConnectToHost, onSessionData, onSplitSessionRef, onToggleBroadcastRef, onToggleWorkspaceViewModeRef, prevFocusedSessionIdRef, refocusActiveTerminalSession, requestAnimationFrame, ResizeObserver, sessionActivityStore, sessions, Set, setAiMountedTabIds, setDropHint, setNotesMountedTabIds, setScriptsMountedTabIds, setSystemMountedTabIds, setSftpHostForTab, setSftpHostSourceSessionForTab, setSftpInitialLocationForTab, setSftpPendingUploadsForTab, setSidePanelOpenTabs, setSidePanelLayouts, setThemeMountedTabIds, setWorkspaceArea, shouldMeasureTerminalLayerLayout, sidePanelPosition, sidePanelHeight, sidePanelWidth, sftpActiveHost, sftpHostForTab, sftpPaneClosedTabIdsRef, shouldMarkSessionActivity, sidePanelOpenTabs, splitHorizontalHandlersRef, splitVerticalHandlersRef, toggleScriptsSidePanelRef, toggleSidePanelRef, validAIScopeTargetIds, validSessionActivityIds, window, workspaceBroadcastHandlersRef, workspaceFocusHandlersRef, workspaceInnerRef, workspaces } = ctx;
|
||||
|
||||
const activeWorkspaceId = activeWorkspace?.id;
|
||||
const activeWorkspaceViewMode = activeWorkspace?.viewMode;
|
||||
@@ -199,6 +200,13 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setSftpHostSourceSessionForTab((prev: Map<string, string>) => {
|
||||
let next = prev;
|
||||
for (const remap of remaps) {
|
||||
next = moveSidePanelTabMap(next, remap);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setSftpInitialLocationForTab((prev: Map<string, any>) => {
|
||||
let next = prev;
|
||||
for (const remap of remaps) {
|
||||
@@ -263,6 +271,7 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
|
||||
setNotesMountedTabIds,
|
||||
setScriptsMountedTabIds,
|
||||
setSftpHostForTab,
|
||||
setSftpHostSourceSessionForTab,
|
||||
setSftpInitialLocationForTab,
|
||||
setSftpPendingUploadsForTab,
|
||||
setSidePanelLayouts,
|
||||
@@ -406,6 +415,7 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
|
||||
useEffect(() => {
|
||||
setSidePanelOpenTabs(prev => filterTabsMap(prev, validAIScopeTargetIds));
|
||||
setSftpHostForTab(prev => filterTabsMap(prev, validAIScopeTargetIds));
|
||||
setSftpHostSourceSessionForTab(prev => filterTabsMap(prev, validAIScopeTargetIds));
|
||||
setSftpInitialLocationForTab(prev => filterTabsMap(prev, validAIScopeTargetIds));
|
||||
setSftpPendingUploadsForTab(prev => filterTabsMap(prev, validAIScopeTargetIds));
|
||||
setAiMountedTabIds((prev) => prev.filter((tabId) => validAIScopeTargetIds.has(tabId)));
|
||||
@@ -490,10 +500,7 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
|
||||
if (!activeTabId || !sftpActiveHost) return;
|
||||
if (!sidePanelLayoutHasTool(activeSidePanelLayout, 'sftp')) return;
|
||||
const stored = sftpHostForTab.get(activeTabId);
|
||||
if (stored?.id === sftpActiveHost.id
|
||||
&& stored?.hostname === sftpActiveHost.hostname
|
||||
&& stored?.port === sftpActiveHost.port
|
||||
&& stored?.protocol === sftpActiveHost.protocol) return;
|
||||
if (isSameSftpHostSelection(stored, sftpActiveHost)) return;
|
||||
setSftpHostForTab(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(activeTabId, sftpActiveHost);
|
||||
@@ -531,6 +538,11 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
|
||||
// Bump initialLocation even when the host is already selected so the
|
||||
// path-navigation effect re-runs after reopen.
|
||||
setSftpHostForTab((prev: Map<string, any>) => new Map(prev).set(tabId, host));
|
||||
setSftpHostSourceSessionForTab((prev: Map<string, string>) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(tabId);
|
||||
return next;
|
||||
});
|
||||
setSftpInitialLocationForTab((prev: Map<string, any>) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(tabId);
|
||||
@@ -657,7 +669,7 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
|
||||
};
|
||||
window.addEventListener('netcatty:open-sftp-transfer-target', handler);
|
||||
return () => window.removeEventListener('netcatty:open-sftp-transfer-target', handler);
|
||||
}, [activeTabIdRef, effectiveHosts, onConnectToHost, openPath, setSftpHostForTab, setSftpInitialLocationForTab, setSidePanelOpenTabs, sftpActiveHost, sftpHostForTab, window]);
|
||||
}, [activeTabIdRef, effectiveHosts, onConnectToHost, openPath, setSftpHostForTab, setSftpHostSourceSessionForTab, setSftpInitialLocationForTab, setSidePanelOpenTabs, sftpActiveHost, sftpHostForTab, window]);
|
||||
|
||||
useEffect(() => {
|
||||
const sessionIdsToClear = getSessionActivityIdsToClear(activeTabId, sessions);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { listPasswordAuthIdentities, resolvePasswordAuthSftpHost } from "./authIdentityPicker.ts";
|
||||
import type { Identity } from "./models.ts";
|
||||
import type { Host } from "./models.ts";
|
||||
|
||||
const passwordIdentity = (overrides: Partial<Identity> = {}): Identity => ({
|
||||
id: "id-1",
|
||||
label: "Default password",
|
||||
username: "root",
|
||||
authMethod: "password",
|
||||
password: "secret",
|
||||
created: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("keeps only password identities with a usable stored password", () => {
|
||||
const identities: Identity[] = [
|
||||
passwordIdentity(),
|
||||
passwordIdentity({ id: "id-2", label: "Password B", username: "deploy" }),
|
||||
passwordIdentity({ id: "id-3", authMethod: "key", keyId: "key-1" }),
|
||||
passwordIdentity({ id: "id-4", password: undefined }),
|
||||
passwordIdentity({ id: "id-5", password: "" }),
|
||||
passwordIdentity({ id: "id-6", password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==" }),
|
||||
passwordIdentity({ id: "id-7", username: "" }),
|
||||
passwordIdentity({ id: "id-8", username: " " }),
|
||||
];
|
||||
|
||||
const result = listPasswordAuthIdentities(identities);
|
||||
|
||||
assert.deepEqual(
|
||||
result.map((identity) => identity.id),
|
||||
["id-1", "id-2"],
|
||||
);
|
||||
});
|
||||
|
||||
test("tolerates missing identity lists", () => {
|
||||
assert.deepEqual(listPasswordAuthIdentities(undefined), []);
|
||||
assert.deepEqual(listPasswordAuthIdentities([]), []);
|
||||
});
|
||||
|
||||
test("temporary deploy identity carries into SFTP without changing the saved host", () => {
|
||||
const host = { id: "host-1", hostname: "example.test", username: "root", identityId: "root-id" } as Host;
|
||||
const identities = [
|
||||
passwordIdentity({ id: "root-id", password: "bad-root" }),
|
||||
passwordIdentity({ id: "deploy-id", username: "deploy", password: "good-deploy" }),
|
||||
];
|
||||
const auth = { authMethod: "password", username: "deploy", password: "good-deploy", savedToHost: false };
|
||||
assert.deepEqual(resolvePasswordAuthSftpHost(host, identities, auth), {
|
||||
...host, username: "deploy", identityId: "deploy-id",
|
||||
});
|
||||
assert.equal(host.username, "root");
|
||||
assert.equal(host.identityId, "root-id");
|
||||
assert.equal(resolvePasswordAuthSftpHost(host, identities, { ...auth, password: "manual" }), host);
|
||||
assert.equal(resolvePasswordAuthSftpHost(host, identities, { ...auth, savedToHost: true }), host);
|
||||
assert.equal(resolvePasswordAuthSftpHost(host, identities, null), host);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Host, Identity } from "./models";
|
||||
import { sanitizeCredentialValue } from "./credentials";
|
||||
|
||||
/**
|
||||
* Saved password identities that can be reused from the terminal
|
||||
* re-authentication dialog. Only identities carrying a usable stored
|
||||
* password and username qualify. Key identities are already selectable
|
||||
* through the existing SSH key picker (#3475). Undecryptable vault ciphertext
|
||||
* (`enc:v1:` placeholders left when hydration fails) is filtered out so
|
||||
* those identities fall back to manual password re-entry.
|
||||
*/
|
||||
export const listPasswordAuthIdentities = (
|
||||
identities: readonly Identity[] | undefined,
|
||||
): Identity[] =>
|
||||
(identities ?? []).filter(
|
||||
(identity) =>
|
||||
identity.authMethod === "password"
|
||||
&& typeof identity.username === "string"
|
||||
&& identity.username.trim().length > 0
|
||||
&& typeof identity.password === "string"
|
||||
&& (sanitizeCredentialValue(identity.password) ?? "").length > 0,
|
||||
);
|
||||
|
||||
/** Use the temporary saved identity for SFTP opened from its authenticated terminal. */
|
||||
export const resolvePasswordAuthSftpHost = (
|
||||
host: Host,
|
||||
identities: readonly Identity[] | undefined,
|
||||
auth: { authMethod: string; username: string; password?: string; savedToHost?: boolean } | null,
|
||||
): Host => {
|
||||
if (!auth || auth.authMethod !== "password" || auth.savedToHost) return host;
|
||||
const identity = listPasswordAuthIdentities(identities).find(
|
||||
(candidate) => candidate.username === auth.username && candidate.password === auth.password,
|
||||
);
|
||||
return identity
|
||||
? { ...host, username: identity.username, identityId: identity.id }
|
||||
: host;
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { Host } from "./models.ts";
|
||||
import { isSameSftpHostSelection, resolveTerminalSftpHost } from "./sftpTerminalIdentity.ts";
|
||||
|
||||
const host = (id: string, username: string): Host => ({
|
||||
id, label: id, hostname: `${id}.example.test`, port: 22, username,
|
||||
tags: [], os: "linux",
|
||||
});
|
||||
|
||||
test("SFTP target changes when a focused session uses another saved identity", () => {
|
||||
const root = host("shared", "root");
|
||||
const deploy = { ...root, username: "deploy", identityId: "deploy-id" };
|
||||
const alternateDeploy = { ...deploy, identityId: "alternate-deploy-id" };
|
||||
|
||||
assert.equal(isSameSftpHostSelection(root, deploy), false);
|
||||
assert.equal(isSameSftpHostSelection(deploy, alternateDeploy), false);
|
||||
assert.equal(isSameSftpHostSelection(deploy, { ...deploy }), true);
|
||||
assert.equal(isSameSftpHostSelection(deploy, { ...deploy, port: 2222 }), false);
|
||||
});
|
||||
|
||||
test("SFTP follows each focused terminal's authenticated identity across focus changes", () => {
|
||||
const root = host("shared", "root");
|
||||
const deploy = { ...root, username: "deploy", identityId: "deploy-id" };
|
||||
const other = host("other", "alice");
|
||||
|
||||
assert.equal(resolveTerminalSftpHost({
|
||||
sessionHost: root, storedHost: other, authenticatedHost: deploy, followsTerminal: true,
|
||||
}), deploy);
|
||||
assert.equal(resolveTerminalSftpHost({
|
||||
sessionHost: root, storedHost: root, authenticatedHost: deploy, followsTerminal: true,
|
||||
}), deploy);
|
||||
assert.equal(resolveTerminalSftpHost({
|
||||
sessionHost: other, storedHost: deploy, authenticatedHost: undefined, followsTerminal: true,
|
||||
}), other);
|
||||
assert.equal(resolveTerminalSftpHost({
|
||||
sessionHost: root, storedHost: other, authenticatedHost: deploy, followsTerminal: true,
|
||||
}), deploy);
|
||||
assert.equal(resolveTerminalSftpHost({
|
||||
sessionHost: root, storedHost: other, authenticatedHost: deploy, followsTerminal: false,
|
||||
}), root);
|
||||
assert.equal(resolveTerminalSftpHost({
|
||||
sessionHost: root, authenticatedHost: { ...deploy, port: 2222 }, followsTerminal: true,
|
||||
}), root);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Host } from "./models";
|
||||
|
||||
/** Compare the selected SFTP target, including the temporary login identity. */
|
||||
export function isSameSftpHostSelection(left: Host | null | undefined, right: Host | null | undefined): boolean {
|
||||
if (!left || !right) return false;
|
||||
return left.id === right.id
|
||||
&& left.hostname === right.hostname
|
||||
&& left.port === right.port
|
||||
&& left.protocol === right.protocol
|
||||
&& left.username === right.username
|
||||
&& left.identityId === right.identityId
|
||||
&& left.sftpSudo === right.sftpSudo
|
||||
&& (left.sftpFileProtocol || "auto") === (right.sftpFileProtocol || "auto");
|
||||
}
|
||||
|
||||
/** Keep a terminal's authenticated identity when its SFTP panel follows that terminal. */
|
||||
export function resolveTerminalSftpHost({
|
||||
sessionHost,
|
||||
storedHost,
|
||||
authenticatedHost,
|
||||
followsTerminal,
|
||||
}: {
|
||||
sessionHost?: Host | null;
|
||||
storedHost?: Host | null;
|
||||
authenticatedHost?: Host | null;
|
||||
followsTerminal: boolean;
|
||||
}): Host | null {
|
||||
if (followsTerminal && sessionHost && authenticatedHost
|
||||
&& authenticatedHost.id === sessionHost.id
|
||||
&& authenticatedHost.hostname === sessionHost.hostname
|
||||
&& (authenticatedHost.port || 22) === (sessionHost.port || 22)) {
|
||||
return authenticatedHost;
|
||||
}
|
||||
return sessionHost ?? storedHost ?? null;
|
||||
}
|
||||
@@ -11,7 +11,7 @@ const pool = require("./sshConnectionPool.cjs");
|
||||
|
||||
const PASSWORD = "temporary-test-password";
|
||||
|
||||
async function fixture(t) {
|
||||
async function fixture(t, acceptedUsername = "root") {
|
||||
pool.resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
||||
const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
||||
const sockets = new Set();
|
||||
@@ -25,7 +25,7 @@ async function fixture(t) {
|
||||
client.on("authentication", (ctx) => {
|
||||
if (ctx.method === "password") {
|
||||
authRounds += 1;
|
||||
if (ctx.username === "root" && ctx.password === PASSWORD) {
|
||||
if (ctx.username === acceptedUsername && ctx.password === PASSWORD) {
|
||||
ctx.accept();
|
||||
return;
|
||||
}
|
||||
@@ -97,10 +97,27 @@ async function fixture(t) {
|
||||
assert.equal(authRounds, 1);
|
||||
return options;
|
||||
};
|
||||
return { original, event, login, sessions, sftpClients,
|
||||
return { original, event, login, sessions, sftpClients, handlers,
|
||||
counts: () => ({ authRounds, connections }) };
|
||||
}
|
||||
|
||||
test("selected deploy identity opens SFTP on the live deploy session, never root", { timeout: 60000 }, async (t) => {
|
||||
const f = await fixture(t, "deploy");
|
||||
const deploy = { ...f.original, username: "deploy", password: PASSWORD };
|
||||
await f.handlers.get("netcatty:start")(f.event, {
|
||||
...deploy, sessionId: "terminal-3351", skipShellPidDiscovery: true,
|
||||
sftpReuseOptions: f.original,
|
||||
});
|
||||
await assert.rejects(sftpBridge.openSftpForSession(f.event, {
|
||||
sessionId: "terminal-3351", expectedEndpoint: f.original, fileProtocol: "sftp",
|
||||
}), { code: "ERR_SFTP_SOURCE_ROUTE_MISMATCH" });
|
||||
const result = await sftpBridge.openSftpForSession(f.event, {
|
||||
sessionId: "terminal-3351", expectedEndpoint: deploy, fileProtocol: "sftp",
|
||||
});
|
||||
assert.equal(await f.sftpClients.get(result.sftpId).realPath("."), "/root");
|
||||
assert.deepEqual(f.counts(), { authRounds: 1, connections: 1 });
|
||||
});
|
||||
|
||||
test("unsaved terminal password supports pooled and explicit SFTP without another login (#3351)", { timeout: 60000 }, async (t) => {
|
||||
const f = await fixture(t);
|
||||
const options = await f.login();
|
||||
|
||||
Reference in new issue
Block a user