update
This commit is contained in:
@@ -76,6 +76,7 @@ export const createConnectionsTableSQL = `
|
|||||||
CREATE TABLE IF NOT EXISTS connections (
|
CREATE TABLE IF NOT EXISTS connections (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NULL, -- 允许 name 为空
|
name TEXT NULL, -- 允许 name 为空
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('SSH', 'RDP')) DEFAULT 'SSH',
|
||||||
host TEXT NOT NULL,
|
host TEXT NOT NULL,
|
||||||
port INTEGER NOT NULL,
|
port INTEGER NOT NULL,
|
||||||
username TEXT NOT NULL,
|
username TEXT NOT NULL,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/conn
|
|||||||
interface ConnectionBase {
|
interface ConnectionBase {
|
||||||
id: number;
|
id: number;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
|
type: 'SSH' | 'RDP'; // Add type field
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -17,15 +18,18 @@ interface ConnectionBase {
|
|||||||
last_connected_at: number | null;
|
last_connected_at: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConnectionWithTagsRow implicitly includes 'type' via ConnectionBase
|
||||||
interface ConnectionWithTagsRow extends ConnectionBase {
|
interface ConnectionWithTagsRow extends ConnectionBase {
|
||||||
tag_ids_str: string | null;
|
tag_ids_str: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConnectionWithTags implicitly includes 'type' via ConnectionBase
|
||||||
export interface ConnectionWithTags extends ConnectionBase {
|
export interface ConnectionWithTags extends ConnectionBase {
|
||||||
tag_ids: number[];
|
tag_ids: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 包含加密字段的完整类型,用于插入/更新
|
// 包含加密字段的完整类型,用于插入/更新
|
||||||
|
// FullConnectionData implicitly includes 'type' via ConnectionBase
|
||||||
export interface FullConnectionData extends ConnectionBase {
|
export interface FullConnectionData extends ConnectionBase {
|
||||||
encrypted_password?: string | null;
|
encrypted_password?: string | null;
|
||||||
encrypted_private_key?: string | null;
|
encrypted_private_key?: string | null;
|
||||||
@@ -34,6 +38,7 @@ export interface FullConnectionData extends ConnectionBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// FullConnectionDbRow implicitly includes 'type' via FullConnectionData
|
||||||
interface FullConnectionDbRow extends FullConnectionData {
|
interface FullConnectionDbRow extends FullConnectionData {
|
||||||
proxy_db_id: number | null;
|
proxy_db_id: number | null;
|
||||||
proxy_name: string | null;
|
proxy_name: string | null;
|
||||||
@@ -53,7 +58,7 @@ interface FullConnectionDbRow extends FullConnectionData {
|
|||||||
export const findAllConnectionsWithTags = async (): Promise<ConnectionWithTags[]> => {
|
export const findAllConnectionsWithTags = async (): Promise<ConnectionWithTags[]> => {
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT
|
SELECT
|
||||||
c.id, c.name, c.host, c.port, c.username, c.auth_method, c.proxy_id,
|
c.id, c.name, c.type, c.host, c.port, c.username, c.auth_method, c.proxy_id,
|
||||||
c.created_at, c.updated_at, c.last_connected_at,
|
c.created_at, c.updated_at, c.last_connected_at,
|
||||||
GROUP_CONCAT(ct.tag_id) as tag_ids_str
|
GROUP_CONCAT(ct.tag_id) as tag_ids_str
|
||||||
FROM connections c
|
FROM connections c
|
||||||
@@ -79,7 +84,7 @@ export const findAllConnectionsWithTags = async (): Promise<ConnectionWithTags[]
|
|||||||
export const findConnectionByIdWithTags = async (id: number): Promise<ConnectionWithTags | null> => {
|
export const findConnectionByIdWithTags = async (id: number): Promise<ConnectionWithTags | null> => {
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT
|
SELECT
|
||||||
c.id, c.name, c.host, c.port, c.username, c.auth_method, c.proxy_id,
|
c.id, c.name, c.type, c.host, c.port, c.username, c.auth_method, c.proxy_id,
|
||||||
c.created_at, c.updated_at, c.last_connected_at,
|
c.created_at, c.updated_at, c.last_connected_at,
|
||||||
GROUP_CONCAT(ct.tag_id) as tag_ids_str
|
GROUP_CONCAT(ct.tag_id) as tag_ids_str
|
||||||
FROM connections c
|
FROM connections c
|
||||||
@@ -132,13 +137,15 @@ export const findFullConnectionById = async (id: number): Promise<FullConnection
|
|||||||
/**
|
/**
|
||||||
* 创建新连接 (不处理标签)
|
* 创建新连接 (不处理标签)
|
||||||
*/
|
*/
|
||||||
|
// Update input type to reflect FullConnectionData now has 'type'
|
||||||
export const createConnection = async (data: Omit<FullConnectionData, 'id' | 'created_at' | 'updated_at' | 'last_connected_at' | 'tag_ids'>): Promise<number> => {
|
export const createConnection = async (data: Omit<FullConnectionData, 'id' | 'created_at' | 'updated_at' | 'last_connected_at' | 'tag_ids'>): Promise<number> => {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const sql = `
|
const sql = `
|
||||||
INSERT INTO connections (name, host, port, username, auth_method, encrypted_password, encrypted_private_key, encrypted_passphrase, proxy_id, created_at, updated_at)
|
INSERT INTO connections (name, type, host, port, username, auth_method, encrypted_password, encrypted_private_key, encrypted_passphrase, proxy_id, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`; // Add type column and placeholder
|
||||||
const params = [
|
const params = [
|
||||||
data.name ?? null,
|
data.name ?? null,
|
||||||
|
data.type, // Add type parameter
|
||||||
data.host, data.port, data.username, data.auth_method,
|
data.host, data.port, data.username, data.auth_method,
|
||||||
data.encrypted_password ?? null, data.encrypted_private_key ?? null, data.encrypted_passphrase ?? null,
|
data.encrypted_password ?? null, data.encrypted_private_key ?? null, data.encrypted_passphrase ?? null,
|
||||||
data.proxy_id ?? null,
|
data.proxy_id ?? null,
|
||||||
@@ -160,6 +167,7 @@ export const createConnection = async (data: Omit<FullConnectionData, 'id' | 'cr
|
|||||||
/**
|
/**
|
||||||
* 更新连接信息 (不处理标签)
|
* 更新连接信息 (不处理标签)
|
||||||
*/
|
*/
|
||||||
|
// Update input type to reflect FullConnectionData now has 'type'
|
||||||
export const updateConnection = async (id: number, data: Partial<Omit<FullConnectionData, 'id' | 'created_at' | 'last_connected_at' | 'tag_ids'>>): Promise<boolean> => {
|
export const updateConnection = async (id: number, data: Partial<Omit<FullConnectionData, 'id' | 'created_at' | 'last_connected_at' | 'tag_ids'>>): Promise<boolean> => {
|
||||||
const fieldsToUpdate: { [key: string]: any } = { ...data };
|
const fieldsToUpdate: { [key: string]: any } = { ...data };
|
||||||
const params: any[] = [];
|
const params: any[] = [];
|
||||||
@@ -270,17 +278,18 @@ export const updateConnectionTags = async (connectionId: number, tagIds: number[
|
|||||||
*/
|
*/
|
||||||
export const bulkInsertConnections = async (
|
export const bulkInsertConnections = async (
|
||||||
db: Database,
|
db: Database,
|
||||||
|
// Update input type to reflect FullConnectionData now has 'type'
|
||||||
connections: Array<Omit<FullConnectionData, 'id' | 'created_at' | 'updated_at' | 'last_connected_at'> & { tag_ids?: number[] }>
|
connections: Array<Omit<FullConnectionData, 'id' | 'created_at' | 'updated_at' | 'last_connected_at'> & { tag_ids?: number[] }>
|
||||||
): Promise<{ connectionId: number, originalData: any }[]> => {
|
): Promise<{ connectionId: number, originalData: any }[]> => {
|
||||||
|
|
||||||
const insertConnSql = `INSERT INTO connections (name, host, port, username, auth_method, encrypted_password, encrypted_private_key, encrypted_passphrase, proxy_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
|
const insertConnSql = `INSERT INTO connections (name, type, host, port, username, auth_method, encrypted_password, encrypted_private_key, encrypted_passphrase, proxy_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`; // Add type column and placeholder
|
||||||
const results: { connectionId: number, originalData: any }[] = [];
|
const results: { connectionId: number, originalData: any }[] = [];
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
|
||||||
for (const connData of connections) {
|
for (const connData of connections) {
|
||||||
const params = [
|
const params = [
|
||||||
connData.name ?? null, connData.host, connData.port, connData.username, connData.auth_method,
|
connData.name ?? null, connData.type, connData.host, connData.port, connData.username, connData.auth_method, // Add type parameter
|
||||||
connData.encrypted_password || null,
|
connData.encrypted_password || null,
|
||||||
connData.encrypted_private_key || null,
|
connData.encrypted_private_key || null,
|
||||||
connData.encrypted_passphrase || null,
|
connData.encrypted_passphrase || null,
|
||||||
|
|||||||
@@ -18,57 +18,93 @@ const auditLogService = new AuditLogService(); // 实例化 AuditLogService
|
|||||||
* 获取所有连接(包含标签)
|
* 获取所有连接(包含标签)
|
||||||
*/
|
*/
|
||||||
export const getAllConnections = async (): Promise<ConnectionWithTags[]> => {
|
export const getAllConnections = async (): Promise<ConnectionWithTags[]> => {
|
||||||
return ConnectionRepository.findAllConnectionsWithTags();
|
// Repository now returns ConnectionWithTags including 'type'
|
||||||
|
// Explicit type assertion to ensure compatibility
|
||||||
|
return ConnectionRepository.findAllConnectionsWithTags() as Promise<ConnectionWithTags[]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据 ID 获取单个连接(包含标签)
|
* 根据 ID 获取单个连接(包含标签)
|
||||||
*/
|
*/
|
||||||
export const getConnectionById = async (id: number): Promise<ConnectionWithTags | null> => {
|
export const getConnectionById = async (id: number): Promise<ConnectionWithTags | null> => {
|
||||||
return ConnectionRepository.findConnectionByIdWithTags(id);
|
// Repository now returns ConnectionWithTags including 'type'
|
||||||
|
// Explicit type assertion to ensure compatibility
|
||||||
|
return ConnectionRepository.findConnectionByIdWithTags(id) as Promise<ConnectionWithTags | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建新连接
|
* 创建新连接
|
||||||
*/
|
*/
|
||||||
export const createConnection = async (input: CreateConnectionInput): Promise<ConnectionWithTags> => {
|
export const createConnection = async (input: CreateConnectionInput): Promise<ConnectionWithTags> => {
|
||||||
// 1. 验证输入
|
console.log('[Service:createConnection] Received input:', JSON.stringify(input, null, 2)); // Log input
|
||||||
if (!input.host || !input.username || !input.auth_method) {
|
// 1. 验证输入 (包含 type)
|
||||||
throw new Error('缺少必要的连接信息 (host, username, auth_method)。');
|
// Convert type to uppercase for validation and consistency
|
||||||
|
const connectionType = input.type?.toUpperCase() as 'SSH' | 'RDP' | undefined; // Ensure type safety
|
||||||
|
if (!connectionType || !['SSH', 'RDP'].includes(connectionType)) {
|
||||||
|
throw new Error('必须提供有效的连接类型 (SSH 或 RDP)。');
|
||||||
}
|
}
|
||||||
if (input.auth_method === 'password' && !input.password) {
|
if (!input.host || !input.username) {
|
||||||
throw new Error('密码认证方式需要提供 password。');
|
throw new Error('缺少必要的连接信息 (host, username)。');
|
||||||
}
|
}
|
||||||
if (input.auth_method === 'key' && !input.private_key) {
|
// Type-specific validation using the uppercase version
|
||||||
throw new Error('密钥认证方式需要提供 private_key。');
|
if (connectionType === 'SSH') {
|
||||||
|
if (!input.auth_method || !['password', 'key'].includes(input.auth_method)) {
|
||||||
|
throw new Error('SSH 连接必须提供有效的认证方式 (password 或 key)。');
|
||||||
|
}
|
||||||
|
if (input.auth_method === 'password' && !input.password) {
|
||||||
|
throw new Error('SSH 密码认证方式需要提供 password。');
|
||||||
|
}
|
||||||
|
if (input.auth_method === 'key' && !input.private_key) {
|
||||||
|
throw new Error('SSH 密钥认证方式需要提供 private_key。');
|
||||||
|
}
|
||||||
|
} else if (connectionType === 'RDP') {
|
||||||
|
if (!input.password) {
|
||||||
|
throw new Error('RDP 连接需要提供 password。');
|
||||||
|
}
|
||||||
|
// For RDP, we'll ignore auth_method, private_key, passphrase from input if provided
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 加密凭证
|
// 2. 加密凭证 (根据 type)
|
||||||
let encryptedPassword = null;
|
let encryptedPassword = null;
|
||||||
let encryptedPrivateKey = null;
|
let encryptedPrivateKey = null;
|
||||||
let encryptedPassphrase = null;
|
let encryptedPassphrase = null;
|
||||||
|
// Default to 'password' for DB compatibility, especially for RDP
|
||||||
|
let authMethodForDb: 'password' | 'key' = 'password';
|
||||||
|
|
||||||
if (input.auth_method === 'password') {
|
if (connectionType === 'SSH') {
|
||||||
encryptedPassword = encrypt(input.password!);
|
authMethodForDb = input.auth_method!; // Already validated above
|
||||||
} else if (input.auth_method === 'key') {
|
if (input.auth_method === 'password') {
|
||||||
encryptedPrivateKey = encrypt(input.private_key!);
|
encryptedPassword = encrypt(input.password!);
|
||||||
if (input.passphrase) {
|
} else { // key
|
||||||
encryptedPassphrase = encrypt(input.passphrase);
|
encryptedPrivateKey = encrypt(input.private_key!);
|
||||||
|
if (input.passphrase) {
|
||||||
|
encryptedPassphrase = encrypt(input.passphrase);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} else { // RDP (connectionType is 'RDP')
|
||||||
|
encryptedPassword = encrypt(input.password!);
|
||||||
|
// authMethodForDb remains 'password' for RDP to satisfy DB constraint
|
||||||
|
// Ensure SSH specific fields are null for RDP
|
||||||
|
encryptedPrivateKey = null;
|
||||||
|
encryptedPassphrase = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 准备仓库数据
|
// 3. 准备仓库数据
|
||||||
const connectionData = {
|
const defaultPort = input.type === 'RDP' ? 3389 : 22;
|
||||||
name: input.name || '', // 如果 name 为空或 undefined,则使用空字符串 ''
|
// Explicitly type the object being passed to the repository
|
||||||
|
const connectionData: Omit<ConnectionRepository.FullConnectionData, 'id' | 'created_at' | 'updated_at' | 'last_connected_at' | 'tag_ids'> = {
|
||||||
|
name: input.name || '',
|
||||||
|
type: connectionType, // Use the validated uppercase type
|
||||||
host: input.host,
|
host: input.host,
|
||||||
port: input.port ?? 22, // 默认端口
|
port: input.port ?? defaultPort, // Use type-specific default port
|
||||||
username: input.username,
|
username: input.username,
|
||||||
auth_method: input.auth_method,
|
auth_method: authMethodForDb, // Use determined auth method
|
||||||
encrypted_password: encryptedPassword,
|
encrypted_password: encryptedPassword,
|
||||||
encrypted_private_key: encryptedPrivateKey,
|
encrypted_private_key: encryptedPrivateKey, // Will be null for RDP
|
||||||
encrypted_passphrase: encryptedPassphrase,
|
encrypted_passphrase: encryptedPassphrase, // Will be null for RDP
|
||||||
proxy_id: input.proxy_id ?? null,
|
proxy_id: input.proxy_id ?? null,
|
||||||
};
|
};
|
||||||
|
console.log('[Service:createConnection] Data to be saved:', JSON.stringify(connectionData, null, 2)); // Log data before saving
|
||||||
|
|
||||||
// 4. 在仓库中创建连接记录
|
// 4. 在仓库中创建连接记录
|
||||||
const newConnectionId = await ConnectionRepository.createConnection(connectionData);
|
const newConnectionId = await ConnectionRepository.createConnection(connectionData);
|
||||||
@@ -86,7 +122,7 @@ export const createConnection = async (input: CreateConnectionInput): Promise<Co
|
|||||||
console.error(`[Audit Log Error] Failed to retrieve connection ${newConnectionId} after creation.`);
|
console.error(`[Audit Log Error] Failed to retrieve connection ${newConnectionId} after creation.`);
|
||||||
throw new Error('创建连接后无法检索到该连接。');
|
throw new Error('创建连接后无法检索到该连接。');
|
||||||
}
|
}
|
||||||
auditLogService.logAction('CONNECTION_CREATED', { connectionId: newConnection.id, name: newConnection.name, host: newConnection.host });
|
auditLogService.logAction('CONNECTION_CREATED', { connectionId: newConnection.id, type: newConnection.type, name: newConnection.name, host: newConnection.host }); // Add type to audit log
|
||||||
|
|
||||||
// 7. 返回新创建的带标签的连接
|
// 7. 返回新创建的带标签的连接
|
||||||
return newConnection;
|
return newConnection;
|
||||||
@@ -103,57 +139,85 @@ export const updateConnection = async (id: number, input: UpdateConnectionInput)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. 准备更新数据
|
// 2. 准备更新数据
|
||||||
const dataToUpdate: Partial<ConnectionRepository.FullConnectionData> = {};
|
// Explicitly type dataToUpdate to match the repository's expected input
|
||||||
|
const dataToUpdate: Partial<Omit<ConnectionRepository.FullConnectionData, 'id' | 'created_at' | 'last_connected_at' | 'tag_ids'>> = {};
|
||||||
let needsCredentialUpdate = false;
|
let needsCredentialUpdate = false;
|
||||||
let newAuthMethod = input.auth_method || currentFullConnection.auth_method;
|
// Determine the final type, converting input type to uppercase if provided
|
||||||
|
const targetType = input.type?.toUpperCase() as 'SSH' | 'RDP' | undefined || currentFullConnection.type;
|
||||||
|
|
||||||
// 更新非凭证字段
|
// 更新非凭证字段
|
||||||
if (input.name !== undefined) dataToUpdate.name = input.name || ''; // 如果 name 是空字符串或 null/undefined,则使用空字符串 ''
|
if (input.name !== undefined) dataToUpdate.name = input.name || '';
|
||||||
|
// Update type if changed, using the uppercase version
|
||||||
|
if (input.type !== undefined && targetType !== currentFullConnection.type) dataToUpdate.type = targetType;
|
||||||
if (input.host !== undefined) dataToUpdate.host = input.host;
|
if (input.host !== undefined) dataToUpdate.host = input.host;
|
||||||
if (input.port !== undefined) dataToUpdate.port = input.port;
|
if (input.port !== undefined) dataToUpdate.port = input.port;
|
||||||
if (input.username !== undefined) dataToUpdate.username = input.username;
|
if (input.username !== undefined) dataToUpdate.username = input.username;
|
||||||
if (input.proxy_id !== undefined) dataToUpdate.proxy_id = input.proxy_id; // 允许设置为 null
|
if (input.proxy_id !== undefined) dataToUpdate.proxy_id = input.proxy_id;
|
||||||
|
|
||||||
// 处理认证方法更改或凭证更新
|
// 处理认证方法更改或凭证更新 (根据 targetType)
|
||||||
if (input.auth_method && input.auth_method !== currentFullConnection.auth_method) {
|
// Use the validated targetType for logic
|
||||||
// 认证方法已更改
|
if (targetType === 'SSH') {
|
||||||
dataToUpdate.auth_method = input.auth_method;
|
const currentAuthMethod = currentFullConnection.auth_method;
|
||||||
needsCredentialUpdate = true;
|
const inputAuthMethod = input.auth_method;
|
||||||
if (input.auth_method === 'password') {
|
|
||||||
if (!input.password) throw new Error('切换到密码认证时需要提供 password。');
|
// Determine the final auth method for SSH
|
||||||
dataToUpdate.encrypted_password = encrypt(input.password);
|
const finalAuthMethod = inputAuthMethod || currentAuthMethod;
|
||||||
|
if (finalAuthMethod !== currentAuthMethod) {
|
||||||
|
dataToUpdate.auth_method = finalAuthMethod; // Update auth_method if it changed
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalAuthMethod === 'password') {
|
||||||
|
// If switching to password or updating password
|
||||||
|
if (input.password !== undefined) { // Check if password was provided in input
|
||||||
|
if (!input.password && finalAuthMethod !== currentAuthMethod) {
|
||||||
|
// Switching to password requires a password
|
||||||
|
throw new Error('切换到密码认证时需要提供 password。');
|
||||||
|
}
|
||||||
|
// Encrypt if password is not empty, otherwise set to null (to clear)
|
||||||
|
dataToUpdate.encrypted_password = input.password ? encrypt(input.password) : null;
|
||||||
|
needsCredentialUpdate = true;
|
||||||
|
}
|
||||||
|
// When switching to password, clear key fields
|
||||||
|
if (finalAuthMethod !== currentAuthMethod) {
|
||||||
|
dataToUpdate.encrypted_private_key = null;
|
||||||
|
dataToUpdate.encrypted_passphrase = null;
|
||||||
|
}
|
||||||
|
} else { // finalAuthMethod is 'key'
|
||||||
|
let keyUpdated = false;
|
||||||
|
// If switching to key or updating key
|
||||||
|
if (input.private_key !== undefined) {
|
||||||
|
if (!input.private_key && finalAuthMethod !== currentAuthMethod) {
|
||||||
|
// Switching to key requires a private key
|
||||||
|
throw new Error('切换到密钥认证时需要提供 private_key。');
|
||||||
|
}
|
||||||
|
// Encrypt if key is not empty, otherwise set to null (to clear)
|
||||||
|
dataToUpdate.encrypted_private_key = input.private_key ? encrypt(input.private_key) : null;
|
||||||
|
needsCredentialUpdate = true;
|
||||||
|
keyUpdated = true;
|
||||||
|
}
|
||||||
|
// Update passphrase only if key was updated OR passphrase itself was provided
|
||||||
|
if (keyUpdated || input.passphrase !== undefined) {
|
||||||
|
// Encrypt if passphrase is not empty, otherwise set to null (to clear)
|
||||||
|
dataToUpdate.encrypted_passphrase = input.passphrase ? encrypt(input.passphrase) : null;
|
||||||
|
needsCredentialUpdate = true; // Consider passphrase change a credential update
|
||||||
|
}
|
||||||
|
// When switching to key, clear password field
|
||||||
|
if (finalAuthMethod !== currentAuthMethod) {
|
||||||
|
dataToUpdate.encrypted_password = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else { // targetType is 'RDP'
|
||||||
|
// RDP only uses password
|
||||||
|
if (input.password !== undefined) { // Check if password was provided
|
||||||
|
// Encrypt if password is not empty, otherwise set to null (to clear)
|
||||||
|
dataToUpdate.encrypted_password = input.password ? encrypt(input.password) : null;
|
||||||
|
needsCredentialUpdate = true;
|
||||||
|
}
|
||||||
|
// Ensure SSH specific fields are nullified if switching to RDP or updating RDP
|
||||||
|
if (targetType !== currentFullConnection.type || needsCredentialUpdate) {
|
||||||
|
dataToUpdate.auth_method = 'password'; // RDP uses password auth method in DB
|
||||||
dataToUpdate.encrypted_private_key = null;
|
dataToUpdate.encrypted_private_key = null;
|
||||||
dataToUpdate.encrypted_passphrase = null;
|
dataToUpdate.encrypted_passphrase = null;
|
||||||
} else { // 密钥
|
|
||||||
if (!input.private_key) throw new Error('切换到密钥认证时需要提供 private_key。');
|
|
||||||
dataToUpdate.encrypted_private_key = encrypt(input.private_key);
|
|
||||||
// 仅当密码短语为非空字符串时才加密
|
|
||||||
dataToUpdate.encrypted_passphrase = (input.passphrase && input.passphrase.trim() !== '') ? encrypt(input.passphrase) : null;
|
|
||||||
dataToUpdate.encrypted_password = null;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 认证方法未更改,检查是否提供了当前方法的凭证
|
|
||||||
// 仅当提供了非空字符串时才加密和更新
|
|
||||||
if (newAuthMethod === 'password' && input.password && input.password.trim() !== '') {
|
|
||||||
dataToUpdate.encrypted_password = encrypt(input.password);
|
|
||||||
needsCredentialUpdate = true;
|
|
||||||
} else if (newAuthMethod === 'key') {
|
|
||||||
let passphraseChanged = false;
|
|
||||||
if (input.private_key && input.private_key.trim() !== '') {
|
|
||||||
dataToUpdate.encrypted_private_key = encrypt(input.private_key);
|
|
||||||
// 如果私钥更新,则必须更新(或清除)密码短语
|
|
||||||
// 仅当非空时加密,否则设置为 null
|
|
||||||
dataToUpdate.encrypted_passphrase = (input.passphrase && input.passphrase.trim() !== '') ? encrypt(input.passphrase) : null;
|
|
||||||
needsCredentialUpdate = true;
|
|
||||||
passphraseChanged = true; // 如果密钥更改,则将密码短语标记为已处理
|
|
||||||
}
|
|
||||||
// 处理仅更改密码短语(且密钥未更改)的情况
|
|
||||||
// 检查 input.passphrase 是否已定义(可能是空字符串以清除)
|
|
||||||
if (!passphraseChanged && input.passphrase !== undefined) {
|
|
||||||
// 仅当非空时加密,否则设置为 null
|
|
||||||
dataToUpdate.encrypted_passphrase = (input.passphrase && input.passphrase.trim() !== '') ? encrypt(input.passphrase) : null;
|
|
||||||
needsCredentialUpdate = true; // 将此视为凭证更新
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +246,12 @@ export const updateConnection = async (id: number, input: UpdateConnectionInput)
|
|||||||
|
|
||||||
// 5. 如果进行了任何更改,则记录审计操作
|
// 5. 如果进行了任何更改,则记录审计操作
|
||||||
if (hasNonTagChanges || input.tag_ids !== undefined) {
|
if (hasNonTagChanges || input.tag_ids !== undefined) {
|
||||||
auditLogService.logAction('CONNECTION_UPDATED', { connectionId: id, updatedFields: updatedFieldsForAudit });
|
// Add type to audit log if it was updated
|
||||||
|
const auditDetails: any = { connectionId: id, updatedFields: updatedFieldsForAudit };
|
||||||
|
if (dataToUpdate.type) {
|
||||||
|
auditDetails.newType = dataToUpdate.type;
|
||||||
|
}
|
||||||
|
auditLogService.logAction('CONNECTION_UPDATED', auditDetails);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. 获取并返回更新后的连接
|
// 6. 获取并返回更新后的连接
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/conn
|
|||||||
|
|
||||||
interface ImportedConnectionData {
|
interface ImportedConnectionData {
|
||||||
name: string;
|
name: string;
|
||||||
|
type: 'SSH' | 'RDP'; // Add type field
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -42,10 +43,11 @@ export const exportConnections = async (): Promise<ExportedConnectionData[]> =>
|
|||||||
try {
|
try {
|
||||||
const db = await getDbInstance();
|
const db = await getDbInstance();
|
||||||
|
|
||||||
|
// Ensure ExportRow reflects the updated FullConnectionData (which now includes 'type')
|
||||||
type ExportRow = ConnectionRepository.FullConnectionData & {
|
type ExportRow = ConnectionRepository.FullConnectionData & {
|
||||||
proxy_db_id: number | null;
|
proxy_db_id: number | null;
|
||||||
proxy_name: string | null;
|
proxy_name: string | null;
|
||||||
proxy_type: 'SOCKS5' | 'HTTP' | null;
|
proxy_type: 'SOCKS5' | 'HTTP' | null; // Proxy type remains the same
|
||||||
proxy_host: string | null;
|
proxy_host: string | null;
|
||||||
proxy_port: number | null;
|
proxy_port: number | null;
|
||||||
proxy_username: string | null;
|
proxy_username: string | null;
|
||||||
@@ -86,6 +88,7 @@ export const exportConnections = async (): Promise<ExportedConnectionData[]> =>
|
|||||||
const formattedData: ExportedConnectionData[] = connectionsWithProxies.map(row => {
|
const formattedData: ExportedConnectionData[] = connectionsWithProxies.map(row => {
|
||||||
const connection: ExportedConnectionData = {
|
const connection: ExportedConnectionData = {
|
||||||
name: row.name ?? 'Unnamed',
|
name: row.name ?? 'Unnamed',
|
||||||
|
type: row.type, // Add type field
|
||||||
host: row.host,
|
host: row.host,
|
||||||
port: row.port,
|
port: row.port,
|
||||||
username: row.username,
|
username: row.username,
|
||||||
@@ -154,9 +157,18 @@ export const importConnections = async (fileBuffer: Buffer): Promise<ImportResul
|
|||||||
for (const connData of importedData) {
|
for (const connData of importedData) {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
if (!connData.name || !connData.host || !connData.port || !connData.username || !connData.auth_method) {
|
// Validate imported data, including type
|
||||||
throw new Error('缺少必要的连接字段 (name, host, port, username, auth_method)。');
|
if (!connData.type || !['SSH', 'RDP'].includes(connData.type)) {
|
||||||
|
throw new Error('缺少或无效的连接类型 (type)。');
|
||||||
}
|
}
|
||||||
|
if (!connData.name || !connData.host || !connData.port || !connData.username) {
|
||||||
|
throw new Error('缺少必要的连接字段 (name, host, port, username)。');
|
||||||
|
}
|
||||||
|
// Validate SSH specific fields only if type is SSH
|
||||||
|
if (connData.type === 'SSH' && (!connData.auth_method || !['password', 'key'].includes(connData.auth_method))) {
|
||||||
|
throw new Error('SSH 连接缺少有效的认证方式 (auth_method)。');
|
||||||
|
}
|
||||||
|
// RDP specific validation (e.g., password required) could be added here if needed
|
||||||
|
|
||||||
|
|
||||||
let proxyIdToUse: number | null = null;
|
let proxyIdToUse: number | null = null;
|
||||||
@@ -192,12 +204,15 @@ export const importConnections = async (fileBuffer: Buffer): Promise<ImportResul
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prepare data for repository, ensuring correct auth_method for RDP
|
||||||
|
const authMethodForDb = connData.type === 'RDP' ? 'password' : connData.auth_method!;
|
||||||
connectionsToInsert.push({
|
connectionsToInsert.push({
|
||||||
name: connData.name,
|
name: connData.name,
|
||||||
|
type: connData.type, // Add type
|
||||||
host: connData.host,
|
host: connData.host,
|
||||||
port: connData.port,
|
port: connData.port,
|
||||||
username: connData.username,
|
username: connData.username,
|
||||||
auth_method: connData.auth_method,
|
auth_method: authMethodForDb, // Use determined auth method
|
||||||
encrypted_password: connData.encrypted_password || null,
|
encrypted_password: connData.encrypted_password || null,
|
||||||
encrypted_private_key: connData.encrypted_private_key || null,
|
encrypted_private_key: connData.encrypted_private_key || null,
|
||||||
encrypted_passphrase: connData.encrypted_passphrase || null,
|
encrypted_passphrase: connData.encrypted_passphrase || null,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
export interface ConnectionBase {
|
export interface ConnectionBase {
|
||||||
id: number;
|
id: number;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
|
type: 'SSH' | 'RDP';
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -20,6 +21,7 @@ export interface ConnectionWithTags extends ConnectionBase {
|
|||||||
|
|
||||||
export interface CreateConnectionInput {
|
export interface CreateConnectionInput {
|
||||||
name?: string;
|
name?: string;
|
||||||
|
type: 'SSH' | 'RDP';
|
||||||
host: string;
|
host: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -34,6 +36,7 @@ export interface CreateConnectionInput {
|
|||||||
|
|
||||||
export interface UpdateConnectionInput {
|
export interface UpdateConnectionInput {
|
||||||
name?: string;
|
name?: string;
|
||||||
|
type?: 'SSH' | 'RDP';
|
||||||
host?: string;
|
host?: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
username?: string;
|
username?: string;
|
||||||
@@ -49,6 +52,7 @@ export interface UpdateConnectionInput {
|
|||||||
export interface FullConnectionData {
|
export interface FullConnectionData {
|
||||||
id: number;
|
id: number;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
|
type: 'SSH' | 'RDP';
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
username: string;
|
username: string;
|
||||||
|
|||||||
@@ -26,16 +26,18 @@ const { tags, isLoading: isTagLoading, error: tagStoreError } = storeToRefs(tags
|
|||||||
|
|
||||||
// 表单数据模型
|
// 表单数据模型
|
||||||
const initialFormData = {
|
const initialFormData = {
|
||||||
|
type: 'SSH' as 'SSH' | 'RDP', // Use uppercase to match ConnectionInfo
|
||||||
name: '',
|
name: '',
|
||||||
host: '',
|
host: '',
|
||||||
port: 22,
|
port: 22,
|
||||||
username: '',
|
username: '',
|
||||||
auth_method: 'password' as 'password' | 'key',
|
auth_method: 'password' as 'password' | 'key', // SSH specific
|
||||||
password: '',
|
password: '',
|
||||||
private_key: '',
|
private_key: '', // SSH specific
|
||||||
passphrase: '',
|
passphrase: '', // SSH specific
|
||||||
proxy_id: null as number | null,
|
proxy_id: null as number | null,
|
||||||
tag_ids: [] as number[], // 新增 tag_ids 字段
|
tag_ids: [] as number[], // 新增 tag_ids 字段
|
||||||
|
// Add RDP specific fields later if needed, e.g., domain
|
||||||
};
|
};
|
||||||
const formData = reactive({ ...initialFormData });
|
const formData = reactive({ ...initialFormData });
|
||||||
|
|
||||||
@@ -71,6 +73,7 @@ watch(() => props.connectionToEdit, (newVal) => {
|
|||||||
formError.value = null; // 清除错误
|
formError.value = null; // 清除错误
|
||||||
if (newVal) {
|
if (newVal) {
|
||||||
// 编辑模式:填充表单,但不填充敏感信息
|
// 编辑模式:填充表单,但不填充敏感信息
|
||||||
|
formData.type = newVal.type; // Correctly set the type for editing
|
||||||
formData.name = newVal.name;
|
formData.name = newVal.name;
|
||||||
formData.host = newVal.host;
|
formData.host = newVal.host;
|
||||||
formData.port = newVal.port;
|
formData.port = newVal.port;
|
||||||
@@ -94,6 +97,21 @@ onMounted(() => {
|
|||||||
tagsStore.fetchTags(); // 获取标签列表
|
tagsStore.fetchTags(); // 获取标签列表
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 监听连接类型变化,动态调整默认端口
|
||||||
|
watch(() => formData.type, (newType) => {
|
||||||
|
// Use uppercase for comparison
|
||||||
|
if (newType === 'RDP' && formData.port === 22) {
|
||||||
|
formData.port = 3389; // RDP 默认端口
|
||||||
|
} else if (newType === 'SSH' && formData.port === 3389) {
|
||||||
|
formData.port = 22; // SSH 默认端口
|
||||||
|
}
|
||||||
|
// 重置或调整认证方式等逻辑可以在这里添加
|
||||||
|
if (newType === 'RDP') {
|
||||||
|
// RDP 通常只用密码,可以强制或隐藏 auth_method
|
||||||
|
// formData.auth_method = 'password'; // Example: Force password for RDP
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 处理表单提交
|
// 处理表单提交
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
formError.value = null;
|
formError.value = null;
|
||||||
@@ -110,71 +128,108 @@ const handleSubmit = async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 更新后的验证逻辑 ---
|
// --- 更新后的验证逻辑 (区分 SSH 和 RDP) ---
|
||||||
// 1. 添加模式下,密码/密钥是必填的
|
// Use uppercase for comparison
|
||||||
if (!isEditMode.value) {
|
if (formData.type === 'SSH') {
|
||||||
if (formData.auth_method === 'password' && !formData.password) {
|
// SSH Validation
|
||||||
formError.value = t('connections.form.errorPasswordRequired');
|
// 1. 添加模式下,密码/密钥是必填的
|
||||||
|
if (!isEditMode.value) {
|
||||||
|
if (formData.auth_method === 'password' && !formData.password) {
|
||||||
|
formError.value = t('connections.form.errorPasswordRequired');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (formData.auth_method === 'key' && !formData.private_key) {
|
||||||
|
formError.value = t('connections.form.errorPrivateKeyRequired');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2. 编辑模式下,如果切换到密码认证,则密码必填
|
||||||
|
else if (isEditMode.value && formData.auth_method === 'password' && !formData.password) {
|
||||||
|
// 检查原始连接的认证方式,如果原始不是密码,则切换时必须提供密码
|
||||||
|
// 注意: props.connectionToEdit 可能没有 type 字段,需要后端配合或前端自行判断
|
||||||
|
if (props.connectionToEdit?.auth_method !== 'password') {
|
||||||
|
formError.value = t('connections.form.errorPasswordRequiredOnSwitch');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 如果原始就是密码,编辑时密码可以不填(表示不修改)
|
||||||
|
}
|
||||||
|
// 3. 编辑模式下,如果切换到密钥认证,则私钥必填
|
||||||
|
else if (isEditMode.value && formData.auth_method === 'key' && !formData.private_key) {
|
||||||
|
// 检查原始连接的认证方式,如果原始不是密钥,则切换时必须提供私钥
|
||||||
|
if (props.connectionToEdit?.auth_method !== 'key') {
|
||||||
|
formError.value = t('connections.form.errorPrivateKeyRequiredOnSwitch');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 如果原始就是密钥,编辑时私钥可以不填(表示不修改)
|
||||||
|
}
|
||||||
|
// Use uppercase for comparison
|
||||||
|
} else if (formData.type === 'RDP') {
|
||||||
|
// RDP Validation
|
||||||
|
// 1. 添加模式下,密码是必填的
|
||||||
|
if (!isEditMode.value && !formData.password) {
|
||||||
|
formError.value = t('connections.form.errorPasswordRequired'); // 可以复用密码必填的翻译
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (formData.auth_method === 'key' && !formData.private_key) {
|
// 2. 编辑模式下,密码可以不填(表示不修改),除非是从非 RDP 类型切换过来(这个逻辑比较复杂,暂时简化为密码非必填)
|
||||||
formError.value = t('connections.form.errorPrivateKeyRequired');
|
// 如果需要更严格的验证(例如从 SSH 编辑为 RDP 时强制要求输入密码),可以在这里添加
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 2. 编辑模式下,如果切换到密码认证,则密码必填
|
|
||||||
else if (isEditMode.value && formData.auth_method === 'password' && !formData.password) {
|
|
||||||
// 检查原始连接的认证方式,如果原始不是密码,则切换时必须提供密码
|
|
||||||
if (props.connectionToEdit?.auth_method !== 'password') {
|
|
||||||
formError.value = t('connections.form.errorPasswordRequiredOnSwitch'); // 新增翻译键
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 如果原始就是密码,编辑时密码可以不填(表示不修改)
|
|
||||||
}
|
|
||||||
// 3. 编辑模式下,如果切换到密钥认证,则私钥必填
|
|
||||||
else if (isEditMode.value && formData.auth_method === 'key' && !formData.private_key) {
|
|
||||||
// 检查原始连接的认证方式,如果原始不是密钥,则切换时必须提供私钥
|
|
||||||
if (props.connectionToEdit?.auth_method !== 'key') {
|
|
||||||
formError.value = t('connections.form.errorPrivateKeyRequiredOnSwitch'); // 新增翻译键
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 如果原始就是密钥,编辑时私钥可以不填(表示不修改)
|
|
||||||
}
|
}
|
||||||
// --- 验证逻辑结束 ---
|
// --- 验证逻辑结束 ---
|
||||||
|
|
||||||
|
|
||||||
// 构建要发送的数据 (区分添加和编辑)
|
// 构建要发送的数据 (区分添加和编辑)
|
||||||
const dataToSend: any = {
|
const dataToSend: any = {
|
||||||
|
type: formData.type, // 发送连接类型
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
host: formData.host,
|
host: formData.host,
|
||||||
port: formData.port,
|
port: formData.port,
|
||||||
username: formData.username,
|
username: formData.username,
|
||||||
auth_method: formData.auth_method,
|
|
||||||
proxy_id: formData.proxy_id || null,
|
proxy_id: formData.proxy_id || null,
|
||||||
tag_ids: formData.tag_ids || [], // 发送 tag_ids
|
tag_ids: formData.tag_ids || [], // 发送 tag_ids
|
||||||
|
// domain: formData.domain, // 如果添加了 domain 字段
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理敏感字段
|
// 处理认证相关字段 (根据类型)
|
||||||
if (formData.auth_method === 'password') {
|
// Use uppercase for comparison
|
||||||
// 仅当用户输入新密码或在编辑模式下明确清空时才发送
|
if (formData.type === 'SSH') {
|
||||||
|
dataToSend.auth_method = formData.auth_method;
|
||||||
|
if (formData.auth_method === 'password') {
|
||||||
|
// SSH 密码处理
|
||||||
|
if (formData.password) {
|
||||||
|
dataToSend.password = formData.password;
|
||||||
|
} else if (isEditMode.value && formData.password === '') {
|
||||||
|
// 编辑模式下,空密码字符串可能表示清空或不修改,取决于后端实现
|
||||||
|
// 假设发送 null 表示清空 (如果后端支持)
|
||||||
|
// dataToSend.password = null;
|
||||||
|
// 或者不发送 password 字段表示不修改
|
||||||
|
}
|
||||||
|
} else if (formData.auth_method === 'key') {
|
||||||
|
// SSH 密钥处理
|
||||||
|
if (formData.private_key) {
|
||||||
|
dataToSend.private_key = formData.private_key;
|
||||||
|
}
|
||||||
|
// SSH 密码短语处理
|
||||||
|
if (formData.passphrase) {
|
||||||
|
dataToSend.passphrase = formData.passphrase;
|
||||||
|
} else if (isEditMode.value && formData.passphrase === '') {
|
||||||
|
// dataToSend.passphrase = null; // 发送 null 表示清空
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Use uppercase for comparison
|
||||||
|
} else if (formData.type === 'RDP') {
|
||||||
|
// RDP 密码处理 (通常 RDP 没有 auth_method 选择)
|
||||||
if (formData.password) {
|
if (formData.password) {
|
||||||
dataToSend.password = formData.password;
|
dataToSend.password = formData.password;
|
||||||
} else if (isEditMode.value && formData.password === '') {
|
} else if (isEditMode.value && formData.password === '') {
|
||||||
dataToSend.password = null; // 发送 null 表示清空密码 (后端需要能处理 null)
|
// 编辑 RDP 时,空密码字符串处理逻辑同上
|
||||||
}
|
// dataToSend.password = null;
|
||||||
} else if (formData.auth_method === 'key') {
|
|
||||||
// 仅当用户输入新私钥时才发送
|
|
||||||
if (formData.private_key) {
|
|
||||||
dataToSend.private_key = formData.private_key;
|
|
||||||
}
|
|
||||||
// 仅当用户输入新密码短语或在编辑模式下明确清空时才发送
|
|
||||||
if (formData.passphrase) {
|
|
||||||
dataToSend.passphrase = formData.passphrase;
|
|
||||||
} else if (isEditMode.value && formData.passphrase === '') {
|
|
||||||
dataToSend.passphrase = null; // 发送 null 表示清空密码短语
|
|
||||||
}
|
}
|
||||||
|
// RDP 不发送 SSH 特有的字段
|
||||||
|
delete dataToSend.auth_method;
|
||||||
|
delete dataToSend.private_key;
|
||||||
|
delete dataToSend.passphrase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
let success = false;
|
let success = false;
|
||||||
if (isEditMode.value && props.connectionToEdit) {
|
if (isEditMode.value && props.connectionToEdit) {
|
||||||
// 调用更新 action
|
// 调用更新 action
|
||||||
@@ -300,6 +355,16 @@ const testButtonText = computed(() => {
|
|||||||
<input type="text" id="conn-name" v-model="formData.name"
|
<input type="text" id="conn-name" v-model="formData.name"
|
||||||
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" />
|
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" />
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Connection Type -->
|
||||||
|
<div>
|
||||||
|
<label for="conn-type" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.connectionType', '连接类型') }}</label>
|
||||||
|
<select id="conn-type" v-model="formData.type"
|
||||||
|
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary appearance-none bg-no-repeat bg-right pr-8"
|
||||||
|
style="background-image: url('data:image/svg+xml,%3csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 16 16\'%3e%3cpath fill=\'none\' stroke=\'%236c757d\' stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M2 5l6 6 6-6\'/%3e%3c/svg%3e'); background-position: right 0.75rem center; background-size: 16px 12px;">
|
||||||
|
<option value="SSH">{{ t('connections.form.typeSsh', 'SSH') }}</option>
|
||||||
|
<option value="RDP">{{ t('connections.form.typeRdp', 'RDP') }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<!-- Host and Port Row -->
|
<!-- Host and Port Row -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div class="md:col-span-2">
|
<div class="md:col-span-2">
|
||||||
@@ -317,44 +382,65 @@ const testButtonText = computed(() => {
|
|||||||
|
|
||||||
<!-- Authentication Section -->
|
<!-- Authentication Section -->
|
||||||
<div class="space-y-4 p-4 border border-border rounded-md bg-header/30">
|
<div class="space-y-4 p-4 border border-border rounded-md bg-header/30">
|
||||||
<h4 class="text-base font-semibold mb-3 pb-2 border-b border-border/50">{{ t('connections.form.sectionAuth', '认证方式') }}</h4>
|
<h4 class="text-base font-semibold mb-3 pb-2 border-b border-border/50">{{ t('connections.form.sectionAuth', '认证信息') }}</h4>
|
||||||
<div>
|
<div>
|
||||||
<label for="conn-username" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.username') }}</label>
|
<label for="conn-username" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.username') }}</label>
|
||||||
<input type="text" id="conn-username" v-model="formData.username" required
|
<input type="text" id="conn-username" v-model="formData.username" required
|
||||||
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" />
|
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label for="conn-auth-method" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.authMethod') }}</label>
|
|
||||||
<select id="conn-auth-method" v-model="formData.auth_method"
|
|
||||||
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary appearance-none bg-no-repeat bg-right pr-8"
|
|
||||||
style="background-image: url('data:image/svg+xml,%3csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 16 16\'%3e%3cpath fill=\'none\' stroke=\'%236c757d\' stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M2 5l6 6 6-6\'/%3e%3c/svg%3e'); background-position: right 0.75rem center; background-size: 16px 12px;">
|
|
||||||
<option value="password">{{ t('connections.form.authMethodPassword') }}</option>
|
|
||||||
<option value="key">{{ t('connections.form.authMethodKey') }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="formData.auth_method === 'password'">
|
<!-- SSH Specific Auth -->
|
||||||
<label for="conn-password" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.password') }}</label>
|
<!-- Use uppercase for comparison -->
|
||||||
<input type="password" id="conn-password" v-model="formData.password" :required="formData.auth_method === 'password' && !isEditMode" autocomplete="new-password"
|
<template v-if="formData.type === 'SSH'">
|
||||||
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" />
|
<div>
|
||||||
</div>
|
<label for="conn-auth-method" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.authMethod') }}</label>
|
||||||
|
<select id="conn-auth-method" v-model="formData.auth_method"
|
||||||
|
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary appearance-none bg-no-repeat bg-right pr-8"
|
||||||
|
style="background-image: url('data:image/svg+xml,%3csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 16 16\'%3e%3cpath fill=\'none\' stroke=\'%236c757d\' stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M2 5l6 6 6-6\'/%3e%3c/svg%3e'); background-position: right 0.75rem center; background-size: 16px 12px;">
|
||||||
|
<option value="password">{{ t('connections.form.authMethodPassword') }}</option>
|
||||||
|
<option value="key">{{ t('connections.form.authMethodKey') }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="formData.auth_method === 'key'" class="space-y-4">
|
<div v-if="formData.auth_method === 'password'">
|
||||||
|
<label for="conn-password" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.password') }}</label>
|
||||||
|
<input type="password" id="conn-password" v-model="formData.password" :required="formData.auth_method === 'password' && !isEditMode" autocomplete="new-password"
|
||||||
|
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="formData.auth_method === 'key'" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="conn-private-key" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.privateKey') }}</label>
|
||||||
|
<textarea id="conn-private-key" v-model="formData.private_key" rows="4" :required="formData.auth_method === 'key' && !isEditMode"
|
||||||
|
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary font-mono text-sm"></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="conn-passphrase" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.passphrase') }} ({{ t('connections.form.optional') }})</label>
|
||||||
|
<input type="password" id="conn-passphrase" v-model="formData.passphrase" autocomplete="new-password"
|
||||||
|
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" />
|
||||||
|
</div>
|
||||||
|
<div v-if="isEditMode && formData.auth_method === 'key'">
|
||||||
|
<small class="block text-xs text-text-secondary">{{ t('connections.form.keyUpdateNote') }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- RDP Specific Auth (Password only for now) -->
|
||||||
|
<!-- Use uppercase for comparison -->
|
||||||
|
<template v-if="formData.type === 'RDP'">
|
||||||
<div>
|
<div>
|
||||||
<label for="conn-private-key" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.privateKey') }}</label>
|
<label for="conn-password-rdp" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.password') }}</label>
|
||||||
<textarea id="conn-private-key" v-model="formData.private_key" rows="4" :required="formData.auth_method === 'key' && !isEditMode"
|
<input type="password" id="conn-password-rdp" v-model="formData.password" :required="!isEditMode" autocomplete="new-password"
|
||||||
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary font-mono text-sm"></textarea>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="conn-passphrase" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.passphrase') }} ({{ t('connections.form.optional') }})</label>
|
|
||||||
<input type="password" id="conn-passphrase" v-model="formData.passphrase" autocomplete="new-password"
|
|
||||||
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" />
|
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" />
|
||||||
|
<!-- Add domain field if needed -->
|
||||||
|
<!--
|
||||||
|
<label for="conn-domain" class="block text-sm font-medium text-text-secondary mb-1 mt-4">{{ t('connections.form.domain', '域') }} ({{ t('connections.form.optional') }})</label>
|
||||||
|
<input type="text" id="conn-domain" v-model="formData.domain"
|
||||||
|
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" />
|
||||||
|
-->
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isEditMode && formData.auth_method === 'key'">
|
</template>
|
||||||
<small class="block text-xs text-text-secondary">{{ t('connections.form.keyUpdateNote') }}</small>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Advanced Options Section -->
|
<!-- Advanced Options Section -->
|
||||||
<div class="space-y-4 p-4 border border-border rounded-md bg-header/30">
|
<div class="space-y-4 p-4 border border-border rounded-md bg-header/30">
|
||||||
@@ -388,7 +474,9 @@ const testButtonText = computed(() => {
|
|||||||
|
|
||||||
<!-- Form Actions -->
|
<!-- Form Actions -->
|
||||||
<div class="flex justify-between items-center pt-5 mt-6 flex-shrink-0">
|
<div class="flex justify-between items-center pt-5 mt-6 flex-shrink-0">
|
||||||
<div class="flex flex-col items-start gap-1"> <!-- Test Area -->
|
<!-- Test Area (Only show for SSH) -->
|
||||||
|
<!-- Use uppercase for comparison -->
|
||||||
|
<div v-if="formData.type === 'SSH'" class="flex flex-col items-start gap-1">
|
||||||
<div class="flex items-center gap-2"> <!-- Button and Icon -->
|
<div class="flex items-center gap-2"> <!-- Button and Icon -->
|
||||||
<button type="button" @click="handleTestConnection" :disabled="isLoading || testStatus === 'testing'"
|
<button type="button" @click="handleTestConnection" :disabled="isLoading || testStatus === 'testing'"
|
||||||
class="px-3 py-1.5 border border-border rounded-md text-sm font-medium text-text-secondary bg-background hover:bg-border focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center transition-colors duration-150">
|
class="px-3 py-1.5 border border-border rounded-md text-sm font-medium text-text-secondary bg-background hover:bg-border focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center transition-colors duration-150">
|
||||||
@@ -418,12 +506,14 @@ const testButtonText = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Placeholder for alignment when test button is hidden -->
|
||||||
|
<div v-else class="flex-1"></div>
|
||||||
<div class="flex space-x-3"> <!-- Main Actions -->
|
<div class="flex space-x-3"> <!-- Main Actions -->
|
||||||
<button type="submit" @click="handleSubmit" :disabled="isLoading || testStatus === 'testing'"
|
<button type="submit" @click="handleSubmit" :disabled="isLoading || (formData.type === 'SSH' && testStatus === 'testing')"
|
||||||
class="px-4 py-2 bg-button text-button-text rounded-md shadow-sm hover:bg-button-hover focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed transition duration-150 ease-in-out">
|
class="px-4 py-2 bg-button text-button-text rounded-md shadow-sm hover:bg-button-hover focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed transition duration-150 ease-in-out">
|
||||||
{{ submitButtonText }}
|
{{ submitButtonText }}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" @click="emit('close')" :disabled="isLoading || testStatus === 'testing'"
|
<button type="button" @click="emit('close')" :disabled="isLoading || (formData.type === 'SSH' && testStatus === 'testing')"
|
||||||
class="px-4 py-2 bg-transparent text-text-secondary border border-border rounded-md shadow-sm hover:bg-border hover:text-foreground focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed transition duration-150 ease-in-out">
|
class="px-4 py-2 bg-transparent text-text-secondary border border-border rounded-md shadow-sm hover:bg-border hover:text-foreground focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed transition duration-150 ease-in-out">
|
||||||
{{ t('connections.form.cancel') }}
|
{{ t('connections.form.cancel') }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, computed, ref, reactive } from 'vue'; // 统一导入
|
import { onMounted, computed, ref, reactive, watch } from 'vue'; // 统一导入, 添加 watch
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
@@ -30,6 +30,11 @@ onMounted(() => {
|
|||||||
tagsStore.fetchTags(); // 获取标签列表
|
tagsStore.fetchTags(); // 获取标签列表
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Log received props for debugging
|
||||||
|
watch(() => props.connections, (newVal: ConnectionInfo[]) => { // Add type annotation for newVal
|
||||||
|
console.log('[ConnectionList] Received connections prop:', JSON.stringify(newVal, null, 2));
|
||||||
|
}, { immediate: true, deep: true });
|
||||||
|
|
||||||
// 创建标签 ID 到名称的映射
|
// 创建标签 ID 到名称的映射
|
||||||
const tagMap = computed(() => {
|
const tagMap = computed(() => {
|
||||||
const map = new Map<number, string>();
|
const map = new Map<number, string>();
|
||||||
@@ -176,7 +181,10 @@ const handleDelete = async (conn: ConnectionInfo) => {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-border">
|
<tbody class="divide-y divide-border">
|
||||||
<tr v-for="conn in groupConnections" :key="conn.id" class="hover:bg-hover transition-colors duration-150">
|
<tr v-for="conn in groupConnections" :key="conn.id" class="hover:bg-hover transition-colors duration-150">
|
||||||
<td class="px-4 py-3 text-sm text-foreground whitespace-nowrap">{{ conn.name }}</td>
|
<td class="px-4 py-3 text-sm text-foreground whitespace-nowrap">
|
||||||
|
<!-- Icon logic removed for now -->
|
||||||
|
{{ conn.name }}
|
||||||
|
</td>
|
||||||
<td class="px-4 py-3 text-sm text-foreground whitespace-nowrap">{{ conn.host }}</td>
|
<td class="px-4 py-3 text-sm text-foreground whitespace-nowrap">{{ conn.host }}</td>
|
||||||
<td class="px-4 py-3 text-sm text-foreground whitespace-nowrap">{{ conn.port }}</td>
|
<td class="px-4 py-3 text-sm text-foreground whitespace-nowrap">{{ conn.port }}</td>
|
||||||
<td class="px-4 py-3 text-sm text-foreground whitespace-nowrap">{{ conn.username }}</td>
|
<td class="px-4 py-3 text-sm text-foreground whitespace-nowrap">{{ conn.username }}</td>
|
||||||
|
|||||||
@@ -159,6 +159,9 @@
|
|||||||
"proxy": "Proxy:",
|
"proxy": "Proxy:",
|
||||||
"noProxy": "No Proxy",
|
"noProxy": "No Proxy",
|
||||||
"tags": "Tags:",
|
"tags": "Tags:",
|
||||||
|
"connectionType": "Connection Type:",
|
||||||
|
"typeSsh": "SSH",
|
||||||
|
"typeRdp": "RDP",
|
||||||
"sectionBasic": "Basic Information",
|
"sectionBasic": "Basic Information",
|
||||||
"sectionAuth": "Authentication",
|
"sectionAuth": "Authentication",
|
||||||
"sectionAdvanced": "Advanced Options",
|
"sectionAdvanced": "Advanced Options",
|
||||||
|
|||||||
@@ -159,6 +159,9 @@
|
|||||||
"proxy": "プロキシ:",
|
"proxy": "プロキシ:",
|
||||||
"noProxy": "プロキシなし",
|
"noProxy": "プロキシなし",
|
||||||
"tags": "タグ:",
|
"tags": "タグ:",
|
||||||
|
"connectionType": "接続タイプ:",
|
||||||
|
"typeSsh": "SSH",
|
||||||
|
"typeRdp": "RDP",
|
||||||
"sectionBasic": "基本情報",
|
"sectionBasic": "基本情報",
|
||||||
"sectionAuth": "認証情報",
|
"sectionAuth": "認証情報",
|
||||||
"sectionAdvanced": "詳細設定",
|
"sectionAdvanced": "詳細設定",
|
||||||
|
|||||||
@@ -159,6 +159,9 @@
|
|||||||
"proxy": "代理:",
|
"proxy": "代理:",
|
||||||
"noProxy": "无代理",
|
"noProxy": "无代理",
|
||||||
"tags": "标签:",
|
"tags": "标签:",
|
||||||
|
"connectionType": "连接类型",
|
||||||
|
"typeSsh": "SSH",
|
||||||
|
"typeRdp": "RDP",
|
||||||
"sectionBasic": "基本信息",
|
"sectionBasic": "基本信息",
|
||||||
"sectionAuth": "认证信息",
|
"sectionAuth": "认证信息",
|
||||||
"sectionAdvanced": "高级选项",
|
"sectionAdvanced": "高级选项",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import apiClient from '../utils/apiClient'; // 使用统一的 apiClient
|
|||||||
export interface ConnectionInfo {
|
export interface ConnectionInfo {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
type: 'SSH' | 'RDP'; // Use uppercase to match backend data
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -59,6 +60,7 @@ export const useConnectionsStore = defineStore('connections', {
|
|||||||
console.log('[ConnectionsStore] Fetching latest connections from server...');
|
console.log('[ConnectionsStore] Fetching latest connections from server...');
|
||||||
const response = await apiClient.get<ConnectionInfo[]>('/connections');
|
const response = await apiClient.get<ConnectionInfo[]>('/connections');
|
||||||
const freshData = response.data;
|
const freshData = response.data;
|
||||||
|
console.log('[ConnectionsStore] Data received from API:', JSON.stringify(freshData, null, 2)); // Log received data
|
||||||
const freshDataString = JSON.stringify(freshData);
|
const freshDataString = JSON.stringify(freshData);
|
||||||
|
|
||||||
// 3. 对比并更新
|
// 3. 对比并更新
|
||||||
@@ -66,6 +68,7 @@ export const useConnectionsStore = defineStore('connections', {
|
|||||||
if (currentDataString !== freshDataString) {
|
if (currentDataString !== freshDataString) {
|
||||||
console.log('[ConnectionsStore] Connections data changed, updating state and cache.');
|
console.log('[ConnectionsStore] Connections data changed, updating state and cache.');
|
||||||
this.connections = freshData;
|
this.connections = freshData;
|
||||||
|
console.log('[ConnectionsStore] State updated with fresh data:', JSON.stringify(this.connections, null, 2)); // Log state after update
|
||||||
localStorage.setItem(cacheKey, freshDataString); // 更新缓存
|
localStorage.setItem(cacheKey, freshDataString); // 更新缓存
|
||||||
} else {
|
} else {
|
||||||
console.log('[ConnectionsStore] Connections data is up-to-date.');
|
console.log('[ConnectionsStore] Connections data is up-to-date.');
|
||||||
@@ -88,6 +91,7 @@ export const useConnectionsStore = defineStore('connections', {
|
|||||||
// 更新参数类型以接受新的认证字段
|
// 更新参数类型以接受新的认证字段
|
||||||
async addConnection(newConnectionData: {
|
async addConnection(newConnectionData: {
|
||||||
name: string;
|
name: string;
|
||||||
|
type: 'SSH' | 'RDP'; // Use uppercase
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -122,7 +126,8 @@ export const useConnectionsStore = defineStore('connections', {
|
|||||||
|
|
||||||
// 更新连接 Action
|
// 更新连接 Action
|
||||||
// 更新参数类型以包含 proxy_id 和 tag_ids
|
// 更新参数类型以包含 proxy_id 和 tag_ids
|
||||||
async updateConnection(connectionId: number, updatedData: Partial<Omit<ConnectionInfo, 'id' | 'created_at' | 'updated_at' | 'last_connected_at'> & { password?: string; private_key?: string; passphrase?: string; proxy_id?: number | null; tag_ids?: number[] }>) {
|
// Update parameter type to include 'type'
|
||||||
|
async updateConnection(connectionId: number, updatedData: Partial<Omit<ConnectionInfo, 'id' | 'created_at' | 'updated_at' | 'last_connected_at'> & { type?: 'SSH' | 'RDP'; password?: string; private_key?: string; passphrase?: string; proxy_id?: number | null; tag_ids?: number[] }>) {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user