From 522fe3e571b5a818d016119cb8ba0aa7beea30a0 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sat, 4 Oct 2025 04:17:07 +0800 Subject: [PATCH 1/4] Add support for passwordless host authentication Allow adding SSH hosts without authentication credentials by introducing a new "none" auth type. This enables users to configure hosts for later use with SSH agent or manual credential addition. Changes: - Add authType "none" to type definitions - Update frontend form to support "None" authentication option - Skip credential validation for "none" auth type - Update backend to accept hosts without credentials - Add i18n support for English and Chinese Fixes #278 --- src/backend/database/routes/ssh.ts | 22 ++++++++++- src/locales/en/translation.json | 2 + src/locales/zh/translation.json | 2 + src/types/index.ts | 4 +- .../Apps/Host Manager/HostManagerEditor.tsx | 39 +++++++++++++++---- 5 files changed, 57 insertions(+), 12 deletions(-) diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index 3cb76e67..fcec0541 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -281,7 +281,14 @@ router.post( sshDataObj.keyPassword = keyPassword || null; sshDataObj.keyType = keyType; sshDataObj.password = null; + } else if (effectiveAuthType === "none") { + // No authentication credentials - set all to null + sshDataObj.password = null; + sshDataObj.key = null; + sshDataObj.keyPassword = null; + sshDataObj.keyType = null; } else { + // credential type or fallback - set all to null except credentialId sshDataObj.password = null; sshDataObj.key = null; sshDataObj.keyPassword = null; @@ -471,7 +478,14 @@ router.put( sshDataObj.keyType = keyType; } sshDataObj.password = null; + } else if (effectiveAuthType === "none") { + // No authentication credentials - set all to null + sshDataObj.password = null; + sshDataObj.key = null; + sshDataObj.keyPassword = null; + sshDataObj.keyType = null; } else { + // credential type or fallback - set all to null except credentialId sshDataObj.password = null; sshDataObj.key = null; sshDataObj.keyPassword = null; @@ -1356,10 +1370,12 @@ router.post( continue; } - if (!["password", "key", "credential"].includes(hostData.authType)) { + if ( + !["password", "key", "credential", "none"].includes(hostData.authType) + ) { results.failed++; results.errors.push( - `Host ${i + 1}: Invalid authType. Must be 'password', 'key', or 'credential'`, + `Host ${i + 1}: Invalid authType. Must be 'password', 'key', 'credential', or 'none'`, ); continue; } @@ -1391,6 +1407,8 @@ router.post( continue; } + // "none" authType requires no validation - no credentials needed + const sshDataObj: any = { userId: userId, name: hostData.name || `${hostData.username}@${hostData.ip}`, diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 0e6d735e..7b2b4063 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -639,6 +639,8 @@ "password": "Password", "key": "Key", "credential": "Credential", + "none": "None", + "noneDescription": "No authentication credentials required. You can add credentials later or use SSH agent for authentication.", "selectCredential": "Select Credential", "selectCredentialPlaceholder": "Choose a credential...", "credentialRequired": "Credential is required when using credential authentication", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 62069e11..d573da45 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -635,6 +635,8 @@ "password": "密码", "key": "密钥", "credential": "凭证", + "none": "无", + "noneDescription": "无需认证凭证。您可以稍后添加凭证或使用 SSH 代理进行认证。", "selectCredential": "选择凭证", "selectCredentialPlaceholder": "选择一个凭证...", "credentialRequired": "使用凭证认证时需要选择凭证", diff --git a/src/types/index.ts b/src/types/index.ts index ee7cedb2..05a3405b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -18,7 +18,7 @@ export interface SSHHost { folder: string; tags: string[]; pin: boolean; - authType: "password" | "key" | "credential"; + authType: "password" | "key" | "credential" | "none"; password?: string; key?: string; keyPassword?: string; @@ -47,7 +47,7 @@ export interface SSHHostData { folder?: string; tags?: string[]; pin?: boolean; - authType: "password" | "key" | "credential"; + authType: "password" | "key" | "credential" | "none"; password?: string; key?: File | null; keyPassword?: string; diff --git a/src/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx b/src/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx index 08a41724..b660b051 100644 --- a/src/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx +++ b/src/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx @@ -48,7 +48,7 @@ interface SSHHost { folder: string; tags: string[]; pin: boolean; - authType: string; + authType: "password" | "key" | "credential" | "none"; password?: string; key?: string; keyPassword?: string; @@ -79,9 +79,9 @@ export function HostManagerEditor({ const [credentials, setCredentials] = useState([]); const [loading, setLoading] = useState(true); - const [authTab, setAuthTab] = useState<"password" | "key" | "credential">( - "password", - ); + const [authTab, setAuthTab] = useState< + "password" | "key" | "credential" | "none" + >("password"); const [keyInputMethod, setKeyInputMethod] = useState<"upload" | "paste">( "upload", ); @@ -174,7 +174,7 @@ export function HostManagerEditor({ folder: z.string().optional(), tags: z.array(z.string().min(1)).default([]), pin: z.boolean().default(false), - authType: z.enum(["password", "key", "credential"]), + authType: z.enum(["password", "key", "credential", "none"]), credentialId: z.number().optional().nullable(), password: z.string().optional(), key: z.any().optional().nullable(), @@ -210,6 +210,11 @@ export function HostManagerEditor({ defaultPath: z.string().optional(), }) .superRefine((data, ctx) => { + // Skip authentication validation for "none" type + if (data.authType === "none") { + return; + } + if (data.authType === "key") { if ( !data.key || @@ -313,7 +318,9 @@ export function HostManagerEditor({ ? "credential" : cleanedHost.key ? "key" - : "password"; + : cleanedHost.password + ? "password" + : "none"; setAuthTab(defaultAuthType); const formData = { @@ -324,7 +331,7 @@ export function HostManagerEditor({ folder: cleanedHost.folder || "", tags: cleanedHost.tags || [], pin: Boolean(cleanedHost.pin), - authType: defaultAuthType as "password" | "key" | "credential", + authType: defaultAuthType as "password" | "key" | "credential" | "none", credentialId: null, password: "", key: null, @@ -863,7 +870,8 @@ export function HostManagerEditor({ const newAuthType = value as | "password" | "key" - | "credential"; + | "credential" + | "none"; setAuthTab(newAuthType); form.setValue("authType", newAuthType); @@ -880,6 +888,13 @@ export function HostManagerEditor({ form.setValue("key", null); form.setValue("keyPassword", ""); form.setValue("keyType", "auto"); + } else if (newAuthType === "none") { + // Clear all authentication fields for "none" type + form.setValue("password", ""); + form.setValue("key", null); + form.setValue("keyPassword", ""); + form.setValue("keyType", "auto"); + form.setValue("credentialId", null); } }} className="flex-1 flex flex-col h-full min-h-0" @@ -892,6 +907,7 @@ export function HostManagerEditor({ {t("hosts.credential")} + {t("hosts.none")} + + + + {t("hosts.noneDescription")} + + + -- 2.52.0 From fb3f5f435b9a30beb86ad9f43c472e7d90a324d1 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sat, 4 Oct 2025 04:49:18 +0800 Subject: [PATCH 2/4] Add TOTP/2FA authentication support for SSH connections Implement keyboard-interactive authentication to support servers with PAM-based two-factor authentication configured. When TOTP verification is detected, users are prompted with an inline modal to enter their authentication code. Fixes #220 --- src/backend/ssh/terminal.ts | 73 +++++++++++++++++++++ src/locales/en/translation.json | 6 +- src/locales/zh/translation.json | 6 +- src/ui/Desktop/Apps/Terminal/Terminal.tsx | 77 +++++++++++++++++++++++ 4 files changed, 160 insertions(+), 2 deletions(-) diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index 94650bd6..42c29d3f 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -154,6 +154,7 @@ wss.on("connection", async (ws: WebSocket, req) => { let sshConn: Client | null = null; let sshStream: ClientChannel | null = null; let pingInterval: NodeJS.Timeout | null = null; + let keyboardInteractiveFinish: ((responses: string[]) => void) | null = null; ws.on("close", () => { const userWs = userConnections.get(userId); @@ -257,6 +258,34 @@ wss.on("connection", async (ws: WebSocket, req) => { ws.send(JSON.stringify({ type: "pong" })); break; + case "totp_response": + // Handle TOTP code submitted by user + if (keyboardInteractiveFinish && data.code) { + sshLogger.info("TOTP code received from user", { + operation: "totp_response", + userId, + codeLength: data.code.length, + }); + + // Call the finish callback with the TOTP code + keyboardInteractiveFinish([data.code]); + keyboardInteractiveFinish = null; + } else { + sshLogger.warn("TOTP response received but no callback available", { + operation: "totp_response_error", + userId, + hasCallback: !!keyboardInteractiveFinish, + hasCode: !!data.code, + }); + ws.send( + JSON.stringify({ + type: "error", + message: "TOTP authentication state lost. Please reconnect.", + }), + ); + } + break; + default: sshLogger.warn("Unknown message type received", { operation: "websocket_message_unknown_type", @@ -557,10 +586,54 @@ wss.on("connection", async (ws: WebSocket, req) => { cleanupSSH(connectionTimeout); }); + // Handle keyboard-interactive authentication (TOTP/2FA) + sshConn.on( + "keyboard-interactive", + ( + name: string, + instructions: string, + instructionsLang: string, + prompts: Array<{ prompt: string; echo: boolean }>, + finish: (responses: string[]) => void, + ) => { + sshLogger.info("Keyboard-interactive authentication requested", { + operation: "ssh_keyboard_interactive", + hostId: id, + promptsCount: prompts.length, + instructions: instructions || "none", + }); + + // Check if any prompt looks like TOTP/2FA/OTP verification + const totpPrompt = prompts.find((p) => + /verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test( + p.prompt, + ), + ); + + if (totpPrompt) { + // TOTP detected - request code from user via frontend + keyboardInteractiveFinish = finish; + ws.send( + JSON.stringify({ + type: "totp_required", + prompt: totpPrompt.prompt, + promptCount: prompts.length, + }), + ); + } else { + // Non-TOTP keyboard-interactive (e.g., password prompt) + // Provide password if available + const responses = prompts.map(() => resolvedCredentials.password || ""); + finish(responses); + } + }, + ); + const connectConfig: any = { host: ip, port, username, + tryKeyboard: true, keepaliveInterval: 30000, keepaliveCountMax: 3, readyTimeout: 60000, diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 7b2b4063..eb6aaaeb 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -711,7 +711,11 @@ "connectionTimeout": "Connection timeout", "terminalTitle": "Terminal - {{host}}", "terminalWithPath": "Terminal - {{host}}:{{path}}", - "runTitle": "Running {{command}} - {{host}}" + "runTitle": "Running {{command}} - {{host}}", + "totpRequired": "Two-Factor Authentication Required", + "totpPlaceholder": "000000", + "submit": "Submit", + "cancel": "Cancel" }, "fileManager": { "title": "File Manager", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index d573da45..43fb04f1 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -732,7 +732,11 @@ "reconnecting": "重新连接中... ({{attempt}}/{{max}})", "reconnected": "重新连接成功", "maxReconnectAttemptsReached": "已达到最大重连尝试次数", - "connectionTimeout": "连接超时" + "connectionTimeout": "连接超时", + "totpRequired": "需要双因素认证", + "totpPlaceholder": "000000", + "submit": "提交", + "cancel": "取消" }, "fileManager": { "title": "文件管理器", diff --git a/src/ui/Desktop/Apps/Terminal/Terminal.tsx b/src/ui/Desktop/Apps/Terminal/Terminal.tsx index 7367ff6f..aad1a344 100644 --- a/src/ui/Desktop/Apps/Terminal/Terminal.tsx +++ b/src/ui/Desktop/Apps/Terminal/Terminal.tsx @@ -55,6 +55,8 @@ export const Terminal = forwardRef(function SSHTerminal( const [isConnecting, setIsConnecting] = useState(false); const [connectionError, setConnectionError] = useState(null); const [isAuthenticated, setIsAuthenticated] = useState(false); + const [totpRequired, setTotpRequired] = useState(false); + const [totpPrompt, setTotpPrompt] = useState(""); const isVisibleRef = useRef(false); const reconnectTimeoutRef = useRef(null); const reconnectAttempts = useRef(0); @@ -102,6 +104,20 @@ export const Terminal = forwardRef(function SSHTerminal( } catch (_) {} } + // Handle TOTP code submission + const handleTotpSubmit = (code: string) => { + if (webSocketRef.current && code.trim()) { + webSocketRef.current.send( + JSON.stringify({ + type: "totp_response", + code: code.trim(), + }), + ); + setTotpRequired(false); + setTotpPrompt(""); + } + }; + function scheduleNotify(cols: number, rows: number) { if (!(cols > 0 && rows > 0)) return; pendingSizeRef.current = { cols, rows }; @@ -422,6 +438,10 @@ export const Terminal = forwardRef(function SSHTerminal( if (onClose) { onClose(); } + } else if (msg.type === "totp_required") { + // TOTP/2FA verification required + setTotpRequired(true); + setTotpPrompt(msg.prompt || "Verification code:"); } } catch (error) { toast.error(t("terminal.messageParseError")); @@ -729,6 +749,63 @@ export const Terminal = forwardRef(function SSHTerminal( )} + + {totpRequired && ( +
+
+

