Add SSH key generation and deployment features (#234)
* Fix SSH key upload and credential editing issues Fixed two major credential management issues: 1. Fix SSH key upload button not responding (Issue #232) - Error handling was silently swallowing exceptions - Added proper error propagation in axios functions - Improved error display to show specific error messages - Users now see actual error details instead of generic messages 2. Improve credential editing to show actual content - Both "Upload File" and "Paste Key" modes now display existing data - Upload mode: shows current key content in read-only preview area - Paste mode: shows editable key content in textarea - Smart input method switching preserves existing data - Enhanced button labels and status indicators Key changes: - Fixed handleApiError propagation in main-axios.ts credential functions - Enhanced CredentialEditor.tsx with key content preview - Improved error handling with console logging for debugging - Better UX with clear status indicators and preserved data These fixes resolve the "Add Credential button does nothing" issue and provide full visibility of credential content during editing. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Add comprehensive SSH key management and validation features - Add support for both private and public key storage - Implement automatic SSH key type detection for all major formats (RSA, Ed25519, ECDSA, DSA) - Add real-time key pair validation to verify private/public key correspondence - Enhance credential editor UI with unified key input interface supporting upload/paste - Improve file format support including extensionless files (id_rsa, id_ed25519, etc.) - Add comprehensive fallback detection for OpenSSH format keys - Implement debounced API calls for better UX during real-time validation - Update database schema with backward compatibility for existing credentials - Add API endpoints for key detection and pair validation - Fix SSH2 module integration issues in TypeScript environment 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Optimize credentials interface and add i18n improvements - Merge upload/paste tabs into unified SSH key input interface - Remove manual key type selection dropdown (rely on auto-detection) - Add public key generation from private key functionality - Complete key pair validation removal to fix errors - Add missing translation keys for better internationalization - Improve UX with streamlined credential editing workflow * Implement direct SSH key generation with ssh2 native API - Replace complex PEM-to-SSH conversion logic with ssh2's generateKeyPairSync - Add three key generation buttons: Ed25519, ECDSA P-256, and RSA - Generate keys directly in SSH format (ssh-ed25519, ecdsa-sha2-nistp256, ssh-rsa) - Fix ECDSA parameter bug: use bits (256) instead of curve for ssh2 API - Enhance generate-public-key endpoint with SSH format conversion - Add comprehensive key type detection and parsing fallbacks - Add internationalization support for key generation UI - Simplify codebase from 300+ lines to ~80 lines of clean SSH generation 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Add passphrase support for SSH key generation - Add optional passphrase input field in key generation container - Implement AES-128-CBC encryption for protected private keys - Auto-fill key password field when passphrase is provided - Support passphrase protection for all key types (Ed25519, ECDSA, RSA) - Enhance user experience with automatic form field population * Implement SSH key deployment feature with credential resolution - Add SSH key deployment endpoint supporting all authentication types - Implement automatic credential resolution for credential-based hosts - Add deployment UI with host selection and progress tracking - Support password, key, and credential authentication methods - Include deployment verification and error handling - Add public key field to credential types and API responses - Implement secure SSH connection handling with proper timeout 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
This commit was merged in pull request #234.
This commit is contained in:
@@ -137,9 +137,12 @@ export const sshCredentials = sqliteTable("ssh_credentials", {
|
|||||||
authType: text("auth_type").notNull(),
|
authType: text("auth_type").notNull(),
|
||||||
username: text("username").notNull(),
|
username: text("username").notNull(),
|
||||||
password: text("password"),
|
password: text("password"),
|
||||||
key: text("key", { length: 16384 }),
|
key: text("key", { length: 16384 }), // backward compatibility
|
||||||
|
privateKey: text("private_key", { length: 16384 }),
|
||||||
|
publicKey: text("public_key", { length: 4096 }),
|
||||||
keyPassword: text("key_password"),
|
keyPassword: text("key_password"),
|
||||||
keyType: text("key_type"),
|
keyType: text("key_type"),
|
||||||
|
detectedKeyType: text("detected_key_type"),
|
||||||
usageCount: integer("usage_count").notNull().default(0),
|
usageCount: integer("usage_count").notNull().default(0),
|
||||||
lastUsed: text("last_used"),
|
lastUsed: text("last_used"),
|
||||||
createdAt: text("created_at")
|
createdAt: text("created_at")
|
||||||
|
|||||||
@@ -5,6 +5,56 @@ import { eq, and, desc, sql } from "drizzle-orm";
|
|||||||
import type { Request, Response, NextFunction } from "express";
|
import type { Request, Response, NextFunction } from "express";
|
||||||
import jwt from "jsonwebtoken";
|
import jwt from "jsonwebtoken";
|
||||||
import { authLogger } from "../../utils/logger.js";
|
import { authLogger } from "../../utils/logger.js";
|
||||||
|
import { parseSSHKey, parsePublicKey, detectKeyType, validateKeyPair } from "../../utils/ssh-key-utils.js";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import ssh2Pkg from "ssh2";
|
||||||
|
const { utils: ssh2Utils, Client } = ssh2Pkg;
|
||||||
|
|
||||||
|
// Direct SSH key generation with ssh2 - the right way
|
||||||
|
function generateSSHKeyPair(keyType: string, keySize?: number, passphrase?: string): { success: boolean; privateKey?: string; publicKey?: string; error?: string } {
|
||||||
|
console.log('Generating SSH key pair with ssh2:', keyType);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Convert our keyType to ssh2 format
|
||||||
|
let ssh2Type = keyType;
|
||||||
|
const options: any = {};
|
||||||
|
|
||||||
|
if (keyType === 'ssh-rsa') {
|
||||||
|
ssh2Type = 'rsa';
|
||||||
|
options.bits = keySize || 2048;
|
||||||
|
} else if (keyType === 'ssh-ed25519') {
|
||||||
|
ssh2Type = 'ed25519';
|
||||||
|
} else if (keyType === 'ecdsa-sha2-nistp256') {
|
||||||
|
ssh2Type = 'ecdsa';
|
||||||
|
options.bits = 256; // ECDSA P-256 uses 256 bits
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add passphrase protection if provided
|
||||||
|
if (passphrase && passphrase.trim()) {
|
||||||
|
options.passphrase = passphrase;
|
||||||
|
options.cipher = 'aes128-cbc'; // Default cipher for encrypted private keys
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use ssh2's native key generation
|
||||||
|
const keyPair = ssh2Utils.generateKeyPairSync(ssh2Type as any, options);
|
||||||
|
|
||||||
|
console.log('SSH key pair generated successfully!');
|
||||||
|
console.log('Private key length:', keyPair.private.length);
|
||||||
|
console.log('Public key preview:', keyPair.public.substring(0, 50) + '...');
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
privateKey: keyPair.private,
|
||||||
|
publicKey: keyPair.public
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SSH key generation failed:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'SSH key generation failed'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -109,6 +159,22 @@ router.post("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
const plainKeyPassword =
|
const plainKeyPassword =
|
||||||
authType === "key" && keyPassword ? keyPassword : null;
|
authType === "key" && keyPassword ? keyPassword : null;
|
||||||
|
|
||||||
|
let keyInfo = null;
|
||||||
|
if (authType === "key" && plainKey) {
|
||||||
|
keyInfo = parseSSHKey(plainKey, plainKeyPassword);
|
||||||
|
if (!keyInfo.success) {
|
||||||
|
authLogger.warn("SSH key parsing failed", {
|
||||||
|
operation: "credential_create",
|
||||||
|
userId,
|
||||||
|
name,
|
||||||
|
error: keyInfo.error,
|
||||||
|
});
|
||||||
|
return res.status(400).json({
|
||||||
|
error: `Invalid SSH key: ${keyInfo.error}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const credentialData = {
|
const credentialData = {
|
||||||
userId,
|
userId,
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
@@ -118,9 +184,12 @@ router.post("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
authType,
|
authType,
|
||||||
username: username.trim(),
|
username: username.trim(),
|
||||||
password: plainPassword,
|
password: plainPassword,
|
||||||
key: plainKey,
|
key: plainKey, // backward compatibility
|
||||||
|
privateKey: keyInfo?.privateKey || plainKey,
|
||||||
|
publicKey: keyInfo?.publicKey || null,
|
||||||
keyPassword: plainKeyPassword,
|
keyPassword: plainKeyPassword,
|
||||||
keyType: keyType || null,
|
keyType: keyType || null,
|
||||||
|
detectedKeyType: keyInfo?.keyType || null,
|
||||||
usageCount: 0,
|
usageCount: 0,
|
||||||
lastUsed: null,
|
lastUsed: null,
|
||||||
};
|
};
|
||||||
@@ -248,7 +317,13 @@ router.get("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
(output as any).password = credential.password;
|
(output as any).password = credential.password;
|
||||||
}
|
}
|
||||||
if (credential.key) {
|
if (credential.key) {
|
||||||
(output as any).key = credential.key;
|
(output as any).key = credential.key; // backward compatibility
|
||||||
|
}
|
||||||
|
if (credential.privateKey) {
|
||||||
|
(output as any).privateKey = credential.privateKey;
|
||||||
|
}
|
||||||
|
if (credential.publicKey) {
|
||||||
|
(output as any).publicKey = credential.publicKey;
|
||||||
}
|
}
|
||||||
if (credential.keyPassword) {
|
if (credential.keyPassword) {
|
||||||
(output as any).keyPassword = credential.keyPassword;
|
(output as any).keyPassword = credential.keyPassword;
|
||||||
@@ -314,7 +389,26 @@ router.put("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
updateFields.password = updateData.password || null;
|
updateFields.password = updateData.password || null;
|
||||||
}
|
}
|
||||||
if (updateData.key !== undefined) {
|
if (updateData.key !== undefined) {
|
||||||
updateFields.key = updateData.key || null;
|
updateFields.key = updateData.key || null; // backward compatibility
|
||||||
|
|
||||||
|
// Parse SSH key if provided
|
||||||
|
if (updateData.key && existing[0].authType === "key") {
|
||||||
|
const keyInfo = parseSSHKey(updateData.key, updateData.keyPassword);
|
||||||
|
if (!keyInfo.success) {
|
||||||
|
authLogger.warn("SSH key parsing failed during update", {
|
||||||
|
operation: "credential_update",
|
||||||
|
userId,
|
||||||
|
credentialId: parseInt(id),
|
||||||
|
error: keyInfo.error,
|
||||||
|
});
|
||||||
|
return res.status(400).json({
|
||||||
|
error: `Invalid SSH key: ${keyInfo.error}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
updateFields.privateKey = keyInfo.privateKey;
|
||||||
|
updateFields.publicKey = keyInfo.publicKey;
|
||||||
|
updateFields.detectedKeyType = keyInfo.keyType;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (updateData.keyPassword !== undefined) {
|
if (updateData.keyPassword !== undefined) {
|
||||||
updateFields.keyPassword = updateData.keyPassword || null;
|
updateFields.keyPassword = updateData.keyPassword || null;
|
||||||
@@ -584,7 +678,9 @@ function formatCredentialOutput(credential: any): any {
|
|||||||
: [],
|
: [],
|
||||||
authType: credential.authType,
|
authType: credential.authType,
|
||||||
username: credential.username,
|
username: credential.username,
|
||||||
|
publicKey: credential.publicKey,
|
||||||
keyType: credential.keyType,
|
keyType: credential.keyType,
|
||||||
|
detectedKeyType: credential.detectedKeyType,
|
||||||
usageCount: credential.usageCount || 0,
|
usageCount: credential.usageCount || 0,
|
||||||
lastUsed: credential.lastUsed,
|
lastUsed: credential.lastUsed,
|
||||||
createdAt: credential.createdAt,
|
createdAt: credential.createdAt,
|
||||||
@@ -661,4 +757,642 @@ router.put(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Detect SSH key type endpoint
|
||||||
|
// POST /credentials/detect-key-type
|
||||||
|
router.post("/detect-key-type", authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
const { privateKey, keyPassword } = req.body;
|
||||||
|
|
||||||
|
console.log("=== Key Detection API Called ===");
|
||||||
|
console.log("Request body keys:", Object.keys(req.body));
|
||||||
|
console.log("Private key provided:", !!privateKey);
|
||||||
|
console.log("Private key type:", typeof privateKey);
|
||||||
|
|
||||||
|
if (!privateKey || typeof privateKey !== "string") {
|
||||||
|
console.log("Invalid private key provided");
|
||||||
|
return res.status(400).json({ error: "Private key is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("Calling parseSSHKey...");
|
||||||
|
const keyInfo = parseSSHKey(privateKey, keyPassword);
|
||||||
|
console.log("parseSSHKey result:", keyInfo);
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
success: keyInfo.success,
|
||||||
|
keyType: keyInfo.keyType,
|
||||||
|
detectedKeyType: keyInfo.keyType,
|
||||||
|
hasPublicKey: !!keyInfo.publicKey,
|
||||||
|
error: keyInfo.error || null
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("Sending response:", response);
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Exception in detect-key-type endpoint:", error);
|
||||||
|
authLogger.error("Failed to detect key type", error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: error instanceof Error ? error.message : "Failed to detect key type"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Detect SSH public key type endpoint
|
||||||
|
// POST /credentials/detect-public-key-type
|
||||||
|
router.post("/detect-public-key-type", authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
const { publicKey } = req.body;
|
||||||
|
|
||||||
|
console.log("=== Public Key Detection API Called ===");
|
||||||
|
console.log("Request body keys:", Object.keys(req.body));
|
||||||
|
console.log("Public key provided:", !!publicKey);
|
||||||
|
console.log("Public key type:", typeof publicKey);
|
||||||
|
|
||||||
|
if (!publicKey || typeof publicKey !== "string") {
|
||||||
|
console.log("Invalid public key provided");
|
||||||
|
return res.status(400).json({ error: "Public key is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("Calling parsePublicKey...");
|
||||||
|
const keyInfo = parsePublicKey(publicKey);
|
||||||
|
console.log("parsePublicKey result:", keyInfo);
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
success: keyInfo.success,
|
||||||
|
keyType: keyInfo.keyType,
|
||||||
|
detectedKeyType: keyInfo.keyType,
|
||||||
|
error: keyInfo.error || null
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("Sending response:", response);
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Exception in detect-public-key-type endpoint:", error);
|
||||||
|
authLogger.error("Failed to detect public key type", error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: error instanceof Error ? error.message : "Failed to detect public key type"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate SSH key pair endpoint
|
||||||
|
// POST /credentials/validate-key-pair
|
||||||
|
router.post("/validate-key-pair", authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
const { privateKey, publicKey, keyPassword } = req.body;
|
||||||
|
|
||||||
|
console.log("=== Key Pair Validation API Called ===");
|
||||||
|
console.log("Request body keys:", Object.keys(req.body));
|
||||||
|
console.log("Private key provided:", !!privateKey);
|
||||||
|
console.log("Public key provided:", !!publicKey);
|
||||||
|
|
||||||
|
if (!privateKey || typeof privateKey !== "string") {
|
||||||
|
console.log("Invalid private key provided");
|
||||||
|
return res.status(400).json({ error: "Private key is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!publicKey || typeof publicKey !== "string") {
|
||||||
|
console.log("Invalid public key provided");
|
||||||
|
return res.status(400).json({ error: "Public key is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("Calling validateKeyPair...");
|
||||||
|
const validationResult = validateKeyPair(privateKey, publicKey, keyPassword);
|
||||||
|
console.log("validateKeyPair result:", validationResult);
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
isValid: validationResult.isValid,
|
||||||
|
privateKeyType: validationResult.privateKeyType,
|
||||||
|
publicKeyType: validationResult.publicKeyType,
|
||||||
|
generatedPublicKey: validationResult.generatedPublicKey,
|
||||||
|
error: validationResult.error || null
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("Sending response:", response);
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Exception in validate-key-pair endpoint:", error);
|
||||||
|
authLogger.error("Failed to validate key pair", error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: error instanceof Error ? error.message : "Failed to validate key pair"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate new SSH key pair endpoint
|
||||||
|
// POST /credentials/generate-key-pair
|
||||||
|
router.post("/generate-key-pair", authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
const { keyType = 'ssh-ed25519', keySize = 2048, passphrase } = req.body;
|
||||||
|
|
||||||
|
console.log("=== Generate Key Pair API Called ===");
|
||||||
|
console.log("Key type:", keyType);
|
||||||
|
console.log("Key size:", keySize);
|
||||||
|
console.log("Has passphrase:", !!passphrase);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Generate SSH keys directly with ssh2
|
||||||
|
const result = generateSSHKeyPair(keyType, keySize, passphrase);
|
||||||
|
|
||||||
|
if (result.success && result.privateKey && result.publicKey) {
|
||||||
|
const response = {
|
||||||
|
success: true,
|
||||||
|
privateKey: result.privateKey,
|
||||||
|
publicKey: result.publicKey,
|
||||||
|
keyType: keyType,
|
||||||
|
format: 'ssh',
|
||||||
|
algorithm: keyType,
|
||||||
|
keySize: keyType === 'ssh-rsa' ? keySize : undefined,
|
||||||
|
curve: keyType === 'ecdsa-sha2-nistp256' ? 'nistp256' : undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("SSH key pair generated successfully:", keyType);
|
||||||
|
res.json(response);
|
||||||
|
} else {
|
||||||
|
console.error("SSH key generation failed:", result.error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: result.error || "Failed to generate SSH key pair"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Exception in generate-key-pair endpoint:", error);
|
||||||
|
authLogger.error("Failed to generate key pair", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Failed to generate key pair"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate public key from private key endpoint
|
||||||
|
// POST /credentials/generate-public-key
|
||||||
|
router.post("/generate-public-key", authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
const { privateKey, keyPassword } = req.body;
|
||||||
|
|
||||||
|
console.log("=== Generate Public Key API Called ===");
|
||||||
|
console.log("Request body keys:", Object.keys(req.body));
|
||||||
|
console.log("Private key provided:", !!privateKey);
|
||||||
|
console.log("Private key type:", typeof privateKey);
|
||||||
|
|
||||||
|
if (!privateKey || typeof privateKey !== "string") {
|
||||||
|
console.log("Invalid private key provided");
|
||||||
|
return res.status(400).json({ error: "Private key is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("Using Node.js crypto to generate public key from private key...");
|
||||||
|
console.log("Private key length:", privateKey.length);
|
||||||
|
console.log("Private key first 100 chars:", privateKey.substring(0, 100));
|
||||||
|
|
||||||
|
// First try to create private key object from the input
|
||||||
|
let privateKeyObj;
|
||||||
|
let parseAttempts = [];
|
||||||
|
|
||||||
|
// Attempt 1: Direct parsing with passphrase
|
||||||
|
try {
|
||||||
|
privateKeyObj = crypto.createPrivateKey({
|
||||||
|
key: privateKey,
|
||||||
|
passphrase: keyPassword
|
||||||
|
});
|
||||||
|
console.log("Successfully parsed with passphrase method");
|
||||||
|
} catch (error) {
|
||||||
|
parseAttempts.push(`Method 1 (with passphrase): ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt 2: Direct parsing without passphrase
|
||||||
|
if (!privateKeyObj) {
|
||||||
|
try {
|
||||||
|
privateKeyObj = crypto.createPrivateKey(privateKey);
|
||||||
|
console.log("Successfully parsed without passphrase");
|
||||||
|
} catch (error) {
|
||||||
|
parseAttempts.push(`Method 2 (without passphrase): ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt 3: Try with explicit format specification
|
||||||
|
if (!privateKeyObj) {
|
||||||
|
try {
|
||||||
|
privateKeyObj = crypto.createPrivateKey({
|
||||||
|
key: privateKey,
|
||||||
|
format: 'pem',
|
||||||
|
type: 'pkcs8'
|
||||||
|
});
|
||||||
|
console.log("Successfully parsed as PKCS#8");
|
||||||
|
} catch (error) {
|
||||||
|
parseAttempts.push(`Method 3 (PKCS#8): ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt 4: Try as PKCS#1 RSA
|
||||||
|
if (!privateKeyObj && privateKey.includes('-----BEGIN RSA PRIVATE KEY-----')) {
|
||||||
|
try {
|
||||||
|
privateKeyObj = crypto.createPrivateKey({
|
||||||
|
key: privateKey,
|
||||||
|
format: 'pem',
|
||||||
|
type: 'pkcs1'
|
||||||
|
});
|
||||||
|
console.log("Successfully parsed as PKCS#1 RSA");
|
||||||
|
} catch (error) {
|
||||||
|
parseAttempts.push(`Method 4 (PKCS#1): ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt 5: Try as SEC1 EC
|
||||||
|
if (!privateKeyObj && privateKey.includes('-----BEGIN EC PRIVATE KEY-----')) {
|
||||||
|
try {
|
||||||
|
privateKeyObj = crypto.createPrivateKey({
|
||||||
|
key: privateKey,
|
||||||
|
format: 'pem',
|
||||||
|
type: 'sec1'
|
||||||
|
});
|
||||||
|
console.log("Successfully parsed as SEC1 EC");
|
||||||
|
} catch (error) {
|
||||||
|
parseAttempts.push(`Method 5 (SEC1): ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final attempt: Try using ssh2 as fallback
|
||||||
|
if (!privateKeyObj) {
|
||||||
|
console.log("Attempting fallback to parseSSHKey function...");
|
||||||
|
try {
|
||||||
|
const keyInfo = parseSSHKey(privateKey, keyPassword);
|
||||||
|
console.log("parseSSHKey fallback result:", keyInfo);
|
||||||
|
|
||||||
|
if (keyInfo.success && keyInfo.publicKey) {
|
||||||
|
// Ensure SSH2 fallback also returns proper string
|
||||||
|
const publicKeyString = String(keyInfo.publicKey);
|
||||||
|
console.log("SSH2 fallback public key type:", typeof publicKeyString);
|
||||||
|
console.log("SSH2 fallback public key length:", publicKeyString.length);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
publicKey: publicKeyString,
|
||||||
|
keyType: keyInfo.keyType
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
parseAttempts.push(`SSH2 fallback: ${keyInfo.error || 'No public key generated'}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
parseAttempts.push(`SSH2 fallback exception: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!privateKeyObj) {
|
||||||
|
console.error("All parsing attempts failed:", parseAttempts);
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Unable to parse private key. Tried multiple formats.",
|
||||||
|
details: parseAttempts
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate public key from private key
|
||||||
|
const publicKeyObj = crypto.createPublicKey(privateKeyObj);
|
||||||
|
const publicKeyPem = publicKeyObj.export({
|
||||||
|
type: 'spki',
|
||||||
|
format: 'pem'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Debug: Check what we're actually generating
|
||||||
|
console.log("Generated public key type:", typeof publicKeyPem);
|
||||||
|
console.log("Generated public key is Buffer:", Buffer.isBuffer(publicKeyPem));
|
||||||
|
|
||||||
|
// Ensure publicKeyPem is a string
|
||||||
|
const publicKeyString = typeof publicKeyPem === 'string' ? publicKeyPem : publicKeyPem.toString('utf8');
|
||||||
|
|
||||||
|
console.log("Public key string length:", publicKeyString.length);
|
||||||
|
console.log("Generated public key first 100 chars:", publicKeyString.substring(0, 100));
|
||||||
|
console.log("Public key is string:", typeof publicKeyString === 'string');
|
||||||
|
console.log("Public key contains PEM header:", publicKeyString.includes('-----BEGIN PUBLIC KEY-----'));
|
||||||
|
|
||||||
|
// Detect key type from the private key object
|
||||||
|
let keyType = 'unknown';
|
||||||
|
const asymmetricKeyType = privateKeyObj.asymmetricKeyType;
|
||||||
|
|
||||||
|
if (asymmetricKeyType === 'rsa') {
|
||||||
|
keyType = 'ssh-rsa';
|
||||||
|
} else if (asymmetricKeyType === 'ed25519') {
|
||||||
|
keyType = 'ssh-ed25519';
|
||||||
|
} else if (asymmetricKeyType === 'ec') {
|
||||||
|
// For EC keys, we need to check the curve
|
||||||
|
keyType = 'ecdsa-sha2-nistp256'; // Default assumption for P-256
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use ssh2 to generate SSH format public key
|
||||||
|
let finalPublicKey = publicKeyString; // PEM fallback
|
||||||
|
let formatType = 'pem';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ssh2PrivateKey = ssh2Utils.parseKey(privateKey, keyPassword);
|
||||||
|
if (!(ssh2PrivateKey instanceof Error)) {
|
||||||
|
const publicKeyBuffer = ssh2PrivateKey.getPublicSSH();
|
||||||
|
const base64Data = publicKeyBuffer.toString('base64');
|
||||||
|
finalPublicKey = `${keyType} ${base64Data}`;
|
||||||
|
formatType = 'ssh';
|
||||||
|
console.log("SSH format public key generated!");
|
||||||
|
} else {
|
||||||
|
console.warn("ssh2 parsing failed, using PEM format");
|
||||||
|
}
|
||||||
|
} catch (sshError) {
|
||||||
|
console.warn("ssh2 failed, using PEM format");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
success: true,
|
||||||
|
publicKey: finalPublicKey,
|
||||||
|
keyType: keyType,
|
||||||
|
format: formatType
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("Final response publicKey type:", typeof response.publicKey);
|
||||||
|
console.log("Final response publicKey format:", response.format);
|
||||||
|
console.log("Final response publicKey length:", response.publicKey.length);
|
||||||
|
console.log("Public key generated successfully using crypto module:", keyType);
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Exception in generate-public-key endpoint:", error);
|
||||||
|
authLogger.error("Failed to generate public key", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Failed to generate public key"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// SSH Key Deployment Function
|
||||||
|
async function deploySSHKeyToHost(
|
||||||
|
hostConfig: any,
|
||||||
|
publicKey: string,
|
||||||
|
credentialData: any
|
||||||
|
): Promise<{ success: boolean; message?: string; error?: string }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const conn = new Client();
|
||||||
|
let connectionTimeout: NodeJS.Timeout;
|
||||||
|
|
||||||
|
// Connection timeout
|
||||||
|
connectionTimeout = setTimeout(() => {
|
||||||
|
conn.destroy();
|
||||||
|
resolve({ success: false, error: "Connection timeout" });
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
conn.on('ready', async () => {
|
||||||
|
clearTimeout(connectionTimeout);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Create ~/.ssh directory if it doesn't exist
|
||||||
|
await new Promise<void>((resolveCmd, rejectCmd) => {
|
||||||
|
conn.exec('mkdir -p ~/.ssh && chmod 700 ~/.ssh', (err, stream) => {
|
||||||
|
if (err) return rejectCmd(err);
|
||||||
|
|
||||||
|
stream.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolveCmd();
|
||||||
|
} else {
|
||||||
|
rejectCmd(new Error(`mkdir command failed with code ${code}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: Check if public key already exists
|
||||||
|
const keyExists = await new Promise<boolean>((resolveCheck, rejectCheck) => {
|
||||||
|
const keyPattern = publicKey.split(' ')[1]; // Get the key part without algorithm
|
||||||
|
conn.exec(`grep -q "${keyPattern}" ~/.ssh/authorized_keys 2>/dev/null`, (err, stream) => {
|
||||||
|
if (err) return rejectCheck(err);
|
||||||
|
|
||||||
|
stream.on('close', (code) => {
|
||||||
|
resolveCheck(code === 0); // code 0 means key found
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (keyExists) {
|
||||||
|
conn.end();
|
||||||
|
resolve({ success: true, message: "SSH key already deployed" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Add public key to authorized_keys
|
||||||
|
await new Promise<void>((resolveAdd, rejectAdd) => {
|
||||||
|
const escapedKey = publicKey.replace(/'/g, "'\\''");
|
||||||
|
conn.exec(`echo '${escapedKey}' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`, (err, stream) => {
|
||||||
|
if (err) return rejectAdd(err);
|
||||||
|
|
||||||
|
stream.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolveAdd();
|
||||||
|
} else {
|
||||||
|
rejectAdd(new Error(`Key deployment failed with code ${code}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 4: Verify deployment
|
||||||
|
const verifySuccess = await new Promise<boolean>((resolveVerify, rejectVerify) => {
|
||||||
|
const keyPattern = publicKey.split(' ')[1];
|
||||||
|
conn.exec(`grep -q "${keyPattern}" ~/.ssh/authorized_keys`, (err, stream) => {
|
||||||
|
if (err) return rejectVerify(err);
|
||||||
|
|
||||||
|
stream.on('close', (code) => {
|
||||||
|
resolveVerify(code === 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
conn.end();
|
||||||
|
|
||||||
|
if (verifySuccess) {
|
||||||
|
resolve({ success: true, message: "SSH key deployed successfully" });
|
||||||
|
} else {
|
||||||
|
resolve({ success: false, error: "Key deployment verification failed" });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
conn.end();
|
||||||
|
resolve({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Deployment failed"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
conn.on('error', (err) => {
|
||||||
|
clearTimeout(connectionTimeout);
|
||||||
|
resolve({ success: false, error: err.message });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connect to the target host
|
||||||
|
try {
|
||||||
|
const connectionConfig: any = {
|
||||||
|
host: hostConfig.ip,
|
||||||
|
port: hostConfig.port || 22,
|
||||||
|
username: hostConfig.username,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (hostConfig.authType === 'password' && hostConfig.password) {
|
||||||
|
connectionConfig.password = hostConfig.password;
|
||||||
|
} else if (hostConfig.authType === 'key' && hostConfig.privateKey) {
|
||||||
|
connectionConfig.privateKey = hostConfig.privateKey;
|
||||||
|
if (hostConfig.keyPassword) {
|
||||||
|
connectionConfig.passphrase = hostConfig.keyPassword;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resolve({ success: false, error: "Invalid authentication configuration" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.connect(connectionConfig);
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(connectionTimeout);
|
||||||
|
resolve({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Connection failed"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deploy SSH Key to Host endpoint
|
||||||
|
// POST /credentials/:id/deploy-to-host
|
||||||
|
router.post("/:id/deploy-to-host", authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
const credentialId = parseInt(req.params.id);
|
||||||
|
const { targetHostId } = req.body;
|
||||||
|
|
||||||
|
|
||||||
|
if (!credentialId || !targetHostId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Credential ID and target host ID are required"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get credential details
|
||||||
|
const credential = await db
|
||||||
|
.select()
|
||||||
|
.from(sshCredentials)
|
||||||
|
.where(eq(sshCredentials.id, credentialId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!credential || credential.length === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: "Credential not found"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const credData = credential[0];
|
||||||
|
|
||||||
|
// Only support key-based credentials for deployment
|
||||||
|
if (credData.authType !== 'key') {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Only SSH key-based credentials can be deployed"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!credData.publicKey) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Public key is required for deployment"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get target host details
|
||||||
|
const targetHost = await db
|
||||||
|
.select()
|
||||||
|
.from(sshData)
|
||||||
|
.where(eq(sshData.id, targetHostId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!targetHost || targetHost.length === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: "Target host not found"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostData = targetHost[0];
|
||||||
|
|
||||||
|
// Prepare host configuration for connection
|
||||||
|
let hostConfig = {
|
||||||
|
ip: hostData.ip,
|
||||||
|
port: hostData.port,
|
||||||
|
username: hostData.username,
|
||||||
|
authType: hostData.authType,
|
||||||
|
password: hostData.password,
|
||||||
|
privateKey: hostData.key,
|
||||||
|
keyPassword: hostData.keyPassword
|
||||||
|
};
|
||||||
|
|
||||||
|
// If host uses credential authentication, resolve the credential
|
||||||
|
if (hostData.authType === 'credential' && hostData.credentialId) {
|
||||||
|
const hostCredential = await db
|
||||||
|
.select()
|
||||||
|
.from(sshCredentials)
|
||||||
|
.where(eq(sshCredentials.id, hostData.credentialId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (hostCredential && hostCredential.length > 0) {
|
||||||
|
const cred = hostCredential[0];
|
||||||
|
|
||||||
|
// Update hostConfig with credential data
|
||||||
|
hostConfig.authType = cred.authType;
|
||||||
|
hostConfig.username = cred.username; // Use credential's username
|
||||||
|
|
||||||
|
if (cred.authType === 'password') {
|
||||||
|
hostConfig.password = cred.password;
|
||||||
|
} else if (cred.authType === 'key') {
|
||||||
|
hostConfig.privateKey = cred.privateKey || cred.key; // Try both fields
|
||||||
|
hostConfig.keyPassword = cred.keyPassword;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Host credential not found"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deploy the SSH key
|
||||||
|
const deployResult = await deploySSHKeyToHost(
|
||||||
|
hostConfig,
|
||||||
|
credData.publicKey,
|
||||||
|
credData
|
||||||
|
);
|
||||||
|
|
||||||
|
if (deployResult.success) {
|
||||||
|
// Log successful deployment
|
||||||
|
authLogger.info(`SSH key deployed successfully`, {
|
||||||
|
credentialId,
|
||||||
|
targetHostId,
|
||||||
|
operation: "deploy_ssh_key"
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: deployResult.message || "SSH key deployed successfully"
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
authLogger.error(`SSH key deployment failed`, {
|
||||||
|
credentialId,
|
||||||
|
targetHostId,
|
||||||
|
error: deployResult.error,
|
||||||
|
operation: "deploy_ssh_key"
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: deployResult.error || "Deployment failed"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
authLogger.error("Failed to deploy SSH key", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Failed to deploy SSH key"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ wss.on("connection", (ws: WebSocket) => {
|
|||||||
const credential = credentials[0];
|
const credential = credentials[0];
|
||||||
resolvedCredentials = {
|
resolvedCredentials = {
|
||||||
password: credential.password,
|
password: credential.password,
|
||||||
key: credential.key,
|
key: credential.privateKey || credential.key, // prefer new privateKey field
|
||||||
keyPassword: credential.keyPassword,
|
keyPassword: credential.keyPassword,
|
||||||
keyType: credential.keyType,
|
keyType: credential.keyType,
|
||||||
authType: credential.authType,
|
authType: credential.authType,
|
||||||
|
|||||||
@@ -455,7 +455,7 @@ async function connectSSHTunnel(
|
|||||||
const credential = credentials[0];
|
const credential = credentials[0];
|
||||||
resolvedSourceCredentials = {
|
resolvedSourceCredentials = {
|
||||||
password: credential.password,
|
password: credential.password,
|
||||||
sshKey: credential.key,
|
sshKey: credential.privateKey || credential.key, // prefer new privateKey field
|
||||||
keyPassword: credential.keyPassword,
|
keyPassword: credential.keyPassword,
|
||||||
keyType: credential.keyType,
|
keyType: credential.keyType,
|
||||||
authMethod: credential.authType,
|
authMethod: credential.authType,
|
||||||
@@ -501,7 +501,7 @@ async function connectSSHTunnel(
|
|||||||
const credential = credentials[0];
|
const credential = credentials[0];
|
||||||
resolvedEndpointCredentials = {
|
resolvedEndpointCredentials = {
|
||||||
password: credential.password,
|
password: credential.password,
|
||||||
sshKey: credential.key,
|
sshKey: credential.privateKey || credential.key, // prefer new privateKey field
|
||||||
keyPassword: credential.keyPassword,
|
keyPassword: credential.keyPassword,
|
||||||
keyType: credential.keyType,
|
keyType: credential.keyType,
|
||||||
authMethod: credential.authType,
|
authMethod: credential.authType,
|
||||||
|
|||||||
527
src/backend/utils/ssh-key-utils.ts
Normal file
527
src/backend/utils/ssh-key-utils.ts
Normal file
@@ -0,0 +1,527 @@
|
|||||||
|
// Import SSH2 using ES modules
|
||||||
|
import ssh2Pkg from 'ssh2';
|
||||||
|
const ssh2Utils = ssh2Pkg.utils;
|
||||||
|
|
||||||
|
// Simple fallback SSH key type detection
|
||||||
|
function detectKeyTypeFromContent(keyContent: string): string {
|
||||||
|
const content = keyContent.trim();
|
||||||
|
|
||||||
|
// Check for OpenSSH format headers
|
||||||
|
if (content.includes('-----BEGIN OPENSSH PRIVATE KEY-----')) {
|
||||||
|
// Look for key type indicators in the content
|
||||||
|
if (content.includes('ssh-ed25519') || content.includes('AAAAC3NzaC1lZDI1NTE5')) {
|
||||||
|
return 'ssh-ed25519';
|
||||||
|
}
|
||||||
|
if (content.includes('ssh-rsa') || content.includes('AAAAB3NzaC1yc2E')) {
|
||||||
|
return 'ssh-rsa';
|
||||||
|
}
|
||||||
|
if (content.includes('ecdsa-sha2-nistp256')) {
|
||||||
|
return 'ecdsa-sha2-nistp256';
|
||||||
|
}
|
||||||
|
if (content.includes('ecdsa-sha2-nistp384')) {
|
||||||
|
return 'ecdsa-sha2-nistp384';
|
||||||
|
}
|
||||||
|
if (content.includes('ecdsa-sha2-nistp521')) {
|
||||||
|
return 'ecdsa-sha2-nistp521';
|
||||||
|
}
|
||||||
|
|
||||||
|
// For OpenSSH format, try to detect by analyzing the base64 content structure
|
||||||
|
try {
|
||||||
|
const base64Content = content
|
||||||
|
.replace('-----BEGIN OPENSSH PRIVATE KEY-----', '')
|
||||||
|
.replace('-----END OPENSSH PRIVATE KEY-----', '')
|
||||||
|
.replace(/\s/g, '');
|
||||||
|
|
||||||
|
// OpenSSH format starts with "openssh-key-v1" followed by key type
|
||||||
|
const decoded = Buffer.from(base64Content, 'base64').toString('binary');
|
||||||
|
|
||||||
|
if (decoded.includes('ssh-rsa')) {
|
||||||
|
return 'ssh-rsa';
|
||||||
|
}
|
||||||
|
if (decoded.includes('ssh-ed25519')) {
|
||||||
|
return 'ssh-ed25519';
|
||||||
|
}
|
||||||
|
if (decoded.includes('ecdsa-sha2-nistp256')) {
|
||||||
|
return 'ecdsa-sha2-nistp256';
|
||||||
|
}
|
||||||
|
if (decoded.includes('ecdsa-sha2-nistp384')) {
|
||||||
|
return 'ecdsa-sha2-nistp384';
|
||||||
|
}
|
||||||
|
if (decoded.includes('ecdsa-sha2-nistp521')) {
|
||||||
|
return 'ecdsa-sha2-nistp521';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to RSA for OpenSSH format if we can't detect specifically
|
||||||
|
return 'ssh-rsa';
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to decode OpenSSH key content:', error);
|
||||||
|
// If decoding fails, default to RSA as it's most common for OpenSSH format
|
||||||
|
return 'ssh-rsa';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for traditional PEM headers
|
||||||
|
if (content.includes('-----BEGIN RSA PRIVATE KEY-----')) {
|
||||||
|
return 'ssh-rsa';
|
||||||
|
}
|
||||||
|
if (content.includes('-----BEGIN DSA PRIVATE KEY-----')) {
|
||||||
|
return 'ssh-dss';
|
||||||
|
}
|
||||||
|
if (content.includes('-----BEGIN EC PRIVATE KEY-----')) {
|
||||||
|
return 'ecdsa-sha2-nistp256'; // Default ECDSA type
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for PKCS#8 format (modern format)
|
||||||
|
if (content.includes('-----BEGIN PRIVATE KEY-----')) {
|
||||||
|
// Try to decode and analyze the DER structure for better detection
|
||||||
|
try {
|
||||||
|
const base64Content = content
|
||||||
|
.replace('-----BEGIN PRIVATE KEY-----', '')
|
||||||
|
.replace('-----END PRIVATE KEY-----', '')
|
||||||
|
.replace(/\s/g, '');
|
||||||
|
|
||||||
|
const decoded = Buffer.from(base64Content, 'base64');
|
||||||
|
const decodedString = decoded.toString('binary');
|
||||||
|
|
||||||
|
// Check for algorithm identifiers in the DER structure
|
||||||
|
if (decodedString.includes('1.2.840.113549.1.1.1')) {
|
||||||
|
// RSA OID
|
||||||
|
return 'ssh-rsa';
|
||||||
|
} else if (decodedString.includes('1.2.840.10045.2.1')) {
|
||||||
|
// EC Private Key OID - this indicates ECDSA
|
||||||
|
if (decodedString.includes('1.2.840.10045.3.1.7')) {
|
||||||
|
// prime256v1 curve OID
|
||||||
|
return 'ecdsa-sha2-nistp256';
|
||||||
|
}
|
||||||
|
return 'ecdsa-sha2-nistp256'; // Default to P-256
|
||||||
|
} else if (decodedString.includes('1.3.101.112')) {
|
||||||
|
// Ed25519 OID
|
||||||
|
return 'ssh-ed25519';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// If decoding fails, fall back to length-based detection
|
||||||
|
console.warn('Failed to decode private key for type detection:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Try to detect key type from the content structure
|
||||||
|
// This is a fallback for PKCS#8 format keys
|
||||||
|
if (content.length < 800) {
|
||||||
|
// Ed25519 keys are typically shorter
|
||||||
|
return 'ssh-ed25519';
|
||||||
|
} else if (content.length > 1600) {
|
||||||
|
// RSA keys are typically longer
|
||||||
|
return 'ssh-rsa';
|
||||||
|
} else {
|
||||||
|
// ECDSA keys are typically medium length
|
||||||
|
return 'ecdsa-sha2-nistp256';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect public key type from public key content
|
||||||
|
function detectPublicKeyTypeFromContent(publicKeyContent: string): string {
|
||||||
|
const content = publicKeyContent.trim();
|
||||||
|
|
||||||
|
// SSH public keys start with the key type
|
||||||
|
if (content.startsWith('ssh-rsa ')) {
|
||||||
|
return 'ssh-rsa';
|
||||||
|
}
|
||||||
|
if (content.startsWith('ssh-ed25519 ')) {
|
||||||
|
return 'ssh-ed25519';
|
||||||
|
}
|
||||||
|
if (content.startsWith('ecdsa-sha2-nistp256 ')) {
|
||||||
|
return 'ecdsa-sha2-nistp256';
|
||||||
|
}
|
||||||
|
if (content.startsWith('ecdsa-sha2-nistp384 ')) {
|
||||||
|
return 'ecdsa-sha2-nistp384';
|
||||||
|
}
|
||||||
|
if (content.startsWith('ecdsa-sha2-nistp521 ')) {
|
||||||
|
return 'ecdsa-sha2-nistp521';
|
||||||
|
}
|
||||||
|
if (content.startsWith('ssh-dss ')) {
|
||||||
|
return 'ssh-dss';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for PEM format public keys
|
||||||
|
if (content.includes('-----BEGIN PUBLIC KEY-----')) {
|
||||||
|
// Try to decode the base64 content to detect key type
|
||||||
|
try {
|
||||||
|
const base64Content = content
|
||||||
|
.replace('-----BEGIN PUBLIC KEY-----', '')
|
||||||
|
.replace('-----END PUBLIC KEY-----', '')
|
||||||
|
.replace(/\s/g, '');
|
||||||
|
|
||||||
|
const decoded = Buffer.from(base64Content, 'base64');
|
||||||
|
const decodedString = decoded.toString('binary');
|
||||||
|
|
||||||
|
// Check for algorithm identifiers in the DER structure
|
||||||
|
if (decodedString.includes('1.2.840.113549.1.1.1')) {
|
||||||
|
// RSA OID
|
||||||
|
return 'ssh-rsa';
|
||||||
|
} else if (decodedString.includes('1.2.840.10045.2.1')) {
|
||||||
|
// EC Public Key OID - this indicates ECDSA
|
||||||
|
if (decodedString.includes('1.2.840.10045.3.1.7')) {
|
||||||
|
// prime256v1 curve OID
|
||||||
|
return 'ecdsa-sha2-nistp256';
|
||||||
|
}
|
||||||
|
return 'ecdsa-sha2-nistp256'; // Default to P-256
|
||||||
|
} else if (decodedString.includes('1.3.101.112')) {
|
||||||
|
// Ed25519 OID
|
||||||
|
return 'ssh-ed25519';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// If decoding fails, fall back to length-based detection
|
||||||
|
console.warn('Failed to decode public key for type detection:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Try to guess based on key length
|
||||||
|
if (content.length < 400) {
|
||||||
|
return 'ssh-ed25519';
|
||||||
|
} else if (content.length > 600) {
|
||||||
|
return 'ssh-rsa';
|
||||||
|
} else {
|
||||||
|
return 'ecdsa-sha2-nistp256';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content.includes('-----BEGIN RSA PUBLIC KEY-----')) {
|
||||||
|
return 'ssh-rsa';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for base64 encoded key data patterns
|
||||||
|
if (content.includes('AAAAB3NzaC1yc2E')) {
|
||||||
|
return 'ssh-rsa';
|
||||||
|
}
|
||||||
|
if (content.includes('AAAAC3NzaC1lZDI1NTE5')) {
|
||||||
|
return 'ssh-ed25519';
|
||||||
|
}
|
||||||
|
if (content.includes('AAAAE2VjZHNhLXNoYTItbmlzdHAyNTY')) {
|
||||||
|
return 'ecdsa-sha2-nistp256';
|
||||||
|
}
|
||||||
|
if (content.includes('AAAAE2VjZHNhLXNoYTItbmlzdHAzODQ')) {
|
||||||
|
return 'ecdsa-sha2-nistp384';
|
||||||
|
}
|
||||||
|
if (content.includes('AAAAE2VjZHNhLXNoYTItbmlzdHA1MjE')) {
|
||||||
|
return 'ecdsa-sha2-nistp521';
|
||||||
|
}
|
||||||
|
if (content.includes('AAAAB3NzaC1kc3M')) {
|
||||||
|
return 'ssh-dss';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyInfo {
|
||||||
|
privateKey: string;
|
||||||
|
publicKey: string;
|
||||||
|
keyType: string;
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicKeyInfo {
|
||||||
|
publicKey: string;
|
||||||
|
keyType: string;
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyPairValidationResult {
|
||||||
|
isValid: boolean;
|
||||||
|
privateKeyType: string;
|
||||||
|
publicKeyType: string;
|
||||||
|
generatedPublicKey?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse SSH private key and extract public key and type information
|
||||||
|
*/
|
||||||
|
export function parseSSHKey(privateKeyData: string, passphrase?: string): KeyInfo {
|
||||||
|
console.log('=== SSH Key Parsing Debug ===');
|
||||||
|
console.log('Key length:', privateKeyData?.length || 'undefined');
|
||||||
|
console.log('First 100 chars:', privateKeyData?.substring(0, 100) || 'undefined');
|
||||||
|
console.log('ssh2Utils available:', typeof ssh2Utils);
|
||||||
|
console.log('parseKey function available:', typeof ssh2Utils?.parseKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let keyType = 'unknown';
|
||||||
|
let publicKey = '';
|
||||||
|
let useSSH2 = false;
|
||||||
|
|
||||||
|
// Try SSH2 first if available
|
||||||
|
if (ssh2Utils && typeof ssh2Utils.parseKey === 'function') {
|
||||||
|
try {
|
||||||
|
console.log('Calling ssh2Utils.parseKey...');
|
||||||
|
const parsedKey = ssh2Utils.parseKey(privateKeyData, passphrase);
|
||||||
|
console.log('parseKey returned:', typeof parsedKey, parsedKey instanceof Error ? parsedKey.message : 'success');
|
||||||
|
|
||||||
|
if (!(parsedKey instanceof Error)) {
|
||||||
|
// Extract key type
|
||||||
|
if (parsedKey.type) {
|
||||||
|
keyType = parsedKey.type;
|
||||||
|
}
|
||||||
|
console.log('Extracted key type:', keyType);
|
||||||
|
|
||||||
|
// Generate public key in SSH format
|
||||||
|
try {
|
||||||
|
console.log('Attempting to generate public key...');
|
||||||
|
const publicKeyBuffer = parsedKey.getPublicSSH();
|
||||||
|
console.log('Public key buffer type:', typeof publicKeyBuffer);
|
||||||
|
console.log('Public key buffer is Buffer:', Buffer.isBuffer(publicKeyBuffer));
|
||||||
|
|
||||||
|
// ssh2's getPublicSSH() returns binary SSH protocol data, not text
|
||||||
|
// We need to convert this to proper SSH public key format
|
||||||
|
if (Buffer.isBuffer(publicKeyBuffer)) {
|
||||||
|
// Convert binary SSH data to base64 and create proper SSH key format
|
||||||
|
const base64Data = publicKeyBuffer.toString('base64');
|
||||||
|
|
||||||
|
// Create proper SSH public key format: "keytype base64data"
|
||||||
|
if (keyType === 'ssh-rsa') {
|
||||||
|
publicKey = `ssh-rsa ${base64Data}`;
|
||||||
|
} else if (keyType === 'ssh-ed25519') {
|
||||||
|
publicKey = `ssh-ed25519 ${base64Data}`;
|
||||||
|
} else if (keyType.startsWith('ecdsa-')) {
|
||||||
|
publicKey = `${keyType} ${base64Data}`;
|
||||||
|
} else {
|
||||||
|
publicKey = `${keyType} ${base64Data}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Generated SSH public key format, length:', publicKey.length);
|
||||||
|
console.log('Public key starts with:', publicKey.substring(0, 50));
|
||||||
|
} else {
|
||||||
|
console.warn('Unexpected public key buffer type');
|
||||||
|
publicKey = '';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to generate public key:', error);
|
||||||
|
publicKey = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
useSSH2 = true;
|
||||||
|
console.log(`SSH key parsed successfully with SSH2: ${keyType}`);
|
||||||
|
} else {
|
||||||
|
console.warn('SSH2 parsing failed:', parsedKey.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('SSH2 parsing exception:', error instanceof Error ? error.message : error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('SSH2 parseKey function not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to content-based detection
|
||||||
|
if (!useSSH2) {
|
||||||
|
console.log('Using fallback key type detection...');
|
||||||
|
keyType = detectKeyTypeFromContent(privateKeyData);
|
||||||
|
console.log(`Fallback detected key type: ${keyType}`);
|
||||||
|
|
||||||
|
// For fallback, we can't generate public key but the detection is still useful
|
||||||
|
publicKey = '';
|
||||||
|
|
||||||
|
if (keyType !== 'unknown') {
|
||||||
|
console.log(`SSH key type detected successfully with fallback: ${keyType}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
privateKey: privateKeyData,
|
||||||
|
publicKey,
|
||||||
|
keyType,
|
||||||
|
success: keyType !== 'unknown'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Exception during SSH key parsing:', error);
|
||||||
|
console.error('Error stack:', error instanceof Error ? error.stack : 'No stack');
|
||||||
|
|
||||||
|
// Final fallback - try content detection
|
||||||
|
try {
|
||||||
|
const fallbackKeyType = detectKeyTypeFromContent(privateKeyData);
|
||||||
|
if (fallbackKeyType !== 'unknown') {
|
||||||
|
console.log(`Final fallback detection successful: ${fallbackKeyType}`);
|
||||||
|
return {
|
||||||
|
privateKey: privateKeyData,
|
||||||
|
publicKey: '',
|
||||||
|
keyType: fallbackKeyType,
|
||||||
|
success: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (fallbackError) {
|
||||||
|
console.error('Even fallback detection failed:', fallbackError);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
privateKey: privateKeyData,
|
||||||
|
publicKey: '',
|
||||||
|
keyType: 'unknown',
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error parsing key'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse SSH public key and extract type information
|
||||||
|
*/
|
||||||
|
export function parsePublicKey(publicKeyData: string): PublicKeyInfo {
|
||||||
|
console.log('=== SSH Public Key Parsing Debug ===');
|
||||||
|
console.log('Public key length:', publicKeyData?.length || 'undefined');
|
||||||
|
console.log('First 100 chars:', publicKeyData?.substring(0, 100) || 'undefined');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const keyType = detectPublicKeyTypeFromContent(publicKeyData);
|
||||||
|
console.log(`Public key type detected: ${keyType}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
publicKey: publicKeyData,
|
||||||
|
keyType,
|
||||||
|
success: keyType !== 'unknown'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Exception during SSH public key parsing:', error);
|
||||||
|
return {
|
||||||
|
publicKey: publicKeyData,
|
||||||
|
keyType: 'unknown',
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error parsing public key'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect SSH key type from private key content
|
||||||
|
*/
|
||||||
|
export function detectKeyType(privateKeyData: string): string {
|
||||||
|
try {
|
||||||
|
const parsedKey = ssh2Utils.parseKey(privateKeyData);
|
||||||
|
if (parsedKey instanceof Error) {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
return parsedKey.type || 'unknown';
|
||||||
|
} catch (error) {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get friendly key type name
|
||||||
|
*/
|
||||||
|
export function getFriendlyKeyTypeName(keyType: string): string {
|
||||||
|
const keyTypeMap: Record<string, string> = {
|
||||||
|
'ssh-rsa': 'RSA',
|
||||||
|
'ssh-ed25519': 'Ed25519',
|
||||||
|
'ecdsa-sha2-nistp256': 'ECDSA P-256',
|
||||||
|
'ecdsa-sha2-nistp384': 'ECDSA P-384',
|
||||||
|
'ecdsa-sha2-nistp521': 'ECDSA P-521',
|
||||||
|
'ssh-dss': 'DSA',
|
||||||
|
'rsa-sha2-256': 'RSA-SHA2-256',
|
||||||
|
'rsa-sha2-512': 'RSA-SHA2-512',
|
||||||
|
'unknown': 'Unknown'
|
||||||
|
};
|
||||||
|
|
||||||
|
return keyTypeMap[keyType] || keyType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate if a private key and public key form a valid key pair
|
||||||
|
*/
|
||||||
|
export function validateKeyPair(privateKeyData: string, publicKeyData: string, passphrase?: string): KeyPairValidationResult {
|
||||||
|
console.log('=== Key Pair Validation Debug ===');
|
||||||
|
console.log('Private key length:', privateKeyData?.length || 'undefined');
|
||||||
|
console.log('Public key length:', publicKeyData?.length || 'undefined');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// First parse the private key and try to generate public key
|
||||||
|
const privateKeyInfo = parseSSHKey(privateKeyData, passphrase);
|
||||||
|
const publicKeyInfo = parsePublicKey(publicKeyData);
|
||||||
|
|
||||||
|
console.log('Private key parsing result:', privateKeyInfo.success, privateKeyInfo.keyType);
|
||||||
|
console.log('Public key parsing result:', publicKeyInfo.success, publicKeyInfo.keyType);
|
||||||
|
|
||||||
|
if (!privateKeyInfo.success) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
privateKeyType: privateKeyInfo.keyType,
|
||||||
|
publicKeyType: publicKeyInfo.keyType,
|
||||||
|
error: `Invalid private key: ${privateKeyInfo.error}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!publicKeyInfo.success) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
privateKeyType: privateKeyInfo.keyType,
|
||||||
|
publicKeyType: publicKeyInfo.keyType,
|
||||||
|
error: `Invalid public key: ${publicKeyInfo.error}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if key types match
|
||||||
|
if (privateKeyInfo.keyType !== publicKeyInfo.keyType) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
privateKeyType: privateKeyInfo.keyType,
|
||||||
|
publicKeyType: publicKeyInfo.keyType,
|
||||||
|
error: `Key type mismatch: private key is ${privateKeyInfo.keyType}, public key is ${publicKeyInfo.keyType}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have a generated public key from the private key, compare them
|
||||||
|
if (privateKeyInfo.publicKey && privateKeyInfo.publicKey.trim()) {
|
||||||
|
const generatedPublicKey = privateKeyInfo.publicKey.trim();
|
||||||
|
const providedPublicKey = publicKeyData.trim();
|
||||||
|
|
||||||
|
console.log('Generated public key length:', generatedPublicKey.length);
|
||||||
|
console.log('Provided public key length:', providedPublicKey.length);
|
||||||
|
|
||||||
|
// Compare the key data part (excluding comments)
|
||||||
|
const generatedKeyParts = generatedPublicKey.split(' ');
|
||||||
|
const providedKeyParts = providedPublicKey.split(' ');
|
||||||
|
|
||||||
|
if (generatedKeyParts.length >= 2 && providedKeyParts.length >= 2) {
|
||||||
|
// Compare key type and key data (first two parts)
|
||||||
|
const generatedKeyData = generatedKeyParts[0] + ' ' + generatedKeyParts[1];
|
||||||
|
const providedKeyData = providedKeyParts[0] + ' ' + providedKeyParts[1];
|
||||||
|
|
||||||
|
console.log('Generated key data:', generatedKeyData.substring(0, 50) + '...');
|
||||||
|
console.log('Provided key data:', providedKeyData.substring(0, 50) + '...');
|
||||||
|
|
||||||
|
if (generatedKeyData === providedKeyData) {
|
||||||
|
return {
|
||||||
|
isValid: true,
|
||||||
|
privateKeyType: privateKeyInfo.keyType,
|
||||||
|
publicKeyType: publicKeyInfo.keyType,
|
||||||
|
generatedPublicKey: generatedPublicKey
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
privateKeyType: privateKeyInfo.keyType,
|
||||||
|
publicKeyType: publicKeyInfo.keyType,
|
||||||
|
generatedPublicKey: generatedPublicKey,
|
||||||
|
error: 'Public key does not match the private key'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we can't generate public key or compare, just check if types match
|
||||||
|
return {
|
||||||
|
isValid: true, // Assume valid if types match and no errors
|
||||||
|
privateKeyType: privateKeyInfo.keyType,
|
||||||
|
publicKeyType: publicKeyInfo.keyType,
|
||||||
|
error: 'Unable to verify key pair match, but key types are compatible'
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Exception during key pair validation:', error);
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
privateKeyType: 'unknown',
|
||||||
|
publicKeyType: 'unknown',
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error during validation'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -130,7 +130,27 @@
|
|||||||
"folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully",
|
"folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully",
|
||||||
"failedToRenameFolder": "Failed to rename folder",
|
"failedToRenameFolder": "Failed to rename folder",
|
||||||
"movedToFolder": "Credential \"{{name}}\" moved to \"{{folder}}\" successfully",
|
"movedToFolder": "Credential \"{{name}}\" moved to \"{{folder}}\" successfully",
|
||||||
"failedToMoveToFolder": "Failed to move credential to folder"
|
"failedToMoveToFolder": "Failed to move credential to folder",
|
||||||
|
"sshPublicKey": "SSH Public Key",
|
||||||
|
"publicKeyNote": "Public key is optional but recommended for key validation",
|
||||||
|
"publicKeyUploaded": "Public Key Uploaded",
|
||||||
|
"uploadPublicKey": "Upload Public Key",
|
||||||
|
"uploadPrivateKeyFile": "Upload Private Key File",
|
||||||
|
"uploadPublicKeyFile": "Upload Public Key File",
|
||||||
|
"privateKeyRequiredForGeneration": "Private key is required to generate public key",
|
||||||
|
"failedToGeneratePublicKey": "Failed to generate public key",
|
||||||
|
"generatePublicKey": "Generate from Private Key",
|
||||||
|
"publicKeyGeneratedSuccessfully": "Public key generated successfully",
|
||||||
|
"detectedKeyType": "Detected key type",
|
||||||
|
"detectingKeyType": "detecting...",
|
||||||
|
"optional": "Optional",
|
||||||
|
"generateKeyPair": "Generate New Key Pair",
|
||||||
|
"generateEd25519": "Generate Ed25519",
|
||||||
|
"generateECDSA": "Generate ECDSA",
|
||||||
|
"generateRSA": "Generate RSA",
|
||||||
|
"keyPairGeneratedSuccessfully": "{{keyType}} key pair generated successfully",
|
||||||
|
"failedToGenerateKeyPair": "Failed to generate key pair",
|
||||||
|
"generateKeyPairNote": "Generate a new SSH key pair directly. This will replace any existing keys in the form."
|
||||||
},
|
},
|
||||||
"sshTools": {
|
"sshTools": {
|
||||||
"title": "SSH Tools",
|
"title": "SSH Tools",
|
||||||
@@ -878,6 +898,7 @@
|
|||||||
"password": "password",
|
"password": "password",
|
||||||
"keyPassword": "key password",
|
"keyPassword": "key password",
|
||||||
"pastePrivateKey": "Paste your private key here...",
|
"pastePrivateKey": "Paste your private key here...",
|
||||||
|
"pastePublicKey": "Paste your public key here...",
|
||||||
"credentialName": "My SSH Server",
|
"credentialName": "My SSH Server",
|
||||||
"description": "SSH credential description",
|
"description": "SSH credential description",
|
||||||
"searchCredentials": "Search credentials by name, username, or tags...",
|
"searchCredentials": "Search credentials by name, username, or tags...",
|
||||||
|
|||||||
@@ -129,7 +129,27 @@
|
|||||||
"folderRenamed": "文件夹\"{{oldName}}\"已成功重命名为\"{{newName}}\"",
|
"folderRenamed": "文件夹\"{{oldName}}\"已成功重命名为\"{{newName}}\"",
|
||||||
"failedToRenameFolder": "重命名文件夹失败",
|
"failedToRenameFolder": "重命名文件夹失败",
|
||||||
"movedToFolder": "凭据\"{{name}}\"已成功移动到\"{{folder}}\"",
|
"movedToFolder": "凭据\"{{name}}\"已成功移动到\"{{folder}}\"",
|
||||||
"failedToMoveToFolder": "移动凭据到文件夹失败"
|
"failedToMoveToFolder": "移动凭据到文件夹失败",
|
||||||
|
"sshPublicKey": "SSH公钥",
|
||||||
|
"publicKeyNote": "公钥是可选的,但建议提供以验证密钥对",
|
||||||
|
"publicKeyUploaded": "公钥已上传",
|
||||||
|
"uploadPublicKey": "上传公钥",
|
||||||
|
"uploadPrivateKeyFile": "上传私钥文件",
|
||||||
|
"uploadPublicKeyFile": "上传公钥文件",
|
||||||
|
"privateKeyRequiredForGeneration": "生成公钥需要先输入私钥",
|
||||||
|
"failedToGeneratePublicKey": "生成公钥失败",
|
||||||
|
"generatePublicKey": "从私钥生成",
|
||||||
|
"publicKeyGeneratedSuccessfully": "公钥生成成功",
|
||||||
|
"detectedKeyType": "检测到的密钥类型",
|
||||||
|
"detectingKeyType": "检测中...",
|
||||||
|
"optional": "可选",
|
||||||
|
"generateKeyPair": "生成新的密钥对",
|
||||||
|
"generateEd25519": "生成 Ed25519",
|
||||||
|
"generateECDSA": "生成 ECDSA",
|
||||||
|
"generateRSA": "生成 RSA",
|
||||||
|
"keyPairGeneratedSuccessfully": "{{keyType}} 密钥对生成成功",
|
||||||
|
"failedToGenerateKeyPair": "生成密钥对失败",
|
||||||
|
"generateKeyPairNote": "直接生成新的SSH密钥对。这将替换表单中的现有密钥。"
|
||||||
},
|
},
|
||||||
"sshTools": {
|
"sshTools": {
|
||||||
"title": "SSH 工具",
|
"title": "SSH 工具",
|
||||||
@@ -874,6 +894,7 @@
|
|||||||
"searchCredentials": "按名称、用户名或标签搜索凭据...",
|
"searchCredentials": "按名称、用户名或标签搜索凭据...",
|
||||||
"keyPassword": "密钥密码",
|
"keyPassword": "密钥密码",
|
||||||
"pastePrivateKey": "在此粘贴您的私钥...",
|
"pastePrivateKey": "在此粘贴您的私钥...",
|
||||||
|
"pastePublicKey": "在此粘贴您的公钥...",
|
||||||
"sshConfig": "端点 SSH 配置",
|
"sshConfig": "端点 SSH 配置",
|
||||||
"homePath": "/home",
|
"homePath": "/home",
|
||||||
"clientId": "您的客户端 ID",
|
"clientId": "您的客户端 ID",
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export interface Credential {
|
|||||||
username: string;
|
username: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
key?: string;
|
key?: string;
|
||||||
|
publicKey?: string;
|
||||||
keyPassword?: string;
|
keyPassword?: string;
|
||||||
keyType?: string;
|
keyType?: string;
|
||||||
usageCount: number;
|
usageCount: number;
|
||||||
@@ -87,6 +88,7 @@ export interface CredentialData {
|
|||||||
username: string;
|
username: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
key?: string;
|
key?: string;
|
||||||
|
publicKey?: string;
|
||||||
keyPassword?: string;
|
keyPassword?: string;
|
||||||
keyType?: string;
|
keyType?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ import {
|
|||||||
updateCredential,
|
updateCredential,
|
||||||
getCredentials,
|
getCredentials,
|
||||||
getCredentialDetails,
|
getCredentialDetails,
|
||||||
|
detectKeyType,
|
||||||
|
detectPublicKeyType,
|
||||||
|
generatePublicKeyFromPrivate,
|
||||||
|
generateKeyPair,
|
||||||
} from "@/ui/main-axios";
|
} from "@/ui/main-axios";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type {
|
import type {
|
||||||
@@ -42,9 +46,14 @@ export function CredentialEditor({
|
|||||||
useState<Credential | null>(null);
|
useState<Credential | null>(null);
|
||||||
|
|
||||||
const [authTab, setAuthTab] = useState<"password" | "key">("password");
|
const [authTab, setAuthTab] = useState<"password" | "key">("password");
|
||||||
const [keyInputMethod, setKeyInputMethod] = useState<"upload" | "paste">(
|
const [detectedKeyType, setDetectedKeyType] = useState<string | null>(null);
|
||||||
"upload",
|
const [keyDetectionLoading, setKeyDetectionLoading] = useState(false);
|
||||||
);
|
const keyDetectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const [detectedPublicKeyType, setDetectedPublicKeyType] = useState<string | null>(null);
|
||||||
|
const [publicKeyDetectionLoading, setPublicKeyDetectionLoading] = useState(false);
|
||||||
|
const publicKeyDetectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
@@ -101,6 +110,7 @@ export function CredentialEditor({
|
|||||||
username: z.string().min(1),
|
username: z.string().min(1),
|
||||||
password: z.string().optional(),
|
password: z.string().optional(),
|
||||||
key: z.any().optional().nullable(),
|
key: z.any().optional().nullable(),
|
||||||
|
publicKey: z.string().optional(),
|
||||||
keyPassword: z.string().optional(),
|
keyPassword: z.string().optional(),
|
||||||
keyType: z
|
keyType: z
|
||||||
.enum([
|
.enum([
|
||||||
@@ -149,6 +159,7 @@ export function CredentialEditor({
|
|||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
key: null,
|
key: null,
|
||||||
|
publicKey: "",
|
||||||
keyPassword: "",
|
keyPassword: "",
|
||||||
keyType: "auto",
|
keyType: "auto",
|
||||||
},
|
},
|
||||||
@@ -169,6 +180,7 @@ export function CredentialEditor({
|
|||||||
username: fullCredentialDetails.username || "",
|
username: fullCredentialDetails.username || "",
|
||||||
password: "",
|
password: "",
|
||||||
key: null,
|
key: null,
|
||||||
|
publicKey: "",
|
||||||
keyPassword: "",
|
keyPassword: "",
|
||||||
keyType: "auto" as const,
|
keyType: "auto" as const,
|
||||||
};
|
};
|
||||||
@@ -176,7 +188,8 @@ export function CredentialEditor({
|
|||||||
if (defaultAuthType === "password") {
|
if (defaultAuthType === "password") {
|
||||||
formData.password = fullCredentialDetails.password || "";
|
formData.password = fullCredentialDetails.password || "";
|
||||||
} else if (defaultAuthType === "key") {
|
} else if (defaultAuthType === "key") {
|
||||||
formData.key = "existing_key";
|
formData.key = fullCredentialDetails.key || "";
|
||||||
|
formData.publicKey = fullCredentialDetails.publicKey || "";
|
||||||
formData.keyPassword = fullCredentialDetails.keyPassword || "";
|
formData.keyPassword = fullCredentialDetails.keyPassword || "";
|
||||||
formData.keyType =
|
formData.keyType =
|
||||||
(fullCredentialDetails.keyType as any) || ("auto" as const);
|
(fullCredentialDetails.keyType as any) || ("auto" as const);
|
||||||
@@ -196,6 +209,7 @@ export function CredentialEditor({
|
|||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
key: null,
|
key: null,
|
||||||
|
publicKey: "",
|
||||||
keyPassword: "",
|
keyPassword: "",
|
||||||
keyType: "auto",
|
keyType: "auto",
|
||||||
});
|
});
|
||||||
@@ -203,6 +217,104 @@ export function CredentialEditor({
|
|||||||
}
|
}
|
||||||
}, [editingCredential?.id, fullCredentialDetails, form]);
|
}, [editingCredential?.id, fullCredentialDetails, form]);
|
||||||
|
|
||||||
|
// Cleanup timeout on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (keyDetectionTimeoutRef.current) {
|
||||||
|
clearTimeout(keyDetectionTimeoutRef.current);
|
||||||
|
}
|
||||||
|
if (publicKeyDetectionTimeoutRef.current) {
|
||||||
|
clearTimeout(publicKeyDetectionTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Detect key type function
|
||||||
|
const handleKeyTypeDetection = async (keyValue: string, keyPassword?: string) => {
|
||||||
|
if (!keyValue || keyValue.trim() === '') {
|
||||||
|
setDetectedKeyType(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setKeyDetectionLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await detectKeyType(keyValue, keyPassword);
|
||||||
|
if (result.success) {
|
||||||
|
setDetectedKeyType(result.keyType);
|
||||||
|
} else {
|
||||||
|
setDetectedKeyType('invalid');
|
||||||
|
console.warn('Key detection failed:', result.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setDetectedKeyType('error');
|
||||||
|
console.error('Key type detection error:', error);
|
||||||
|
} finally {
|
||||||
|
setKeyDetectionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debounced key type detection
|
||||||
|
const debouncedKeyDetection = (keyValue: string, keyPassword?: string) => {
|
||||||
|
if (keyDetectionTimeoutRef.current) {
|
||||||
|
clearTimeout(keyDetectionTimeoutRef.current);
|
||||||
|
}
|
||||||
|
keyDetectionTimeoutRef.current = setTimeout(() => {
|
||||||
|
handleKeyTypeDetection(keyValue, keyPassword);
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Detect public key type function
|
||||||
|
const handlePublicKeyTypeDetection = async (publicKeyValue: string) => {
|
||||||
|
if (!publicKeyValue || publicKeyValue.trim() === '') {
|
||||||
|
setDetectedPublicKeyType(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPublicKeyDetectionLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await detectPublicKeyType(publicKeyValue);
|
||||||
|
if (result.success) {
|
||||||
|
setDetectedPublicKeyType(result.keyType);
|
||||||
|
} else {
|
||||||
|
setDetectedPublicKeyType('invalid');
|
||||||
|
console.warn('Public key detection failed:', result.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setDetectedPublicKeyType('error');
|
||||||
|
console.error('Public key type detection error:', error);
|
||||||
|
} finally {
|
||||||
|
setPublicKeyDetectionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debounced public key type detection
|
||||||
|
const debouncedPublicKeyDetection = (publicKeyValue: string) => {
|
||||||
|
if (publicKeyDetectionTimeoutRef.current) {
|
||||||
|
clearTimeout(publicKeyDetectionTimeoutRef.current);
|
||||||
|
}
|
||||||
|
publicKeyDetectionTimeoutRef.current = setTimeout(() => {
|
||||||
|
handlePublicKeyTypeDetection(publicKeyValue);
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const getFriendlyKeyTypeName = (keyType: string): string => {
|
||||||
|
const keyTypeMap: Record<string, string> = {
|
||||||
|
'ssh-rsa': 'RSA (SSH)',
|
||||||
|
'ssh-ed25519': 'Ed25519 (SSH)',
|
||||||
|
'ecdsa-sha2-nistp256': 'ECDSA P-256 (SSH)',
|
||||||
|
'ecdsa-sha2-nistp384': 'ECDSA P-384 (SSH)',
|
||||||
|
'ecdsa-sha2-nistp521': 'ECDSA P-521 (SSH)',
|
||||||
|
'ssh-dss': 'DSA (SSH)',
|
||||||
|
'rsa-sha2-256': 'RSA-SHA2-256',
|
||||||
|
'rsa-sha2-512': 'RSA-SHA2-512',
|
||||||
|
'invalid': 'Invalid Key',
|
||||||
|
'error': 'Detection Error',
|
||||||
|
'unknown': 'Unknown'
|
||||||
|
};
|
||||||
|
return keyTypeMap[keyType] || keyType;
|
||||||
|
};
|
||||||
|
|
||||||
const onSubmit = async (data: FormData) => {
|
const onSubmit = async (data: FormData) => {
|
||||||
try {
|
try {
|
||||||
if (!data.name || data.name.trim() === "") {
|
if (!data.name || data.name.trim() === "") {
|
||||||
@@ -221,20 +333,15 @@ export function CredentialEditor({
|
|||||||
|
|
||||||
submitData.password = null;
|
submitData.password = null;
|
||||||
submitData.key = null;
|
submitData.key = null;
|
||||||
|
submitData.publicKey = null;
|
||||||
submitData.keyPassword = null;
|
submitData.keyPassword = null;
|
||||||
submitData.keyType = null;
|
submitData.keyType = null;
|
||||||
|
|
||||||
if (data.authType === "password") {
|
if (data.authType === "password") {
|
||||||
submitData.password = data.password;
|
submitData.password = data.password;
|
||||||
} else if (data.authType === "key") {
|
} else if (data.authType === "key") {
|
||||||
if (data.key instanceof File) {
|
submitData.key = data.key;
|
||||||
const keyContent = await data.key.text();
|
submitData.publicKey = data.publicKey;
|
||||||
submitData.key = keyContent;
|
|
||||||
} else if (data.key === "existing_key") {
|
|
||||||
delete submitData.key;
|
|
||||||
} else {
|
|
||||||
submitData.key = data.key;
|
|
||||||
}
|
|
||||||
submitData.keyPassword = data.keyPassword;
|
submitData.keyPassword = data.keyPassword;
|
||||||
submitData.keyType = data.keyType;
|
submitData.keyType = data.keyType;
|
||||||
}
|
}
|
||||||
@@ -259,11 +366,17 @@ export function CredentialEditor({
|
|||||||
|
|
||||||
form.reset();
|
form.reset();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(t("credentials.failedToSaveCredential"));
|
console.error("Credential save error:", error);
|
||||||
|
if (error instanceof Error) {
|
||||||
|
toast.error(error.message);
|
||||||
|
} else {
|
||||||
|
toast.error(t("credentials.failedToSaveCredential"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const [tagInput, setTagInput] = useState("");
|
const [tagInput, setTagInput] = useState("");
|
||||||
|
const [keyGenerationPassphrase, setKeyGenerationPassphrase] = useState("");
|
||||||
|
|
||||||
const [folderDropdownOpen, setFolderDropdownOpen] = useState(false);
|
const [folderDropdownOpen, setFolderDropdownOpen] = useState(false);
|
||||||
const folderInputRef = useRef<HTMLInputElement>(null);
|
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -305,38 +418,6 @@ export function CredentialEditor({
|
|||||||
};
|
};
|
||||||
}, [folderDropdownOpen]);
|
}, [folderDropdownOpen]);
|
||||||
|
|
||||||
const keyTypeOptions = [
|
|
||||||
{ value: "auto", label: t("hosts.autoDetect") },
|
|
||||||
{ value: "ssh-rsa", label: t("hosts.rsa") },
|
|
||||||
{ value: "ssh-ed25519", label: t("hosts.ed25519") },
|
|
||||||
{ value: "ecdsa-sha2-nistp256", label: t("hosts.ecdsaNistP256") },
|
|
||||||
{ value: "ecdsa-sha2-nistp384", label: t("hosts.ecdsaNistP384") },
|
|
||||||
{ value: "ecdsa-sha2-nistp521", label: t("hosts.ecdsaNistP521") },
|
|
||||||
{ value: "ssh-dss", label: t("hosts.dsa") },
|
|
||||||
{ value: "ssh-rsa-sha2-256", label: t("hosts.rsaSha2256") },
|
|
||||||
{ value: "ssh-rsa-sha2-512", label: t("hosts.rsaSha2512") },
|
|
||||||
];
|
|
||||||
|
|
||||||
const [keyTypeDropdownOpen, setKeyTypeDropdownOpen] = useState(false);
|
|
||||||
const keyTypeButtonRef = useRef<HTMLButtonElement>(null);
|
|
||||||
const keyTypeDropdownRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function onClickOutside(event: MouseEvent) {
|
|
||||||
if (
|
|
||||||
keyTypeDropdownOpen &&
|
|
||||||
keyTypeDropdownRef.current &&
|
|
||||||
!keyTypeDropdownRef.current.contains(event.target as Node) &&
|
|
||||||
keyTypeButtonRef.current &&
|
|
||||||
!keyTypeButtonRef.current.contains(event.target as Node)
|
|
||||||
) {
|
|
||||||
setKeyTypeDropdownOpen(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("mousedown", onClickOutside);
|
|
||||||
return () => document.removeEventListener("mousedown", onClickOutside);
|
|
||||||
}, [keyTypeDropdownOpen]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -589,74 +670,305 @@ export function CredentialEditor({
|
|||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="key">
|
<TabsContent value="key">
|
||||||
<Tabs
|
<div className="mt-4">
|
||||||
value={keyInputMethod}
|
{/* Generate Key Pair Buttons */}
|
||||||
onValueChange={(value) => {
|
<div className="mb-4 p-4 bg-muted/20 border border-muted rounded-md">
|
||||||
setKeyInputMethod(value as "upload" | "paste");
|
<FormLabel className="mb-3 font-bold block">
|
||||||
if (value === "upload") {
|
{t("credentials.generateKeyPair")}
|
||||||
form.setValue("key", null);
|
</FormLabel>
|
||||||
} else {
|
|
||||||
form.setValue("key", "");
|
{/* Key Generation Passphrase Input */}
|
||||||
}
|
<div className="mb-3">
|
||||||
}}
|
<FormLabel className="text-sm mb-2 block">
|
||||||
className="w-full"
|
{t("credentials.keyPassword")} ({t("credentials.optional")})
|
||||||
>
|
</FormLabel>
|
||||||
<TabsList className="inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground">
|
<PasswordInput
|
||||||
<TabsTrigger value="upload">
|
placeholder={t("placeholders.keyPassword")}
|
||||||
{t("hosts.uploadFile")}
|
value={keyGenerationPassphrase}
|
||||||
</TabsTrigger>
|
onChange={(e) => setKeyGenerationPassphrase(e.target.value)}
|
||||||
<TabsTrigger value="paste">
|
className="max-w-xs"
|
||||||
{t("hosts.pasteKey")}
|
/>
|
||||||
</TabsTrigger>
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
</TabsList>
|
{t("credentials.keyPassphraseOptional")}
|
||||||
<TabsContent value="upload" className="mt-4">
|
</div>
|
||||||
<Controller
|
</div>
|
||||||
control={form.control}
|
|
||||||
name="key"
|
<div className="flex gap-2 flex-wrap">
|
||||||
render={({ field }) => (
|
<Button
|
||||||
<FormItem className="mb-4">
|
type="button"
|
||||||
<FormLabel>
|
variant="outline"
|
||||||
{t("credentials.sshPrivateKey")}
|
size="sm"
|
||||||
</FormLabel>
|
onClick={async () => {
|
||||||
<FormControl>
|
try {
|
||||||
<div className="relative inline-block">
|
const result = await generateKeyPair('ssh-ed25519', undefined, keyGenerationPassphrase);
|
||||||
<input
|
|
||||||
id="key-upload"
|
if (result.success) {
|
||||||
type="file"
|
form.setValue("key", result.privateKey);
|
||||||
accept=".pem,.key,.txt,.ppk"
|
form.setValue("publicKey", result.publicKey);
|
||||||
|
// Auto-fill the key password field if passphrase was used
|
||||||
|
if (keyGenerationPassphrase) {
|
||||||
|
form.setValue("keyPassword", keyGenerationPassphrase);
|
||||||
|
}
|
||||||
|
debouncedKeyDetection(result.privateKey, keyGenerationPassphrase);
|
||||||
|
debouncedPublicKeyDetection(result.publicKey);
|
||||||
|
toast.success(t("credentials.keyPairGeneratedSuccessfully", { keyType: "Ed25519" }));
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || t("credentials.failedToGenerateKeyPair"));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate Ed25519 key pair:', error);
|
||||||
|
toast.error(t("credentials.failedToGenerateKeyPair"));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("credentials.generateEd25519")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const result = await generateKeyPair('ecdsa-sha2-nistp256', undefined, keyGenerationPassphrase);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
form.setValue("key", result.privateKey);
|
||||||
|
form.setValue("publicKey", result.publicKey);
|
||||||
|
// Auto-fill the key password field if passphrase was used
|
||||||
|
if (keyGenerationPassphrase) {
|
||||||
|
form.setValue("keyPassword", keyGenerationPassphrase);
|
||||||
|
}
|
||||||
|
debouncedKeyDetection(result.privateKey, keyGenerationPassphrase);
|
||||||
|
debouncedPublicKeyDetection(result.publicKey);
|
||||||
|
toast.success(t("credentials.keyPairGeneratedSuccessfully", { keyType: "ECDSA" }));
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || t("credentials.failedToGenerateKeyPair"));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate ECDSA key pair:', error);
|
||||||
|
toast.error(t("credentials.failedToGenerateKeyPair"));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("credentials.generateECDSA")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const result = await generateKeyPair('ssh-rsa', 2048, keyGenerationPassphrase);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
form.setValue("key", result.privateKey);
|
||||||
|
form.setValue("publicKey", result.publicKey);
|
||||||
|
// Auto-fill the key password field if passphrase was used
|
||||||
|
if (keyGenerationPassphrase) {
|
||||||
|
form.setValue("keyPassword", keyGenerationPassphrase);
|
||||||
|
}
|
||||||
|
debouncedKeyDetection(result.privateKey, keyGenerationPassphrase);
|
||||||
|
debouncedPublicKeyDetection(result.publicKey);
|
||||||
|
toast.success(t("credentials.keyPairGeneratedSuccessfully", { keyType: "RSA" }));
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || t("credentials.failedToGenerateKeyPair"));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate RSA key pair:', error);
|
||||||
|
toast.error(t("credentials.failedToGenerateKeyPair"));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("credentials.generateRSA")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-2">
|
||||||
|
{t("credentials.generateKeyPairNote")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4 items-start">
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="key"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="mb-4 flex flex-col">
|
||||||
|
<FormLabel className="mb-2 min-h-[20px]">
|
||||||
|
{t("credentials.sshPrivateKey")}
|
||||||
|
</FormLabel>
|
||||||
|
<div className="mb-2">
|
||||||
|
<div className="relative inline-block w-full">
|
||||||
|
<input
|
||||||
|
id="key-upload"
|
||||||
|
type="file"
|
||||||
|
accept="*,.pem,.key,.txt,.ppk"
|
||||||
|
onChange={async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
try {
|
||||||
|
const fileContent = await file.text();
|
||||||
|
field.onChange(fileContent);
|
||||||
|
debouncedKeyDetection(fileContent, form.watch("keyPassword"));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to read uploaded file:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start text-left"
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{t("credentials.uploadPrivateKeyFile")}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<textarea
|
||||||
|
placeholder={t(
|
||||||
|
"placeholders.pastePrivateKey",
|
||||||
|
)}
|
||||||
|
className="flex min-h-[120px] 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"
|
||||||
|
value={
|
||||||
|
typeof field.value === "string"
|
||||||
|
? field.value
|
||||||
|
: ""
|
||||||
|
}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const file = e.target.files?.[0];
|
field.onChange(e.target.value);
|
||||||
field.onChange(file || null);
|
debouncedKeyDetection(e.target.value, form.watch("keyPassword"));
|
||||||
}}
|
}}
|
||||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
|
||||||
/>
|
/>
|
||||||
|
</FormControl>
|
||||||
|
{detectedKeyType && (
|
||||||
|
<div className="text-sm mt-2">
|
||||||
|
<span className="text-muted-foreground">{t("credentials.detectedKeyType")}: </span>
|
||||||
|
<span className={`font-medium ${
|
||||||
|
detectedKeyType === 'invalid' || detectedKeyType === 'error'
|
||||||
|
? 'text-destructive'
|
||||||
|
: 'text-green-600'
|
||||||
|
}`}>
|
||||||
|
{getFriendlyKeyTypeName(detectedKeyType)}
|
||||||
|
</span>
|
||||||
|
{keyDetectionLoading && (
|
||||||
|
<span className="ml-2 text-muted-foreground">({t("credentials.detectingKeyType")})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="publicKey"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="mb-4 flex flex-col">
|
||||||
|
<FormLabel className="mb-2 min-h-[20px]">
|
||||||
|
{t("credentials.sshPublicKey")} ({t("credentials.optional")})
|
||||||
|
</FormLabel>
|
||||||
|
<div className="mb-2 flex gap-2">
|
||||||
|
<div className="relative inline-block flex-1">
|
||||||
|
<input
|
||||||
|
id="public-key-upload"
|
||||||
|
type="file"
|
||||||
|
accept="*,.pub,.txt"
|
||||||
|
onChange={async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
try {
|
||||||
|
const fileContent = await file.text();
|
||||||
|
field.onChange(fileContent);
|
||||||
|
debouncedPublicKeyDetection(fileContent);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to read uploaded public key file:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start text-left"
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{t("credentials.uploadPublicKeyFile")}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="justify-start text-left"
|
className="flex-shrink-0"
|
||||||
>
|
onClick={async () => {
|
||||||
<span
|
const privateKey = form.watch("key");
|
||||||
className="truncate"
|
if (!privateKey || typeof privateKey !== "string" || !privateKey.trim()) {
|
||||||
title={
|
toast.error(t("credentials.privateKeyRequiredForGeneration"));
|
||||||
field.value?.name ||
|
return;
|
||||||
t("credentials.upload")
|
|
||||||
}
|
}
|
||||||
>
|
|
||||||
{field.value === "existing_key"
|
try {
|
||||||
? t("hosts.existingKey")
|
const keyPassword = form.watch("keyPassword");
|
||||||
: field.value
|
const result = await generatePublicKeyFromPrivate(privateKey, keyPassword);
|
||||||
? editingCredential
|
|
||||||
? t("credentials.updateKey")
|
if (result.success && result.publicKey) {
|
||||||
: field.value.name
|
// Set the generated public key
|
||||||
: t("credentials.upload")}
|
field.onChange(result.publicKey);
|
||||||
</span>
|
// Trigger public key detection
|
||||||
|
debouncedPublicKeyDetection(result.publicKey);
|
||||||
|
|
||||||
|
toast.success(t("credentials.publicKeyGeneratedSuccessfully"));
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || t("credentials.failedToGeneratePublicKey"));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate public key:', error);
|
||||||
|
toast.error(t("credentials.failedToGeneratePublicKey"));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("credentials.generatePublicKey")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</FormControl>
|
<FormControl>
|
||||||
</FormItem>
|
<textarea
|
||||||
)}
|
placeholder={t(
|
||||||
/>
|
"placeholders.pastePublicKey",
|
||||||
<div className="grid grid-cols-15 gap-4 mt-4">
|
)}
|
||||||
|
className="flex min-h-[120px] 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"
|
||||||
|
value={field.value || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
field.onChange(e.target.value);
|
||||||
|
debouncedPublicKeyDetection(e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
|
{t("credentials.publicKeyNote")}
|
||||||
|
</div>
|
||||||
|
{detectedPublicKeyType && field.value && (
|
||||||
|
<div className="text-sm mt-2">
|
||||||
|
<span className="text-muted-foreground">{t("credentials.detectedKeyType")}: </span>
|
||||||
|
<span className={`font-medium ${
|
||||||
|
detectedPublicKeyType === 'invalid' || detectedPublicKeyType === 'error'
|
||||||
|
? 'text-destructive'
|
||||||
|
: 'text-green-600'
|
||||||
|
}`}>
|
||||||
|
{getFriendlyKeyTypeName(detectedPublicKeyType)}
|
||||||
|
</span>
|
||||||
|
{publicKeyDetectionLoading && (
|
||||||
|
<span className="ml-2 text-muted-foreground">({t("credentials.detectingKeyType")})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-8 gap-4 mt-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="keyPassword"
|
name="keyPassword"
|
||||||
@@ -674,161 +986,8 @@ export function CredentialEditor({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="keyType"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="relative col-span-3">
|
|
||||||
<FormLabel>
|
|
||||||
{t("credentials.keyType")}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<div className="relative">
|
|
||||||
<Button
|
|
||||||
ref={keyTypeButtonRef}
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="w-full justify-start text-left rounded-md px-2 py-2 bg-dark-bg border border-input text-foreground"
|
|
||||||
onClick={() =>
|
|
||||||
setKeyTypeDropdownOpen((open) => !open)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{keyTypeOptions.find(
|
|
||||||
(opt) => opt.value === field.value,
|
|
||||||
)?.label || t("credentials.keyTypeRSA")}
|
|
||||||
</Button>
|
|
||||||
{keyTypeDropdownOpen && (
|
|
||||||
<div
|
|
||||||
ref={keyTypeDropdownRef}
|
|
||||||
className="absolute bottom-full left-0 z-50 mb-1 w-full bg-dark-bg border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1"
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-1 gap-1 p-0">
|
|
||||||
{keyTypeOptions.map((opt) => (
|
|
||||||
<Button
|
|
||||||
key={opt.value}
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="w-full justify-start text-left rounded-md px-2 py-1.5 bg-dark-bg text-foreground hover:bg-white/15 focus:bg-white/20 focus:outline-none"
|
|
||||||
onClick={() => {
|
|
||||||
field.onChange(opt.value);
|
|
||||||
setKeyTypeDropdownOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</div>
|
||||||
<TabsContent value="paste" className="mt-4">
|
|
||||||
<Controller
|
|
||||||
control={form.control}
|
|
||||||
name="key"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="mb-4">
|
|
||||||
<FormLabel>
|
|
||||||
{t("credentials.sshPrivateKey")}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<textarea
|
|
||||||
placeholder={t(
|
|
||||||
"placeholders.pastePrivateKey",
|
|
||||||
)}
|
|
||||||
className="flex min-h-[120px] 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"
|
|
||||||
value={
|
|
||||||
typeof field.value === "string"
|
|
||||||
? field.value
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
onChange={(e) =>
|
|
||||||
field.onChange(e.target.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="grid grid-cols-15 gap-4 mt-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="keyPassword"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="col-span-8">
|
|
||||||
<FormLabel>
|
|
||||||
{t("credentials.keyPassword")}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<PasswordInput
|
|
||||||
placeholder={t("placeholders.keyPassword")}
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="keyType"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="relative col-span-3">
|
|
||||||
<FormLabel>
|
|
||||||
{t("credentials.keyType")}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<div className="relative">
|
|
||||||
<Button
|
|
||||||
ref={keyTypeButtonRef}
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="w-full justify-start text-left rounded-md px-2 py-2 bg-dark-bg border border-input text-foreground"
|
|
||||||
onClick={() =>
|
|
||||||
setKeyTypeDropdownOpen((open) => !open)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{keyTypeOptions.find(
|
|
||||||
(opt) => opt.value === field.value,
|
|
||||||
)?.label || t("credentials.keyTypeRSA")}
|
|
||||||
</Button>
|
|
||||||
{keyTypeDropdownOpen && (
|
|
||||||
<div
|
|
||||||
ref={keyTypeDropdownRef}
|
|
||||||
className="absolute bottom-full left-0 z-50 mb-1 w-full bg-dark-bg border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1"
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-1 gap-1 p-0">
|
|
||||||
{keyTypeOptions.map((opt) => (
|
|
||||||
<Button
|
|
||||||
key={opt.value}
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="w-full justify-start text-left rounded-md px-2 py-1.5 bg-dark-bg text-foreground hover:bg-white/15 focus:bg-white/20 focus:outline-none"
|
|
||||||
onClick={() => {
|
|
||||||
field.onChange(opt.value);
|
|
||||||
setKeyTypeDropdownOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -9,6 +9,21 @@ import {
|
|||||||
AccordionItem,
|
AccordionItem,
|
||||||
AccordionTrigger,
|
AccordionTrigger,
|
||||||
} from "@/components/ui/accordion";
|
} from "@/components/ui/accordion";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetFooter,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -29,12 +44,17 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
X,
|
X,
|
||||||
Check,
|
Check,
|
||||||
|
Upload,
|
||||||
|
Server,
|
||||||
|
User,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
getCredentials,
|
getCredentials,
|
||||||
deleteCredential,
|
deleteCredential,
|
||||||
updateCredential,
|
updateCredential,
|
||||||
renameCredentialFolder,
|
renameCredentialFolder,
|
||||||
|
deployCredentialToHost,
|
||||||
|
getSSHHosts,
|
||||||
} from "@/ui/main-axios";
|
} from "@/ui/main-axios";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -65,12 +85,27 @@ export function CredentialsManager({
|
|||||||
const [editingFolder, setEditingFolder] = useState<string | null>(null);
|
const [editingFolder, setEditingFolder] = useState<string | null>(null);
|
||||||
const [editingFolderName, setEditingFolderName] = useState("");
|
const [editingFolderName, setEditingFolderName] = useState("");
|
||||||
const [operationLoading, setOperationLoading] = useState(false);
|
const [operationLoading, setOperationLoading] = useState(false);
|
||||||
|
const [showDeployDialog, setShowDeployDialog] = useState(false);
|
||||||
|
const [deployingCredential, setDeployingCredential] = useState<Credential | null>(null);
|
||||||
|
const [availableHosts, setAvailableHosts] = useState<any[]>([]);
|
||||||
|
const [selectedHostId, setSelectedHostId] = useState<string>("");
|
||||||
|
const [deployLoading, setDeployLoading] = useState(false);
|
||||||
const dragCounter = useRef(0);
|
const dragCounter = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchCredentials();
|
fetchCredentials();
|
||||||
|
fetchHosts();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const fetchHosts = async () => {
|
||||||
|
try {
|
||||||
|
const hosts = await getSSHHosts();
|
||||||
|
setAvailableHosts(hosts);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch hosts:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchCredentials = async () => {
|
const fetchCredentials = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -90,6 +125,49 @@ export function CredentialsManager({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeploy = (credential: Credential) => {
|
||||||
|
if (credential.authType !== 'key') {
|
||||||
|
toast.error("Only SSH key-based credentials can be deployed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!credential.publicKey) {
|
||||||
|
toast.error("Public key is required for deployment");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDeployingCredential(credential);
|
||||||
|
setSelectedHostId("");
|
||||||
|
setShowDeployDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const performDeploy = async () => {
|
||||||
|
if (!deployingCredential || !selectedHostId) {
|
||||||
|
toast.error("Please select a target host");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeployLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await deployCredentialToHost(
|
||||||
|
deployingCredential.id,
|
||||||
|
parseInt(selectedHostId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(result.message || "SSH key deployed successfully");
|
||||||
|
setShowDeployDialog(false);
|
||||||
|
setDeployingCredential(null);
|
||||||
|
setSelectedHostId("");
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Deployment failed");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Deployment error:', error);
|
||||||
|
toast.error("Failed to deploy SSH key");
|
||||||
|
} finally {
|
||||||
|
setDeployLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = async (credentialId: number, credentialName: string) => {
|
const handleDelete = async (credentialId: number, credentialName: string) => {
|
||||||
confirmWithToast(
|
confirmWithToast(
|
||||||
t("credentials.confirmDeleteCredential", { name: credentialName }),
|
t("credentials.confirmDeleteCredential", { name: credentialName }),
|
||||||
@@ -577,6 +655,26 @@ export function CredentialsManager({
|
|||||||
<p>Edit credential</p>
|
<p>Edit credential</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
{credential.authType === 'key' && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDeploy(credential);
|
||||||
|
}}
|
||||||
|
className="h-5 w-5 p-0 text-green-600 hover:text-green-700 hover:bg-green-500/10"
|
||||||
|
>
|
||||||
|
<Upload className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Deploy SSH key to host</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -687,6 +785,145 @@ export function CredentialsManager({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Sheet open={showDeployDialog} onOpenChange={setShowDeployDialog}>
|
||||||
|
<SheetContent className="w-[500px] max-w-[50vw] overflow-y-auto">
|
||||||
|
<SheetHeader className="space-y-6 pb-8">
|
||||||
|
<SheetTitle className="flex items-center space-x-4">
|
||||||
|
<div className="p-2 rounded-lg bg-zinc-100 dark:bg-zinc-800">
|
||||||
|
<Upload className="h-5 w-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-xl font-semibold">Deploy SSH Key</div>
|
||||||
|
<div className="text-sm font-normal text-zinc-600 dark:text-zinc-400 mt-1">
|
||||||
|
Deploy public key to target server
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Credential Information Card */}
|
||||||
|
{deployingCredential && (
|
||||||
|
<div className="border border-zinc-200 dark:border-zinc-700 rounded-lg p-4 bg-zinc-50 dark:bg-zinc-900/50">
|
||||||
|
<h4 className="text-sm font-semibold text-zinc-800 dark:text-zinc-200 mb-3 flex items-center">
|
||||||
|
<Key className="h-4 w-4 mr-2 text-zinc-500" />
|
||||||
|
Source Credential
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="p-1.5 rounded-md bg-zinc-100 dark:bg-zinc-800">
|
||||||
|
<User className="h-3 w-3 text-zinc-500 dark:text-zinc-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-zinc-500 dark:text-zinc-400">Name</div>
|
||||||
|
<div className="text-sm font-medium text-zinc-800 dark:text-zinc-200">
|
||||||
|
{deployingCredential.name || deployingCredential.username}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="p-1.5 rounded-md bg-zinc-100 dark:bg-zinc-800">
|
||||||
|
<User className="h-3 w-3 text-zinc-500 dark:text-zinc-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-zinc-500 dark:text-zinc-400">Username</div>
|
||||||
|
<div className="text-sm font-medium text-zinc-800 dark:text-zinc-200">
|
||||||
|
{deployingCredential.username}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="p-1.5 rounded-md bg-zinc-100 dark:bg-zinc-800">
|
||||||
|
<Key className="h-3 w-3 text-zinc-500 dark:text-zinc-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-zinc-500 dark:text-zinc-400">Key Type</div>
|
||||||
|
<div className="text-sm font-medium text-zinc-800 dark:text-zinc-200">
|
||||||
|
{deployingCredential.keyType || 'SSH Key'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Target Host Selection */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-sm font-semibold text-zinc-800 dark:text-zinc-200 flex items-center">
|
||||||
|
<Server className="h-4 w-4 mr-2 text-zinc-500" />
|
||||||
|
Target Host
|
||||||
|
</label>
|
||||||
|
<Select value={selectedHostId} onValueChange={setSelectedHostId}>
|
||||||
|
<SelectTrigger className="h-12 border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-900/50">
|
||||||
|
<SelectValue placeholder="Choose a host to deploy to..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableHosts.map((host) => (
|
||||||
|
<SelectItem key={host.id} value={host.id.toString()}>
|
||||||
|
<div className="flex items-center gap-3 py-1">
|
||||||
|
<div className="p-1.5 rounded-md bg-zinc-100 dark:bg-zinc-800">
|
||||||
|
<Server className="h-3 w-3 text-zinc-500 dark:text-zinc-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-zinc-800 dark:text-zinc-200">
|
||||||
|
{host.name || host.ip}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
{host.username}@{host.ip}:{host.port}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Information Note */}
|
||||||
|
<div className="border border-blue-200 dark:border-blue-800 rounded-lg p-4 bg-blue-50 dark:bg-blue-900/20">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<Info className="h-4 w-4 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="text-sm text-blue-800 dark:text-blue-200">
|
||||||
|
<p className="font-medium mb-1">Deployment Process</p>
|
||||||
|
<p className="text-blue-700 dark:text-blue-300">
|
||||||
|
This will safely add the public key to the target host's ~/.ssh/authorized_keys file
|
||||||
|
without overwriting existing keys. The operation is reversible.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SheetFooter className="mt-8 flex space-x-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowDeployDialog(false)}
|
||||||
|
disabled={deployLoading}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={performDeploy}
|
||||||
|
disabled={!selectedHostId || deployLoading}
|
||||||
|
className="flex-1 bg-green-600 hover:bg-green-700 text-white"
|
||||||
|
>
|
||||||
|
{deployLoading ? (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent mr-2"></div>
|
||||||
|
Deploying...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Upload className="h-4 w-4 mr-2" />
|
||||||
|
Deploy SSH Key
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1506,7 +1506,7 @@ export async function getCredentials(): Promise<any> {
|
|||||||
const response = await authApi.get("/credentials");
|
const response = await authApi.get("/credentials");
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "fetch credentials");
|
throw handleApiError(error, "fetch credentials");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1515,7 +1515,7 @@ export async function getCredentialDetails(credentialId: number): Promise<any> {
|
|||||||
const response = await authApi.get(`/credentials/${credentialId}`);
|
const response = await authApi.get(`/credentials/${credentialId}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "fetch credential details");
|
throw handleApiError(error, "fetch credential details");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1524,7 +1524,7 @@ export async function createCredential(credentialData: any): Promise<any> {
|
|||||||
const response = await authApi.post("/credentials", credentialData);
|
const response = await authApi.post("/credentials", credentialData);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "create credential");
|
throw handleApiError(error, "create credential");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1539,7 +1539,7 @@ export async function updateCredential(
|
|||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "update credential");
|
throw handleApiError(error, "update credential");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1548,7 +1548,7 @@ export async function deleteCredential(credentialId: number): Promise<any> {
|
|||||||
const response = await authApi.delete(`/credentials/${credentialId}`);
|
const response = await authApi.delete(`/credentials/${credentialId}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "delete credential");
|
throw handleApiError(error, "delete credential");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1594,7 +1594,7 @@ export async function applyCredentialToHost(
|
|||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "apply credential to host");
|
throw handleApiError(error, "apply credential to host");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1604,7 +1604,7 @@ export async function removeCredentialFromHost(hostId: number): Promise<any> {
|
|||||||
const response = await sshHostApi.delete(`/db/host/${hostId}/credential`);
|
const response = await sshHostApi.delete(`/db/host/${hostId}/credential`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "remove credential from host");
|
throw handleApiError(error, "remove credential from host");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1620,7 +1620,7 @@ export async function migrateHostToCredential(
|
|||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "migrate host to credential");
|
throw handleApiError(error, "migrate host to credential");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1663,6 +1663,98 @@ export async function renameCredentialFolder(
|
|||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "rename credential folder");
|
throw handleApiError(error, "rename credential folder");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function detectKeyType(
|
||||||
|
privateKey: string,
|
||||||
|
keyPassword?: string,
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post("/credentials/detect-key-type", {
|
||||||
|
privateKey,
|
||||||
|
keyPassword,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "detect key type");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function detectPublicKeyType(
|
||||||
|
publicKey: string,
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post("/credentials/detect-public-key-type", {
|
||||||
|
publicKey,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "detect public key type");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateKeyPair(
|
||||||
|
privateKey: string,
|
||||||
|
publicKey: string,
|
||||||
|
keyPassword?: string,
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post("/credentials/validate-key-pair", {
|
||||||
|
privateKey,
|
||||||
|
publicKey,
|
||||||
|
keyPassword,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "validate key pair");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generatePublicKeyFromPrivate(
|
||||||
|
privateKey: string,
|
||||||
|
keyPassword?: string,
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post("/credentials/generate-public-key", {
|
||||||
|
privateKey,
|
||||||
|
keyPassword,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "generate public key from private key");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateKeyPair(
|
||||||
|
keyType: 'ssh-ed25519' | 'ssh-rsa' | 'ecdsa-sha2-nistp256',
|
||||||
|
keySize?: number,
|
||||||
|
passphrase?: string,
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post("/credentials/generate-key-pair", {
|
||||||
|
keyType,
|
||||||
|
keySize,
|
||||||
|
passphrase,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "generate SSH key pair");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deployCredentialToHost(
|
||||||
|
credentialId: number,
|
||||||
|
targetHostId: number,
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post(
|
||||||
|
`/credentials/${credentialId}/deploy-to-host`,
|
||||||
|
{ targetHostId }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "deploy credential to host");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
266
unified_key_section.tsx
Normal file
266
unified_key_section.tsx
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
<TabsContent value="key">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Private Key Section */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormLabel className="text-sm font-medium">
|
||||||
|
{t("credentials.sshPrivateKey")}
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* File Upload */}
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="key"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-col">
|
||||||
|
<FormLabel className="text-xs text-muted-foreground">
|
||||||
|
{t("hosts.uploadFile")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="relative inline-block w-full">
|
||||||
|
<input
|
||||||
|
id="key-upload"
|
||||||
|
type="file"
|
||||||
|
accept="*,.pem,.key,.txt,.ppk"
|
||||||
|
onChange={async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
field.onChange(file);
|
||||||
|
try {
|
||||||
|
const fileContent = await file.text();
|
||||||
|
debouncedKeyDetection(fileContent, form.watch("keyPassword"));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to read uploaded file:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start text-left"
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{field.value instanceof File
|
||||||
|
? field.value.name
|
||||||
|
: t("credentials.upload")}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Text Input */}
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="key"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-col">
|
||||||
|
<FormLabel className="text-xs text-muted-foreground">
|
||||||
|
{t("hosts.pasteKey")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<textarea
|
||||||
|
placeholder={t("placeholders.pastePrivateKey")}
|
||||||
|
className="flex min-h-[120px] 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"
|
||||||
|
value={typeof field.value === "string" ? field.value : ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
field.onChange(e.target.value);
|
||||||
|
debouncedKeyDetection(e.target.value, form.watch("keyPassword"));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Key type detection display */}
|
||||||
|
{detectedKeyType && (
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-muted-foreground">{t("credentials.detectedKeyType")}: </span>
|
||||||
|
<span className={`font-medium ${
|
||||||
|
detectedKeyType === 'invalid' || detectedKeyType === 'error'
|
||||||
|
? 'text-destructive'
|
||||||
|
: 'text-green-600'
|
||||||
|
}`}>
|
||||||
|
{getFriendlyKeyTypeName(detectedKeyType)}
|
||||||
|
</span>
|
||||||
|
{keyDetectionLoading && (
|
||||||
|
<span className="ml-2 text-muted-foreground">({t("credentials.detecting")}...)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show existing private key for editing */}
|
||||||
|
{editingCredential && fullCredentialDetails?.key && (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("credentials.sshPrivateKey")} ({t("hosts.existingKey")})</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<textarea
|
||||||
|
readOnly
|
||||||
|
className="flex min-h-[120px] w-full rounded-md border border-input bg-muted 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"
|
||||||
|
value={fullCredentialDetails.key}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
|
{t("credentials.currentKeyContent")}
|
||||||
|
</div>
|
||||||
|
{fullCredentialDetails?.detectedKeyType && (
|
||||||
|
<div className="text-sm mt-2">
|
||||||
|
<span className="text-muted-foreground">Key type: </span>
|
||||||
|
<span className="font-medium text-green-600">
|
||||||
|
{getFriendlyKeyTypeName(fullCredentialDetails.detectedKeyType)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Public Key Section */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormLabel className="text-sm font-medium">
|
||||||
|
{t("credentials.sshPublicKey")} ({t("credentials.optional")})
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* File Upload */}
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="publicKey"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-col">
|
||||||
|
<FormLabel className="text-xs text-muted-foreground">
|
||||||
|
{t("hosts.uploadFile")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="relative inline-block w-full">
|
||||||
|
<input
|
||||||
|
id="public-key-upload"
|
||||||
|
type="file"
|
||||||
|
accept="*,.pub,.txt"
|
||||||
|
onChange={async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
try {
|
||||||
|
const fileContent = await file.text();
|
||||||
|
field.onChange(fileContent);
|
||||||
|
debouncedPublicKeyDetection(fileContent);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to read uploaded public key file:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start text-left"
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{field.value ? t("credentials.publicKeyUploaded") : t("credentials.uploadPublicKey")}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Text Input */}
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="publicKey"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-col">
|
||||||
|
<FormLabel className="text-xs text-muted-foreground">
|
||||||
|
{t("hosts.pasteKey")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<textarea
|
||||||
|
placeholder={t("placeholders.pastePublicKey")}
|
||||||
|
className="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"
|
||||||
|
value={field.value || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
field.onChange(e.target.value);
|
||||||
|
debouncedPublicKeyDetection(e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Public key type detection */}
|
||||||
|
{detectedPublicKeyType && form.watch("publicKey") && (
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-muted-foreground">{t("credentials.detectedKeyType")}: </span>
|
||||||
|
<span className={`font-medium ${
|
||||||
|
detectedPublicKeyType === 'invalid' || detectedPublicKeyType === 'error'
|
||||||
|
? 'text-destructive'
|
||||||
|
: 'text-green-600'
|
||||||
|
}`}>
|
||||||
|
{getFriendlyKeyTypeName(detectedPublicKeyType)}
|
||||||
|
</span>
|
||||||
|
{publicKeyDetectionLoading && (
|
||||||
|
<span className="ml-2 text-muted-foreground">({t("credentials.detecting")}...)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t("credentials.publicKeyNote")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Show existing public key for editing */}
|
||||||
|
{editingCredential && fullCredentialDetails?.publicKey && (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("credentials.sshPublicKey")} ({t("hosts.existingKey")})</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<textarea
|
||||||
|
readOnly
|
||||||
|
className="flex min-h-[80px] w-full rounded-md border border-input bg-muted 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"
|
||||||
|
value={fullCredentialDetails.publicKey}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
|
{t("credentials.currentPublicKeyContent")}
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Generate Public Key Button */}
|
||||||
|
{form.watch("key") && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleGeneratePublicKey}
|
||||||
|
disabled={generatePublicKeyLoading}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{generatePublicKeyLoading ? (
|
||||||
|
<>
|
||||||
|
<span className="mr-2">{t("credentials.generating")}...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>{t("credentials.generatePublicKey")}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-muted-foreground mt-2 text-center">
|
||||||
|
{t("credentials.generatePublicKeyNote")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
Reference in New Issue
Block a user