diff --git a/packages/frontend/src/components/BatchEditConnectionForm.vue b/packages/frontend/src/components/BatchEditConnectionForm.vue
index f6d5b98..873d984 100644
--- a/packages/frontend/src/components/BatchEditConnectionForm.vue
+++ b/packages/frontend/src/components/BatchEditConnectionForm.vue
@@ -7,6 +7,7 @@ import { useUiNotificationsStore } from '../stores/uiNotifications.store';
import { useProxiesStore } from '../stores/proxies.store';
import { useTagsStore, type TagInfo } from '../stores/tags.store';
import { useSshKeysStore, type SshKeyBasicInfo } from '../stores/sshKeys.store';
+import { useLoginCredentialsStore, type LoginCredentialBasicInfo } from '../stores/loginCredentials.store';
import TagInput from './TagInput.vue';
interface BatchUpdateData {
@@ -14,6 +15,7 @@ interface BatchUpdateData {
username?: string | null;
password?: string | null;
ssh_key_id?: number | null;
+ login_credential_id?: number | null;
proxy_id?: number | null;
tag_ids?: number[];
notes?: string | null;
@@ -38,6 +40,7 @@ const uiNotificationsStore = useUiNotificationsStore();
const proxiesStore = useProxiesStore();
const tagsStore = useTagsStore();
const sshKeysStore = useSshKeysStore();
+const loginCredentialsStore = useLoginCredentialsStore();
const internalVisible = ref(props.visible);
const isLoading = ref(false);
@@ -51,6 +54,14 @@ const enableAdvancedEdit = ref(false);
const availableTags = computed(() => tagsStore.tags as TagInfo[]);
const availableProxies = computed(() => proxiesStore.proxies);
const availableSshKeys = computed(() => sshKeysStore.sshKeys as SshKeyBasicInfo[]);
+const selectedConnections = computed(() => connectionsStore.connections.filter((conn) => props.connectionIds.includes(conn.id)));
+const selectedConnectionTypes = computed(() => Array.from(new Set(selectedConnections.value.map((conn) => conn.type))));
+const availableLoginCredentials = computed(() => {
+ if (selectedConnectionTypes.value.length !== 1) {
+ return [] as LoginCredentialBasicInfo[];
+ }
+ return loginCredentialsStore.loginCredentials.filter((credential) => credential.type === selectedConnectionTypes.value[0]);
+});
watch(() => props.visible, (newVal) => {
internalVisible.value = newVal;
@@ -60,6 +71,7 @@ watch(() => props.visible, (newVal) => {
username: undefined,
password: undefined,
ssh_key_id: undefined,
+ login_credential_id: undefined,
proxy_id: undefined,
tag_ids: undefined,
notes: undefined, // Keep notes initialization
@@ -78,6 +90,9 @@ watch(() => props.visible, (newVal) => {
if (availableSshKeys.value.length === 0 && !sshKeysStore.isLoading) {
sshKeysStore.fetchSshKeys();
}
+ if (loginCredentialsStore.loginCredentials.length === 0 && !loginCredentialsStore.isLoading) {
+ loginCredentialsStore.fetchLoginCredentials();
+ }
}
});
@@ -118,6 +133,14 @@ const handleSave = async () => {
if (formData.value.ssh_key_id !== undefined) {
updatesToApply.ssh_key_id = formData.value.ssh_key_id;
}
+ if (formData.value.login_credential_id !== undefined) {
+ if (selectedConnectionTypes.value.length !== 1 && formData.value.login_credential_id !== null) {
+ uiNotificationsStore.addNotification({ message: t('connections.batchEdit.savedCredentialMixedType', '批量应用已保存凭证前,请先只选择同一种连接类型。'), type: 'warning' });
+ isLoading.value = false;
+ return;
+ }
+ updatesToApply.login_credential_id = formData.value.login_credential_id;
+ }
}
if (enableAdvancedEdit.value) {
@@ -199,6 +222,9 @@ onMounted(() => {
if (availableSshKeys.value.length === 0 && !sshKeysStore.isLoading) {
sshKeysStore.fetchSshKeys();
}
+ if (loginCredentialsStore.loginCredentials.length === 0 && !loginCredentialsStore.isLoading) {
+ loginCredentialsStore.fetchLoginCredentials();
+ }
}
});
@@ -264,6 +290,22 @@ onMounted(() => {
+
+
+
+
@@ -333,4 +375,4 @@ onMounted(() => {
-
\ No newline at end of file
+
diff --git a/packages/frontend/src/components/LoginCredentialManagementModal.vue b/packages/frontend/src/components/LoginCredentialManagementModal.vue
index bcdbe48..3e05dab 100644
--- a/packages/frontend/src/components/LoginCredentialManagementModal.vue
+++ b/packages/frontend/src/components/LoginCredentialManagementModal.vue
@@ -304,7 +304,10 @@ const cancelForm = () => {
-
+ formData.ssh_key_id = value"
+ />
diff --git a/packages/frontend/src/composables/useAddConnectionForm.ts b/packages/frontend/src/composables/useAddConnectionForm.ts
index 0253500..c4b4bf2 100644
--- a/packages/frontend/src/composables/useAddConnectionForm.ts
+++ b/packages/frontend/src/composables/useAddConnectionForm.ts
@@ -612,7 +612,13 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon
const availableTagIds = tags.value.map(t_ => t_.id);
const currentSelectedValidTagIds = formData.tag_ids.filter(id => availableTagIds.includes(id));
- if (!formData.host || !formData.username) {
+ const usingSavedCredential = formData.credential_source === 'saved';
+
+ if (!formData.host) {
+ uiNotificationsStore.showError(t('connections.form.errorRequiredFields'));
+ return;
+ }
+ if (!usingSavedCredential && !formData.username) {
uiNotificationsStore.showError(t('connections.form.errorRequiredFields'));
return;
}
@@ -621,7 +627,12 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon
return;
}
- if (formData.type === 'SSH') {
+ if (usingSavedCredential && !formData.login_credential_id) {
+ uiNotificationsStore.showError(t('connections.form.errorLoginCredentialRequired', '请选择已保存的登录凭证。'));
+ return;
+ }
+
+ if (!usingSavedCredential && formData.type === 'SSH') {
if (!isEditMode.value) {
if (formData.auth_method === 'password' && !formData.password && !formData.host.includes('~')) {
uiNotificationsStore.showError(t('connections.form.errorPasswordRequired'));
@@ -641,12 +652,12 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon
return;
}
}
- } else if (formData.type === 'RDP') {
+ } else if (!usingSavedCredential && formData.type === 'RDP') {
if (!isEditMode.value && !formData.password && !formData.host.includes('~')) {
uiNotificationsStore.showError(t('connections.form.errorPasswordRequired'));
return;
}
- } else if (formData.type === 'VNC') {
+ } else if (!usingSavedCredential && formData.type === 'VNC') {
if (!isEditMode.value && !formData.vncPassword && !formData.host.includes('~')) {
uiNotificationsStore.showError(t('connections.form.errorVncPasswordRequired', 'VNC 密码是必填项。'));
return;
@@ -658,19 +669,23 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon
if (Array.isArray(parsedIpsResult)) {
const ips = parsedIpsResult;
- if (formData.type === 'SSH' && formData.auth_method === 'key' && !formData.selected_ssh_key_id) {
+ if (usingSavedCredential && !formData.login_credential_id) {
+ uiNotificationsStore.showError(t('connections.form.errorLoginCredentialRequired', '请选择已保存的登录凭证。'));
+ return;
+ }
+ if (!usingSavedCredential && formData.type === 'SSH' && formData.auth_method === 'key' && !formData.selected_ssh_key_id) {
uiNotificationsStore.showError(t('connections.form.errorSshKeyRequiredForBatch', '批量添加 SSH (密钥认证) 连接时,必须选择一个 SSH 密钥。'));
return;
}
- if (formData.type === 'SSH' && formData.auth_method === 'password' && !formData.password) {
+ if (!usingSavedCredential && formData.type === 'SSH' && formData.auth_method === 'password' && !formData.password) {
uiNotificationsStore.showError(t('connections.form.errorPasswordRequiredForBatchSSH', '批量添加 SSH (密码认证) 连接时,必须提供密码。'));
return;
}
- if (formData.type === 'RDP' && !formData.password) {
+ if (!usingSavedCredential && formData.type === 'RDP' && !formData.password) {
uiNotificationsStore.showError(t('connections.form.errorPasswordRequiredForBatchRDP', '批量添加 RDP 连接时,必须提供密码。'));
return;
}
- if (formData.type === 'VNC' && !formData.vncPassword) {
+ if (!usingSavedCredential && formData.type === 'VNC' && !formData.vncPassword) {
uiNotificationsStore.showError(t('connections.form.errorPasswordRequiredForBatchVNC', '批量添加 VNC 连接时,必须提供 VNC 密码。'));
return;
}
@@ -689,23 +704,24 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon
host: currentIp,
port: formData.port,
username: formData.username,
+ login_credential_id: usingSavedCredential ? formData.login_credential_id : null,
notes: formData.notes,
proxy_id: formData.proxy_id || null,
tag_ids: currentSelectedValidTagIds,
proxy_type: formData.proxy_type,
};
- if (formData.type === 'SSH') {
+ if (!usingSavedCredential && formData.type === 'SSH') {
dataForThisIp.auth_method = formData.auth_method;
if (formData.auth_method === 'password') {
dataForThisIp.password = formData.password;
} else if (formData.auth_method === 'key') {
dataForThisIp.ssh_key_id = formData.selected_ssh_key_id;
}
- } else if (formData.type === 'RDP') {
+ } else if (!usingSavedCredential && formData.type === 'RDP') {
dataForThisIp.password = formData.password;
delete dataForThisIp.auth_method;
- } else if (formData.type === 'VNC') {
+ } else if (!usingSavedCredential && formData.type === 'VNC') {
dataForThisIp.password = formData.vncPassword;
delete dataForThisIp.auth_method;
}
@@ -755,13 +771,14 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon
port: formData.port,
notes: formData.notes,
username: formData.username,
+ login_credential_id: usingSavedCredential ? formData.login_credential_id : null,
proxy_id: formData.proxy_id || null,
proxy_type: formData.proxy_type,
tag_ids: currentSelectedValidTagIds,
jump_chain: formData.jump_chain ? JSON.parse(JSON.stringify(formData.jump_chain)) : null,
};
- if (formData.type === 'SSH') {
+ if (!usingSavedCredential && formData.type === 'SSH') {
dataToSend.auth_method = formData.auth_method;
if (formData.auth_method === 'password') {
if (formData.password) dataToSend.password = formData.password;
@@ -770,10 +787,10 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon
dataToSend.ssh_key_id = formData.selected_ssh_key_id;
}
}
- } else if (formData.type === 'RDP') {
+ } else if (!usingSavedCredential && formData.type === 'RDP') {
if (formData.password) dataToSend.password = formData.password;
delete dataToSend.auth_method;
- } else if (formData.type === 'VNC') {
+ } else if (!usingSavedCredential && formData.type === 'VNC') {
if (formData.vncPassword) dataToSend.password = formData.vncPassword;
delete dataToSend.auth_method;
}
@@ -866,16 +883,20 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon
auth_method: formData.auth_method,
password: formData.auth_method === 'password' ? formData.password : undefined,
proxy_id: formData.proxy_id || null,
+ login_credential_id: formData.credential_source === 'saved' ? formData.login_credential_id : null,
ssh_key_id: formData.auth_method === 'key' ? formData.selected_ssh_key_id : undefined,
};
- if (!dataToSend.host || !dataToSend.port || !dataToSend.username || !dataToSend.auth_method) {
+ if (!dataToSend.host || !dataToSend.port) {
throw new Error(t('connections.test.errorMissingFields'));
}
- if (dataToSend.auth_method === 'password' && !formData.password) {
+ if (formData.credential_source !== 'saved' && (!dataToSend.username || !dataToSend.auth_method)) {
+ throw new Error(t('connections.test.errorMissingFields'));
+ }
+ if (formData.credential_source !== 'saved' && dataToSend.auth_method === 'password' && !formData.password) {
throw new Error(t('connections.form.errorPasswordRequired'));
}
- if (dataToSend.auth_method === 'key' && !dataToSend.ssh_key_id) {
+ if (formData.credential_source !== 'saved' && dataToSend.auth_method === 'key' && !dataToSend.ssh_key_id) {
throw new Error(t('connections.form.errorSshKeyRequired'));
}
response = await apiClient.post('/connections/test-unsaved', dataToSend);
diff --git a/packages/frontend/src/locales/en-US.json b/packages/frontend/src/locales/en-US.json
index b0fb2bb..ab7c110 100644
--- a/packages/frontend/src/locales/en-US.json
+++ b/packages/frontend/src/locales/en-US.json
@@ -215,6 +215,8 @@
"title": "Batch Edit Connections",
"editSelected": "Edit Selected",
"noChange":"No change",
+ "savedCredentialMixedType": "Select only one connection type before applying a saved credential in batch.",
+ "savedCredentialTypeHint": "Batch apply works only for the same connection type",
"selectedItems":"{count} items selected",
"deleteSelectedButton": "Delete Selected",
"deleteSelectedTooltip": "Delete selected connections",
@@ -273,6 +275,23 @@
"connectionModeProxy": "Proxy Server",
"connectionModeJumpHost": "Jump Host",
"connectionType": "Connection Type:",
+ "credentialSource": "Credential Source",
+ "credentialSourceDirect": "Account / Password / Key",
+ "credentialSourceSaved": "Use Saved Credential",
+ "savedLoginCredential": "Login Credential",
+ "selectLoginCredential": "Select a saved credential",
+ "manageLoginCredentials": "Manage Login Credentials",
+ "savedCredentialHint": "Saved credentials take priority during test and connect. You can switch back to direct input anytime.",
+ "loginCredentialManager": "Login Credentials",
+ "addLoginCredential": "Add Login Credential",
+ "editLoginCredential": "Edit Login Credential",
+ "noLoginCredentials": "No saved login credentials yet",
+ "clearSavedCredential": "Clear saved credential",
+ "errorLoginCredentialRequired": "Please select a saved login credential.",
+ "errorCredentialRequiredFields": "Name and username are required.",
+ "errorCredentialDetails": "Failed to load login credential details.",
+ "confirmDeleteCredential": "Delete login credential {name}?",
+ "errorDeleteCredential": "Failed to delete login credential.",
"typeSsh": "SSH",
"typeRdp": "RDP",
"typeVnc": "VNC",
diff --git a/packages/frontend/src/locales/ja-JP.json b/packages/frontend/src/locales/ja-JP.json
index 90c5197..a28e1d3 100644
--- a/packages/frontend/src/locales/ja-JP.json
+++ b/packages/frontend/src/locales/ja-JP.json
@@ -213,6 +213,23 @@
"errorPasswordRequiredForBatchSSH": "SSH (パスワード認証) 接続を一括追加する場合、パスワードを提供する必要があります。",
"errorPasswordRequiredForBatchRDP": "RDP接続を一括追加する場合、パスワードを提供する必要があります。",
"errorPasswordRequiredForBatchVNC": "VNC接続を一括追加する場合、VNCパスワードを提供する必要があります。",
+ "credentialSource": "認証ソース",
+ "credentialSourceDirect": "アカウント / パスワード / キー",
+ "credentialSourceSaved": "保存済み認証情報を使用",
+ "savedLoginCredential": "ログイン認証情報",
+ "selectLoginCredential": "ログイン認証情報を選択してください",
+ "manageLoginCredentials": "ログイン認証情報を管理",
+ "savedCredentialHint": "保存済み認証情報はテストと接続時に優先して使用されます。いつでも直接入力へ戻せます。",
+ "loginCredentialManager": "ログイン認証情報",
+ "addLoginCredential": "ログイン認証情報を追加",
+ "editLoginCredential": "ログイン認証情報を編集",
+ "noLoginCredentials": "保存済みのログイン認証情報はありません",
+ "clearSavedCredential": "保存済み認証情報を解除",
+ "errorLoginCredentialRequired": "保存済みのログイン認証情報を選択してください。",
+ "errorCredentialRequiredFields": "名前とユーザー名は必須です。",
+ "errorCredentialDetails": "ログイン認証情報の詳細取得に失敗しました。",
+ "confirmDeleteCredential": "ログイン認証情報 {name} を削除しますか?",
+ "errorDeleteCredential": "ログイン認証情報の削除に失敗しました。",
"errorBatchAddResult": "一括追加: {successCount} 件成功, {errorCount} 件失敗。最初のエラー: {firstErrorEncountered}",
"successBatchAddResult": "一括追加成功: {successCount} 件の接続が作成されました。",
"errorIpRangeNotAllowedInEditMode": "編集モードではIP範囲はサポートされていません。単一のIPアドレスを使用してください。",
diff --git a/packages/frontend/src/locales/zh-CN.json b/packages/frontend/src/locales/zh-CN.json
index 29a9e6f..7f25552 100644
--- a/packages/frontend/src/locales/zh-CN.json
+++ b/packages/frontend/src/locales/zh-CN.json
@@ -217,6 +217,8 @@
"title": "批量编辑连接",
"selectedItems": "已选项目",
"noChange": "保持不变",
+ "savedCredentialMixedType": "批量应用已保存凭证前,请先只选择同一种连接类型。",
+ "savedCredentialTypeHint": "仅支持同类型连接批量应用",
"deleteSelectedButton": "删除选中",
"deleteSelectedTooltip": "删除选中的连接",
"confirmMessage": "您确定要删除选中的 {count} 个连接吗?此操作无法撤销。",
@@ -273,6 +275,23 @@
"connectionModeProxy": "代理服务器",
"connectionModeJumpHost": "跳板机",
"connectionType": "连接类型",
+ "credentialSource": "认证来源",
+ "credentialSourceDirect": "账号密码 / 密钥",
+ "credentialSourceSaved": "使用已保存凭证",
+ "savedLoginCredential": "登录凭证",
+ "selectLoginCredential": "请选择登录凭证",
+ "manageLoginCredentials": "管理登录凭证",
+ "savedCredentialHint": "已保存凭证会在连接和测试时优先使用;切回直填后仍可继续手工输入。",
+ "loginCredentialManager": "登录凭证",
+ "addLoginCredential": "新增登录凭证",
+ "editLoginCredential": "编辑登录凭证",
+ "noLoginCredentials": "暂无登录凭证",
+ "clearSavedCredential": "取消已保存凭证",
+ "errorLoginCredentialRequired": "请选择已保存的登录凭证。",
+ "errorCredentialRequiredFields": "名称和用户名为必填项。",
+ "errorCredentialDetails": "获取登录凭证详情失败。",
+ "confirmDeleteCredential": "确认删除登录凭证 {name}?",
+ "errorDeleteCredential": "删除登录凭证失败。",
"typeSsh": "SSH",
"typeRdp": "RDP",
"typeVnc": "VNC",
diff --git a/packages/frontend/src/stores/connections.store.ts b/packages/frontend/src/stores/connections.store.ts
index a492a04..fe1c200 100644
--- a/packages/frontend/src/stores/connections.store.ts
+++ b/packages/frontend/src/stores/connections.store.ts
@@ -10,6 +10,7 @@ export interface ConnectionInfo {
port: number;
username: string;
auth_method: 'password' | 'key';
+ login_credential_id?: number | null;
proxy_id?: number | null; // 关联的代理 ID (可选)
proxy_type?: 'proxy' | 'jump' | null;
tag_ids?: number[]; // 关联的标签 ID 数组 (可选)
@@ -95,6 +96,7 @@ export const useConnectionsStore = defineStore('connections', {
port: number;
username: string;
auth_method: 'password' | 'key'; // SSH specific
+ login_credential_id?: number | null;
password?: string; // SSH password or general password
private_key?: string; // SSH specific
passphrase?: string; // SSH specific
@@ -129,7 +131,7 @@ export const useConnectionsStore = defineStore('connections', {
// 更新连接 Action
// 更新参数类型以包含 proxy_id 和 tag_ids
// Update parameter type to include 'type' and VNC fields
- async updateConnection(connectionId: number, updatedData: Partial & { type?: 'SSH' | 'RDP' | 'VNC'; password?: string; private_key?: string; passphrase?: string; vncPassword?: string; proxy_id?: number | null; proxy_type?: 'proxy' | 'jump' | null; tag_ids?: number[]; jump_chain?: number[] | null; }>) {
+ async updateConnection(connectionId: number, updatedData: Partial & { type?: 'SSH' | 'RDP' | 'VNC'; password?: string; private_key?: string; passphrase?: string; vncPassword?: string; proxy_id?: number | null; proxy_type?: 'proxy' | 'jump' | null; tag_ids?: number[]; jump_chain?: number[] | null; login_credential_id?: number | null; }>) {
this.isLoading = true;
this.error = null;
try {
diff --git a/packages/frontend/src/views/ConnectionsView.vue b/packages/frontend/src/views/ConnectionsView.vue
index 43fbfbe..66be40d 100644
--- a/packages/frontend/src/views/ConnectionsView.vue
+++ b/packages/frontend/src/views/ConnectionsView.vue
@@ -2,6 +2,7 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import AddConnectionForm from '../components/AddConnectionForm.vue';
import BatchEditConnectionForm from '../components/BatchEditConnectionForm.vue';
+import LoginCredentialManagementModal from '../components/LoginCredentialManagementModal.vue';
import { useConnectionsStore } from '../stores/connections.store';
import { useSessionStore } from '../stores/session.store';
import { useTagsStore } from '../stores/tags.store';
@@ -89,6 +90,7 @@ const tagsSectionExpanded = ref(true);
const showAddEditConnectionForm = ref(false);
const connectionToEdit = ref(null);
+const showLoginCredentialManagement = ref(false);
const isBatchEditMode = ref(false);
const selectedConnectionIdsForBatch = ref>(new Set());
@@ -1287,6 +1289,14 @@ onBeforeUnmount(() => {
{{ t('connections.addConnection', '新增连接') }}
+