+ {t("terminal.totpRequired")} +

+

{totpPrompt}

+
{ + e.preventDefault(); + const input = e.currentTarget.elements.namedItem( + "totpCode", + ) as HTMLInputElement; + if (input && input.value.trim()) { + handleTotpSubmit(input.value); + } + }} + > + +
+ + +
+
+
+
+ )} ); }); -- 2.52.0 From f92cfbfec74cb4545f9aa92970653e254bcefb8d Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sat, 4 Oct 2025 06:36:51 +0800 Subject: [PATCH 3/4] Add terminal code snippets feature --- src/backend/database/database.ts | 2 + src/backend/database/db/schema.ts | 16 + src/backend/database/routes/snippets.ts | 260 +++++++++++++++ src/backend/utils/simple-db-ops.ts | 2 +- src/locales/en/translation.json | 31 ++ src/locales/zh/translation.json | 31 ++ src/types/index.ts | 20 ++ .../Desktop/Apps/Terminal/SnippetsSidebar.tsx | 305 ++++++++++++++++++ src/ui/Desktop/Apps/Terminal/Terminal.tsx | 53 +++ 9 files changed, 719 insertions(+), 1 deletion(-) create mode 100644 src/backend/database/routes/snippets.ts create mode 100644 src/ui/Desktop/Apps/Terminal/SnippetsSidebar.tsx diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 55f955e9..c83733ee 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -6,6 +6,7 @@ import userRoutes from "./routes/users.js"; import sshRoutes from "./routes/ssh.js"; import alertRoutes from "./routes/alerts.js"; import credentialsRoutes from "./routes/credentials.js"; +import snippetsRoutes from "./routes/snippets.js"; import cors from "cors"; import fetch from "node-fetch"; import fs from "fs"; @@ -1411,6 +1412,7 @@ app.use("/users", userRoutes); app.use("/ssh", sshRoutes); app.use("/alerts", alertRoutes); app.use("/credentials", credentialsRoutes); +app.use("/snippets", snippetsRoutes); app.use( ( diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index bc2bb4d8..77e5e4ff 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -172,3 +172,19 @@ export const sshCredentialUsage = sqliteTable("ssh_credential_usage", { .notNull() .default(sql`CURRENT_TIMESTAMP`), }); + +export const snippets = sqliteTable("snippets", { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id), + name: text("name").notNull(), + content: text("content").notNull(), + description: text("description"), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); diff --git a/src/backend/database/routes/snippets.ts b/src/backend/database/routes/snippets.ts new file mode 100644 index 00000000..7c5ee428 --- /dev/null +++ b/src/backend/database/routes/snippets.ts @@ -0,0 +1,260 @@ +import express from "express"; +import { db } from "../db/index.js"; +import { snippets } from "../db/schema.js"; +import { eq, and, desc, sql } from "drizzle-orm"; +import type { Request, Response } from "express"; +import { authLogger } from "../../utils/logger.js"; +import { AuthManager } from "../../utils/auth-manager.js"; + +const router = express.Router(); + +function isNonEmptyString(val: any): val is string { + return typeof val === "string" && val.trim().length > 0; +} + +const authManager = AuthManager.getInstance(); +const authenticateJWT = authManager.createAuthMiddleware(); +const requireDataAccess = authManager.createDataAccessMiddleware(); + +// Get all snippets for the authenticated user +// GET /snippets +router.get( + "/", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as any).userId; + + if (!isNonEmptyString(userId)) { + authLogger.warn("Invalid userId for snippets fetch"); + return res.status(400).json({ error: "Invalid userId" }); + } + + try { + const result = await db + .select() + .from(snippets) + .where(eq(snippets.userId, userId)) + .orderBy(desc(snippets.updatedAt)); + + res.json(result); + } catch (err) { + authLogger.error("Failed to fetch snippets", err); + res.status(500).json({ error: "Failed to fetch snippets" }); + } + }, +); + +// Get a specific snippet by ID +// GET /snippets/:id +router.get( + "/:id", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as any).userId; + const { id } = req.params; + + if (!isNonEmptyString(userId) || !id) { + authLogger.warn("Invalid request for snippet fetch"); + return res.status(400).json({ error: "Invalid request" }); + } + + try { + const result = await db + .select() + .from(snippets) + .where( + and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)), + ); + + if (result.length === 0) { + return res.status(404).json({ error: "Snippet not found" }); + } + + res.json(result[0]); + } catch (err) { + authLogger.error("Failed to fetch snippet", err); + res.status(500).json({ + error: err instanceof Error ? err.message : "Failed to fetch snippet", + }); + } + }, +); + +// Create a new snippet +// POST /snippets +router.post( + "/", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as any).userId; + const { name, content, description } = req.body; + + if ( + !isNonEmptyString(userId) || + !isNonEmptyString(name) || + !isNonEmptyString(content) + ) { + authLogger.warn("Invalid snippet creation data validation failed", { + operation: "snippet_create", + userId, + hasName: !!name, + hasContent: !!content, + }); + return res.status(400).json({ error: "Name and content are required" }); + } + + try { + const insertData = { + userId, + name: name.trim(), + content: content.trim(), + description: description?.trim() || null, + }; + + const result = await db.insert(snippets).values(insertData).returning(); + + authLogger.success(`Snippet created: ${name} by user ${userId}`, { + operation: "snippet_create_success", + userId, + snippetId: result[0].id, + name, + }); + + res.status(201).json(result[0]); + } catch (err) { + authLogger.error("Failed to create snippet", err); + res.status(500).json({ + error: err instanceof Error ? err.message : "Failed to create snippet", + }); + } + }, +); + +// Update a snippet +// PUT /snippets/:id +router.put( + "/:id", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as any).userId; + const { id } = req.params; + const updateData = req.body; + + if (!isNonEmptyString(userId) || !id) { + authLogger.warn("Invalid request for snippet update"); + return res.status(400).json({ error: "Invalid request" }); + } + + try { + const existing = await db + .select() + .from(snippets) + .where( + and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)), + ); + + if (existing.length === 0) { + return res.status(404).json({ error: "Snippet not found" }); + } + + const updateFields: any = { + updatedAt: sql`CURRENT_TIMESTAMP`, + }; + + if (updateData.name !== undefined) + updateFields.name = updateData.name.trim(); + if (updateData.content !== undefined) + updateFields.content = updateData.content.trim(); + if (updateData.description !== undefined) + updateFields.description = updateData.description?.trim() || null; + + await db + .update(snippets) + .set(updateFields) + .where( + and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)), + ); + + const updated = await db + .select() + .from(snippets) + .where(eq(snippets.id, parseInt(id))); + + authLogger.success( + `Snippet updated: ${updated[0].name} by user ${userId}`, + { + operation: "snippet_update_success", + userId, + snippetId: parseInt(id), + name: updated[0].name, + }, + ); + + res.json(updated[0]); + } catch (err) { + authLogger.error("Failed to update snippet", err); + res.status(500).json({ + error: err instanceof Error ? err.message : "Failed to update snippet", + }); + } + }, +); + +// Delete a snippet +// DELETE /snippets/:id +router.delete( + "/:id", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as any).userId; + const { id } = req.params; + + if (!isNonEmptyString(userId) || !id) { + authLogger.warn("Invalid request for snippet delete"); + return res.status(400).json({ error: "Invalid request" }); + } + + try { + const existing = await db + .select() + .from(snippets) + .where( + and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)), + ); + + if (existing.length === 0) { + return res.status(404).json({ error: "Snippet not found" }); + } + + await db + .delete(snippets) + .where( + and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)), + ); + + authLogger.success( + `Snippet deleted: ${existing[0].name} by user ${userId}`, + { + operation: "snippet_delete_success", + userId, + snippetId: parseInt(id), + name: existing[0].name, + }, + ); + + res.json({ success: true }); + } catch (err) { + authLogger.error("Failed to delete snippet", err); + res.status(500).json({ + error: err instanceof Error ? err.message : "Failed to delete snippet", + }); + } + }, +); + +export default router; diff --git a/src/backend/utils/simple-db-ops.ts b/src/backend/utils/simple-db-ops.ts index c324e0a2..60416396 100644 --- a/src/backend/utils/simple-db-ops.ts +++ b/src/backend/utils/simple-db-ops.ts @@ -2,7 +2,7 @@ import { getDb, DatabaseSaveTrigger } from "../database/db/index.js"; import { DataCrypto } from "./data-crypto.js"; import type { SQLiteTable } from "drizzle-orm/sqlite-core"; -type TableName = "users" | "ssh_data" | "ssh_credentials"; +type TableName = "users" | "ssh_data" | "ssh_credentials" | "snippets"; class SimpleDBOps { static async insert>( diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index eb6aaaeb..5c8f046a 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -305,6 +305,8 @@ "delete": "Delete", "edit": "Edit", "add": "Add", + "create": "Create", + "update": "Update", "search": "Search", "loading": "Loading...", "error": "Error", @@ -717,6 +719,35 @@ "submit": "Submit", "cancel": "Cancel" }, + "snippets": { + "title": "Snippets", + "new": "New", + "create": "Create Snippet", + "edit": "Edit Snippet", + "run": "Run", + "empty": "No snippets yet", + "emptyHint": "Create a snippet to save commonly used commands", + "name": "Name", + "description": "Description", + "content": "Command", + "namePlaceholder": "e.g., Restart Nginx", + "descriptionPlaceholder": "Optional description", + "contentPlaceholder": "e.g., sudo systemctl restart nginx", + "nameRequired": "Name is required", + "contentRequired": "Command is required", + "createDescription": "Create a new command snippet for quick execution", + "editDescription": "Edit this command snippet", + "deleteConfirmTitle": "Delete Snippet", + "deleteConfirmDescription": "Are you sure you want to delete \"{{name}}\"?", + "createSuccess": "Snippet created successfully", + "updateSuccess": "Snippet updated successfully", + "deleteSuccess": "Snippet deleted successfully", + "createFailed": "Failed to create snippet", + "updateFailed": "Failed to update snippet", + "deleteFailed": "Failed to delete snippet", + "failedToFetch": "Failed to fetch snippets", + "executeSuccess": "Executing: {{name}}" + }, "fileManager": { "title": "File Manager", "file": "File", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 43fb04f1..6556453c 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -298,6 +298,8 @@ "delete": "删除", "edit": "编辑", "add": "添加", + "create": "创建", + "update": "更新", "search": "搜索", "confirm": "确认", "yes": "是", @@ -738,6 +740,35 @@ "submit": "提交", "cancel": "取消" }, + "snippets": { + "title": "代码片段", + "new": "新建", + "create": "创建代码片段", + "edit": "编辑代码片段", + "run": "运行", + "empty": "暂无代码片段", + "emptyHint": "创建代码片段以保存常用命令", + "name": "名称", + "description": "描述", + "content": "命令", + "namePlaceholder": "例如: 重启 Nginx", + "descriptionPlaceholder": "可选描述", + "contentPlaceholder": "例如: sudo systemctl restart nginx", + "nameRequired": "名称不能为空", + "contentRequired": "命令不能为空", + "createDescription": "创建新的命令片段以便快速执行", + "editDescription": "编辑此命令片段", + "deleteConfirmTitle": "删除代码片段", + "deleteConfirmDescription": "确定要删除 \"{{name}}\" 吗?", + "createSuccess": "代码片段创建成功", + "updateSuccess": "代码片段更新成功", + "deleteSuccess": "代码片段删除成功", + "createFailed": "创建代码片段失败", + "updateFailed": "更新代码片段失败", + "deleteFailed": "删除代码片段失败", + "failedToFetch": "获取代码片段失败", + "executeSuccess": "正在执行: {{name}}" + }, "fileManager": { "title": "文件管理器", "file": "文件", diff --git a/src/types/index.ts b/src/types/index.ts index 05a3405b..ad703c5f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -97,6 +97,26 @@ export interface CredentialData { keyType?: string; } +// ============================================================================ +// SNIPPET TYPES +// ============================================================================ + +export interface Snippet { + id: number; + userId: string; + name: string; + content: string; + description?: string; + createdAt: string; + updatedAt: string; +} + +export interface SnippetData { + name: string; + content: string; + description?: string; +} + // ============================================================================ // TUNNEL TYPES // ============================================================================ diff --git a/src/ui/Desktop/Apps/Terminal/SnippetsSidebar.tsx b/src/ui/Desktop/Apps/Terminal/SnippetsSidebar.tsx new file mode 100644 index 00000000..c433cb33 --- /dev/null +++ b/src/ui/Desktop/Apps/Terminal/SnippetsSidebar.tsx @@ -0,0 +1,305 @@ +import React, { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Plus, Play, Edit, Trash2, X } from "lucide-react"; +import axios from "axios"; +import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; +import { useConfirmation } from "@/hooks/use-confirmation.ts"; +import type { Snippet, SnippetData } from "../../../../types/index.js"; + +interface SnippetsSidebarProps { + isOpen: boolean; + onClose: () => void; + onExecute: (content: string) => void; +} + +export function SnippetsSidebar({ + isOpen, + onClose, + onExecute, +}: SnippetsSidebarProps) { + const { t } = useTranslation(); + const { confirmWithToast } = useConfirmation(); + const [snippets, setSnippets] = useState([]); + const [loading, setLoading] = useState(true); + const [showDialog, setShowDialog] = useState(false); + const [editingSnippet, setEditingSnippet] = useState(null); + const [formData, setFormData] = useState({ + name: "", + content: "", + description: "", + }); + const [formErrors, setFormErrors] = useState({ + name: false, + content: false, + }); + + useEffect(() => { + if (isOpen) { + fetchSnippets(); + } + }, [isOpen]); + + const fetchSnippets = async () => { + try { + setLoading(true); + const response = await axios.get("/snippets"); + // Defensive: ensure response.data is an array + setSnippets(Array.isArray(response.data) ? response.data : []); + } catch (err) { + toast.error(t("snippets.failedToFetch")); + setSnippets([]); + } finally { + setLoading(false); + } + }; + + const handleCreate = () => { + setEditingSnippet(null); + setFormData({ name: "", content: "", description: "" }); + setFormErrors({ name: false, content: false }); + setShowDialog(true); + }; + + const handleEdit = (snippet: Snippet) => { + setEditingSnippet(snippet); + setFormData({ + name: snippet.name, + content: snippet.content, + description: snippet.description || "", + }); + setFormErrors({ name: false, content: false }); + setShowDialog(true); + }; + + const handleDelete = async (snippet: Snippet) => { + const confirmed = await confirmWithToast({ + title: t("snippets.deleteConfirmTitle"), + description: t("snippets.deleteConfirmDescription", { + name: snippet.name, + }), + }); + + if (confirmed) { + try { + await axios.delete(`/snippets/${snippet.id}`); + toast.success(t("snippets.deleteSuccess")); + fetchSnippets(); + } catch (err) { + toast.error(t("snippets.deleteFailed")); + } + } + }; + + const handleSubmit = async () => { + // Validate required fields + const errors = { + name: !formData.name.trim(), + content: !formData.content.trim(), + }; + + setFormErrors(errors); + + if (errors.name || errors.content) { + return; + } + + try { + if (editingSnippet) { + await axios.put(`/snippets/${editingSnippet.id}`, formData); + toast.success(t("snippets.updateSuccess")); + } else { + await axios.post("/snippets", formData); + toast.success(t("snippets.createSuccess")); + } + setShowDialog(false); + fetchSnippets(); + } catch (err) { + toast.error( + editingSnippet ? t("snippets.updateFailed") : t("snippets.createFailed"), + ); + } + }; + + const handleExecute = (snippet: Snippet) => { + onExecute(snippet.content); + toast.success(t("snippets.executeSuccess", { name: snippet.name })); + }; + + if (!isOpen) return null; + + return ( + <> + {/* Sidebar - absolutely positioned, doesn't affect terminal layout */} +
+ {/* Header */} +
+

{t("snippets.title")}

+
+ + +
+
+ + {/* Snippets List */} + + {loading ? ( +
+ {t("common.loading")} +
+ ) : snippets.length === 0 ? ( +
+

{t("snippets.empty")}

+

{t("snippets.emptyHint")}

+
+ ) : ( +
+ {snippets.map((snippet) => ( + + + + {snippet.name} + + {snippet.description && ( + + {snippet.description} + + )} + + +
+ + {snippet.content} + +
+
+ + + +
+
+
+ ))} +
+ )} +
+
+ + {/* Create/Edit Dialog - centered modal */} + {showDialog && ( +
+
+
+

+ {editingSnippet ? t("snippets.edit") : t("snippets.create")} +

+

+ {editingSnippet + ? t("snippets.editDescription") + : t("snippets.createDescription")} +

+
+
+
+ + + setFormData({ ...formData, name: e.target.value }) + } + placeholder={t("snippets.namePlaceholder")} + className={formErrors.name ? "border-destructive" : ""} + /> + {formErrors.name && ( +

+ {t("snippets.nameRequired")} +

+ )} +
+
+ + + setFormData({ ...formData, description: e.target.value }) + } + placeholder={t("snippets.descriptionPlaceholder")} + /> +
+
+ +