feat: implement comprehensive SSH credentials management system
- Add complete SSH credentials CRUD operations with AES-256 encryption - Implement database migration system for schema versioning - Create modern UI with Zinc theme for credentials management - Add credential viewer and editor with responsive design - Support password and SSH key authentication methods - Include usage tracking and folder organization - Enhance sidebar width and improve page spacing - Add comprehensive i18n support (EN/ZH) - Integrate with existing SSH host management 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,103 @@
|
||||
{
|
||||
"credentials": {
|
||||
"credentialsManager": "Credentials Manager",
|
||||
"manageYourSSHCredentials": "Manage your SSH credentials securely",
|
||||
"addCredential": "Add Credential",
|
||||
"createCredential": "Create Credential",
|
||||
"editCredential": "Edit Credential",
|
||||
"viewCredential": "View Credential",
|
||||
"duplicateCredential": "Duplicate Credential",
|
||||
"deleteCredential": "Delete Credential",
|
||||
"updateCredential": "Update Credential",
|
||||
"credentialName": "Credential Name",
|
||||
"credentialDescription": "Description",
|
||||
"searchCredentials": "Search credentials...",
|
||||
"selectFolder": "Select Folder",
|
||||
"selectAuthType": "Select Auth Type",
|
||||
"allFolders": "All Folders",
|
||||
"allAuthTypes": "All Auth Types",
|
||||
"uncategorized": "Uncategorized",
|
||||
"totalCredentials": "Total",
|
||||
"keyBased": "Key-based",
|
||||
"passwordBased": "Password-based",
|
||||
"folders": "Folders",
|
||||
"noCredentialsMatchFilters": "No credentials match your filters",
|
||||
"noCredentialsYet": "No credentials created yet",
|
||||
"createFirstCredential": "Create your first credential",
|
||||
"failedToFetchCredentials": "Failed to fetch credentials",
|
||||
"credentialDeletedSuccessfully": "Credential deleted successfully",
|
||||
"failedToDeleteCredential": "Failed to delete credential",
|
||||
"confirmDeleteCredential": "Are you sure you want to delete credential \"{{name}}\"?",
|
||||
"credentialCreatedSuccessfully": "Credential created successfully",
|
||||
"credentialUpdatedSuccessfully": "Credential updated successfully",
|
||||
"failedToSaveCredential": "Failed to save credential",
|
||||
"failedToFetchCredentialDetails": "Failed to fetch credential details",
|
||||
"failedToFetchHostsUsing": "Failed to fetch hosts using this credential",
|
||||
"basicInfo": "Basic Info",
|
||||
"authentication": "Authentication",
|
||||
"organization": "Organization",
|
||||
"basicInformation": "Basic Information",
|
||||
"basicInformationDescription": "Enter the basic information for this credential",
|
||||
"authenticationMethod": "Authentication Method",
|
||||
"authenticationMethodDescription": "Choose how you want to authenticate with SSH servers",
|
||||
"organizationDescription": "Organize your credentials with folders and tags",
|
||||
"enterCredentialName": "Enter credential name",
|
||||
"enterCredentialDescription": "Enter description (optional)",
|
||||
"enterUsername": "Enter username",
|
||||
"nameIsRequired": "Credential name is required",
|
||||
"usernameIsRequired": "Username is required",
|
||||
"authenticationType": "Authentication Type",
|
||||
"passwordAuthDescription": "Use password authentication",
|
||||
"sshKeyAuthDescription": "Use SSH key authentication",
|
||||
"passwordIsRequired": "Password is required",
|
||||
"sshKeyIsRequired": "SSH key is required",
|
||||
"sshKeyType": "SSH Key Type",
|
||||
"privateKey": "Private Key",
|
||||
"enterPassword": "Enter password",
|
||||
"enterPrivateKey": "Enter private key",
|
||||
"keyPassphrase": "Key Passphrase",
|
||||
"enterKeyPassphrase": "Enter key passphrase (optional)",
|
||||
"keyPassphraseOptional": "Optional: leave empty if your key has no passphrase",
|
||||
"leaveEmptyToKeepCurrent": "Leave empty to keep current value",
|
||||
"uploadKeyFile": "Upload Key File",
|
||||
"generateKeyPair": "Generate Key Pair",
|
||||
"sshKeyGenerationNotImplemented": "SSH key generation feature coming soon",
|
||||
"connectionTestingNotImplemented": "Connection testing feature coming soon",
|
||||
"testConnection": "Test Connection",
|
||||
"selectOrCreateFolder": "Select or create folder",
|
||||
"noFolder": "No folder",
|
||||
"orCreateNewFolder": "Or create new folder",
|
||||
"addTag": "Add tag",
|
||||
"saving": "Saving...",
|
||||
"overview": "Overview",
|
||||
"security": "Security",
|
||||
"usage": "Usage",
|
||||
"securityDetails": "Security Details",
|
||||
"securityDetailsDescription": "View encrypted credential information",
|
||||
"credentialSecured": "Credential Secured",
|
||||
"credentialSecuredDescription": "All sensitive data is encrypted with AES-256",
|
||||
"passwordAuthentication": "Password Authentication",
|
||||
"keyAuthentication": "Key Authentication",
|
||||
"keyType": "Key Type",
|
||||
"securityReminder": "Security Reminder",
|
||||
"securityReminderText": "Never share your credentials. All data is encrypted at rest.",
|
||||
"hostsUsingCredential": "Hosts Using This Credential",
|
||||
"noHostsUsingCredential": "No hosts are currently using this credential",
|
||||
"timesUsed": "Times Used",
|
||||
"lastUsed": "Last Used",
|
||||
"connectedHosts": "Connected Hosts",
|
||||
"created": "Created",
|
||||
"lastModified": "Last Modified",
|
||||
"usageStatistics": "Usage Statistics",
|
||||
"copiedToClipboard": "{{field}} copied to clipboard",
|
||||
"failedToCopy": "Failed to copy to clipboard",
|
||||
"sshKey": "SSH Key",
|
||||
"createCredentialDescription": "Create a new SSH credential for secure access",
|
||||
"editCredentialDescription": "Update the credential information",
|
||||
"listView": "List",
|
||||
"folderView": "Folders",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"sshTools": {
|
||||
"title": "SSH Tools",
|
||||
"closeTools": "Close SSH Tools",
|
||||
@@ -120,6 +219,7 @@
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"hosts": "Hosts",
|
||||
"credentials": "Credentials",
|
||||
"terminal": "Terminal",
|
||||
"tunnels": "Tunnels",
|
||||
"fileManager": "File Manager",
|
||||
|
||||
@@ -1,4 +1,103 @@
|
||||
{
|
||||
"credentials": {
|
||||
"credentialsManager": "凭据管理器",
|
||||
"manageYourSSHCredentials": "安全管理您的SSH凭据",
|
||||
"addCredential": "添加凭据",
|
||||
"createCredential": "创建凭据",
|
||||
"editCredential": "编辑凭据",
|
||||
"viewCredential": "查看凭据",
|
||||
"duplicateCredential": "复制凭据",
|
||||
"deleteCredential": "删除凭据",
|
||||
"updateCredential": "更新凭据",
|
||||
"credentialName": "凭据名称",
|
||||
"credentialDescription": "描述",
|
||||
"searchCredentials": "搜索凭据...",
|
||||
"selectFolder": "选择文件夹",
|
||||
"selectAuthType": "选择认证类型",
|
||||
"allFolders": "所有文件夹",
|
||||
"allAuthTypes": "所有认证类型",
|
||||
"uncategorized": "未分类",
|
||||
"totalCredentials": "总计",
|
||||
"keyBased": "密钥认证",
|
||||
"passwordBased": "密码认证",
|
||||
"folders": "文件夹",
|
||||
"noCredentialsMatchFilters": "没有符合筛选条件的凭据",
|
||||
"noCredentialsYet": "还未创建凭据",
|
||||
"createFirstCredential": "创建您的第一个凭据",
|
||||
"failedToFetchCredentials": "获取凭据失败",
|
||||
"credentialDeletedSuccessfully": "凭据删除成功",
|
||||
"failedToDeleteCredential": "删除凭据失败",
|
||||
"confirmDeleteCredential": "确定要删除凭据「{{name}}」吗?",
|
||||
"credentialCreatedSuccessfully": "凭据创建成功",
|
||||
"credentialUpdatedSuccessfully": "凭据更新成功",
|
||||
"failedToSaveCredential": "保存凭据失败",
|
||||
"failedToFetchCredentialDetails": "获取凭据详情失败",
|
||||
"failedToFetchHostsUsing": "获取使用此凭据的主机失败",
|
||||
"basicInfo": "基本信息",
|
||||
"authentication": "认证方式",
|
||||
"organization": "组织管理",
|
||||
"basicInformation": "基本信息",
|
||||
"basicInformationDescription": "输入此凭据的基本信息",
|
||||
"authenticationMethod": "认证方式",
|
||||
"authenticationMethodDescription": "选择如何与SSH服务器进行认证",
|
||||
"organizationDescription": "使用文件夹和标签来组织您的凭据",
|
||||
"enterCredentialName": "输入凭据名称",
|
||||
"enterCredentialDescription": "输入描述(可选)",
|
||||
"enterUsername": "输入用户名",
|
||||
"nameIsRequired": "凭据名称是必需的",
|
||||
"usernameIsRequired": "用户名是必需的",
|
||||
"authenticationType": "认证类型",
|
||||
"passwordAuthDescription": "使用密码认证",
|
||||
"sshKeyAuthDescription": "使用SSH密钥认证",
|
||||
"passwordIsRequired": "密码是必需的",
|
||||
"sshKeyIsRequired": "SSH密钥是必需的",
|
||||
"sshKeyType": "SSH密钥类型",
|
||||
"privateKey": "私钥",
|
||||
"enterPassword": "输入密码",
|
||||
"enterPrivateKey": "输入私钥",
|
||||
"keyPassphrase": "密钥密码",
|
||||
"enterKeyPassphrase": "输入密钥密码(可选)",
|
||||
"keyPassphraseOptional": "可选:如果您的密钥没有密码,请留空",
|
||||
"leaveEmptyToKeepCurrent": "留空以保持当前值",
|
||||
"uploadKeyFile": "上传密钥文件",
|
||||
"generateKeyPair": "生成密钥对",
|
||||
"sshKeyGenerationNotImplemented": "SSH密钥生成功能即将推出",
|
||||
"connectionTestingNotImplemented": "连接测试功能即将推出",
|
||||
"testConnection": "测试连接",
|
||||
"selectOrCreateFolder": "选择或创建文件夹",
|
||||
"noFolder": "无文件夹",
|
||||
"orCreateNewFolder": "或创建新文件夹",
|
||||
"addTag": "添加标签",
|
||||
"saving": "保存中...",
|
||||
"overview": "概览",
|
||||
"security": "安全",
|
||||
"usage": "使用情况",
|
||||
"securityDetails": "安全详情",
|
||||
"securityDetailsDescription": "查看加密的凭据信息",
|
||||
"credentialSecured": "凭据已加密",
|
||||
"credentialSecuredDescription": "所有敏感数据均使用AES-256加密",
|
||||
"passwordAuthentication": "密码认证",
|
||||
"keyAuthentication": "密钥认证",
|
||||
"keyType": "密钥类型",
|
||||
"securityReminder": "安全提醒",
|
||||
"securityReminderText": "请勿分享您的凭据。所有数据均已静态加密。",
|
||||
"hostsUsingCredential": "使用此凭据的主机",
|
||||
"noHostsUsingCredential": "当前没有主机使用此凭据",
|
||||
"timesUsed": "使用次数",
|
||||
"lastUsed": "最后使用",
|
||||
"connectedHosts": "连接的主机",
|
||||
"created": "创建时间",
|
||||
"lastModified": "最后修改",
|
||||
"usageStatistics": "使用统计",
|
||||
"copiedToClipboard": "{{field}}已复制到剪贴板",
|
||||
"failedToCopy": "复制到剪贴板失败",
|
||||
"sshKey": "SSH密钥",
|
||||
"createCredentialDescription": "创建新的SSH凭据以进行安全访问",
|
||||
"editCredentialDescription": "更新凭据信息",
|
||||
"listView": "列表",
|
||||
"folderView": "文件夹",
|
||||
"unknown": "未知"
|
||||
},
|
||||
"sshTools": {
|
||||
"title": "SSH 工具",
|
||||
"closeTools": "关闭 SSH 工具",
|
||||
@@ -120,6 +219,7 @@
|
||||
"nav": {
|
||||
"home": "首页",
|
||||
"hosts": "主机",
|
||||
"credentials": "凭据",
|
||||
"terminal": "终端",
|
||||
"tunnels": "隧道",
|
||||
"fileManager": "文件管理器",
|
||||
|
||||
@@ -3,9 +3,12 @@ import bodyParser from 'body-parser';
|
||||
import userRoutes from './routes/users.js';
|
||||
import sshRoutes from './routes/ssh.js';
|
||||
import alertRoutes from './routes/alerts.js';
|
||||
import credentialsRoutes from './routes/credentials.js';
|
||||
import chalk from 'chalk';
|
||||
import cors from 'cors';
|
||||
import fetch from 'node-fetch';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import 'dotenv/config';
|
||||
|
||||
const app = express();
|
||||
@@ -143,9 +146,26 @@ app.get('/health', (req, res) => {
|
||||
});
|
||||
|
||||
app.get('/version', async (req, res) => {
|
||||
const localVersion = process.env.VERSION;
|
||||
let localVersion = process.env.VERSION;
|
||||
|
||||
// Fallback to package.json version if env variable not set
|
||||
if (!localVersion) {
|
||||
try {
|
||||
const packagePath = path.resolve(process.cwd(), 'package.json');
|
||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||
localVersion = packageJson.version;
|
||||
logger.info(`Using version from package.json: ${localVersion}`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to read version from package.json:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
logger.debug(`Final version: ${localVersion}`);
|
||||
logger.debug(`Working directory: ${process.cwd()}`);
|
||||
|
||||
if (!localVersion) {
|
||||
logger.error('No version information available');
|
||||
return res.status(404).send('Local Version Not Set');
|
||||
}
|
||||
|
||||
@@ -235,19 +255,11 @@ app.get('/releases/rss', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Health check endpoint for Electron backend manager
|
||||
app.get('/health', (req, res) => {
|
||||
res.status(200).json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'database-api',
|
||||
port: PORT
|
||||
});
|
||||
});
|
||||
|
||||
app.use('/users', userRoutes);
|
||||
app.use('/ssh', sshRoutes);
|
||||
app.use('/alerts', alertRoutes);
|
||||
app.use('/credentials', credentialsRoutes);
|
||||
|
||||
app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
logger.error('Unhandled error:', err);
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as schema from './schema.js';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { MigrationManager } from '../migrations/migrator.js';
|
||||
|
||||
const dbIconSymbol = '🗄️';
|
||||
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
@@ -433,6 +434,9 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists('ssh_data', 'created_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
|
||||
addColumnIfNotExists('ssh_data', 'updated_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
|
||||
|
||||
// Add credential_id column for SSH credentials management
|
||||
addColumnIfNotExists('ssh_data', 'credential_id', 'INTEGER REFERENCES ssh_credentials(id)');
|
||||
|
||||
addColumnIfNotExists('file_manager_recent', 'host_id', 'INTEGER NOT NULL');
|
||||
addColumnIfNotExists('file_manager_pinned', 'host_id', 'INTEGER NOT NULL');
|
||||
addColumnIfNotExists('file_manager_shortcuts', 'host_id', 'INTEGER NOT NULL');
|
||||
@@ -440,8 +444,13 @@ const migrateSchema = () => {
|
||||
logger.success('Schema migration completed');
|
||||
};
|
||||
|
||||
const initializeDatabase = async () => {
|
||||
migrateSchema();
|
||||
|
||||
// Run new migration system
|
||||
const migrationManager = new MigrationManager(sqlite);
|
||||
await migrationManager.runMigrations();
|
||||
|
||||
try {
|
||||
const row = sqlite.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get();
|
||||
if (!row) {
|
||||
@@ -450,5 +459,12 @@ try {
|
||||
} catch (e) {
|
||||
logger.warn('Could not initialize default settings');
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize database (async)
|
||||
initializeDatabase().catch(error => {
|
||||
logger.error('Failed to initialize database:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
export const db = drizzle(sqlite, {schema});
|
||||
@@ -39,10 +39,13 @@ export const sshData = sqliteTable('ssh_data', {
|
||||
tags: text('tags'),
|
||||
pin: integer('pin', {mode: 'boolean'}).notNull().default(false),
|
||||
authType: text('auth_type').notNull(),
|
||||
// Legacy credential fields - kept for backward compatibility
|
||||
password: text('password'),
|
||||
key: text('key', {length: 8192}),
|
||||
keyPassword: text('key_password'),
|
||||
keyType: text('key_type'),
|
||||
// New credential management
|
||||
credentialId: integer('credential_id').references(() => sshCredentials.id),
|
||||
enableTerminal: integer('enable_terminal', {mode: 'boolean'}).notNull().default(true),
|
||||
enableTunnel: integer('enable_tunnel', {mode: 'boolean'}).notNull().default(true),
|
||||
tunnelConnections: text('tunnel_connections'),
|
||||
@@ -85,3 +88,31 @@ export const dismissedAlerts = sqliteTable('dismissed_alerts', {
|
||||
alertId: text('alert_id').notNull(),
|
||||
dismissedAt: text('dismissed_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
// SSH Credentials Management Tables
|
||||
export const sshCredentials = sqliteTable('ssh_credentials', {
|
||||
id: integer('id').primaryKey({autoIncrement: true}),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
folder: text('folder'),
|
||||
tags: text('tags'),
|
||||
authType: text('auth_type').notNull(), // 'password' | 'key'
|
||||
username: text('username').notNull(),
|
||||
encryptedPassword: text('encrypted_password'), // AES encrypted
|
||||
encryptedKey: text('encrypted_key', {length: 16384}), // AES encrypted SSH key
|
||||
encryptedKeyPassword: text('encrypted_key_password'), // AES encrypted key passphrase
|
||||
keyType: text('key_type'), // 'rsa' | 'ecdsa' | 'ed25519'
|
||||
usageCount: integer('usage_count').notNull().default(0),
|
||||
lastUsed: text('last_used'),
|
||||
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const sshCredentialUsage = sqliteTable('ssh_credential_usage', {
|
||||
id: integer('id').primaryKey({autoIncrement: true}),
|
||||
credentialId: integer('credential_id').notNull().references(() => sshCredentials.id),
|
||||
hostId: integer('host_id').notNull().references(() => sshData.id),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
usedAt: text('used_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Database } from 'better-sqlite3';
|
||||
|
||||
export const up = (db: Database) => {
|
||||
// Create SSH credentials table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ssh_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
folder TEXT,
|
||||
tags TEXT,
|
||||
auth_type TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
encrypted_password TEXT,
|
||||
encrypted_key TEXT,
|
||||
encrypted_key_password TEXT,
|
||||
key_type TEXT,
|
||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_used TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Create credential usage tracking table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ssh_credential_usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
credential_id INTEGER NOT NULL REFERENCES ssh_credentials(id),
|
||||
host_id INTEGER NOT NULL REFERENCES ssh_data(id),
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Add credential_id column to ssh_data table if it doesn't exist
|
||||
const columns = db.prepare(`PRAGMA table_info(ssh_data)`).all();
|
||||
const hasCredentialId = columns.some((col: any) => col.name === 'credential_id');
|
||||
|
||||
if (!hasCredentialId) {
|
||||
db.exec(`
|
||||
ALTER TABLE ssh_data
|
||||
ADD COLUMN credential_id INTEGER REFERENCES ssh_credentials(id)
|
||||
`);
|
||||
}
|
||||
|
||||
// Create indexes for better performance
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_ssh_credentials_user_id ON ssh_credentials(user_id)`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_ssh_credentials_folder ON ssh_credentials(folder)`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_ssh_credential_usage_credential_id ON ssh_credential_usage(credential_id)`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_ssh_credential_usage_host_id ON ssh_credential_usage(host_id)`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_ssh_data_credential_id ON ssh_data(credential_id)`);
|
||||
|
||||
console.log('✅ Added SSH credentials management tables');
|
||||
};
|
||||
|
||||
export const down = (db: Database) => {
|
||||
// Remove credential_id column from ssh_data table
|
||||
db.exec(`
|
||||
CREATE TABLE ssh_data_backup AS SELECT
|
||||
id, user_id, name, ip, port, username, folder, tags, pin, auth_type,
|
||||
password, key, key_password, key_type, enable_terminal, enable_tunnel,
|
||||
tunnel_connections, enable_file_manager, default_path, created_at, updated_at
|
||||
FROM ssh_data
|
||||
`);
|
||||
|
||||
db.exec(`DROP TABLE ssh_data`);
|
||||
db.exec(`ALTER TABLE ssh_data_backup RENAME TO ssh_data`);
|
||||
|
||||
// Drop credential tables
|
||||
db.exec(`DROP TABLE IF EXISTS ssh_credential_usage`);
|
||||
db.exec(`DROP TABLE IF EXISTS ssh_credentials`);
|
||||
|
||||
console.log('✅ Removed SSH credentials management tables');
|
||||
};
|
||||
@@ -0,0 +1,261 @@
|
||||
import type { Database } from 'better-sqlite3';
|
||||
import chalk from 'chalk';
|
||||
import { readFileSync, readdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const logger = {
|
||||
info: (msg: string): void => {
|
||||
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
console.log(`${timestamp} ${chalk.cyan('[MIGRATION]')} ${msg}`);
|
||||
},
|
||||
warn: (msg: string): void => {
|
||||
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
console.warn(`${timestamp} ${chalk.yellow('[MIGRATION]')} ${msg}`);
|
||||
},
|
||||
error: (msg: string, err?: unknown): void => {
|
||||
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
console.error(`${timestamp} ${chalk.redBright('[MIGRATION]')} ${msg}`);
|
||||
if (err) console.error(err);
|
||||
},
|
||||
success: (msg: string): void => {
|
||||
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
console.log(`${timestamp} ${chalk.greenBright('[MIGRATION]')} ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
interface Migration {
|
||||
id: string;
|
||||
name: string;
|
||||
up: (db: Database) => void;
|
||||
down: (db: Database) => void;
|
||||
}
|
||||
|
||||
class MigrationManager {
|
||||
private db: Database;
|
||||
private migrationsPath: string;
|
||||
|
||||
constructor(db: Database) {
|
||||
this.db = db;
|
||||
this.migrationsPath = __dirname;
|
||||
this.ensureMigrationsTable();
|
||||
}
|
||||
|
||||
private ensureMigrationsTable() {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS migrations (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
private getAppliedMigrations(): Set<string> {
|
||||
const applied = this.db.prepare('SELECT id FROM migrations').all() as { id: string }[];
|
||||
return new Set(applied.map(m => m.id));
|
||||
}
|
||||
|
||||
private async loadMigration(filename: string): Promise<Migration | null> {
|
||||
try {
|
||||
const migrationPath = join(this.migrationsPath, filename);
|
||||
// Convert to file:// URL for Windows compatibility
|
||||
const migrationUrl = process.platform === 'win32'
|
||||
? `file:///${migrationPath.replace(/\\/g, '/')}`
|
||||
: migrationPath;
|
||||
const migration = await import(migrationUrl);
|
||||
|
||||
// Extract migration ID and name from filename
|
||||
const matches = filename.match(/^(\d+)-(.+)\.(ts|js)$/);
|
||||
if (!matches) {
|
||||
logger.warn(`Skipping invalid migration filename: ${filename}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, id, name] = matches;
|
||||
|
||||
return {
|
||||
id: id.padStart(3, '0'),
|
||||
name: name.replace(/-/g, ' '),
|
||||
up: migration.up,
|
||||
down: migration.down
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Failed to load migration ${filename}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private getMigrationFiles(): string[] {
|
||||
try {
|
||||
return readdirSync(this.migrationsPath)
|
||||
.filter(file => (file.endsWith('.ts') || file.endsWith('.js')) && !file.includes('migrator'))
|
||||
.sort();
|
||||
} catch (error) {
|
||||
logger.error('Failed to read migrations directory:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async runMigrations(): Promise<void> {
|
||||
logger.info('Starting database migrations...');
|
||||
|
||||
const migrationFiles = this.getMigrationFiles();
|
||||
if (migrationFiles.length === 0) {
|
||||
logger.info('No migrations found');
|
||||
return;
|
||||
}
|
||||
|
||||
const appliedMigrations = this.getAppliedMigrations();
|
||||
const migrations: Migration[] = [];
|
||||
|
||||
// Load all migrations
|
||||
for (const filename of migrationFiles) {
|
||||
const migration = await this.loadMigration(filename);
|
||||
if (migration) {
|
||||
migrations.push(migration);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out already applied migrations
|
||||
const pendingMigrations = migrations.filter(m => !appliedMigrations.has(m.id));
|
||||
|
||||
if (pendingMigrations.length === 0) {
|
||||
logger.info('All migrations are already applied');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Found ${pendingMigrations.length} pending migration(s)`);
|
||||
|
||||
// Run pending migrations in transaction
|
||||
const transaction = this.db.transaction(() => {
|
||||
for (const migration of pendingMigrations) {
|
||||
logger.info(`Applying migration ${migration.id}: ${migration.name}`);
|
||||
|
||||
try {
|
||||
migration.up(this.db);
|
||||
|
||||
// Record the migration
|
||||
this.db.prepare(`
|
||||
INSERT INTO migrations (id, name)
|
||||
VALUES (?, ?)
|
||||
`).run(migration.id, migration.name);
|
||||
|
||||
logger.success(`Applied migration ${migration.id}: ${migration.name}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to apply migration ${migration.id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
transaction();
|
||||
logger.success(`Successfully applied ${pendingMigrations.length} migration(s)`);
|
||||
} catch (error) {
|
||||
logger.error('Migration transaction failed, rolling back:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async rollbackMigration(targetId?: string): Promise<void> {
|
||||
logger.warn('Starting migration rollback...');
|
||||
|
||||
const appliedMigrations = this.db.prepare(`
|
||||
SELECT id, name FROM migrations
|
||||
ORDER BY id DESC
|
||||
`).all() as { id: string; name: string }[];
|
||||
|
||||
if (appliedMigrations.length === 0) {
|
||||
logger.info('No migrations to rollback');
|
||||
return;
|
||||
}
|
||||
|
||||
const migrationsToRollback = targetId
|
||||
? appliedMigrations.filter(m => m.id >= targetId)
|
||||
: [appliedMigrations[0]]; // Only rollback the latest
|
||||
|
||||
const migrationFiles = this.getMigrationFiles();
|
||||
const migrations: Migration[] = [];
|
||||
|
||||
// Load migrations that need to be rolled back
|
||||
for (const filename of migrationFiles) {
|
||||
const migration = await this.loadMigration(filename);
|
||||
if (migration && migrationsToRollback.some(m => m.id === migration.id)) {
|
||||
migrations.push(migration);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort in reverse order for rollback
|
||||
migrations.sort((a, b) => b.id.localeCompare(a.id));
|
||||
|
||||
const transaction = this.db.transaction(() => {
|
||||
for (const migration of migrations) {
|
||||
logger.info(`Rolling back migration ${migration.id}: ${migration.name}`);
|
||||
|
||||
try {
|
||||
migration.down(this.db);
|
||||
|
||||
// Remove the migration record
|
||||
this.db.prepare(`DELETE FROM migrations WHERE id = ?`).run(migration.id);
|
||||
|
||||
logger.success(`Rolled back migration ${migration.id}: ${migration.name}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to rollback migration ${migration.id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
transaction();
|
||||
logger.success(`Successfully rolled back ${migrations.length} migration(s)`);
|
||||
} catch (error) {
|
||||
logger.error('Rollback transaction failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
getMigrationStatus(): { id: string; name: string; applied: boolean }[] {
|
||||
const migrationFiles = this.getMigrationFiles();
|
||||
const appliedMigrations = this.getAppliedMigrations();
|
||||
|
||||
return migrationFiles.map(filename => {
|
||||
const matches = filename.match(/^(\d+)-(.+)\.(ts|js)$/);
|
||||
if (!matches) return null;
|
||||
|
||||
const [, id, name] = matches;
|
||||
const migrationId = id.padStart(3, '0');
|
||||
|
||||
return {
|
||||
id: migrationId,
|
||||
name: name.replace(/-/g, ' '),
|
||||
applied: appliedMigrations.has(migrationId)
|
||||
};
|
||||
}).filter(Boolean) as { id: string; name: string; applied: boolean }[];
|
||||
}
|
||||
|
||||
printStatus(): void {
|
||||
const status = this.getMigrationStatus();
|
||||
|
||||
logger.info('Migration Status:');
|
||||
console.log(chalk.gray('─'.repeat(60)));
|
||||
|
||||
status.forEach(migration => {
|
||||
const statusIcon = migration.applied ? chalk.green('✓') : chalk.yellow('○');
|
||||
const statusText = migration.applied ? chalk.green('Applied') : chalk.yellow('Pending');
|
||||
console.log(`${statusIcon} ${migration.id} - ${migration.name} [${statusText}]`);
|
||||
});
|
||||
|
||||
console.log(chalk.gray('─'.repeat(60)));
|
||||
const appliedCount = status.filter(m => m.applied).length;
|
||||
console.log(`Total: ${status.length} migrations, ${appliedCount} applied, ${status.length - appliedCount} pending`);
|
||||
}
|
||||
}
|
||||
|
||||
export { MigrationManager };
|
||||
export type { Migration };
|
||||
@@ -0,0 +1,270 @@
|
||||
import express from 'express';
|
||||
import {credentialService} from '../../services/credentials.js';
|
||||
import type {Request, Response, NextFunction} from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import chalk from 'chalk';
|
||||
|
||||
const credIconSymbol = '🔐';
|
||||
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
|
||||
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#0f766e')(`[${credIconSymbol}]`)} ${message}`;
|
||||
};
|
||||
const logger = {
|
||||
info: (msg: string): void => {
|
||||
console.log(formatMessage('info', chalk.cyan, msg));
|
||||
},
|
||||
warn: (msg: string): void => {
|
||||
console.warn(formatMessage('warn', chalk.yellow, msg));
|
||||
},
|
||||
error: (msg: string, err?: unknown): void => {
|
||||
console.error(formatMessage('error', chalk.redBright, msg));
|
||||
if (err) console.error(err);
|
||||
},
|
||||
success: (msg: string): void => {
|
||||
console.log(formatMessage('success', chalk.greenBright, msg));
|
||||
}
|
||||
};
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
interface JWTPayload {
|
||||
userId: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
function isNonEmptyString(val: any): val is string {
|
||||
return typeof val === 'string' && val.trim().length > 0;
|
||||
}
|
||||
|
||||
function authenticateJWT(req: Request, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers['authorization'];
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
logger.warn('Missing or invalid Authorization header');
|
||||
return res.status(401).json({error: 'Missing or invalid Authorization header'});
|
||||
}
|
||||
const token = authHeader.split(' ')[1];
|
||||
const jwtSecret = process.env.JWT_SECRET || 'secret';
|
||||
try {
|
||||
const payload = jwt.verify(token, jwtSecret) as JWTPayload;
|
||||
(req as any).userId = payload.userId;
|
||||
next();
|
||||
} catch (err) {
|
||||
logger.warn('Invalid or expired token');
|
||||
return res.status(401).json({error: 'Invalid or expired token'});
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new credential
|
||||
// POST /credentials
|
||||
router.post('/', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
folder,
|
||||
tags,
|
||||
authType,
|
||||
username,
|
||||
password,
|
||||
key,
|
||||
keyPassword,
|
||||
keyType
|
||||
} = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !isNonEmptyString(name) || !isNonEmptyString(username)) {
|
||||
logger.warn('Invalid credential creation data');
|
||||
return res.status(400).json({error: 'Name and username are required'});
|
||||
}
|
||||
|
||||
if (!['password', 'key'].includes(authType)) {
|
||||
logger.warn('Invalid auth type');
|
||||
return res.status(400).json({error: 'Auth type must be "password" or "key"'});
|
||||
}
|
||||
|
||||
try {
|
||||
const credential = await credentialService.createCredential(userId, {
|
||||
name,
|
||||
description,
|
||||
folder,
|
||||
tags,
|
||||
authType,
|
||||
username,
|
||||
password,
|
||||
key,
|
||||
keyPassword,
|
||||
keyType
|
||||
});
|
||||
|
||||
logger.success(`Created credential: ${name}`);
|
||||
res.status(201).json(credential);
|
||||
} catch (err) {
|
||||
logger.error('Failed to create credential', err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : 'Failed to create credential'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get all credentials for the authenticated user
|
||||
// GET /credentials
|
||||
router.get('/', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
|
||||
if (!isNonEmptyString(userId)) {
|
||||
logger.warn('Invalid userId for credential fetch');
|
||||
return res.status(400).json({error: 'Invalid userId'});
|
||||
}
|
||||
|
||||
try {
|
||||
const credentials = await credentialService.getUserCredentials(userId);
|
||||
res.json(credentials);
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch credentials', err);
|
||||
res.status(500).json({error: 'Failed to fetch credentials'});
|
||||
}
|
||||
});
|
||||
|
||||
// Get all unique credential folders for the authenticated user
|
||||
// GET /credentials/folders
|
||||
router.get('/folders', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
|
||||
if (!isNonEmptyString(userId)) {
|
||||
logger.warn('Invalid userId for credential folder fetch');
|
||||
return res.status(400).json({error: 'Invalid userId'});
|
||||
}
|
||||
|
||||
try {
|
||||
const folders = await credentialService.getCredentialsFolders(userId);
|
||||
res.json(folders);
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch credential folders', err);
|
||||
res.status(500).json({error: 'Failed to fetch credential folders'});
|
||||
}
|
||||
});
|
||||
|
||||
// Get a specific credential by ID (with decrypted secrets)
|
||||
// GET /credentials/:id
|
||||
router.get('/:id', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const {id} = req.params;
|
||||
|
||||
if (!isNonEmptyString(userId) || !id) {
|
||||
logger.warn('Invalid request for credential fetch');
|
||||
return res.status(400).json({error: 'Invalid request'});
|
||||
}
|
||||
|
||||
try {
|
||||
const credential = await credentialService.getCredentialWithSecrets(userId, parseInt(id));
|
||||
|
||||
if (!credential) {
|
||||
return res.status(404).json({error: 'Credential not found'});
|
||||
}
|
||||
|
||||
res.json(credential);
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch credential', err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : 'Failed to fetch credential'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Update a credential
|
||||
// PUT /credentials/:id
|
||||
router.put('/:id', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const {id} = req.params;
|
||||
const updateData = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !id) {
|
||||
logger.warn('Invalid request for credential update');
|
||||
return res.status(400).json({error: 'Invalid request'});
|
||||
}
|
||||
|
||||
try {
|
||||
const credential = await credentialService.updateCredential(userId, parseInt(id), updateData);
|
||||
logger.success(`Updated credential ID ${id}`);
|
||||
res.json(credential);
|
||||
} catch (err) {
|
||||
logger.error('Failed to update credential', err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : 'Failed to update credential'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a credential
|
||||
// DELETE /credentials/:id
|
||||
router.delete('/:id', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const {id} = req.params;
|
||||
|
||||
if (!isNonEmptyString(userId) || !id) {
|
||||
logger.warn('Invalid request for credential deletion');
|
||||
return res.status(400).json({error: 'Invalid request'});
|
||||
}
|
||||
|
||||
try {
|
||||
await credentialService.deleteCredential(userId, parseInt(id));
|
||||
logger.success(`Deleted credential ID ${id}`);
|
||||
res.json({message: 'Credential deleted successfully'});
|
||||
} catch (err) {
|
||||
logger.error('Failed to delete credential', err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : 'Failed to delete credential'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Apply a credential to an SSH host (for quick application)
|
||||
// POST /credentials/:id/apply-to-host/:hostId
|
||||
router.post('/:id/apply-to-host/:hostId', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const {id: credentialId, hostId} = req.params;
|
||||
|
||||
if (!isNonEmptyString(userId) || !credentialId || !hostId) {
|
||||
logger.warn('Invalid request for credential application');
|
||||
return res.status(400).json({error: 'Invalid request'});
|
||||
}
|
||||
|
||||
try {
|
||||
const {sshHostService} = await import('../../services/ssh-host.js');
|
||||
await sshHostService.applyCredentialToHost(userId, parseInt(hostId), parseInt(credentialId));
|
||||
|
||||
logger.success(`Applied credential ${credentialId} to host ${hostId}`);
|
||||
res.json({message: 'Credential applied to host successfully'});
|
||||
} catch (err) {
|
||||
logger.error('Failed to apply credential to host', err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : 'Failed to apply credential to host'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get hosts using a specific credential
|
||||
// GET /credentials/:id/hosts
|
||||
router.get('/:id/hosts', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const {id: credentialId} = req.params;
|
||||
|
||||
if (!isNonEmptyString(userId) || !credentialId) {
|
||||
logger.warn('Invalid request for credential hosts fetch');
|
||||
return res.status(400).json({error: 'Invalid request'});
|
||||
}
|
||||
|
||||
try {
|
||||
const {sshHostService} = await import('../../services/ssh-host.js');
|
||||
const hosts = await sshHostService.getHostsUsingCredential(userId, parseInt(credentialId));
|
||||
|
||||
res.json(hosts);
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch hosts using credential', err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : 'Failed to fetch hosts using credential'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -691,6 +691,109 @@ router.delete('/file_manager/shortcuts', authenticateJWT, async (req: Request, r
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Get SSH host by ID with resolved credentials (requires JWT)
|
||||
// GET /ssh/host/:id/with-credentials
|
||||
router.get('/db/host/:id/with-credentials', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const {id} = req.params;
|
||||
const userId = (req as any).userId;
|
||||
|
||||
if (!isNonEmptyString(userId) || !id) {
|
||||
logger.warn('Invalid request for SSH host with credentials fetch');
|
||||
return res.status(400).json({error: 'Invalid request'});
|
||||
}
|
||||
|
||||
try {
|
||||
const {sshHostService} = await import('../../services/ssh-host.js');
|
||||
const host = await sshHostService.getHostWithCredentials(userId, parseInt(id));
|
||||
|
||||
if (!host) {
|
||||
return res.status(404).json({error: 'SSH host not found'});
|
||||
}
|
||||
|
||||
res.json(host);
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch SSH host with credentials', err);
|
||||
res.status(500).json({error: 'Failed to fetch SSH host with credentials'});
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Apply credential to SSH host (requires JWT)
|
||||
// POST /ssh/host/:id/apply-credential
|
||||
router.post('/db/host/:id/apply-credential', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const {id: hostId} = req.params;
|
||||
const {credentialId} = req.body;
|
||||
const userId = (req as any).userId;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !credentialId) {
|
||||
logger.warn('Invalid request for applying credential to host');
|
||||
return res.status(400).json({error: 'Host ID and credential ID are required'});
|
||||
}
|
||||
|
||||
try {
|
||||
const {sshHostService} = await import('../../services/ssh-host.js');
|
||||
await sshHostService.applyCredentialToHost(userId, parseInt(hostId), parseInt(credentialId));
|
||||
|
||||
res.json({message: 'Credential applied to host successfully'});
|
||||
} catch (err) {
|
||||
logger.error('Failed to apply credential to host', err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : 'Failed to apply credential to host'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Remove credential from SSH host (requires JWT)
|
||||
// DELETE /ssh/host/:id/credential
|
||||
router.delete('/db/host/:id/credential', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const {id: hostId} = req.params;
|
||||
const userId = (req as any).userId;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId) {
|
||||
logger.warn('Invalid request for removing credential from host');
|
||||
return res.status(400).json({error: 'Invalid request'});
|
||||
}
|
||||
|
||||
try {
|
||||
const {sshHostService} = await import('../../services/ssh-host.js');
|
||||
await sshHostService.removeCredentialFromHost(userId, parseInt(hostId));
|
||||
|
||||
res.json({message: 'Credential removed from host successfully'});
|
||||
} catch (err) {
|
||||
logger.error('Failed to remove credential from host', err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : 'Failed to remove credential from host'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Migrate host to managed credential (requires JWT)
|
||||
// POST /ssh/host/:id/migrate-to-credential
|
||||
router.post('/db/host/:id/migrate-to-credential', authenticateJWT, async (req: Request, res: Response) => {
|
||||
const {id: hostId} = req.params;
|
||||
const {credentialName} = req.body;
|
||||
const userId = (req as any).userId;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !credentialName) {
|
||||
logger.warn('Invalid request for migrating host to credential');
|
||||
return res.status(400).json({error: 'Host ID and credential name are required'});
|
||||
}
|
||||
|
||||
try {
|
||||
const {sshHostService} = await import('../../services/ssh-host.js');
|
||||
const credentialId = await sshHostService.migrateHostToCredential(userId, parseInt(hostId), credentialName);
|
||||
|
||||
res.json({
|
||||
message: 'Host migrated to managed credential successfully',
|
||||
credentialId
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('Failed to migrate host to credential', err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : 'Failed to migrate host to credential'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Bulk import SSH hosts from JSON (requires JWT)
|
||||
// POST /ssh/bulk-import
|
||||
router.post('/bulk-import', authenticateJWT, async (req: Request, res: Response) => {
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
import {db} from '../database/db/index.js';
|
||||
import {sshCredentials, sshCredentialUsage, sshData} from '../database/db/schema.js';
|
||||
import {eq, and, desc, sql} from 'drizzle-orm';
|
||||
import {encryptionService} from './encryption.js';
|
||||
import chalk from 'chalk';
|
||||
|
||||
const logger = {
|
||||
info: (msg: string): void => {
|
||||
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
console.log(`${timestamp} ${chalk.cyan('[INFO]')} ${chalk.hex('#0f766e')('[CRED]')} ${msg}`);
|
||||
},
|
||||
warn: (msg: string): void => {
|
||||
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
console.warn(`${timestamp} ${chalk.yellow('[WARN]')} ${chalk.hex('#0f766e')('[CRED]')} ${msg}`);
|
||||
},
|
||||
error: (msg: string, err?: unknown): void => {
|
||||
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
console.error(`${timestamp} ${chalk.redBright('[ERROR]')} ${chalk.hex('#0f766e')('[CRED]')} ${msg}`);
|
||||
if (err) console.error(err);
|
||||
},
|
||||
success: (msg: string): void => {
|
||||
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
console.log(`${timestamp} ${chalk.greenBright('[SUCCESS]')} ${chalk.hex('#0f766e')('[CRED]')} ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
export interface CredentialInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
folder?: string;
|
||||
tags?: string[];
|
||||
authType: 'password' | 'key';
|
||||
username: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
}
|
||||
|
||||
export interface CredentialOutput {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
folder?: string;
|
||||
tags: string[];
|
||||
authType: 'password' | 'key';
|
||||
username: string;
|
||||
keyType?: string;
|
||||
usageCount: number;
|
||||
lastUsed?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CredentialWithSecrets extends CredentialOutput {
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
}
|
||||
|
||||
class CredentialService {
|
||||
/**
|
||||
* Create a new credential
|
||||
*/
|
||||
async createCredential(userId: string, input: CredentialInput): Promise<CredentialOutput> {
|
||||
try {
|
||||
// Validate input
|
||||
if (!input.name?.trim()) {
|
||||
throw new Error('Credential name is required');
|
||||
}
|
||||
if (!input.username?.trim()) {
|
||||
throw new Error('Username is required');
|
||||
}
|
||||
if (!['password', 'key'].includes(input.authType)) {
|
||||
throw new Error('Invalid auth type');
|
||||
}
|
||||
if (input.authType === 'password' && !input.password) {
|
||||
throw new Error('Password is required for password authentication');
|
||||
}
|
||||
if (input.authType === 'key' && !input.key) {
|
||||
throw new Error('SSH key is required for key authentication');
|
||||
}
|
||||
|
||||
// Encrypt sensitive data
|
||||
let encryptedPassword: string | null = null;
|
||||
let encryptedKey: string | null = null;
|
||||
let encryptedKeyPassword: string | null = null;
|
||||
|
||||
if (input.authType === 'password' && input.password) {
|
||||
encryptedPassword = encryptionService.encryptToString(input.password);
|
||||
} else if (input.authType === 'key') {
|
||||
if (input.key) {
|
||||
encryptedKey = encryptionService.encryptToString(input.key);
|
||||
}
|
||||
if (input.keyPassword) {
|
||||
encryptedKeyPassword = encryptionService.encryptToString(input.keyPassword);
|
||||
}
|
||||
}
|
||||
|
||||
const credentialData = {
|
||||
userId,
|
||||
name: input.name.trim(),
|
||||
description: input.description?.trim() || null,
|
||||
folder: input.folder?.trim() || null,
|
||||
tags: Array.isArray(input.tags) ? input.tags.join(',') : (input.tags || ''),
|
||||
authType: input.authType,
|
||||
username: input.username.trim(),
|
||||
encryptedPassword,
|
||||
encryptedKey,
|
||||
encryptedKeyPassword,
|
||||
keyType: input.keyType || null,
|
||||
usageCount: 0,
|
||||
lastUsed: null,
|
||||
};
|
||||
|
||||
const result = await db.insert(sshCredentials).values(credentialData).returning();
|
||||
const created = result[0];
|
||||
|
||||
logger.success(`Created credential "${input.name}" (ID: ${created.id})`);
|
||||
|
||||
return this.formatCredentialOutput(created);
|
||||
} catch (error) {
|
||||
logger.error('Failed to create credential', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all credentials for a user
|
||||
*/
|
||||
async getUserCredentials(userId: string): Promise<CredentialOutput[]> {
|
||||
try {
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(eq(sshCredentials.userId, userId))
|
||||
.orderBy(desc(sshCredentials.updatedAt));
|
||||
|
||||
return credentials.map(cred => this.formatCredentialOutput(cred));
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch user credentials', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a credential by ID with decrypted secrets
|
||||
*/
|
||||
async getCredentialWithSecrets(userId: string, credentialId: number): Promise<CredentialWithSecrets | null> {
|
||||
try {
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(and(
|
||||
eq(sshCredentials.id, credentialId),
|
||||
eq(sshCredentials.userId, userId)
|
||||
));
|
||||
|
||||
if (credentials.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const credential = credentials[0];
|
||||
const output: CredentialWithSecrets = {
|
||||
...this.formatCredentialOutput(credential)
|
||||
};
|
||||
|
||||
// Decrypt sensitive data
|
||||
try {
|
||||
if (credential.encryptedPassword) {
|
||||
output.password = encryptionService.decryptFromString(credential.encryptedPassword);
|
||||
}
|
||||
if (credential.encryptedKey) {
|
||||
output.key = encryptionService.decryptFromString(credential.encryptedKey);
|
||||
}
|
||||
if (credential.encryptedKeyPassword) {
|
||||
output.keyPassword = encryptionService.decryptFromString(credential.encryptedKeyPassword);
|
||||
}
|
||||
} catch (decryptError) {
|
||||
logger.error(`Failed to decrypt credential ${credentialId}`, decryptError);
|
||||
throw new Error('Failed to decrypt credential data');
|
||||
}
|
||||
|
||||
return output;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get credential with secrets', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a credential
|
||||
*/
|
||||
async updateCredential(userId: string, credentialId: number, input: Partial<CredentialInput>): Promise<CredentialOutput> {
|
||||
try {
|
||||
// Check if credential exists and belongs to user
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(and(
|
||||
eq(sshCredentials.id, credentialId),
|
||||
eq(sshCredentials.userId, userId)
|
||||
));
|
||||
|
||||
if (existing.length === 0) {
|
||||
throw new Error('Credential not found');
|
||||
}
|
||||
|
||||
const updateData: any = {
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
if (input.name !== undefined) updateData.name = input.name.trim();
|
||||
if (input.description !== undefined) updateData.description = input.description?.trim() || null;
|
||||
if (input.folder !== undefined) updateData.folder = input.folder?.trim() || null;
|
||||
if (input.tags !== undefined) {
|
||||
updateData.tags = Array.isArray(input.tags) ? input.tags.join(',') : (input.tags || '');
|
||||
}
|
||||
if (input.username !== undefined) updateData.username = input.username.trim();
|
||||
if (input.authType !== undefined) updateData.authType = input.authType;
|
||||
if (input.keyType !== undefined) updateData.keyType = input.keyType;
|
||||
|
||||
// Handle sensitive data updates
|
||||
if (input.password !== undefined) {
|
||||
updateData.encryptedPassword = input.password ? encryptionService.encryptToString(input.password) : null;
|
||||
}
|
||||
if (input.key !== undefined) {
|
||||
updateData.encryptedKey = input.key ? encryptionService.encryptToString(input.key) : null;
|
||||
}
|
||||
if (input.keyPassword !== undefined) {
|
||||
updateData.encryptedKeyPassword = input.keyPassword ? encryptionService.encryptToString(input.keyPassword) : null;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(sshCredentials)
|
||||
.set(updateData)
|
||||
.where(and(
|
||||
eq(sshCredentials.id, credentialId),
|
||||
eq(sshCredentials.userId, userId)
|
||||
));
|
||||
|
||||
// Fetch updated credential
|
||||
const updated = await db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(eq(sshCredentials.id, credentialId));
|
||||
|
||||
logger.success(`Updated credential ID ${credentialId}`);
|
||||
|
||||
return this.formatCredentialOutput(updated[0]);
|
||||
} catch (error) {
|
||||
logger.error('Failed to update credential', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a credential
|
||||
*/
|
||||
async deleteCredential(userId: string, credentialId: number): Promise<void> {
|
||||
try {
|
||||
// Check if credential is in use
|
||||
const hostsUsingCredential = await db
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(and(
|
||||
eq(sshData.credentialId, credentialId),
|
||||
eq(sshData.userId, userId)
|
||||
));
|
||||
|
||||
if (hostsUsingCredential.length > 0) {
|
||||
throw new Error(`Cannot delete credential: it is currently used by ${hostsUsingCredential.length} host(s)`);
|
||||
}
|
||||
|
||||
// Delete usage records
|
||||
await db
|
||||
.delete(sshCredentialUsage)
|
||||
.where(and(
|
||||
eq(sshCredentialUsage.credentialId, credentialId),
|
||||
eq(sshCredentialUsage.userId, userId)
|
||||
));
|
||||
|
||||
// Delete credential
|
||||
const result = await db
|
||||
.delete(sshCredentials)
|
||||
.where(and(
|
||||
eq(sshCredentials.id, credentialId),
|
||||
eq(sshCredentials.userId, userId)
|
||||
));
|
||||
|
||||
logger.success(`Deleted credential ID ${credentialId}`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete credential', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record credential usage
|
||||
*/
|
||||
async recordUsage(userId: string, credentialId: number, hostId: number): Promise<void> {
|
||||
try {
|
||||
// Record usage
|
||||
await db.insert(sshCredentialUsage).values({
|
||||
credentialId,
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
|
||||
// Update credential usage stats
|
||||
await db
|
||||
.update(sshCredentials)
|
||||
.set({
|
||||
usageCount: sql`${sshCredentials.usageCount} + 1`,
|
||||
lastUsed: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
.where(eq(sshCredentials.id, credentialId));
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to record credential usage', error);
|
||||
// Don't throw - this is not critical
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get credentials grouped by folder
|
||||
*/
|
||||
async getCredentialsFolders(userId: string): Promise<string[]> {
|
||||
try {
|
||||
const result = await db
|
||||
.select({folder: sshCredentials.folder})
|
||||
.from(sshCredentials)
|
||||
.where(eq(sshCredentials.userId, userId));
|
||||
|
||||
const folderCounts: Record<string, number> = {};
|
||||
result.forEach(r => {
|
||||
if (r.folder && r.folder.trim() !== '') {
|
||||
folderCounts[r.folder] = (folderCounts[r.folder] || 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
return Object.keys(folderCounts).filter(folder => folderCounts[folder] > 0);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get credential folders', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private formatCredentialOutput(credential: any): CredentialOutput {
|
||||
return {
|
||||
id: credential.id,
|
||||
name: credential.name,
|
||||
description: credential.description,
|
||||
folder: credential.folder,
|
||||
tags: typeof credential.tags === 'string'
|
||||
? (credential.tags ? credential.tags.split(',').filter(Boolean) : [])
|
||||
: [],
|
||||
authType: credential.authType,
|
||||
username: credential.username,
|
||||
keyType: credential.keyType,
|
||||
usageCount: credential.usageCount || 0,
|
||||
lastUsed: credential.lastUsed,
|
||||
createdAt: credential.createdAt,
|
||||
updatedAt: credential.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const credentialService = new CredentialService();
|
||||
@@ -0,0 +1,133 @@
|
||||
import crypto from 'crypto';
|
||||
import chalk from 'chalk';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const KEY_LENGTH = 32; // 256 bits
|
||||
const IV_LENGTH = 16; // 128 bits
|
||||
const TAG_LENGTH = 16; // 128 bits
|
||||
|
||||
interface EncryptionResult {
|
||||
encrypted: string;
|
||||
iv: string;
|
||||
tag: string;
|
||||
}
|
||||
|
||||
interface DecryptionInput {
|
||||
encrypted: string;
|
||||
iv: string;
|
||||
tag: string;
|
||||
}
|
||||
|
||||
class EncryptionService {
|
||||
private key: Buffer;
|
||||
|
||||
constructor() {
|
||||
// Get or generate encryption key
|
||||
const keyEnv = process.env.CREDENTIAL_ENCRYPTION_KEY;
|
||||
if (keyEnv) {
|
||||
this.key = Buffer.from(keyEnv, 'hex');
|
||||
if (this.key.length !== KEY_LENGTH) {
|
||||
throw new Error(`Invalid encryption key length. Expected ${KEY_LENGTH} bytes, got ${this.key.length}`);
|
||||
}
|
||||
} else {
|
||||
// Generate a new key - in production, this should be stored securely
|
||||
this.key = crypto.randomBytes(KEY_LENGTH);
|
||||
console.warn(chalk.yellow(`[SECURITY] Generated new encryption key. Store this in CREDENTIAL_ENCRYPTION_KEY: ${this.key.toString('hex')}`));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt sensitive data
|
||||
* @param plaintext - The data to encrypt
|
||||
* @returns Encryption result with encrypted data, IV, and tag
|
||||
*/
|
||||
encrypt(plaintext: string): EncryptionResult {
|
||||
try {
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, this.key, iv);
|
||||
|
||||
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
return {
|
||||
encrypted,
|
||||
iv: iv.toString('hex'),
|
||||
tag: tag.toString('hex')
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Encryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt sensitive data
|
||||
* @param input - Encrypted data with IV and tag
|
||||
* @returns Decrypted plaintext
|
||||
*/
|
||||
decrypt(input: DecryptionInput): string {
|
||||
try {
|
||||
const iv = Buffer.from(input.iv, 'hex');
|
||||
const tag = Buffer.from(input.tag, 'hex');
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', this.key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
|
||||
let decrypted = decipher.update(input.encrypted, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return decrypted;
|
||||
} catch (error) {
|
||||
throw new Error(`Decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt data and return as single base64-encoded string
|
||||
* Format: iv:tag:encrypted
|
||||
*/
|
||||
encryptToString(plaintext: string): string {
|
||||
const result = this.encrypt(plaintext);
|
||||
const combined = `${result.iv}:${result.tag}:${result.encrypted}`;
|
||||
return Buffer.from(combined).toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt data from base64-encoded string
|
||||
*/
|
||||
decryptFromString(encryptedString: string): string {
|
||||
try {
|
||||
const combined = Buffer.from(encryptedString, 'base64').toString();
|
||||
const parts = combined.split(':');
|
||||
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('Invalid encrypted string format');
|
||||
}
|
||||
|
||||
return this.decrypt({
|
||||
iv: parts[0],
|
||||
tag: parts[1],
|
||||
encrypted: parts[2]
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to decrypt string: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a string can be decrypted (useful for testing)
|
||||
*/
|
||||
canDecrypt(encryptedString: string): boolean {
|
||||
try {
|
||||
this.decryptFromString(encryptedString);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const encryptionService = new EncryptionService();
|
||||
|
||||
// Types for external use
|
||||
export type { EncryptionResult, DecryptionInput };
|
||||
@@ -0,0 +1,277 @@
|
||||
import {db} from '../database/db/index.js';
|
||||
import {sshData, sshCredentials} from '../database/db/schema.js';
|
||||
import {eq, and} from 'drizzle-orm';
|
||||
import {credentialService} from './credentials.js';
|
||||
import {encryptionService} from './encryption.js';
|
||||
import chalk from 'chalk';
|
||||
|
||||
const logger = {
|
||||
info: (msg: string): void => {
|
||||
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
console.log(`${timestamp} ${chalk.cyan('[INFO]')} ${chalk.hex('#7c3aed')('[SSH]')} ${msg}`);
|
||||
},
|
||||
warn: (msg: string): void => {
|
||||
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
console.warn(`${timestamp} ${chalk.yellow('[WARN]')} ${chalk.hex('#7c3aed')('[SSH]')} ${msg}`);
|
||||
},
|
||||
error: (msg: string, err?: unknown): void => {
|
||||
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
console.error(`${timestamp} ${chalk.redBright('[ERROR]')} ${chalk.hex('#7c3aed')('[SSH]')} ${msg}`);
|
||||
if (err) console.error(err);
|
||||
},
|
||||
success: (msg: string): void => {
|
||||
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
|
||||
console.log(`${timestamp} ${chalk.greenBright('[SUCCESS]')} ${chalk.hex('#7c3aed')('[SSH]')} ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
export interface SSHHostWithCredentials {
|
||||
id: number;
|
||||
userId: string;
|
||||
name?: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder?: string;
|
||||
tags: string[];
|
||||
pin: boolean;
|
||||
authType: string;
|
||||
// Auth data - either from credential or legacy fields
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
credentialId?: number;
|
||||
credentialName?: string;
|
||||
// Other fields
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
tunnelConnections: any[];
|
||||
enableFileManager: boolean;
|
||||
defaultPath?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
class SSHHostService {
|
||||
/**
|
||||
* Get SSH host with resolved credentials
|
||||
*/
|
||||
async getHostWithCredentials(userId: string, hostId: number): Promise<SSHHostWithCredentials | null> {
|
||||
try {
|
||||
const hosts = await db
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(and(
|
||||
eq(sshData.id, hostId),
|
||||
eq(sshData.userId, userId)
|
||||
));
|
||||
|
||||
if (hosts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const host = hosts[0];
|
||||
return await this.resolveHostCredentials(host);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get host ${hostId} with credentials`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a credential to an SSH host
|
||||
*/
|
||||
async applyCredentialToHost(userId: string, hostId: number, credentialId: number): Promise<void> {
|
||||
try {
|
||||
// Verify credential exists and belongs to user
|
||||
const credential = await credentialService.getCredentialWithSecrets(userId, credentialId);
|
||||
if (!credential) {
|
||||
throw new Error('Credential not found');
|
||||
}
|
||||
|
||||
// Update host to reference the credential and clear legacy fields
|
||||
await db
|
||||
.update(sshData)
|
||||
.set({
|
||||
credentialId: credentialId,
|
||||
username: credential.username,
|
||||
authType: credential.authType,
|
||||
// Clear legacy credential fields since we're using the credential reference
|
||||
password: null,
|
||||
key: null,
|
||||
keyPassword: null,
|
||||
keyType: null,
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
.where(and(
|
||||
eq(sshData.id, hostId),
|
||||
eq(sshData.userId, userId)
|
||||
));
|
||||
|
||||
// Record credential usage
|
||||
await credentialService.recordUsage(userId, credentialId, hostId);
|
||||
|
||||
logger.success(`Applied credential ${credentialId} to host ${hostId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to apply credential ${credentialId} to host ${hostId}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove credential from host (revert to legacy mode)
|
||||
*/
|
||||
async removeCredentialFromHost(userId: string, hostId: number): Promise<void> {
|
||||
try {
|
||||
await db
|
||||
.update(sshData)
|
||||
.set({
|
||||
credentialId: null,
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
.where(and(
|
||||
eq(sshData.id, hostId),
|
||||
eq(sshData.userId, userId)
|
||||
));
|
||||
|
||||
logger.success(`Removed credential reference from host ${hostId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to remove credential from host ${hostId}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all hosts using a specific credential
|
||||
*/
|
||||
async getHostsUsingCredential(userId: string, credentialId: number): Promise<SSHHostWithCredentials[]> {
|
||||
try {
|
||||
const hosts = await db
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(and(
|
||||
eq(sshData.credentialId, credentialId),
|
||||
eq(sshData.userId, userId)
|
||||
));
|
||||
|
||||
const result: SSHHostWithCredentials[] = [];
|
||||
for (const host of hosts) {
|
||||
const resolved = await this.resolveHostCredentials(host);
|
||||
result.push(resolved);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get hosts using credential ${credentialId}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve host credentials from either credential reference or legacy fields
|
||||
*/
|
||||
private async resolveHostCredentials(host: any): Promise<SSHHostWithCredentials> {
|
||||
const baseHost: SSHHostWithCredentials = {
|
||||
id: host.id,
|
||||
userId: host.userId,
|
||||
name: host.name,
|
||||
ip: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
folder: host.folder,
|
||||
tags: typeof host.tags === 'string'
|
||||
? (host.tags ? host.tags.split(',').filter(Boolean) : [])
|
||||
: [],
|
||||
pin: !!host.pin,
|
||||
authType: host.authType,
|
||||
enableTerminal: !!host.enableTerminal,
|
||||
enableTunnel: !!host.enableTunnel,
|
||||
tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections) : [],
|
||||
enableFileManager: !!host.enableFileManager,
|
||||
defaultPath: host.defaultPath,
|
||||
createdAt: host.createdAt,
|
||||
updatedAt: host.updatedAt,
|
||||
};
|
||||
|
||||
// If host uses a credential reference, get credentials from there
|
||||
if (host.credentialId) {
|
||||
try {
|
||||
const credential = await credentialService.getCredentialWithSecrets(host.userId, host.credentialId);
|
||||
if (credential) {
|
||||
baseHost.credentialId = credential.id;
|
||||
baseHost.credentialName = credential.name;
|
||||
baseHost.username = credential.username;
|
||||
baseHost.authType = credential.authType;
|
||||
baseHost.password = credential.password;
|
||||
baseHost.key = credential.key;
|
||||
baseHost.keyPassword = credential.keyPassword;
|
||||
baseHost.keyType = credential.keyType;
|
||||
} else {
|
||||
logger.warn(`Credential ${host.credentialId} not found for host ${host.id}, using legacy data`);
|
||||
// Fall back to legacy data
|
||||
this.addLegacyCredentials(baseHost, host);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to resolve credential ${host.credentialId} for host ${host.id}`, error);
|
||||
// Fall back to legacy data
|
||||
this.addLegacyCredentials(baseHost, host);
|
||||
}
|
||||
} else {
|
||||
// Use legacy credential fields
|
||||
this.addLegacyCredentials(baseHost, host);
|
||||
}
|
||||
|
||||
return baseHost;
|
||||
}
|
||||
|
||||
private addLegacyCredentials(baseHost: SSHHostWithCredentials, host: any): void {
|
||||
baseHost.password = host.password;
|
||||
baseHost.key = host.key;
|
||||
baseHost.keyPassword = host.keyPassword;
|
||||
baseHost.keyType = host.keyType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate a host from legacy credentials to a managed credential
|
||||
*/
|
||||
async migrateHostToCredential(userId: string, hostId: number, credentialName: string): Promise<number> {
|
||||
try {
|
||||
const host = await this.getHostWithCredentials(userId, hostId);
|
||||
if (!host) {
|
||||
throw new Error('Host not found');
|
||||
}
|
||||
|
||||
if (host.credentialId) {
|
||||
throw new Error('Host already uses managed credentials');
|
||||
}
|
||||
|
||||
// Create a new credential from the host's legacy data
|
||||
const credentialData = {
|
||||
name: credentialName,
|
||||
description: `Migrated from host ${host.name || host.ip}`,
|
||||
folder: host.folder,
|
||||
tags: host.tags,
|
||||
authType: host.authType as 'password' | 'key',
|
||||
username: host.username,
|
||||
password: host.password,
|
||||
key: host.key,
|
||||
keyPassword: host.keyPassword,
|
||||
keyType: host.keyType,
|
||||
};
|
||||
|
||||
const credential = await credentialService.createCredential(userId, credentialData);
|
||||
|
||||
// Apply the new credential to the host
|
||||
await this.applyCredentialToHost(userId, hostId, credential.id);
|
||||
|
||||
logger.success(`Migrated host ${hostId} to managed credential ${credential.id}`);
|
||||
return credential.id;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to migrate host ${hostId} to credential`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const sshHostService = new SSHHostService();
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
@@ -0,0 +1,582 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import {
|
||||
X,
|
||||
Plus,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Upload,
|
||||
Download,
|
||||
Key,
|
||||
Shield,
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Tag,
|
||||
Folder,
|
||||
User,
|
||||
Lock
|
||||
} from 'lucide-react';
|
||||
import { createCredential, updateCredential, getCredentialFolders } from '@/ui/main-axios';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Credential {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
folder?: string;
|
||||
tags: string[];
|
||||
authType: 'password' | 'key';
|
||||
username: string;
|
||||
keyType?: string;
|
||||
usageCount: number;
|
||||
lastUsed?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface CredentialInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
folder?: string;
|
||||
tags: string[];
|
||||
authType: 'password' | 'key';
|
||||
username: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
}
|
||||
|
||||
interface CredentialEditorProps {
|
||||
credential?: Credential | null;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const CredentialEditor: React.FC<CredentialEditorProps> = ({ credential, onSave, onCancel }) => {
|
||||
const { t } = useTranslation();
|
||||
const [formData, setFormData] = useState<CredentialInput>({
|
||||
name: '',
|
||||
description: '',
|
||||
folder: '',
|
||||
tags: [],
|
||||
authType: 'password',
|
||||
username: '',
|
||||
password: '',
|
||||
key: '',
|
||||
keyPassword: '',
|
||||
keyType: 'rsa'
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showKeyPassword, setShowKeyPassword] = useState(false);
|
||||
const [newTag, setNewTag] = useState('');
|
||||
const [existingFolders, setExistingFolders] = useState<string[]>([]);
|
||||
const [keyFile, setKeyFile] = useState<File | null>(null);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (credential) {
|
||||
setFormData({
|
||||
name: credential.name,
|
||||
description: credential.description || '',
|
||||
folder: credential.folder || '',
|
||||
tags: [...credential.tags],
|
||||
authType: credential.authType,
|
||||
username: credential.username,
|
||||
password: '',
|
||||
key: '',
|
||||
keyPassword: '',
|
||||
keyType: credential.keyType || 'rsa'
|
||||
});
|
||||
}
|
||||
fetchExistingFolders();
|
||||
}, [credential]);
|
||||
|
||||
const fetchExistingFolders = async () => {
|
||||
try {
|
||||
const response = await getCredentialFolders();
|
||||
setExistingFolders(response);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch folders:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = t('credentials.nameIsRequired');
|
||||
}
|
||||
|
||||
if (!formData.username.trim()) {
|
||||
newErrors.username = t('credentials.usernameIsRequired');
|
||||
}
|
||||
|
||||
if (formData.authType === 'password' && !formData.password && !credential) {
|
||||
newErrors.password = t('credentials.passwordIsRequired');
|
||||
}
|
||||
|
||||
if (formData.authType === 'key' && !formData.key && !credential) {
|
||||
newErrors.key = t('credentials.sshKeyIsRequired');
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = { ...formData };
|
||||
|
||||
// Don't send empty passwords/keys when editing unless they were changed
|
||||
if (credential) {
|
||||
if (!payload.password) delete payload.password;
|
||||
if (!payload.key) delete payload.key;
|
||||
if (!payload.keyPassword) delete payload.keyPassword;
|
||||
}
|
||||
|
||||
if (credential && credential.id) {
|
||||
await updateCredential(credential.id, payload);
|
||||
toast.success(t('credentials.credentialUpdatedSuccessfully'));
|
||||
} else {
|
||||
await createCredential(payload);
|
||||
toast.success(t('credentials.credentialCreatedSuccessfully'));
|
||||
}
|
||||
|
||||
onSave();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to save credential:', error);
|
||||
toast.error(error.response?.data?.error || t('credentials.failedToSaveCredential'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddTag = () => {
|
||||
if (newTag.trim() && !formData.tags.includes(newTag.trim())) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
tags: [...prev.tags, newTag.trim()]
|
||||
}));
|
||||
setNewTag('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveTag = (tagToRemove: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
tags: prev.tags.filter(tag => tag !== tagToRemove)
|
||||
}));
|
||||
};
|
||||
|
||||
const handleKeyFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setKeyFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const content = event.target?.result as string;
|
||||
setFormData(prev => ({ ...prev, key: content }));
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
};
|
||||
|
||||
const generateSSHKeyPair = () => {
|
||||
toast.info(t('credentials.sshKeyGenerationNotImplemented'));
|
||||
};
|
||||
|
||||
const testConnection = () => {
|
||||
toast.info(t('credentials.connectionTestingNotImplemented'));
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={true} onOpenChange={onCancel}>
|
||||
<SheetContent className="w-[800px] max-w-[90vw] overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="flex items-center space-x-2">
|
||||
<Key className="h-5 w-5 text-blue-600" />
|
||||
<span>
|
||||
{credential ? t('credentials.editCredential') : t('credentials.createCredential')}
|
||||
</span>
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
{credential
|
||||
? t('credentials.editCredentialDescription')
|
||||
: t('credentials.createCredentialDescription')
|
||||
}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<Tabs defaultValue="basic" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3 bg-[#18181b] border-2 border-[#303032]">
|
||||
<TabsTrigger value="basic">{t('credentials.basicInfo')}</TabsTrigger>
|
||||
<TabsTrigger value="auth">{t('credentials.authentication')}</TabsTrigger>
|
||||
<TabsTrigger value="organization">{t('credentials.organization')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="basic" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">{t('credentials.basicInformation')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('credentials.basicInformationDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name" className="flex items-center space-x-1">
|
||||
<User className="h-4 w-4" />
|
||||
<span>{t('credentials.credentialName')}</span>
|
||||
<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder={t('credentials.enterCredentialName')}
|
||||
className={errors.name ? 'border-red-500' : ''}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-sm text-red-500 flex items-center space-x-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
<span>{errors.name}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">{t('credentials.credentialDescription')}</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||
placeholder={t('credentials.enterCredentialDescription')}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username" className="flex items-center space-x-1">
|
||||
<User className="h-4 w-4" />
|
||||
<span>{t('common.username')}</span>
|
||||
<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="username"
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, username: e.target.value }))}
|
||||
placeholder={t('credentials.enterUsername')}
|
||||
className={errors.username ? 'border-red-500' : ''}
|
||||
/>
|
||||
{errors.username && (
|
||||
<p className="text-sm text-red-500 flex items-center space-x-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
<span>{errors.username}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="auth" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center space-x-2">
|
||||
<Shield className="h-5 w-5 text-green-600" />
|
||||
<span>{t('credentials.authenticationMethod')}</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('credentials.authenticationMethodDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<Label>{t('credentials.authenticationType')}</Label>
|
||||
<div className="flex space-x-4">
|
||||
<div
|
||||
className={`flex-1 p-4 rounded-lg border-2 cursor-pointer transition-colors ${
|
||||
formData.authType === 'password'
|
||||
? 'border-blue-500 bg-blue-900/20 dark:bg-blue-900/20'
|
||||
: 'border-gray-600 hover:border-gray-500 dark:border-gray-600 dark:hover:border-gray-500'
|
||||
}`}
|
||||
onClick={() => setFormData(prev => ({ ...prev, authType: 'password' }))}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Lock className="h-5 w-5 text-orange-500" />
|
||||
<div>
|
||||
<div className="font-medium">{t('common.password')}</div>
|
||||
<div className="text-sm text-gray-500">{t('credentials.passwordAuthDescription')}</div>
|
||||
</div>
|
||||
{formData.authType === 'password' && (
|
||||
<Check className="h-5 w-5 text-blue-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`flex-1 p-4 rounded-lg border-2 cursor-pointer transition-colors ${
|
||||
formData.authType === 'key'
|
||||
? 'border-green-500 bg-green-900/20 dark:bg-green-900/20'
|
||||
: 'border-gray-600 hover:border-gray-500 dark:border-gray-600 dark:hover:border-gray-500'
|
||||
}`}
|
||||
onClick={() => setFormData(prev => ({ ...prev, authType: 'key' }))}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Key className="h-5 w-5 text-green-500" />
|
||||
<div>
|
||||
<div className="font-medium">{t('credentials.sshKey')}</div>
|
||||
<div className="text-sm text-gray-500">{t('credentials.sshKeyAuthDescription')}</div>
|
||||
</div>
|
||||
{formData.authType === 'key' && (
|
||||
<Check className="h-5 w-5 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{formData.authType === 'password' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="flex items-center space-x-1">
|
||||
<Lock className="h-4 w-4" />
|
||||
<span>{t('common.password')}</span>
|
||||
{!credential && <span className="text-red-500">*</span>}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, password: e.target.value }))}
|
||||
placeholder={credential ? t('credentials.leaveEmptyToKeepCurrent') : t('credentials.enterPassword')}
|
||||
className={`pr-10 ${errors.password ? 'border-red-500' : ''}`}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2 h-6 w-6 p-0"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="text-sm text-red-500 flex items-center space-x-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
<span>{errors.password}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.authType === 'key' && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center space-x-1">
|
||||
<Key className="h-4 w-4" />
|
||||
<span>{t('credentials.sshKeyType')}</span>
|
||||
</Label>
|
||||
<Select value={formData.keyType} onValueChange={(value) => setFormData(prev => ({ ...prev, keyType: value }))}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="rsa">RSA</SelectItem>
|
||||
<SelectItem value="ecdsa">ECDSA</SelectItem>
|
||||
<SelectItem value="ed25519">Ed25519</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="key" className="flex items-center space-x-1">
|
||||
<Key className="h-4 w-4" />
|
||||
<span>{t('credentials.privateKey')}</span>
|
||||
{!credential && <span className="text-red-500">*</span>}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="key"
|
||||
value={formData.key}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, key: e.target.value }))}
|
||||
placeholder={credential ? t('credentials.leaveEmptyToKeepCurrent') : t('credentials.enterPrivateKey')}
|
||||
rows={8}
|
||||
className={`font-mono text-xs ${errors.key ? 'border-red-500' : ''}`}
|
||||
/>
|
||||
{errors.key && (
|
||||
<p className="text-sm text-red-500 flex items-center space-x-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
<span>{errors.key}</span>
|
||||
</p>
|
||||
)}
|
||||
<div className="flex space-x-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => document.getElementById('key-file')?.click()}>
|
||||
<Upload className="h-4 w-4 mr-1" />
|
||||
{t('credentials.uploadKeyFile')}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={generateSSHKeyPair}>
|
||||
<Key className="h-4 w-4 mr-1" />
|
||||
{t('credentials.generateKeyPair')}
|
||||
</Button>
|
||||
</div>
|
||||
<input
|
||||
id="key-file"
|
||||
type="file"
|
||||
accept=".pem,.key,.pub"
|
||||
className="hidden"
|
||||
onChange={handleKeyFileUpload}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="keyPassword">{t('credentials.keyPassphrase')}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="keyPassword"
|
||||
type={showKeyPassword ? 'text' : 'password'}
|
||||
value={formData.keyPassword}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, keyPassword: e.target.value }))}
|
||||
placeholder={credential ? t('credentials.leaveEmptyToKeepCurrent') : t('credentials.enterKeyPassphrase')}
|
||||
className="pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2 h-6 w-6 p-0"
|
||||
onClick={() => setShowKeyPassword(!showKeyPassword)}
|
||||
>
|
||||
{showKeyPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">{t('credentials.keyPassphraseOptional')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="organization" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center space-x-2">
|
||||
<Folder className="h-5 w-5 text-amber-500" />
|
||||
<span>{t('credentials.organization')}</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('credentials.organizationDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="folder" className="flex items-center space-x-1">
|
||||
<Folder className="h-4 w-4" />
|
||||
<span>{t('common.folder')}</span>
|
||||
</Label>
|
||||
<Select value={formData.folder} onValueChange={(value) => setFormData(prev => ({ ...prev, folder: value }))}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('credentials.selectOrCreateFolder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">{t('credentials.noFolder')}</SelectItem>
|
||||
{existingFolders.map(folder => (
|
||||
<SelectItem key={folder} value={folder}>{folder}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
placeholder={t('credentials.orCreateNewFolder')}
|
||||
value={formData.folder}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, folder: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center space-x-1">
|
||||
<Tag className="h-4 w-4" />
|
||||
<span>{t('hosts.tags')}</span>
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{formData.tags.map((tag, index) => (
|
||||
<Badge key={index} variant="secondary" className="pr-1">
|
||||
{tag}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-4 w-4 p-0 ml-1 hover:bg-red-900/20 dark:hover:bg-red-900/30"
|
||||
onClick={() => handleRemoveTag(tag)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
placeholder={t('credentials.addTag')}
|
||||
value={newTag}
|
||||
onChange={(e) => setNewTag(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddTag();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleAddTag}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<SheetFooter className="flex justify-between">
|
||||
<div className="flex space-x-2">
|
||||
<Button type="button" variant="outline" onClick={testConnection}>
|
||||
{t('credentials.testConnection')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving ? t('credentials.saving') : credential ? t('credentials.updateCredential') : t('credentials.createCredential')}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default CredentialEditor;
|
||||
@@ -0,0 +1,476 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import {
|
||||
Key,
|
||||
User,
|
||||
Calendar,
|
||||
Hash,
|
||||
Folder,
|
||||
Edit3,
|
||||
Copy,
|
||||
Settings,
|
||||
Shield,
|
||||
Clock,
|
||||
Server,
|
||||
Eye,
|
||||
EyeOff,
|
||||
ExternalLink,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
FileText
|
||||
} from 'lucide-react';
|
||||
import { getCredentialDetails, getCredentialHosts } from '@/ui/main-axios';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Credential {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
folder?: string;
|
||||
tags: string[];
|
||||
authType: 'password' | 'key';
|
||||
username: string;
|
||||
keyType?: string;
|
||||
usageCount: number;
|
||||
lastUsed?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface CredentialWithSecrets extends Credential {
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
}
|
||||
|
||||
interface HostInfo {
|
||||
id: number;
|
||||
name?: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface CredentialViewerProps {
|
||||
credential: Credential;
|
||||
onClose: () => void;
|
||||
onEdit: () => void;
|
||||
}
|
||||
|
||||
const CredentialViewer: React.FC<CredentialViewerProps> = ({ credential, onClose, onEdit }) => {
|
||||
const { t } = useTranslation();
|
||||
const [credentialDetails, setCredentialDetails] = useState<CredentialWithSecrets | null>(null);
|
||||
const [hostsUsing, setHostsUsing] = useState<HostInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showSensitive, setShowSensitive] = useState<Record<string, boolean>>({});
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'security' | 'usage'>('overview');
|
||||
|
||||
useEffect(() => {
|
||||
fetchCredentialDetails();
|
||||
fetchHostsUsing();
|
||||
}, [credential.id]);
|
||||
|
||||
const fetchCredentialDetails = async () => {
|
||||
try {
|
||||
const response = await getCredentialDetails(credential.id);
|
||||
setCredentialDetails(response);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch credential details:', error);
|
||||
toast.error(t('credentials.failedToFetchCredentialDetails'));
|
||||
}
|
||||
};
|
||||
|
||||
const fetchHostsUsing = async () => {
|
||||
try {
|
||||
const response = await getCredentialHosts(credential.id);
|
||||
setHostsUsing(response);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch hosts using credential:', error);
|
||||
toast.error(t('credentials.failedToFetchHostsUsing'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSensitiveVisibility = (field: string) => {
|
||||
setShowSensitive(prev => ({
|
||||
...prev,
|
||||
[field]: !prev[field]
|
||||
}));
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string, fieldName: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(t('copiedToClipboard', { field: fieldName }));
|
||||
} catch (error) {
|
||||
toast.error(t('credentials.failedToCopy'));
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
const getAuthIcon = (authType: string) => {
|
||||
return authType === 'password' ? (
|
||||
<Key className="h-5 w-5 text-orange-500" />
|
||||
) : (
|
||||
<Shield className="h-5 w-5 text-green-500" />
|
||||
);
|
||||
};
|
||||
|
||||
const renderSensitiveField = (
|
||||
value: string | undefined,
|
||||
fieldName: string,
|
||||
label: string,
|
||||
isMultiline = false
|
||||
) => {
|
||||
if (!value) return null;
|
||||
|
||||
const isVisible = showSensitive[fieldName];
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{label}
|
||||
</label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleSensitiveVisibility(fieldName)}
|
||||
>
|
||||
{isVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(value, label)}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`p-3 rounded-md bg-gray-800 dark:bg-gray-800 ${isMultiline ? '' : 'min-h-[2.5rem]'}`}>
|
||||
{isVisible ? (
|
||||
<pre className={`text-sm ${isMultiline ? 'whitespace-pre-wrap' : 'whitespace-nowrap'} font-mono`}>
|
||||
{value}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500">
|
||||
{'•'.repeat(isMultiline ? 50 : 20)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading || !credentialDetails) {
|
||||
return (
|
||||
<Sheet open={true} onOpenChange={onClose}>
|
||||
<SheetContent className="w-[800px] max-w-[90vw]">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={true} onOpenChange={onClose}>
|
||||
<SheetContent className="w-[800px] max-w-[90vw] overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="flex items-center space-x-3">
|
||||
{getAuthIcon(credentialDetails.authType)}
|
||||
<div>
|
||||
<div>{credentialDetails.name}</div>
|
||||
<div className="text-sm font-normal text-gray-600 dark:text-gray-400">
|
||||
{credentialDetails.description}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 ml-auto">
|
||||
<Badge variant={credentialDetails.authType === 'password' ? 'secondary' : 'outline'}>
|
||||
{credentialDetails.authType}
|
||||
</Badge>
|
||||
{credentialDetails.keyType && (
|
||||
<Badge variant="outline">{credentialDetails.keyType}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex space-x-1 p-1 bg-[#18181b] border-2 border-[#303032] rounded-lg">
|
||||
<Button
|
||||
variant={activeTab === 'overview' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab('overview')}
|
||||
className="flex-1"
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
{t('credentials.overview')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'security' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab('security')}
|
||||
className="flex-1"
|
||||
>
|
||||
<Shield className="h-4 w-4 mr-2" />
|
||||
{t('credentials.security')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'usage' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab('usage')}
|
||||
className="flex-1"
|
||||
>
|
||||
<Server className="h-4 w-4 mr-2" />
|
||||
{t('credentials.usage')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">{t('credentials.basicInformation')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<User className="h-4 w-4 text-gray-500" />
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">{t('common.username')}</div>
|
||||
<div className="font-medium">{credentialDetails.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{credentialDetails.folder && (
|
||||
<div className="flex items-center space-x-3">
|
||||
<Folder className="h-4 w-4 text-gray-500" />
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">{t('common.folder')}</div>
|
||||
<div className="font-medium">{credentialDetails.folder}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{credentialDetails.tags.length > 0 && (
|
||||
<div className="flex items-start space-x-3">
|
||||
<Hash className="h-4 w-4 text-gray-500 mt-1" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm text-gray-500 mb-2">{t('hosts.tags')}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{credentialDetails.tags.map((tag, index) => (
|
||||
<Badge key={index} variant="outline" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<Calendar className="h-4 w-4 text-gray-500" />
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">{t('credentials.created')}</div>
|
||||
<div className="font-medium">{formatDate(credentialDetails.createdAt)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<Calendar className="h-4 w-4 text-gray-500" />
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">{t('credentials.lastModified')}</div>
|
||||
<div className="font-medium">{formatDate(credentialDetails.updatedAt)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">{t('credentials.usageStatistics')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="text-center p-4 bg-blue-900/20 dark:bg-blue-900/20 rounded-lg">
|
||||
<div className="text-3xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{credentialDetails.usageCount}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('credentials.timesUsed')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{credentialDetails.lastUsed && (
|
||||
<div className="flex items-center space-x-3 p-3 bg-green-900/20 dark:bg-green-900/20 rounded-lg">
|
||||
<Clock className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">{t('credentials.lastUsed')}</div>
|
||||
<div className="font-medium">{formatDate(credentialDetails.lastUsed)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-3 p-3 bg-purple-900/20 dark:bg-purple-900/20 rounded-lg">
|
||||
<Server className="h-5 w-5 text-purple-600 dark:text-purple-400" />
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">{t('credentials.connectedHosts')}</div>
|
||||
<div className="font-medium">{hostsUsing.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'security' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center space-x-2">
|
||||
<Shield className="h-5 w-5 text-green-600" />
|
||||
<span>{t('credentials.securityDetails')}</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('credentials.securityDetailsDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center space-x-3 p-4 bg-green-900/20 dark:bg-green-900/20 rounded-lg">
|
||||
<CheckCircle className="h-6 w-6 text-green-600" />
|
||||
<div>
|
||||
<div className="font-medium text-green-800 dark:text-green-200">
|
||||
{t('credentials.credentialSecured')}
|
||||
</div>
|
||||
<div className="text-sm text-green-700 dark:text-green-300">
|
||||
{t('credentials.credentialSecuredDescription')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{credentialDetails.authType === 'password' && (
|
||||
<div>
|
||||
<h3 className="font-semibold mb-3">{t('credentials.passwordAuthentication')}</h3>
|
||||
{renderSensitiveField(credentialDetails.password, 'password', t('common.password'))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{credentialDetails.authType === 'key' && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold">{t('credentials.keyAuthentication')}</h3>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('credentials.keyType')}
|
||||
</div>
|
||||
<Badge variant="outline" className="text-sm">
|
||||
{credentialDetails.keyType?.toUpperCase() || t('unknown').toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderSensitiveField(credentialDetails.key, 'key', t('credentials.privateKey'), true)}
|
||||
|
||||
{credentialDetails.keyPassword && renderSensitiveField(
|
||||
credentialDetails.keyPassword,
|
||||
'keyPassword',
|
||||
t('credentials.keyPassphrase')
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start space-x-3 p-4 bg-amber-900/20 dark:bg-amber-900/20 rounded-lg">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-600 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<div className="font-medium text-amber-800 dark:text-amber-200 mb-1">
|
||||
{t('credentials.securityReminder')}
|
||||
</div>
|
||||
<div className="text-amber-700 dark:text-amber-300">
|
||||
{t('credentials.securityReminderText')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'usage' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center space-x-2">
|
||||
<Server className="h-5 w-5 text-blue-600" />
|
||||
<span>{t('credentials.hostsUsingCredential')}</span>
|
||||
<Badge variant="secondary">{hostsUsing.length}</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{hostsUsing.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Server className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||
<p>{t('credentials.noHostsUsingCredential')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-64">
|
||||
<div className="space-y-3">
|
||||
{hostsUsing.map((host) => (
|
||||
<div
|
||||
key={host.id}
|
||||
className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-800 dark:hover:bg-gray-700"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 bg-blue-100 dark:bg-blue-900 rounded">
|
||||
<Server className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{host.name || `${host.ip}:${host.port}`}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{host.ip}:{host.port}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-sm text-gray-500">
|
||||
{formatDate(host.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SheetFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
<Button onClick={onEdit}>
|
||||
<Edit3 className="h-4 w-4 mr-2" />
|
||||
{t('credentials.editCredential')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default CredentialViewer;
|
||||
@@ -0,0 +1,538 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Key,
|
||||
User,
|
||||
Calendar,
|
||||
Hash,
|
||||
Folder,
|
||||
Edit3,
|
||||
Trash2,
|
||||
Copy,
|
||||
Settings,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Shield,
|
||||
Clock,
|
||||
Server
|
||||
} from 'lucide-react';
|
||||
import { getCredentials, getCredentialFolders, deleteCredential } from '@/ui/main-axios';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CredentialEditor from './CredentialEditor';
|
||||
import CredentialViewer from './CredentialViewer';
|
||||
|
||||
interface Credential {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
folder?: string;
|
||||
tags: string[];
|
||||
authType: 'password' | 'key';
|
||||
username: string;
|
||||
keyType?: string;
|
||||
usageCount: number;
|
||||
lastUsed?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface GroupedCredentials {
|
||||
[folder: string]: Credential[];
|
||||
}
|
||||
|
||||
const CredentialsManager: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||
const [filteredCredentials, setFilteredCredentials] = useState<Credential[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedFolder, setSelectedFolder] = useState<string>('all');
|
||||
const [selectedAuthType, setSelectedAuthType] = useState<string>('all');
|
||||
const [showEditor, setShowEditor] = useState(false);
|
||||
const [showViewer, setShowViewer] = useState(false);
|
||||
const [editingCredential, setEditingCredential] = useState<Credential | null>(null);
|
||||
const [viewingCredential, setViewingCredential] = useState<Credential | null>(null);
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
const [viewMode, setViewMode] = useState<'list' | 'folder'>('list');
|
||||
|
||||
useEffect(() => {
|
||||
fetchCredentials();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
filterCredentials();
|
||||
}, [credentials, searchQuery, selectedFolder, selectedAuthType]);
|
||||
|
||||
const fetchCredentials = async () => {
|
||||
try {
|
||||
const response = await getCredentials();
|
||||
setCredentials(response);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch credentials:', error);
|
||||
toast.error(t('credentials.failedToFetchCredentials'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filterCredentials = () => {
|
||||
let filtered = credentials;
|
||||
|
||||
if (searchQuery) {
|
||||
filtered = filtered.filter(cred =>
|
||||
cred.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
cred.username.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
cred.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
cred.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedFolder !== 'all') {
|
||||
if (selectedFolder === 'none') {
|
||||
filtered = filtered.filter(cred => !cred.folder);
|
||||
} else {
|
||||
filtered = filtered.filter(cred => cred.folder === selectedFolder);
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedAuthType !== 'all') {
|
||||
filtered = filtered.filter(cred => cred.authType === selectedAuthType);
|
||||
}
|
||||
|
||||
setFilteredCredentials(filtered);
|
||||
};
|
||||
|
||||
const handleCreateCredential = () => {
|
||||
setEditingCredential(null);
|
||||
setShowEditor(true);
|
||||
};
|
||||
|
||||
const handleEditCredential = (credential: Credential) => {
|
||||
setEditingCredential(credential);
|
||||
setShowEditor(true);
|
||||
};
|
||||
|
||||
const handleViewCredential = (credential: Credential) => {
|
||||
setViewingCredential(credential);
|
||||
setShowViewer(true);
|
||||
};
|
||||
|
||||
const handleDeleteCredential = async (credential: Credential) => {
|
||||
if (!confirm(t('credentials.confirmDeleteCredential', { name: credential.name }))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteCredential(credential.id);
|
||||
|
||||
toast.success(t('credentials.credentialDeletedSuccessfully'));
|
||||
fetchCredentials();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to delete credential:', error);
|
||||
toast.error(error.response?.data?.error || t('credentials.failedToDeleteCredential'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicateCredential = (credential: Credential) => {
|
||||
const duplicated: Credential = {
|
||||
...credential,
|
||||
id: 0, // Will be assigned by server
|
||||
name: `${credential.name} (Copy)`,
|
||||
usageCount: 0,
|
||||
lastUsed: undefined,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
setEditingCredential(duplicated);
|
||||
setShowEditor(true);
|
||||
};
|
||||
|
||||
const handleCredentialSaved = () => {
|
||||
setShowEditor(false);
|
||||
setEditingCredential(null);
|
||||
fetchCredentials();
|
||||
};
|
||||
|
||||
const toggleFolder = (folder: string) => {
|
||||
const newExpanded = new Set(expandedFolders);
|
||||
if (newExpanded.has(folder)) {
|
||||
newExpanded.delete(folder);
|
||||
} else {
|
||||
newExpanded.add(folder);
|
||||
}
|
||||
setExpandedFolders(newExpanded);
|
||||
};
|
||||
|
||||
const groupCredentialsByFolder = (credentials: Credential[]): GroupedCredentials => {
|
||||
const grouped: GroupedCredentials = {};
|
||||
|
||||
credentials.forEach(credential => {
|
||||
const folder = credential.folder || t('credentials.uncategorized');
|
||||
if (!grouped[folder]) {
|
||||
grouped[folder] = [];
|
||||
}
|
||||
grouped[folder].push(credential);
|
||||
});
|
||||
|
||||
return grouped;
|
||||
};
|
||||
|
||||
const getUniqueValues = (field: keyof Credential): string[] => {
|
||||
const values = credentials
|
||||
.map(cred => cred[field])
|
||||
.filter((value): value is string => typeof value === 'string' && value.length > 0);
|
||||
return Array.from(new Set(values));
|
||||
};
|
||||
|
||||
const renderCredentialCard = (credential: Credential) => (
|
||||
<Card key={credential.id} className="hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors border-zinc-200 dark:border-zinc-700">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 rounded-lg bg-zinc-100 dark:bg-zinc-800">
|
||||
{credential.authType === 'password' ? (
|
||||
<Key className="h-4 w-4 text-zinc-600 dark:text-zinc-400" />
|
||||
) : (
|
||||
<Shield className="h-4 w-4 text-zinc-600 dark:text-zinc-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-sm font-medium">{credential.name}</CardTitle>
|
||||
{credential.description && (
|
||||
<CardDescription className="text-xs mt-1">
|
||||
{credential.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleViewCredential(credential)}
|
||||
title={t('credentials.viewCredential')}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditCredential(credential)}
|
||||
title={t('credentials.editCredential')}
|
||||
>
|
||||
<Edit3 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDuplicateCredential(credential)}
|
||||
title={t('credentials.duplicateCredential')}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteCredential(credential)}
|
||||
title={t('credentials.deleteCredential')}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-center space-x-3">
|
||||
<User className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
|
||||
<span className="text-zinc-700 dark:text-zinc-300 font-medium">{credential.username}</span>
|
||||
<Badge variant="outline" className="text-xs border-zinc-300 dark:border-zinc-600 text-zinc-600 dark:text-zinc-400">
|
||||
{credential.authType}
|
||||
</Badge>
|
||||
{credential.keyType && (
|
||||
<Badge variant="secondary" className="text-xs bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300">
|
||||
{credential.keyType}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{credential.tags.length > 0 && (
|
||||
<div className="flex items-center space-x-2 flex-wrap gap-1">
|
||||
<Hash className="h-4 w-4 text-zinc-500 dark:text-zinc-400 flex-shrink-0" />
|
||||
{credential.tags.map((tag, index) => (
|
||||
<Badge key={index} variant="outline" className="text-xs border-zinc-300 dark:border-zinc-600 text-zinc-600 dark:text-zinc-400">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-3 border-t border-zinc-200 dark:border-zinc-700">
|
||||
<div className="flex items-center space-x-4 text-zinc-500 dark:text-zinc-400">
|
||||
<div className="flex items-center space-x-1.5">
|
||||
<Server className="h-3.5 w-3.5" />
|
||||
<span className="text-xs">{credential.usageCount}</span>
|
||||
</div>
|
||||
{credential.lastUsed && (
|
||||
<div className="flex items-center space-x-1.5">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
<span className="text-xs">{new Date(credential.lastUsed).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-1.5 text-zinc-500 dark:text-zinc-400">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
<span className="text-xs">{new Date(credential.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const renderListView = () => (
|
||||
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{filteredCredentials.map(renderCredentialCard)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderFolderView = () => {
|
||||
const grouped = groupCredentialsByFolder(filteredCredentials);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(grouped).map(([folder, folderCredentials]) => (
|
||||
<div key={folder} className="space-y-2">
|
||||
<div
|
||||
className="flex items-center space-x-3 cursor-pointer p-3 rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
|
||||
onClick={() => toggleFolder(folder)}
|
||||
>
|
||||
{expandedFolders.has(folder) ? (
|
||||
<ChevronDown className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
|
||||
)}
|
||||
<Folder className="h-4 w-4 text-zinc-600 dark:text-zinc-400" />
|
||||
<span className="font-medium text-zinc-800 dark:text-zinc-200">{folder === t('credentials.uncategorized') ? t('credentials.uncategorized') : folder}</span>
|
||||
<Badge variant="secondary" className="text-xs bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300">
|
||||
{folderCredentials.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{expandedFolders.has(folder) && (
|
||||
<div className="ml-8 grid gap-6 md:grid-cols-2 xl:grid-cols-3 pt-2">
|
||||
{folderCredentials.map(renderCredentialCard)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-8 pb-6">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-bold">{t('credentials.credentialsManager')}</h1>
|
||||
<p className="text-zinc-600 dark:text-zinc-400 text-lg">
|
||||
{t('credentials.manageYourSSHCredentials')}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleCreateCredential} size="lg">
|
||||
<Plus className="h-5 w-5 mr-2" />
|
||||
{t('credentials.addCredential')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="px-8 pb-6">
|
||||
<Card>
|
||||
<CardContent className="pt-8">
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder={t('credentials.searchCredentials')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Select value={selectedFolder} onValueChange={setSelectedFolder}>
|
||||
<SelectTrigger className="w-full md:w-48">
|
||||
<SelectValue placeholder={t('credentials.selectFolder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('credentials.allFolders')}</SelectItem>
|
||||
<SelectItem value="none">{t('credentials.uncategorized')}</SelectItem>
|
||||
{getUniqueValues('folder').map(folder => (
|
||||
<SelectItem key={folder} value={folder}>{folder}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={selectedAuthType} onValueChange={setSelectedAuthType}>
|
||||
<SelectTrigger className="w-full md:w-48">
|
||||
<SelectValue placeholder={t('credentials.selectAuthType')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('credentials.allAuthTypes')}</SelectItem>
|
||||
<SelectItem value="password">{t('common.password')}</SelectItem>
|
||||
<SelectItem value="key">{t('credentials.sshKey')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex border rounded-md">
|
||||
<Button
|
||||
variant={viewMode === 'list' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('list')}
|
||||
className="rounded-r-none"
|
||||
>
|
||||
{t('credentials.listView')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'folder' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('folder')}
|
||||
className="rounded-l-none"
|
||||
>
|
||||
{t('credentials.folderView')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="px-8 py-4">
|
||||
<Separator />
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="px-8 pb-8">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="text-3xl font-bold text-zinc-700 dark:text-zinc-300">{credentials.length}</div>
|
||||
<div className="text-sm text-zinc-600 dark:text-zinc-400">{t('credentials.totalCredentials')}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="text-3xl font-bold text-zinc-700 dark:text-zinc-300">
|
||||
{credentials.filter(c => c.authType === 'key').length}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-600 dark:text-zinc-400">{t('credentials.keyBased')}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="text-3xl font-bold text-zinc-700 dark:text-zinc-300">
|
||||
{credentials.filter(c => c.authType === 'password').length}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-600 dark:text-zinc-400">{t('credentials.passwordBased')}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="text-3xl font-bold text-zinc-700 dark:text-zinc-300">
|
||||
{getUniqueValues('folder').length}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-600 dark:text-zinc-400">{t('credentials.folders')}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Credentials List */}
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden px-8 pb-8">
|
||||
<Card className="flex-1 flex flex-col min-h-0">
|
||||
<CardHeader className="pb-6">
|
||||
<CardTitle className="text-xl">
|
||||
{t('nav.credentials')} ({filteredCredentials.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 px-6">
|
||||
<ScrollArea className="flex-1">
|
||||
{filteredCredentials.length === 0 ? (
|
||||
<div className="text-center py-16 text-zinc-500 dark:text-zinc-400">
|
||||
{searchQuery || selectedFolder !== 'all' || selectedAuthType !== 'all' ? (
|
||||
<div className="space-y-4">
|
||||
<Search className="h-16 w-16 mx-auto text-zinc-300 dark:text-zinc-600" />
|
||||
<p className="text-lg">{t('credentials.noCredentialsMatchFilters')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<Key className="h-16 w-16 mx-auto text-zinc-300 dark:text-zinc-600" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-lg font-medium">{t('credentials.noCredentialsYet')}</p>
|
||||
<p className="text-sm text-zinc-400">开始创建你的第一个SSH凭据</p>
|
||||
</div>
|
||||
<Button size="lg" onClick={handleCreateCredential}>
|
||||
<Plus className="h-5 w-5 mr-2" />
|
||||
{t('credentials.createFirstCredential')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
viewMode === 'list' ? renderListView() : renderFolderView()
|
||||
)}
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
{showEditor && (
|
||||
<CredentialEditor
|
||||
credential={editingCredential}
|
||||
onSave={handleCredentialSaved}
|
||||
onCancel={() => setShowEditor(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showViewer && viewingCredential && (
|
||||
<CredentialViewer
|
||||
credential={viewingCredential}
|
||||
onClose={() => setShowViewer(false)}
|
||||
onEdit={() => {
|
||||
setShowViewer(false);
|
||||
handleEditCredential(viewingCredential);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CredentialsManager;
|
||||
+77
-1
@@ -1024,7 +1024,7 @@ export async function getReleasesRSS(perPage: number = 100): Promise<any> {
|
||||
|
||||
export async function getVersionInfo(): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.get('/version/');
|
||||
const response = await authApi.get('/version');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'fetch version info');
|
||||
@@ -1043,3 +1043,79 @@ export async function getDatabaseHealth(): Promise<any> {
|
||||
handleApiError(error, 'check database health');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SSH CREDENTIALS MANAGEMENT
|
||||
// ============================================================================
|
||||
|
||||
export async function getCredentials(): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.get('/credentials');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'fetch credentials');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCredentialDetails(credentialId: number): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.get(`/credentials/${credentialId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'fetch credential details');
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCredential(credentialData: any): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.post('/credentials', credentialData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'create credential');
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCredential(credentialId: number, credentialData: any): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.put(`/credentials/${credentialId}`, credentialData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'update credential');
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCredential(credentialId: number): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.delete(`/credentials/${credentialId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'delete credential');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCredentialHosts(credentialId: number): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.get(`/credentials/${credentialId}/hosts`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'fetch credential hosts');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCredentialFolders(): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.get('/credentials/folders');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'fetch credential folders');
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyCredentialToHost(credentialId: number, hostId: number): Promise<any> {
|
||||
try {
|
||||
const response = await authApi.post(`/credentials/${credentialId}/apply-to-host/${hostId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, 'apply credential to host');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user