* feat: add draggable server stats dashboard with customizable widgets * fix: widget deletion and layout persistence issues * fix: improve widget deletion UX and add debug logs for persistence * fix: resolve widget deletion and layout persistence issues - Add drag handles to widget title bars for precise drag control - Prevent delete button from triggering drag via event stopPropagation - Include statsConfig field in all GET/PUT API responses - Remove debug console logs from production code 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: complete statsConfig field support across all API routes - Add statsConfig to POST /db/host (create) route - Add statsConfig to all GET routes for consistent API responses - Remove incorrect statsConfig schema from HostManagerEditor - statsConfig is now only managed by Server page layout editor 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: add statsConfig to metrics API response - Add statsConfig field to SSHHostWithCredentials interface - Include statsConfig in resolveHostCredentials baseHost object - Ensures /metrics/:id API returns complete host configuration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: include statsConfig in SSH host create/update requests The statsConfig field was being dropped by createSSHHost and updateSSHHost functions in main-axios.ts, preventing layout customization from persisting. Fixed by adding statsConfig to the submitData object in both functions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: refactor server stats widgets into modular structure Created dedicated widgets directory with individual components: - CpuWidget, MemoryWidget, DiskWidget as separate components - Widget registry for centralized widget configuration - AddWidgetDialog for user-friendly widget selection - Updated Server.tsx to use modular widget system Benefits: - Better code organization and maintainability - Easier to add new widget types in the future - Centralized widget metadata and configuration - User can now add widgets via dialog interface 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: exit edit mode after saving layout * feat: add customizable widget sizes with chart visualizations Add three-tier size system (small/medium/large) for server stats widgets. Integrate recharts library for visualizing trends in large widgets with line charts (CPU), area charts (Memory), and radial bar charts (Disk). Fix layout overflow issues with proper flexbox patterns. * refactor: simplify server stats widget system Replaced complex drag-and-drop grid layout with simple checkbox-based configuration and static responsive grid display. - Removed react-grid-layout dependency and 6 related packages - Simplified StatsConfig from complex Widget objects to simple array - Added Statistics tab in HostManagerEditor for checkbox selection - Refactored Server.tsx to use CSS Grid instead of ResponsiveGridLayout - Simplified widget components by removing edit mode and size selection - Deleted unused AddWidgetDialog and registry files - Fixed statsConfig serialization in backend routes Net result: -787 lines of code, cleaner architecture. * feat: add system, uptime, network and processes widgets Add four new server statistics widgets: - SystemWidget: displays hostname, OS, and kernel information - UptimeWidget: shows server total uptime with formatted display - NetworkWidget: lists network interfaces with IP and status - ProcessesWidget: displays top processes by CPU usage Backend changes: - Extended SSH metrics collection to gather network, uptime, process, and system data - Added commands to parse /proc/uptime, ip addr, ps aux output Frontend changes: - Created 4 new widget components with consistent styling - Updated widget type definitions and HostManagerEditor - Unified all widget heights to 280px for consistent layout - Added translations for all new widgets (EN/ZH) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: improve widget styling and UX consistency Enhance all server stats widgets with improved styling and user experience: Widget improvements: - Fix hardcoded titles, now use i18n translations for all widgets - Improve data formatting with consistent translation keys - Enhance empty state displays with better visual hierarchy - Add smooth hover transitions and visual feedback - Standardize spacing and layout patterns across widgets Specific optimizations: - CPU: Use translated load average display - Memory: Translate "Free" label - Disk: Translate "Available" label - System: Improve icon colors and spacing consistency - Network: Better empty state, enhanced card styling - Processes: Improved card borders and spacing Visual polish: - Unified icon sizing and opacity for empty states - Consistent border radius (rounded-lg) - Better hover states with subtle transitions - Enhanced font weights for improved readability * fix: replace explicit any types with proper TypeScript types - Replace 'any' with 'unknown' in catch blocks and add type assertions - Create explicit interfaces for complex objects (HostConfig, TabData, TerminalHandle) - Fix window/document object type extensions - Update Electron API type definitions - Improve type safety in database routes and utilities - Add proper types to Terminal components (Desktop & Mobile) - Fix navigation component types (TopNavbar, LeftSidebar, AppView) Reduces TypeScript lint errors from 394 to 358 (-36 errors) Fixes 45 @typescript-eslint/no-explicit-any violations * fix: replace explicit any types with proper TypeScript types - Create explicit interfaces for Request extensions (AuthenticatedRequest, RequestWithHeaders) - Add type definitions for WebSocket messages and SSH connection data - Use generic types in DataCrypto methods instead of any return types - Define proper interfaces for file manager data structures - Replace catch block any types with unknown and proper type assertions - Add HostConfig and TabData interfaces for Server component Fixes 32 @typescript-eslint/no-explicit-any violations across 5 files * fix: resolve 6 TypeScript compilation errors Fixed field name mismatches and generic type issues: - database.ts: Changed camelCase to snake_case for key_password, private_key, public_key fields - simple-db-ops.ts: Added explicit generic type parameters to DataCrypto method calls Co-Authored-By: Claude <noreply@anthropic.com> * fix: resolve unused variables in backend utils Fixed @typescript-eslint/no-unused-vars errors in: - starter.ts: removed unused error variables (2 fixes) - auto-ssl-setup.ts: removed unused error variable (1 fix) - ssh-key-utils.ts: removed unused error variables (3 fixes) - user-crypto.ts: removed unused error variables (5 fixes) - data-crypto.ts: removed unused plaintextFields and error variables (2 fixes) - simple-db-ops.ts: removed unused parameters _userId and _tableName (2 fixes) Total: 15 unused variable errors fixed Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove unused variable in terminal.ts Fixed @typescript-eslint/no-unused-vars errors: - Removed unused userPayload variable (line 123) - Removed unused cols and rows from destructuring (line 348) Co-Authored-By: Claude <noreply@anthropic.com> * fix: resolve unused variables in server-stats.ts Fixed @typescript-eslint/no-unused-vars errors: - Removed unused _reject parameter in Promise (line 64) - Removed shadowed now variable in pollStatusesOnce (line 1130) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove unused variables in tunnel.ts Removed 5 unused variables: - Removed unused data parameter from stdout event handler - Removed hasSourcePassword, hasSourceKey, hasEndpointPassword, hasEndpointKey variables 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove unused variables in main-axios.ts Removed 8 unused variables: - Removed unused type imports (Credential, CredentialData, HostInfo, ApiResponse) - Removed unused apiPort variable - Removed unused error variables in 3 catch blocks * fix: remove unused variables in terminal.ts and starter.ts Removed 2 unused variables: - Removed unused JWTPayload type import from terminal.ts - Removed unused _promise parameter from starter.ts * fix: remove unused variables in sidebar.tsx Removed 9 unused variables: - Removed 5 unused Sheet component imports - Removed unused SIDEBAR_WIDTH_MOBILE constant - Removed 3 unused variables from useSidebar destructuring * fix: remove 13 unused variables in frontend files - version-check-modal.tsx: removed 4 unused imports and functions - main.tsx: removed unused isMobile state - AdminSettings.tsx: removed 8 unused imports and error variables * fix: remove 28 unused variables across frontend components Cleaned up unused imports, state variables, and function parameters: - CredentialsManager.tsx: removed 8 unused variables (Sheet/Select imports) - FileManager.tsx: removed 10 unused variables (icons, state, functions) - Terminal.tsx (Desktop): removed 5 unused variables (state, handlers) - Terminal.tsx (Mobile): removed 5 unused variables (imports, state) Reduced lint errors from 271 to 236 (35 errors fixed) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 10 unused variables in File Manager and config files Cleaned up more unused imports, parameters, and variables: - FileManagerGrid.tsx: removed 4 unused variables (params, function) - FileManagerContextMenu.tsx: removed Share import - FileManagerSidebar.tsx: removed onLoadDirectory parameter - DraggableWindow.tsx: removed Square import - FileWindow.tsx: removed updateWindow variable - ServerConfig.tsx: removed 2 unused error parameters Reduced lint errors from 236 to 222 (14 errors fixed total) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 7 unused variables in widgets and Homepage components Cleaned up unused imports, parameters, and variables: - DiskWidget.tsx: removed metricsHistory parameter - FileManagerContextMenu.tsx: removed ExternalLink import - Homepage.tsx: removed useTranslation import - HomepageAlertManager.tsx: removed loading variable - HomepageAuth.tsx: removed setCookie import (Desktop & Mobile) - HompageUpdateLog.tsx: removed err parameter Reduced lint errors from 222 to 216 (6 errors fixed) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 8 unused variables in File Manager and Host Manager components Cleaned up unused imports, state variables, and function parameters: - DiffViewer.tsx: removed unused error parameter in catch block - FileViewer.tsx: removed ReactPlayer import, unused originalContent state, node parameters from markdown code components, audio variable - HostManager.tsx: removed onSelectView and updatedHost parameters - TunnelViewer.tsx: removed TunnelConnection import Reduced lint errors from 271 to 208 (63 errors fixed total) * fix: remove 7 unused variables in UI hooks and components Cleaned up unused parameters and functions: - status/index.tsx: removed unused className parameter from StatusIndicator - useDragToDesktop.ts: removed unused sshHost parameter and from dependency arrays (4 occurrences) - useDragToSystemDesktop.ts: removed unused sshHost parameter and getLastSaveDirectory function (29 lines removed) Continued reducing frontend lint errors * fix: remove 2 unused variables in hooks and TabContext - useDragToDesktop.ts: removed unused onSuccess in dragFolderToDesktop - TabContext.tsx: removed unused useTranslation import and t variable Continued reducing frontend lint errors * fix: remove 2 unused variables in Homepage component - Removed unused isAdmin state variable (changed to setter only) - Removed unused jwt variable by inlining getCookie check Continued reducing frontend lint errors * fix: remove 3 unused variables in Mobile navigation components - Host.tsx: removed unused Server icon import - LeftSidebar.tsx: removed unused setHostsLoading setter and err parameter Continued reducing frontend lint errors * fix: remove 9 unused variables across multiple files Fixed unused variables in: - database-file-encryption.ts: removed currentFingerprint (backend) - FileManagerContextMenu.tsx: removed ExternalLink import, hasDirectories - frontend-logger.ts: removed 5 unused shortUrl variables Continued reducing lint errors * fix: remove 18 unused variables across 4 files - HostManagerViewer.tsx: remove 9 unused error variables and parameters - HostManagerEditor.tsx: remove WidgetType import, hosts/loading states, error variable - CredentialViewer.tsx: remove 3 unused error variables - Server.tsx: remove 2 unused error variables 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 9 unused variables across 4 files - SnippetsSidebar.tsx: remove 3 unused err variables in catch blocks - TunnelViewer.tsx: remove 2 unused parameters from callback - DesktopApp.tsx: remove getCookie import and unused state variables - HomepageAlertManager.tsx: remove 2 unused err variables in catch blocks 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 10 unused variables and imports across 4 navigation files - Homepage.tsx: remove unused username state variable - AppView.tsx: remove 3 unused Lucide icon imports - TopNavbar.tsx: remove 4 unused Accordion component imports - LeftSidebar.tsx: remove 2 unused variables (err, jwt) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 5 unused variables across 4 user/credentials files - PasswordReset.tsx: remove unused result variable - UserProfile.tsx: remove unused Key import and err variable - version-check-modal.tsx: remove unused setVersionDismissed setter - CredentialsManager.tsx: remove unused e parameter from handleDragLeave 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 2 unused variables in FileViewer and TerminalWindow - FileViewer.tsx: remove unused node parameter from code component - TerminalWindow.tsx: remove unused handleMinimize function 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 10 unused variables in HomepageAuth.tsx Removed unused variables: - getCookie import - dbError prop - visibility state and toggleVisibility - error state variable - result variable in handleInitiatePasswordReset - token URL parameter - err parameters in catch blocks - retryDatabaseConnection function - Multiple setError(null) calls 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 9 unused variables across multiple files Files fixed: - DesktopApp.tsx: Removed _nextView parameter - TerminalWindow.tsx: Removed minimizeWindow - Mobile Host.tsx: Removed Server import - Mobile LeftSidebar.tsx: Removed setHostsLoading, err in catch - Desktop LeftSidebar.tsx: Removed getCookie, setCookie, onSelectView, getView, setHostsLoading 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 10 unused variables in Mobile files Files fixed: - MobileApp.tsx: Removed getCookie, removeTab, isAdmin, id, err parameters - Mobile/HomepageAuth.tsx: Removed getCookie, error state, result, token, err parameters All @typescript-eslint/no-unused-vars errors in frontend now resolved! 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove unused t variable in TabContext Removed useTranslation import and unused t variable in Mobile TabContext.tsx All @typescript-eslint/no-unused-vars errors now resolved! Total fixed: 154 unused variables 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: resolve TypeScript and ESLint errors across the codebase - Fixed @typescript-eslint/no-unused-vars errors (31 instances) - Fixed @typescript-eslint/no-explicit-any errors in backend (~22 instances) - Fixed @typescript-eslint/no-explicit-any errors in frontend (~60 instances) - Fixed prefer-const errors (5 instances) - Fixed no-empty-object-type and rules-of-hooks errors - Added proper type assertions for database operations - Improved type safety in authentication and encryption modules - Enhanced type definitions for API routes and SSH operations All TypeScript compilation errors resolved. Application builds and runs successfully. * fix: disable react-refresh/only-export-components rule for component files Disable the react-refresh/only-export-components ESLint rule in files that export both components and related utilities (hooks, types, constants). This is a pragmatic solution to maintain code organization without splitting files unnecessarily. * style: fix prettier formatting issues Fix code style issues in translation file and TOTP dialog component to pass CI prettier check. * chore: fix rollup optional dependencies installation in CI Add step to force reinstall rollup after npm ci to fix the known npm bug with optional dependencies on Linux x64 platform. * chore: fix lightningcss optional dependencies in CI Add lightningcss to the force reinstall step to fix npm optional dependencies bug for both rollup and lightningcss on Linux x64. * chore: fix npm optional dependencies bug in CI Remove package-lock.json and node_modules before install to properly handle optional dependencies for rollup, lightningcss, and tailwindcss native bindings on Linux x64 platform as recommended by npm. * Update src/types/index.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Set terminal environment variables for SSH Added environment variables for terminal configuration. --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Karmaa <88517757+LukeGus@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1137 lines
46 KiB
TypeScript
1137 lines
46 KiB
TypeScript
import { zodResolver } from "@hookform/resolvers/zod";
|
||
import { Controller, useForm } from "react-hook-form";
|
||
import { z } from "zod";
|
||
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
Form,
|
||
FormControl,
|
||
FormField,
|
||
FormItem,
|
||
FormLabel,
|
||
} from "@/components/ui/form";
|
||
import { Input } from "@/components/ui/input";
|
||
import { PasswordInput } from "@/components/ui/password-input";
|
||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||
import { Separator } from "@/components/ui/separator";
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||
import React, { useEffect, useRef, useState } from "react";
|
||
import { toast } from "sonner";
|
||
import {
|
||
createCredential,
|
||
updateCredential,
|
||
getCredentials,
|
||
getCredentialDetails,
|
||
detectKeyType,
|
||
detectPublicKeyType,
|
||
generatePublicKeyFromPrivate,
|
||
generateKeyPair,
|
||
} from "@/ui/main-axios";
|
||
import { useTranslation } from "react-i18next";
|
||
import CodeMirror from "@uiw/react-codemirror";
|
||
import { oneDark } from "@codemirror/theme-one-dark";
|
||
import { EditorView } from "@codemirror/view";
|
||
import type {
|
||
Credential,
|
||
CredentialEditorProps,
|
||
CredentialData,
|
||
} from "../../../../types/index.js";
|
||
|
||
export function CredentialEditor({
|
||
editingCredential,
|
||
onFormSubmit,
|
||
}: CredentialEditorProps) {
|
||
const { t } = useTranslation();
|
||
const [, setCredentials] = useState<Credential[]>([]);
|
||
const [folders, setFolders] = useState<string[]>([]);
|
||
const [, setLoading] = useState(true);
|
||
const [fullCredentialDetails, setFullCredentialDetails] =
|
||
useState<Credential | null>(null);
|
||
|
||
const [authTab, setAuthTab] = useState<"password" | "key">("password");
|
||
const [detectedKeyType, setDetectedKeyType] = useState<string | null>(null);
|
||
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(() => {
|
||
const fetchData = async () => {
|
||
try {
|
||
setLoading(true);
|
||
const credentialsData = await getCredentials();
|
||
setCredentials(credentialsData);
|
||
|
||
const uniqueFolders = [
|
||
...new Set(
|
||
credentialsData
|
||
.filter(
|
||
(credential) =>
|
||
credential.folder && credential.folder.trim() !== "",
|
||
)
|
||
.map((credential) => credential.folder!),
|
||
),
|
||
].sort() as string[];
|
||
|
||
setFolders(uniqueFolders);
|
||
} catch {
|
||
// Failed to load credentials
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
fetchData();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const fetchCredentialDetails = async () => {
|
||
if (editingCredential) {
|
||
try {
|
||
const fullDetails = await getCredentialDetails(editingCredential.id);
|
||
setFullCredentialDetails(fullDetails);
|
||
} catch {
|
||
toast.error(t("credentials.failedToFetchCredentialDetails"));
|
||
}
|
||
} else {
|
||
setFullCredentialDetails(null);
|
||
}
|
||
};
|
||
|
||
fetchCredentialDetails();
|
||
}, [editingCredential, t]);
|
||
|
||
const formSchema = z
|
||
.object({
|
||
name: z.string().min(1),
|
||
description: z.string().optional(),
|
||
folder: z.string().optional(),
|
||
tags: z.array(z.string().min(1)).default([]),
|
||
authType: z.enum(["password", "key"]),
|
||
username: z.string().min(1),
|
||
password: z.string().optional(),
|
||
key: z.any().optional().nullable(),
|
||
publicKey: z.string().optional(),
|
||
keyPassword: z.string().optional(),
|
||
keyType: z
|
||
.enum([
|
||
"auto",
|
||
"ssh-rsa",
|
||
"ssh-ed25519",
|
||
"ecdsa-sha2-nistp256",
|
||
"ecdsa-sha2-nistp384",
|
||
"ecdsa-sha2-nistp521",
|
||
"ssh-dss",
|
||
"ssh-rsa-sha2-256",
|
||
"ssh-rsa-sha2-512",
|
||
])
|
||
.optional(),
|
||
})
|
||
.superRefine((data, ctx) => {
|
||
if (data.authType === "password") {
|
||
if (!data.password || data.password.trim() === "") {
|
||
ctx.addIssue({
|
||
code: z.ZodIssueCode.custom,
|
||
message: t("credentials.passwordRequired"),
|
||
path: ["password"],
|
||
});
|
||
}
|
||
} else if (data.authType === "key") {
|
||
if (!data.key && !editingCredential) {
|
||
ctx.addIssue({
|
||
code: z.ZodIssueCode.custom,
|
||
message: t("credentials.sshKeyRequired"),
|
||
path: ["key"],
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
type FormData = z.infer<typeof formSchema>;
|
||
|
||
const form = useForm<FormData>({
|
||
resolver: zodResolver(formSchema) as unknown as Parameters<
|
||
typeof useForm<FormData>
|
||
>[0]["resolver"],
|
||
defaultValues: {
|
||
name: "",
|
||
description: "",
|
||
folder: "",
|
||
tags: [],
|
||
authType: "password",
|
||
username: "",
|
||
password: "",
|
||
key: null,
|
||
publicKey: "",
|
||
keyPassword: "",
|
||
keyType: "auto",
|
||
},
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (editingCredential && fullCredentialDetails) {
|
||
const defaultAuthType = fullCredentialDetails.authType;
|
||
setAuthTab(defaultAuthType);
|
||
|
||
setTimeout(() => {
|
||
const formData = {
|
||
name: fullCredentialDetails.name || "",
|
||
description: fullCredentialDetails.description || "",
|
||
folder: fullCredentialDetails.folder || "",
|
||
tags: fullCredentialDetails.tags || [],
|
||
authType: defaultAuthType as "password" | "key",
|
||
username: fullCredentialDetails.username || "",
|
||
password: "",
|
||
key: null,
|
||
publicKey: "",
|
||
keyPassword: "",
|
||
keyType: "auto" as const,
|
||
};
|
||
|
||
if (defaultAuthType === "password") {
|
||
formData.password = fullCredentialDetails.password || "";
|
||
} else if (defaultAuthType === "key") {
|
||
formData.key = fullCredentialDetails.key || "";
|
||
formData.publicKey = fullCredentialDetails.publicKey || "";
|
||
formData.keyPassword = fullCredentialDetails.keyPassword || "";
|
||
formData.keyType =
|
||
(fullCredentialDetails.keyType as string) || ("auto" as const);
|
||
}
|
||
|
||
form.reset(formData);
|
||
setTagInput("");
|
||
}, 100);
|
||
} else if (!editingCredential) {
|
||
setAuthTab("password");
|
||
form.reset({
|
||
name: "",
|
||
description: "",
|
||
folder: "",
|
||
tags: [],
|
||
authType: "password",
|
||
username: "",
|
||
password: "",
|
||
key: null,
|
||
publicKey: "",
|
||
keyPassword: "",
|
||
keyType: "auto",
|
||
});
|
||
setTagInput("");
|
||
}
|
||
}, [editingCredential?.id, fullCredentialDetails, form]);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (keyDetectionTimeoutRef.current) {
|
||
clearTimeout(keyDetectionTimeoutRef.current);
|
||
}
|
||
if (publicKeyDetectionTimeoutRef.current) {
|
||
clearTimeout(publicKeyDetectionTimeoutRef.current);
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
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");
|
||
}
|
||
} catch (error) {
|
||
setDetectedKeyType("error");
|
||
console.error("Key type detection error:", error);
|
||
} finally {
|
||
setKeyDetectionLoading(false);
|
||
}
|
||
};
|
||
|
||
const debouncedKeyDetection = (keyValue: string, keyPassword?: string) => {
|
||
if (keyDetectionTimeoutRef.current) {
|
||
clearTimeout(keyDetectionTimeoutRef.current);
|
||
}
|
||
keyDetectionTimeoutRef.current = setTimeout(() => {
|
||
handleKeyTypeDetection(keyValue, keyPassword);
|
||
}, 1000);
|
||
};
|
||
|
||
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);
|
||
}
|
||
};
|
||
|
||
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: t("credentials.invalidKey"),
|
||
error: t("credentials.detectionError"),
|
||
unknown: t("credentials.unknown"),
|
||
};
|
||
return keyTypeMap[keyType] || keyType;
|
||
};
|
||
|
||
const onSubmit = async (data: FormData) => {
|
||
try {
|
||
if (!data.name || data.name.trim() === "") {
|
||
data.name = data.username;
|
||
}
|
||
|
||
const submitData: CredentialData = {
|
||
name: data.name,
|
||
description: data.description,
|
||
folder: data.folder,
|
||
tags: data.tags,
|
||
authType: data.authType,
|
||
username: data.username,
|
||
keyType: data.keyType,
|
||
};
|
||
|
||
submitData.password = null;
|
||
submitData.key = null;
|
||
submitData.publicKey = null;
|
||
submitData.keyPassword = null;
|
||
submitData.keyType = null;
|
||
|
||
if (data.authType === "password") {
|
||
submitData.password = data.password;
|
||
} else if (data.authType === "key") {
|
||
submitData.key = data.key;
|
||
submitData.publicKey = data.publicKey;
|
||
submitData.keyPassword = data.keyPassword;
|
||
submitData.keyType = data.keyType;
|
||
}
|
||
|
||
if (editingCredential) {
|
||
await updateCredential(editingCredential.id, submitData);
|
||
toast.success(
|
||
t("credentials.credentialUpdatedSuccessfully", { name: data.name }),
|
||
);
|
||
} else {
|
||
await createCredential(submitData);
|
||
toast.success(
|
||
t("credentials.credentialAddedSuccessfully", { name: data.name }),
|
||
);
|
||
}
|
||
|
||
if (onFormSubmit) {
|
||
onFormSubmit();
|
||
}
|
||
|
||
window.dispatchEvent(new CustomEvent("credentials:changed"));
|
||
|
||
form.reset();
|
||
} catch (error) {
|
||
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 [folderDropdownOpen, setFolderDropdownOpen] = useState(false);
|
||
const folderInputRef = useRef<HTMLInputElement>(null);
|
||
const folderDropdownRef = useRef<HTMLDivElement>(null);
|
||
|
||
const folderValue = form.watch("folder");
|
||
const filteredFolders = React.useMemo(() => {
|
||
if (!folderValue) return folders;
|
||
return folders.filter((f) =>
|
||
f.toLowerCase().includes(folderValue.toLowerCase()),
|
||
);
|
||
}, [folderValue, folders]);
|
||
|
||
const handleFolderClick = (folder: string) => {
|
||
form.setValue("folder", folder);
|
||
setFolderDropdownOpen(false);
|
||
};
|
||
|
||
useEffect(() => {
|
||
function handleClickOutside(event: MouseEvent) {
|
||
if (
|
||
folderDropdownRef.current &&
|
||
!folderDropdownRef.current.contains(event.target as Node) &&
|
||
folderInputRef.current &&
|
||
!folderInputRef.current.contains(event.target as Node)
|
||
) {
|
||
setFolderDropdownOpen(false);
|
||
}
|
||
}
|
||
|
||
if (folderDropdownOpen) {
|
||
document.addEventListener("mousedown", handleClickOutside);
|
||
} else {
|
||
document.removeEventListener("mousedown", handleClickOutside);
|
||
}
|
||
|
||
return () => {
|
||
document.removeEventListener("mousedown", handleClickOutside);
|
||
};
|
||
}, [folderDropdownOpen]);
|
||
|
||
return (
|
||
<div
|
||
className="flex-1 flex flex-col h-full min-h-0 w-full"
|
||
key={editingCredential?.id || "new"}
|
||
>
|
||
<Form {...form}>
|
||
<form
|
||
onSubmit={form.handleSubmit(onSubmit)}
|
||
className="flex flex-col flex-1 min-h-0 h-full"
|
||
>
|
||
<ScrollArea className="flex-1 min-h-0 w-full my-1 pb-2">
|
||
<Tabs defaultValue="general" className="w-full">
|
||
<TabsList>
|
||
<TabsTrigger value="general">
|
||
{t("credentials.general")}
|
||
</TabsTrigger>
|
||
<TabsTrigger value="authentication">
|
||
{t("credentials.authentication")}
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
<TabsContent value="general" className="pt-2">
|
||
<FormLabel className="mb-2 font-bold">
|
||
{t("credentials.basicInformation")}
|
||
</FormLabel>
|
||
<div className="grid grid-cols-12 gap-3">
|
||
<FormField
|
||
control={form.control}
|
||
name="name"
|
||
render={({ field }) => (
|
||
<FormItem className="col-span-6">
|
||
<FormLabel>{t("credentials.credentialName")}</FormLabel>
|
||
<FormControl>
|
||
<Input
|
||
placeholder={t("placeholders.credentialName")}
|
||
{...field}
|
||
/>
|
||
</FormControl>
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name="username"
|
||
render={({ field }) => (
|
||
<FormItem className="col-span-6">
|
||
<FormLabel>{t("credentials.username")}</FormLabel>
|
||
<FormControl>
|
||
<Input
|
||
placeholder={t("placeholders.username")}
|
||
{...field}
|
||
/>
|
||
</FormControl>
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
</div>
|
||
<FormLabel className="mb-2 mt-4 font-bold">
|
||
{t("credentials.organization")}
|
||
</FormLabel>
|
||
<div className="grid grid-cols-26 gap-3">
|
||
<FormField
|
||
control={form.control}
|
||
name="description"
|
||
render={({ field }) => (
|
||
<FormItem className="col-span-10">
|
||
<FormLabel>{t("credentials.description")}</FormLabel>
|
||
<FormControl>
|
||
<Input
|
||
placeholder={t("placeholders.description")}
|
||
{...field}
|
||
/>
|
||
</FormControl>
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name="folder"
|
||
render={({ field }) => (
|
||
<FormItem className="col-span-10 relative">
|
||
<FormLabel>{t("credentials.folder")}</FormLabel>
|
||
<FormControl>
|
||
<Input
|
||
ref={folderInputRef}
|
||
placeholder={t("placeholders.folder")}
|
||
className="min-h-[40px]"
|
||
autoComplete="off"
|
||
value={field.value}
|
||
onFocus={() => setFolderDropdownOpen(true)}
|
||
onChange={(e) => {
|
||
field.onChange(e);
|
||
setFolderDropdownOpen(true);
|
||
}}
|
||
/>
|
||
</FormControl>
|
||
{folderDropdownOpen && filteredFolders.length > 0 && (
|
||
<div
|
||
ref={folderDropdownRef}
|
||
className="absolute top-full left-0 z-50 mt-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">
|
||
{filteredFolders.map((folder) => (
|
||
<Button
|
||
key={folder}
|
||
type="button"
|
||
variant="ghost"
|
||
size="sm"
|
||
className="w-full justify-start text-left rounded px-2 py-1.5 hover:bg-white/15 focus:bg-white/20 focus:outline-none"
|
||
onClick={() => handleFolderClick(folder)}
|
||
>
|
||
{folder}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name="tags"
|
||
render={({ field }) => (
|
||
<FormItem className="col-span-10 overflow-visible">
|
||
<FormLabel>{t("credentials.tags")}</FormLabel>
|
||
<FormControl>
|
||
<div className="flex flex-wrap items-center gap-1 border border-input rounded-md px-3 py-2 bg-dark-bg-input focus-within:ring-2 ring-ring min-h-[40px]">
|
||
{(field.value || []).map(
|
||
(tag: string, idx: number) => (
|
||
<span
|
||
key={`${tag}-${idx}`}
|
||
className="flex items-center bg-gray-200 text-gray-800 rounded-full px-2 py-0.5 text-xs"
|
||
>
|
||
{tag}
|
||
<button
|
||
type="button"
|
||
className="ml-1 text-gray-500 hover:text-red-500 focus:outline-none"
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const newTags = (
|
||
field.value || []
|
||
).filter(
|
||
(_: string, i: number) => i !== idx,
|
||
);
|
||
field.onChange(newTags);
|
||
}}
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
),
|
||
)}
|
||
<input
|
||
type="text"
|
||
className="flex-1 min-w-[60px] border-none outline-none bg-transparent p-0 h-6 text-sm"
|
||
value={tagInput}
|
||
onChange={(e) => setTagInput(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === " " && tagInput.trim() !== "") {
|
||
e.preventDefault();
|
||
const currentTags = field.value || [];
|
||
if (!currentTags.includes(tagInput.trim())) {
|
||
field.onChange([
|
||
...currentTags,
|
||
tagInput.trim(),
|
||
]);
|
||
}
|
||
setTagInput("");
|
||
} else if (
|
||
e.key === "Enter" &&
|
||
tagInput.trim() !== ""
|
||
) {
|
||
e.preventDefault();
|
||
const currentTags = field.value || [];
|
||
if (!currentTags.includes(tagInput.trim())) {
|
||
field.onChange([
|
||
...currentTags,
|
||
tagInput.trim(),
|
||
]);
|
||
}
|
||
setTagInput("");
|
||
} else if (
|
||
e.key === "Backspace" &&
|
||
tagInput === "" &&
|
||
(field.value || []).length > 0
|
||
) {
|
||
const currentTags = field.value || [];
|
||
field.onChange(currentTags.slice(0, -1));
|
||
}
|
||
}}
|
||
placeholder={t("credentials.addTagsSpaceToAdd")}
|
||
/>
|
||
</div>
|
||
</FormControl>
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
</div>
|
||
</TabsContent>
|
||
<TabsContent value="authentication">
|
||
<FormLabel className="mb-2 font-bold">
|
||
{t("credentials.authentication")}
|
||
</FormLabel>
|
||
<Tabs
|
||
value={authTab}
|
||
onValueChange={(value) => {
|
||
const newAuthType = value as "password" | "key";
|
||
setAuthTab(newAuthType);
|
||
form.setValue("authType", newAuthType);
|
||
|
||
form.setValue("password", "");
|
||
form.setValue("key", null);
|
||
form.setValue("keyPassword", "");
|
||
form.setValue("keyType", "auto");
|
||
}}
|
||
className="flex-1 flex flex-col h-full min-h-0"
|
||
>
|
||
<TabsList>
|
||
<TabsTrigger value="password">
|
||
{t("credentials.password")}
|
||
</TabsTrigger>
|
||
<TabsTrigger value="key">
|
||
{t("credentials.key")}
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
<TabsContent value="password">
|
||
<FormField
|
||
control={form.control}
|
||
name="password"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>{t("credentials.password")}</FormLabel>
|
||
<FormControl>
|
||
<PasswordInput
|
||
placeholder={t("placeholders.password")}
|
||
{...field}
|
||
/>
|
||
</FormControl>
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
</TabsContent>
|
||
<TabsContent value="key">
|
||
<div className="mt-2">
|
||
<div className="mb-3 p-3 bg-muted/20 border border-muted rounded-md">
|
||
<FormLabel className="mb-2 font-bold block">
|
||
{t("credentials.generateKeyPair")}
|
||
</FormLabel>
|
||
|
||
<div className="mb-2">
|
||
<div className="text-sm text-muted-foreground">
|
||
{t("credentials.generateKeyPairDescription")}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex gap-2 flex-wrap">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={async () => {
|
||
try {
|
||
const currentKeyPassword =
|
||
form.watch("keyPassword");
|
||
const result = await generateKeyPair(
|
||
"ssh-ed25519",
|
||
undefined,
|
||
currentKeyPassword,
|
||
);
|
||
|
||
if (result.success) {
|
||
form.setValue("key", result.privateKey);
|
||
form.setValue("publicKey", result.publicKey);
|
||
debouncedKeyDetection(
|
||
result.privateKey,
|
||
currentKeyPassword,
|
||
);
|
||
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 currentKeyPassword =
|
||
form.watch("keyPassword");
|
||
const result = await generateKeyPair(
|
||
"ecdsa-sha2-nistp256",
|
||
undefined,
|
||
currentKeyPassword,
|
||
);
|
||
|
||
if (result.success) {
|
||
form.setValue("key", result.privateKey);
|
||
form.setValue("publicKey", result.publicKey);
|
||
debouncedKeyDetection(
|
||
result.privateKey,
|
||
currentKeyPassword,
|
||
);
|
||
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 currentKeyPassword =
|
||
form.watch("keyPassword");
|
||
const result = await generateKeyPair(
|
||
"ssh-rsa",
|
||
2048,
|
||
currentKeyPassword,
|
||
);
|
||
|
||
if (result.success) {
|
||
form.setValue("key", result.privateKey);
|
||
form.setValue("publicKey", result.publicKey);
|
||
debouncedKeyDetection(
|
||
result.privateKey,
|
||
currentKeyPassword,
|
||
);
|
||
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>
|
||
<div className="grid grid-cols-2 gap-3 items-start">
|
||
<Controller
|
||
control={form.control}
|
||
name="key"
|
||
render={({ field }) => (
|
||
<FormItem className="mb-3 flex flex-col">
|
||
<FormLabel className="mb-1 min-h-[20px]">
|
||
{t("credentials.sshPrivateKey")}
|
||
</FormLabel>
|
||
<div className="mb-1">
|
||
<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>
|
||
<CodeMirror
|
||
value={
|
||
typeof field.value === "string"
|
||
? field.value
|
||
: ""
|
||
}
|
||
onChange={(value) => {
|
||
field.onChange(value);
|
||
debouncedKeyDetection(
|
||
value,
|
||
form.watch("keyPassword"),
|
||
);
|
||
}}
|
||
placeholder={t(
|
||
"placeholders.pastePrivateKey",
|
||
)}
|
||
theme={oneDark}
|
||
className="border border-input rounded-md"
|
||
minHeight="120px"
|
||
basicSetup={{
|
||
lineNumbers: true,
|
||
foldGutter: false,
|
||
dropCursor: false,
|
||
allowMultipleSelections: false,
|
||
highlightSelectionMatches: false,
|
||
searchKeymap: false,
|
||
scrollPastEnd: false,
|
||
}}
|
||
extensions={[
|
||
EditorView.theme({
|
||
".cm-scroller": {
|
||
overflow: "auto",
|
||
},
|
||
}),
|
||
]}
|
||
/>
|
||
</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-3 flex flex-col">
|
||
<FormLabel className="mb-1 min-h-[20px]">
|
||
{t("credentials.sshPublicKey")}
|
||
</FormLabel>
|
||
<div className="mb-1 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
|
||
type="button"
|
||
variant="outline"
|
||
className="flex-shrink-0"
|
||
onClick={async () => {
|
||
const privateKey = form.watch("key");
|
||
if (
|
||
!privateKey ||
|
||
typeof privateKey !== "string" ||
|
||
!privateKey.trim()
|
||
) {
|
||
toast.error(
|
||
t(
|
||
"credentials.privateKeyRequiredForGeneration",
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const keyPassword =
|
||
form.watch("keyPassword");
|
||
const result =
|
||
await generatePublicKeyFromPrivate(
|
||
privateKey,
|
||
keyPassword,
|
||
);
|
||
|
||
if (result.success && result.publicKey) {
|
||
field.onChange(result.publicKey);
|
||
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>
|
||
</div>
|
||
<FormControl>
|
||
<CodeMirror
|
||
value={field.value || ""}
|
||
onChange={(value) => {
|
||
field.onChange(value);
|
||
debouncedPublicKeyDetection(value);
|
||
}}
|
||
placeholder={t("placeholders.pastePublicKey")}
|
||
theme={oneDark}
|
||
className="border border-input rounded-md"
|
||
minHeight="120px"
|
||
basicSetup={{
|
||
lineNumbers: true,
|
||
foldGutter: false,
|
||
dropCursor: false,
|
||
allowMultipleSelections: false,
|
||
highlightSelectionMatches: false,
|
||
searchKeymap: false,
|
||
scrollPastEnd: false,
|
||
}}
|
||
extensions={[
|
||
EditorView.theme({
|
||
".cm-scroller": {
|
||
overflow: "auto",
|
||
},
|
||
}),
|
||
]}
|
||
/>
|
||
</FormControl>
|
||
{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-3 mt-3">
|
||
<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>
|
||
)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</TabsContent>
|
||
</Tabs>
|
||
</TabsContent>
|
||
</Tabs>
|
||
</ScrollArea>
|
||
<footer className="shrink-0 w-full pb-0">
|
||
<Separator className="p-0.25" />
|
||
<Button className="translate-y-2" type="submit" variant="outline">
|
||
{editingCredential
|
||
? t("credentials.updateCredential")
|
||
: t("credentials.addCredential")}
|
||
</Button>
|
||
</footer>
|
||
</form>
|
||
</Form>
|
||
</div>
|
||
);
|
||
}
|