From 58da36bc85aa865cd5591f7ceed5bb2c3c291d08 Mon Sep 17 00:00:00 2001 From: Holger Schmermbeck Date: Sat, 22 Nov 2025 09:26:30 +0100 Subject: [PATCH 1/6] feat: implement secret create/edit forms (Phase 2) - Add reusable SecretForm component with validation - Add SecretCreate page for new secrets - Add SecretEdit page for updating existing secrets - Extend secretApi with createSecret() and updateSecret() - Add routes: /secrets/new, /secrets/:id/edit - 28 new tests (all passing) - TypeScript & ESLint clean Implements #193 --- src/App.tsx | 4 + src/components/SecretForm.test.tsx | 195 +++++++++++++++++ src/components/SecretForm.tsx | 211 +++++++++++++++++++ src/pages/Secrets/SecretCreate.test.tsx | 123 +++++++++++ src/pages/Secrets/SecretCreate.tsx | 74 +++++++ src/pages/Secrets/SecretEdit.test.tsx | 194 +++++++++++++++++ src/pages/Secrets/SecretEdit.tsx | 143 +++++++++++++ src/services/secretApi.create.test.ts | 269 ++++++++++++++++++++++++ src/services/secretApi.ts | 119 +++++++++++ 9 files changed, 1332 insertions(+) create mode 100644 src/components/SecretForm.test.tsx create mode 100644 src/components/SecretForm.tsx create mode 100644 src/pages/Secrets/SecretCreate.test.tsx create mode 100644 src/pages/Secrets/SecretCreate.tsx create mode 100644 src/pages/Secrets/SecretEdit.test.tsx create mode 100644 src/pages/Secrets/SecretEdit.tsx create mode 100644 src/services/secretApi.create.test.ts diff --git a/src/App.tsx b/src/App.tsx index 8e06204..6f6baf3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,8 @@ import { SyncStatusIndicator } from "./components/SyncStatusIndicator"; import { ShareTarget } from "./pages/ShareTarget"; import { SecretList } from "./pages/Secrets/SecretList"; import { SecretDetail } from "./pages/Secrets/SecretDetail"; +import { SecretCreate } from "./pages/Secrets/SecretCreate"; +import { SecretEdit } from "./pages/Secrets/SecretEdit"; import { getApiBaseUrl } from "./config"; function Home() { @@ -68,7 +70,9 @@ function App() { } /> } /> } /> + } /> } /> + } /> diff --git a/src/components/SecretForm.test.tsx b/src/components/SecretForm.test.tsx new file mode 100644 index 0000000..ad7a72f --- /dev/null +++ b/src/components/SecretForm.test.tsx @@ -0,0 +1,195 @@ +// SPDX-FileCopyrightText: 2025 SecPal +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { SecretForm } from "./SecretForm"; + +describe("SecretForm", () => { + it("should render all form fields", () => { + const mockOnSubmit = vi.fn(); + const mockOnCancel = vi.fn(); + + render( + + ); + + expect(screen.getByLabelText(/^title/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/^username/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/^password$/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/^url/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/^notes/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /create/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument(); + }); + + it("should validate required title field", async () => { + const mockOnSubmit = vi.fn(); + const mockOnCancel = vi.fn(); + + render( + + ); + + // Try to submit without filling title + const titleInput = screen.getByLabelText(/^title/i); + + // HTML5 validation should prevent submission + expect(titleInput).toBeRequired(); + expect(titleInput).toHaveValue(""); + + expect(mockOnSubmit).not.toHaveBeenCalled(); + }); + + it("should call onSubmit with form data", async () => { + const mockOnSubmit = vi.fn(); + const mockOnCancel = vi.fn(); + + render( + + ); + + fireEvent.change(screen.getByLabelText(/^title/i), { + target: { value: "Gmail Account" }, + }); + fireEvent.change(screen.getByLabelText(/^username/i), { + target: { value: "user@example.com" }, + }); + fireEvent.change(screen.getByLabelText(/^password$/i), { + target: { value: "super-secret-123" }, + }); + + const submitButton = screen.getByRole("button", { name: /create/i }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(mockOnSubmit).toHaveBeenCalledWith({ + title: "Gmail Account", + username: "user@example.com", + password: "super-secret-123", + url: "", + notes: "", + tags: [], + expires_at: undefined, + }); + }); + }); + + it("should call onCancel when cancel button clicked", () => { + const mockOnSubmit = vi.fn(); + const mockOnCancel = vi.fn(); + + render( + + ); + + const cancelButton = screen.getByRole("button", { name: /cancel/i }); + fireEvent.click(cancelButton); + + expect(mockOnCancel).toHaveBeenCalled(); + }); + + it("should populate form with initial values", () => { + const mockOnSubmit = vi.fn(); + const mockOnCancel = vi.fn(); + + const initialValues = { + title: "Existing Secret", + username: "existing@example.com", + password: "existing-password", + url: "https://example.com", + notes: "Some notes", + tags: ["work", "email"], + expires_at: "2025-12-31", + }; + + render( + + ); + + expect(screen.getByLabelText(/^title/i)).toHaveValue("Existing Secret"); + expect(screen.getByLabelText(/^username/i)).toHaveValue( + "existing@example.com" + ); + expect(screen.getByLabelText(/^password$/i)).toHaveValue( + "existing-password" + ); + expect(screen.getByLabelText(/^url/i)).toHaveValue("https://example.com"); + expect(screen.getByLabelText(/^notes/i)).toHaveValue("Some notes"); + }); + + it("should show/hide password on toggle", () => { + const mockOnSubmit = vi.fn(); + const mockOnCancel = vi.fn(); + + render( + + ); + + const passwordInput = screen.getByLabelText(/^password$/i); + expect(passwordInput).toHaveAttribute("type", "password"); + + const toggleButton = screen.getByLabelText(/show password/i); + fireEvent.click(toggleButton); + + expect(passwordInput).toHaveAttribute("type", "text"); + }); + + it("should display loading state when submitting", () => { + const mockOnSubmit = vi.fn(); + const mockOnCancel = vi.fn(); + + render( + + ); + + expect(screen.getByText(/creating/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /creating/i })).toBeDisabled(); + }); + + it("should display error message", () => { + const mockOnSubmit = vi.fn(); + const mockOnCancel = vi.fn(); + + render( + + ); + + expect(screen.getByText(/failed to create secret/i)).toBeInTheDocument(); + }); +}); diff --git a/src/components/SecretForm.tsx b/src/components/SecretForm.tsx new file mode 100644 index 0000000..642814f --- /dev/null +++ b/src/components/SecretForm.tsx @@ -0,0 +1,211 @@ +// SPDX-FileCopyrightText: 2025 SecPal +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { useState, FormEvent } from "react"; +import { Button } from "@headlessui/react"; + +export interface SecretFormData { + title: string; + username: string; + password: string; + url: string; + notes: string; + tags: string[]; + expires_at?: string; +} + +export interface SecretFormProps { + onSubmit: (data: SecretFormData) => void | Promise; + onCancel: () => void; + submitLabel: string; + initialValues?: Partial; + isSubmitting?: boolean; + error?: string; +} + +/** + * Reusable form component for creating and editing secrets + * + * Features: + * - All secret fields (title, username, password, URL, notes, tags, expiration) + * - Password show/hide toggle + * - Client-side validation + * - Loading states + * - Error display + */ +export function SecretForm({ + onSubmit, + onCancel, + submitLabel, + initialValues = {}, + isSubmitting = false, + error, +}: SecretFormProps) { + const [formData, setFormData] = useState({ + title: initialValues.title || "", + username: initialValues.username || "", + password: initialValues.password || "", + url: initialValues.url || "", + notes: initialValues.notes || "", + tags: initialValues.tags || [], + expires_at: initialValues.expires_at, + }); + + const [showPassword, setShowPassword] = useState(false); + const [validationError, setValidationError] = useState(""); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setValidationError(""); + + // Validate required fields + if (!formData.title || formData.title.trim() === "") { + setValidationError("Title is required"); + return; + } + + await onSubmit(formData); + }; + + const handleChange = ( + field: keyof SecretFormData, + value: string | string[] + ) => { + setFormData((prev) => ({ ...prev, [field]: value })); + setValidationError(""); // Clear validation error on change + }; + + return ( +
+ {/* Error Message */} + {(error || validationError) && ( +
+ {error || validationError} +
+ )} + + {/* Title (Required) */} +
+ + handleChange("title", e.target.value)} + disabled={isSubmitting} + className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-zinc-900 focus:outline-none focus:ring-1 focus:ring-zinc-900 disabled:bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-800 dark:text-white dark:focus:border-white dark:focus:ring-white" + required + /> +
+ + {/* Username */} +
+ + handleChange("username", e.target.value)} + disabled={isSubmitting} + className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-zinc-900 focus:outline-none focus:ring-1 focus:ring-zinc-900 disabled:bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-800 dark:text-white dark:focus:border-white dark:focus:ring-white" + /> +
+ + {/* Password */} +
+ +
+ handleChange("password", e.target.value)} + disabled={isSubmitting} + className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-zinc-900 focus:outline-none focus:ring-1 focus:ring-zinc-900 disabled:bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-800 dark:text-white dark:focus:border-white dark:focus:ring-white" + /> + +
+
+ + {/* URL */} +
+ + handleChange("url", e.target.value)} + disabled={isSubmitting} + placeholder="https://example.com" + className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-zinc-900 focus:outline-none focus:ring-1 focus:ring-zinc-900 disabled:bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-800 dark:text-white dark:focus:border-white dark:focus:ring-white" + /> +
+ + {/* Notes */} +
+ +