diff --git a/src/components/SecretForm.enhanced.test.tsx b/src/components/SecretForm.enhanced.test.tsx new file mode 100644 index 0000000..4b4b9d6 --- /dev/null +++ b/src/components/SecretForm.enhanced.test.tsx @@ -0,0 +1,287 @@ +// SPDX-FileCopyrightText: 2025 SecPal +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { SecretForm } from "./SecretForm"; + +describe("SecretForm", () => { + const mockOnSubmit = vi.fn(); + const mockOnCancel = vi.fn(); + + const defaultProps = { + onSubmit: mockOnSubmit, + onCancel: mockOnCancel, + submitLabel: "Save", + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("Password Generator", () => { + it("should generate password when generate button clicked", async () => { + render(); + + const passwordInput = screen.getByLabelText("Password", { selector: "input" }); + const generateButton = screen.getByLabelText(/generate password/i); + + expect(passwordInput).toHaveValue(""); + + await userEvent.click(generateButton); + + expect(passwordInput).not.toHaveValue(""); + const generatedPassword = (passwordInput as HTMLInputElement).value; + expect(generatedPassword).toHaveLength(16); + }); + + it("should show password strength indicator when password is entered", async () => { + render(); + + const passwordInput = screen.getByLabelText("Password", { selector: "input" }); + + // Type weak password + await userEvent.type(passwordInput, "abc"); + + await waitFor(() => { + expect(screen.getByText(/weak/i)).toBeInTheDocument(); + }); + }); + + it("should update strength indicator as password improves", async () => { + render(); + + const passwordInput = screen.getByLabelText("Password", { selector: "input" }); + + // Type weak password + await userEvent.type(passwordInput, "abc"); + await waitFor(() => { + expect(screen.getByText(/weak/i)).toBeInTheDocument(); + }); + + // Improve to strong password + await userEvent.clear(passwordInput); + await userEvent.type(passwordInput, "MyP@ssw0rd!"); + await waitFor(() => { + expect(screen.getByText(/strong/i)).toBeInTheDocument(); + }); + }); + + it("should toggle password visibility", async () => { + render(); + + const passwordInput = screen.getByLabelText( + "Password", { selector: "input" } + ) as HTMLInputElement; + const toggleButton = screen.getByLabelText(/show password/i); + + expect(passwordInput.type).toBe("password"); + + await userEvent.click(toggleButton); + expect(passwordInput.type).toBe("text"); + + await userEvent.click(toggleButton); + expect(passwordInput.type).toBe("password"); + }); + }); + + describe("Tag Input", () => { + it("should add tag when Add button clicked", async () => { + render(); + + const tagInput = screen.getByPlaceholderText(/enter tag name/i); + const addButton = screen.getByRole("button", { name: /add/i }); + + await userEvent.type(tagInput, "work"); + await userEvent.click(addButton); + + expect(screen.getByText(/#work/i)).toBeInTheDocument(); + expect(tagInput).toHaveValue(""); + }); + + it("should add tag when Enter key pressed", async () => { + render(); + + const tagInput = screen.getByPlaceholderText(/enter tag name/i); + + await userEvent.type(tagInput, "email{Enter}"); + + expect(screen.getByText(/#email/i)).toBeInTheDocument(); + expect(tagInput).toHaveValue(""); + }); + + it("should remove tag when X button clicked", async () => { + render( + + ); + + const removeButton = screen.getByLabelText(/remove tag work/i); + await userEvent.click(removeButton); + + expect(screen.queryByText(/#work/i)).not.toBeInTheDocument(); + expect(screen.getByText(/#email/i)).toBeInTheDocument(); + }); + + it("should remove last tag on Backspace when input empty", async () => { + render( + + ); + + const tagInput = screen.getByPlaceholderText(/enter tag name/i); + + // Focus input and press Backspace + await userEvent.click(tagInput); + await userEvent.keyboard("{Backspace}"); + + // Last tag (email) should be removed + expect(screen.queryByText(/#email/i)).not.toBeInTheDocument(); + expect(screen.getByText(/#work/i)).toBeInTheDocument(); + }); + + it("should not add duplicate tags", async () => { + render( + + ); + + const tagInput = screen.getByPlaceholderText(/enter tag name/i); + + await userEvent.type(tagInput, "work{Enter}"); + + // Should still have only one "work" tag + const workTags = screen.getAllByText(/#work/i); + expect(workTags).toHaveLength(1); + }); + + it("should trim whitespace from tags", async () => { + render(); + + const tagInput = screen.getByPlaceholderText(/enter tag name/i); + + await userEvent.type(tagInput, " work {Enter}"); + + expect(screen.getByText(/#work/i)).toBeInTheDocument(); + }); + }); + + describe("Expiration Date Picker", () => { + it("should set expiration date", async () => { + render(); + + const dateInput = screen.getByLabelText(/expiration date/i); + + await userEvent.type(dateInput, "2025-12-31"); + + expect(dateInput).toHaveValue("2025-12-31"); + }); + + it("should submit form with expiration date in ISO format", async () => { + render(); + + const titleInput = screen.getByLabelText(/title/i); + const dateInput = screen.getByLabelText(/expiration date/i); + const submitButton = screen.getByRole("button", { name: /save/i }); + + await userEvent.type(titleInput, "Test Secret"); + await userEvent.type(dateInput, "2025-12-31"); + await userEvent.click(submitButton); + + await waitFor(() => { + expect(mockOnSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Test Secret", + expires_at: "2025-12-31T23:59:59Z", + }) + ); + }); + }); + + it("should load initial expiration date", () => { + render( + + ); + + const dateInput = screen.getByLabelText( + /expiration date/i + ) as HTMLInputElement; + expect(dateInput.value).toBe("2025-12-31"); + }); + + it("should have minimum date set to today", () => { + render(); + + const dateInput = screen.getByLabelText( + /expiration date/i + ) as HTMLInputElement; + const today = new Date().toISOString().split("T")[0]; + + expect(dateInput.min).toBe(today); + }); + }); + + describe("Form Submission", () => { + it("should include all new features in submission", async () => { + render(); + + const titleInput = screen.getByLabelText(/title/i); + const passwordInput = screen.getByLabelText("Password", { selector: "input" }); + const tagInput = screen.getByPlaceholderText(/enter tag name/i); + const dateInput = screen.getByLabelText(/expiration date/i); + const submitButton = screen.getByRole("button", { name: /save/i }); + + // Fill form + await userEvent.type(titleInput, "Test Secret"); + await userEvent.type(passwordInput, "MyP@ssw0rd!"); + await userEvent.type(tagInput, "work{Enter}"); + await userEvent.type(tagInput, "important{Enter}"); + await userEvent.type(dateInput, "2025-12-31"); + await userEvent.click(submitButton); + + await waitFor(() => { + expect(mockOnSubmit).toHaveBeenCalledWith({ + title: "Test Secret", + username: "", + password: "MyP@ssw0rd!", + url: "", + notes: "", + tags: ["work", "important"], + expires_at: "2025-12-31T23:59:59Z", + }); + }); + }); + }); + + describe("Accessibility", () => { + it("should have proper labels for new fields", () => { + render(); + + expect(screen.getByLabelText(/generate password/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/expiration date/i)).toBeInTheDocument(); + expect( + screen.getByPlaceholderText(/enter tag name/i) + ).toBeInTheDocument(); + }); + + it("should have keyboard navigation for tags", async () => { + render(); + + const tagInput = screen.getByPlaceholderText(/enter tag name/i); + + // Add tags with keyboard + await userEvent.type(tagInput, "work{Enter}"); + await userEvent.type(tagInput, "email{Enter}"); + + expect(screen.getByText(/#work/i)).toBeInTheDocument(); + expect(screen.getByText(/#email/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/SecretForm.tsx b/src/components/SecretForm.tsx index a5e0604..1ae67ac 100644 --- a/src/components/SecretForm.tsx +++ b/src/components/SecretForm.tsx @@ -1,8 +1,9 @@ // SPDX-FileCopyrightText: 2025 SecPal // SPDX-License-Identifier: AGPL-3.0-or-later -import { useState, FormEvent } from "react"; +import { useState, FormEvent, useMemo } from "react"; import { Button } from "@headlessui/react"; +import { generatePassword, assessPasswordStrength } from "../lib/passwordUtils"; export interface SecretFormData { title: string; @@ -23,6 +24,39 @@ export interface SecretFormProps { error?: string; } +// Helper functions for password strength indicator styling +type PasswordStrengthLevel = "weak" | "medium" | "strong" | "very-strong"; + +function getStrengthStyles(strength: PasswordStrengthLevel): string { + const styles: Record = { + weak: "w-1/4 bg-red-500", + medium: "w-2/4 bg-yellow-500", + strong: "w-3/4 bg-green-500", + "very-strong": "w-full bg-green-600", + }; + return styles[strength] ?? styles.weak; +} + +function getStrengthTextColor(strength: PasswordStrengthLevel): string { + const colors: Record = { + weak: "text-red-600 dark:text-red-400", + medium: "text-yellow-600 dark:text-yellow-400", + strong: "text-green-600 dark:text-green-400", + "very-strong": "text-green-700 dark:text-green-300", + }; + return colors[strength] ?? colors.weak; +} + +function getStrengthLabel(strength: PasswordStrengthLevel): string { + const labels: Record = { + weak: "Weak", + medium: "Medium", + strong: "Strong", + "very-strong": "Very Strong", + }; + return labels[strength] ?? labels.weak; +} + /** * Reusable form component for creating and editing secrets * @@ -53,6 +87,54 @@ export function SecretForm({ const [showPassword, setShowPassword] = useState(false); const [validationError, setValidationError] = useState(""); + const [tagInput, setTagInput] = useState(""); + + // Memoize password strength to avoid recalculating on every render + const passwordStrength = useMemo( + () => + formData.password ? assessPasswordStrength(formData.password) : null, + [formData.password] + ); + + const handleGeneratePassword = () => { + const newPassword = generatePassword(16); + setFormData((prev) => ({ ...prev, password: newPassword })); + }; + + const handleAddTag = () => { + const tag = tagInput.trim(); + if (tag && !formData.tags.includes(tag)) { + setFormData((prev) => ({ + ...prev, + tags: [...prev.tags, tag], + })); + setTagInput(""); + } + }; + + const handleRemoveTag = (tagToRemove: string) => { + setFormData((prev) => ({ + ...prev, + tags: prev.tags.filter((tag) => tag !== tagToRemove), + })); + }; + + const handleTagInputKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAddTag(); + } else if ( + e.key === "Backspace" && + tagInput === "" && + formData.tags.length > 0 + ) { + // Remove last tag on backspace when input is empty + const lastTag = formData.tags[formData.tags.length - 1]; + if (lastTag) { + handleRemoveTag(lastTag); + } + } + }; const handleSubmit = async (e: FormEvent) => { e.preventDefault(); @@ -146,7 +228,47 @@ export function SecretForm({ > {showPassword ? "Hide" : "Show"} + + + {/* Password Strength Indicator */} + {passwordStrength && formData.password.length > 0 && ( +
+
+
+
+
+ + {getStrengthLabel( + passwordStrength.strength as PasswordStrengthLevel + )} + +
+ {passwordStrength.feedback.length > 0 && ( +
    + {passwordStrength.feedback.map((fb, idx) => ( +
  • • {fb}
  • + ))} +
+ )} +
+ )}
{/* URL */} @@ -186,6 +308,87 @@ export function SecretForm({ /> + {/* Tags */} +
+ +
+ {/* Display existing tags */} + {formData.tags.length > 0 && ( +
+ {formData.tags.map((tag) => ( + + #{tag} + + + ))} +
+ )} + {/* Tag input */} +
+ setTagInput(e.target.value)} + onKeyDown={handleTagInputKeyDown} + disabled={isSubmitting} + placeholder="Enter tag name" + 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" + /> + +
+

+ Press Enter to add a tag +

+
+
+ + {/* Expiration Date */} +
+ + + handleChange( + "expires_at", + e.target.value ? `${e.target.value}T23:59:59Z` : "" + ) + } + disabled={isSubmitting} + min={new Date().toISOString().split("T")[0]} + 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" + /> +
+ {/* Action Buttons */}