* fix: Resolve database encryption atomicity issues and enhance debugging (#430) * fix: Resolve database encryption atomicity issues and enhance debugging This commit addresses critical data corruption issues caused by non-atomic file writes during database encryption, and adds comprehensive diagnostic logging to help debug encryption-related failures. **Problem:** Users reported "Unsupported state or unable to authenticate data" errors when starting the application after system crashes or Docker container restarts. The root cause was non-atomic writes of encrypted database files: 1. Encrypted data file written (step 1) 2. Metadata file written (step 2) → If process crashes between steps 1 and 2, files become inconsistent → New IV/tag in data file, old IV/tag in metadata → GCM authentication fails on next startup → User data permanently inaccessible **Solution - Atomic Writes:** 1. Write-to-temp + atomic-rename pattern: - Write to temporary files (*.tmp-timestamp-pid) - Perform atomic rename operations - Clean up temp files on failure 2. Data integrity validation: - Add dataSize field to metadata - Verify file size before decryption - Early detection of corrupted writes 3. Enhanced error diagnostics: - Key fingerprints (SHA256 prefix) for verification - File modification timestamps - Detailed GCM auth failure messages - Automatic diagnostic info generation **Changes:** database-file-encryption.ts: - Implement atomic write pattern in encryptDatabaseFromBuffer - Implement atomic write pattern in encryptDatabaseFile - Add dataSize field to EncryptedFileMetadata interface - Validate file size before decryption in decryptDatabaseToBuffer - Enhanced error messages for GCM auth failures - Add getDiagnosticInfo() function for comprehensive debugging - Add debug logging for all encryption/decryption operations system-crypto.ts: - Add detailed logging for DATABASE_KEY initialization - Log key source (env var vs .env file) - Add key fingerprints to all log messages - Better error messages when key loading fails db/index.ts: - Automatically generate diagnostic info on decryption failure - Log detailed debugging information to help users troubleshoot **Debugging Info Added:** - Key initialization: source, fingerprint, length, path - Encryption: original size, encrypted size, IV/tag prefixes, temp paths - Decryption: file timestamps, metadata content, key fingerprint matching - Auth failures: .env file status, key availability, file consistency - File diagnostics: existence, readability, size validation, mtime comparison **Backward Compatibility:** - dataSize field is optional (metadata.dataSize?: number) - Old encrypted files without dataSize continue to work - No migration required **Testing:** - Compiled successfully - No breaking changes to existing APIs - Graceful handling of legacy v1 encrypted files Fixes data loss issues reported by users experiencing container restarts and system crashes during database saves. * fix: Cleanup PR * Update src/backend/utils/database-file-encryption.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/backend/utils/database-file-encryption.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/backend/utils/database-file-encryption.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/backend/utils/database-file-encryption.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/backend/utils/database-file-encryption.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: Merge metadata and DB into 1 file * fix: Add initial command palette * Feature/german language support (#431) * Update translation.json Fixed some translation issues for German, made it more user friendly and common. * Update translation.json added updated block for serverStats * Update translation.json Added translations * Update translation.json Removed duplicate of "free":"Free" * feat: Finalize command palette * fix: Several bug fixes for terminals, server stats, and general feature improvements * feat: Enhanced security, UI improvements, and animations (#432) * fix: Remove empty catch blocks and add error logging * refactor: Modularize server stats widget collectors * feat: Add i18n support for terminal customization and login stats - Add comprehensive terminal customization translations (60+ keys) for appearance, behavior, and advanced settings across all 4 languages - Add SSH login statistics translations - Update HostManagerEditor to use i18n for all terminal customization UI elements - Update LoginStatsWidget to use i18n for all UI text - Add missing logger imports in backend files for improved debugging * feat: Add keyboard shortcut enhancements with Kbd component - Add shadcn kbd component for displaying keyboard shortcuts - Enhance file manager context menu to display shortcuts with Kbd component - Add 5 new keyboard shortcuts to file manager: - Ctrl+D: Download selected files - Ctrl+N: Create new file - Ctrl+Shift+N: Create new folder - Ctrl+U: Upload files - Enter: Open/run selected file - Add keyboard shortcut hints to command palette footer - Create helper function to parse and render keyboard shortcuts * feat: Add i18n support for command palette - Add commandPalette translation section with 22 keys to all 4 languages - Update CommandPalette component to use i18n for all UI text - Translate search placeholder, group headings, menu items, and shortcut hints - Support multilingual command palette interface * feat: Add smooth transitions and animations to UI - Add fade-in/fade-out transition to command palette (200ms) - Add scale animation to command palette on open/close - Add smooth popup animation to context menu (150ms) - Add visual feedback for file selection with ring effect - Add hover scale effect to file grid items - Add transition-all to list view items for consistent behavior - Zero JavaScript overhead, pure CSS transitions - All animations under 200ms for instant feel * feat: Add button active state and dashboard card animations - Add active:scale-95 to all buttons for tactile click feedback - Add hover border effect to dashboard cards (150ms transition) - Add pulse animation to dashboard loading states - Pure CSS transitions with zero JavaScript overhead - Improves enterprise-level feel of UI * feat: Add smooth macOS-style page transitions - Add fullscreen crossfade transition for login/logout (300ms fade-out + 400ms fade-in) - Add slide-in-from-right animation for all page switches (Dashboard, Terminal, SSH Manager, Admin, Profile) - Fix TypeScript compilation by adding esModuleInterop to tsconfig.node.json - Pass handleLogout from DesktopApp to LeftSidebar for consistent transition behavior All page transitions now use Tailwind animate-in utilities with 300ms duration for smooth, native-feeling UX * fix: Add key prop to force animation re-trigger on tab switch Each page container now has key={currentTab} to ensure React unmounts and remounts the element on every tab switch, properly triggering the slide-in animation * revert: Remove page transition animations Page switching animations were not noticeable enough and felt unnecessary. Keep only the login/logout fullscreen crossfade transitions which provide clear visual feedback for authentication state changes * feat: Add ripple effect to login/logout transitions Add three-layer expanding ripple animation during fadeOut phase: - Ripples expand from screen center using primary theme color - Each layer has staggered delay (0ms, 150ms, 300ms) for wave effect - Ripples fade out as they expand to create elegant visual feedback - Uses pure CSS keyframe animation, no external libraries Total animation: 800ms ripple + 300ms screen fade * feat: Add smooth TERMIX logo animation to transitions Changes: - Extend transition duration from 300ms/400ms to 800ms/600ms for more elegant feel - Reduce ripple intensity from /20,/15,/10 to /8,/5 for subtlety - Slow down ripple animation from 0.8s to 2s with cubic-bezier easing - Add centered TERMIX logo with monospace font and subtitle - Logo fades in from 80% scale, holds, then fades out at 110% scale - Total effect: 1.2s logo animation synced with 2s ripple waves Creates a premium, branded transition experience * feat: Enhance transition animation with premium details Timing adjustments: - Extend fadeOut from 800ms to 1200ms - Extend fadeIn from 600ms to 800ms - Slow background fade to 700ms for elegance Visual enhancements: - Add 4-layer ripple waves (10%, 7%, 5%, 3% opacity) with staggered delays - Ripple animation extended to 2.5s with refined opacity curve - Logo blur effect: starts at 8px, sharpens to 0px, exits at 4px - Logo glow effect: triple-layer text-shadow using primary theme color - Increase logo size from text-6xl to text-7xl - Subtitle delayed fade-in from bottom with smooth slide animation Creates a cinematic, polished brand experience * feat: Redesign login page with split-screen cinematic layout Major redesign of authentication page: Left Side (40% width): - Full-height gradient background using primary theme color - Large TERMIX logo with glow effect - Subtitle and tagline - Infinite animated ripple waves (3 layers) - Hidden on mobile, shows brand identity Right Side (60% width): - Centered glassmorphism card with backdrop blur - Refined tab switcher with pill-style active state - Enlarged title with gradient text effect - Added welcome subtitles for better UX - Card slides in from bottom on load - All existing functionality preserved Visual enhancements: - Tab navigation: segmented control style in muted container - Active tab: white background with subtle shadow - Smooth 200ms transitions on all interactions - Card: rounded-2xl, shadow-xl, semi-transparent border Creates premium, modern login experience matching transition animations * feat: Update login page theme colors and add i18n support - Changed login page gradient from blue to match dark theme colors - Updated ripple effects to use theme primary color - Added i18n translation keys for login page (auth.tagline, auth.description, auth.welcomeBack, auth.createAccount, auth.continueExternal) - Updated all language files (en, zh, de, ru, pt-BR) with new translations - Fixed TypeScript compilation issues by clearing build cache * refactor: Use shadcn Tabs component and fix modal styling - Replace custom tab navigation with shadcn Tabs component - Restore border-2 border-dark-border for modal consistency - Remove circular icon from login success message - Simplify authentication success display * refactor: Remove ripple effects and gradient from login page - Remove animated ripple background effects - Remove gradient background, use solid color (bg-dark-bg-darker) - Remove text-shadow glow effect from logo - Simplify brand showcase to clean, minimal design * feat: Add decorative slash and remove subtitle from login page - Add decorative slash divider with gradient lines below TERMIX logo - Remove subtitle text (welcomeBack and createAccount) - Simplify page title to show only the main heading * feat: Add diagonal line pattern background to login page - Replace decorative slash with subtle diagonal line pattern background - Use repeating-linear-gradient at 45deg angle - Set very low opacity (0.03) for subtle effect - Pattern uses theme primary color * fix: Display diagonal line pattern on login background - Combine background color and pattern in single style attribute - Use white semi-transparent lines (rgba 0.03 opacity) - 45deg angle, 35px spacing, 2px width - Remove separate overlay div to ensure pattern visibility * security: Fix user enumeration vulnerability in login - Unify error messages for invalid username and incorrect password - Both return 401 status with 'Invalid username or password' - Prevent attackers from enumerating valid usernames - Maintain detailed logging for debugging purposes - Changed from 404 'User not found' to generic auth failure message * security: Add login rate limiting to prevent brute force attacks - Implement LoginRateLimiter with IP and username-based tracking - Block after 5 failed attempts within 15 minutes - Lock account/IP for 15 minutes after threshold - Automatic cleanup of expired entries every 5 minutes - Track remaining attempts in logs for monitoring - Return 429 status with remaining time on rate limit - Reset counters on successful login - Dual protection: both IP-based and username-based limits * French translation (#434) * Adding French Language * Enhancements * feat: Replace the old ssh tools system with a new dedicated sidebar * fix: Merge zac/luke * fix: Finalize new sidebar, improve and loading animations * Added ability to close non-primary tabs involved in a split view (#435) * fix: General bug fixes/small feature improvements * feat: General UI improvements and translation updates * fix: Command history and file manager styling issues * feat: General bug fixes, added server stat commands, improved split screen, link accounts, etc * fix: add Accept header for OIDC callback request (#436) * Delete DOWNLOADS.md * fix: add Accept header for OIDC callback request --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: More bug fixes and QOL fixes * fix: Server stats not respecting interval and fixed SSH toool type issues * fix: Remove github links * fix: Delete account spacing * fix: Increment version * fix: Unable to delete hosts and add nginx for terminal * fix: Unable to delete hosts * fix: Unable to delete hosts * fix: Unable to delete hosts * fix: OIDC/local account linking breaking both logins * chore: File cleanup * feat: Max terminal tab size and save current file manager sorting type * fix: Terminal display issue, migrate host editor to use combobox * feat: Add snippet folder/customization system * fix: Fix OIDC linking and prep release * fix: Increment version --------- Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Max <herzmaximilian@gmail.com> Co-authored-by: SlimGary <trash.slim@gmail.com> Co-authored-by: jarrah31 <jarrah31@gmail.com> Co-authored-by: Kf637 <mail@kf637.tech>
1176 lines
47 KiB
TypeScript
1176 lines
47 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 { Alert, AlertDescription } from "@/components/ui/alert";
|
||
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 [activeTab, setActiveTab] = useState("general");
|
||
const [formError, setFormError] = useState<string | null>(null);
|
||
|
||
const [detectedPublicKeyType, setDetectedPublicKeyType] = useState<
|
||
string | null
|
||
>(null);
|
||
const [publicKeyDetectionLoading, setPublicKeyDetectionLoading] =
|
||
useState(false);
|
||
const publicKeyDetectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||
|
||
useEffect(() => {
|
||
setFormError(null);
|
||
}, [activeTab]);
|
||
|
||
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 {
|
||
} 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 {
|
||
setFormError(null);
|
||
|
||
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 handleFormError = () => {
|
||
const errors = form.formState.errors;
|
||
|
||
if (
|
||
errors.name ||
|
||
errors.username ||
|
||
errors.description ||
|
||
errors.folder ||
|
||
errors.tags
|
||
) {
|
||
setActiveTab("general");
|
||
} else if (
|
||
errors.password ||
|
||
errors.key ||
|
||
errors.publicKey ||
|
||
errors.keyPassword ||
|
||
errors.keyType
|
||
) {
|
||
setActiveTab("authentication");
|
||
}
|
||
};
|
||
|
||
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, handleFormError)}
|
||
className="flex flex-col flex-1 min-h-0 h-full"
|
||
>
|
||
<ScrollArea className="flex-1 min-h-0 w-full my-1 pb-2">
|
||
{formError && (
|
||
<Alert variant="destructive" className="mb-4">
|
||
<AlertDescription>{formError}</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
<Tabs
|
||
value={activeTab}
|
||
onValueChange={setActiveTab}
|
||
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>
|
||
);
|
||
}
|