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")} + /> +
+
+ +