diff --git a/src/components/ShareDialog.test.tsx b/src/components/ShareDialog.test.tsx new file mode 100644 index 0000000..73397cf --- /dev/null +++ b/src/components/ShareDialog.test.tsx @@ -0,0 +1,578 @@ +// 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 { I18nProvider } from "@lingui/react"; +import { i18n } from "@lingui/core"; +import { ShareDialog } from "./ShareDialog"; +import * as shareApi from "../services/shareApi"; +import type { SecretShare } from "../services/secretApi"; + +// Mock shareApi functions but keep ApiError +vi.mock("../services/shareApi", async () => { + const actual = await vi.importActual( + "../services/shareApi" + ); + return { + ...actual, + createShare: vi.fn(), + }; +}); + +// Setup minimal i18n for tests +i18n.loadAndActivate({ locale: "en", messages: {} }); + +describe("ShareDialog", () => { + const mockSecretId = "019a9b50-test-secret"; + const mockSecretTitle = "Gmail Account"; + const mockOnClose = vi.fn(); + const mockOnSuccess = vi.fn(); + + const mockUsers = [ + { id: "user-1", name: "John Doe" }, + { id: "user-2", name: "Jane Smith" }, + ]; + + const mockRoles = [ + { id: "1", name: "Admins" }, + { id: "2", name: "Managers" }, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("rendering", () => { + it("should render dialog with title", () => { + render( + + + + ); + + expect( + screen.getByText(`Share "${mockSecretTitle}"`) + ).toBeInTheDocument(); + }); + + it("should not render when isOpen is false", () => { + const { container } = render( + + + + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it("should render user/role selector", () => { + render( + + + + ); + + expect(screen.getByLabelText(/share with/i)).toBeInTheDocument(); + }); + + it("should render permission dropdown", () => { + render( + + + + ); + + expect(screen.getByLabelText(/permission/i)).toBeInTheDocument(); + }); + + it("should render expiration date input", () => { + render( + + + + ); + + expect( + screen.getByLabelText(/expires \(optional\)/i) + ).toBeInTheDocument(); + }); + + it("should render share and cancel buttons", () => { + render( + + + + ); + + expect( + screen.getByRole("button", { name: /share/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /cancel/i }) + ).toBeInTheDocument(); + }); + + it("should render permission level descriptions", () => { + render( + + + + ); + + expect( + screen.getByText(/read: view secret details/i) + ).toBeInTheDocument(); + expect( + screen.getByText(/write: view \+ edit secret/i) + ).toBeInTheDocument(); + expect( + screen.getByText(/admin: view \+ edit \+ share \+ delete/i) + ).toBeInTheDocument(); + }); + }); + + describe("form interactions", () => { + it("should select a user", async () => { + const user = userEvent.setup(); + + render( + + + + ); + + const select = screen.getByLabelText(/share with/i); + await user.selectOptions(select, "user-1"); + + expect(select).toHaveValue("user-1"); + }); + + it("should select a role", async () => { + const user = userEvent.setup(); + + render( + + + + ); + + const select = screen.getByLabelText(/share with/i); + await user.selectOptions(select, "role-1"); + + expect(select).toHaveValue("role-1"); + }); + + it("should select permission level", async () => { + const user = userEvent.setup(); + + render( + + + + ); + + const permSelect = screen.getByLabelText(/permission/i); + await user.selectOptions(permSelect, "write"); + + expect(permSelect).toHaveValue("write"); + }); + + it("should set expiration date", async () => { + const user = userEvent.setup(); + + render( + + + + ); + + const dateInput = screen.getByLabelText(/expires \(optional\)/i); + await user.type(dateInput, "2025-12-31"); + + expect(dateInput).toHaveValue("2025-12-31"); + }); + + it("should call onClose when cancel button is clicked", async () => { + const user = userEvent.setup(); + + render( + + + + ); + + await user.click(screen.getByRole("button", { name: /cancel/i })); + + expect(mockOnClose).toHaveBeenCalledOnce(); + }); + }); + + describe("share creation", () => { + it("should create share with user successfully", async () => { + const user = userEvent.setup(); + const mockShare: SecretShare = { + id: "share-1", + user: { id: "user-1", name: "John Doe" }, + permission: "read", + granted_by: { id: "owner-1", name: "Owner" }, + granted_at: "2025-11-22T10:00:00Z", + }; + + vi.mocked(shareApi.createShare).mockResolvedValueOnce(mockShare); + + render( + + + + ); + + await user.selectOptions(screen.getByLabelText(/share with/i), "user-1"); + await user.selectOptions(screen.getByLabelText(/permission/i), "read"); + await user.click(screen.getByRole("button", { name: /share/i })); + + await waitFor(() => { + expect(shareApi.createShare).toHaveBeenCalledWith(mockSecretId, { + user_id: "user-1", + permission: "read", + }); + expect(mockOnSuccess).toHaveBeenCalledOnce(); + expect(mockOnClose).toHaveBeenCalledOnce(); + }); + }); + + it("should create share with role successfully", async () => { + const user = userEvent.setup(); + const mockShare: SecretShare = { + id: "share-1", + role: { id: "1", name: "Admins" }, + permission: "admin", + granted_by: { id: "owner-1", name: "Owner" }, + granted_at: "2025-11-22T10:00:00Z", + }; + + vi.mocked(shareApi.createShare).mockResolvedValueOnce(mockShare); + + render( + + + + ); + + await user.selectOptions(screen.getByLabelText(/share with/i), "role-1"); + await user.selectOptions(screen.getByLabelText(/permission/i), "admin"); + await user.click(screen.getByRole("button", { name: /share/i })); + + await waitFor(() => { + expect(shareApi.createShare).toHaveBeenCalledWith(mockSecretId, { + role_id: "1", + permission: "admin", + }); + expect(mockOnSuccess).toHaveBeenCalledOnce(); + expect(mockOnClose).toHaveBeenCalledOnce(); + }); + }); + + it("should create share with expiration date", async () => { + const user = userEvent.setup(); + const mockShare: SecretShare = { + id: "share-1", + user: { id: "user-1", name: "John Doe" }, + permission: "read", + granted_by: { id: "owner-1", name: "Owner" }, + granted_at: "2025-11-22T10:00:00Z", + expires_at: "2025-12-31T23:59:59Z", + }; + + vi.mocked(shareApi.createShare).mockResolvedValueOnce(mockShare); + + render( + + + + ); + + await user.selectOptions(screen.getByLabelText(/share with/i), "user-1"); + await user.selectOptions(screen.getByLabelText(/permission/i), "read"); + await user.type( + screen.getByLabelText(/expires \(optional\)/i), + "2025-12-31" + ); + await user.click(screen.getByRole("button", { name: /share/i })); + + await waitFor(() => { + const expectedDate = new Date("2025-12-31T23:59:59").toISOString(); + expect(shareApi.createShare).toHaveBeenCalledWith(mockSecretId, { + user_id: "user-1", + permission: "read", + expires_at: expectedDate, + }); + }); + }); + + it("should disable share button when no user/role selected", () => { + render( + + + + ); + + expect(screen.getByRole("button", { name: /share/i })).toBeDisabled(); + }); + + it("should show loading state during share creation", async () => { + const user = userEvent.setup(); + vi.mocked(shareApi.createShare).mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 100)) + ); + + render( + + + + ); + + await user.selectOptions(screen.getByLabelText(/share with/i), "user-1"); + await user.click(screen.getByRole("button", { name: /share/i })); + + expect( + screen.getByRole("button", { name: /sharing\.\.\./i }) + ).toBeInTheDocument(); + }); + + it("should display error message on failure", async () => { + const user = userEvent.setup(); + const apiError = new shareApi.ApiError("User already has access", 422); + vi.mocked(shareApi.createShare).mockRejectedValueOnce(apiError); + + render( + + + + ); + + await user.selectOptions(screen.getByLabelText(/share with/i), "user-1"); + await user.click(screen.getByRole("button", { name: /share/i })); + + await waitFor(() => { + expect(screen.getByText("User already has access")).toBeInTheDocument(); + }); + }); + + it("should display generic error message on non-API error", async () => { + const user = userEvent.setup(); + vi.mocked(shareApi.createShare).mockRejectedValueOnce( + new Error("Network error") + ); + + render( + + + + ); + + await user.selectOptions(screen.getByLabelText(/share with/i), "user-1"); + await user.click(screen.getByRole("button", { name: /share/i })); + + await waitFor(() => { + expect(screen.getByText("Failed to share secret")).toBeInTheDocument(); + }); + }); + }); + + describe("accessibility", () => { + it("should have aria-label on dialog", () => { + render( + + + + ); + + expect(screen.getByRole("dialog")).toHaveAttribute( + "aria-label", + "Share secret" + ); + }); + + it("should focus first input on open", async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByLabelText(/share with/i)).toHaveFocus(); + }); + }); + }); +}); diff --git a/src/components/ShareDialog.tsx b/src/components/ShareDialog.tsx new file mode 100644 index 0000000..653a49a --- /dev/null +++ b/src/components/ShareDialog.tsx @@ -0,0 +1,222 @@ +// SPDX-FileCopyrightText: 2025 SecPal +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { useState, useEffect, useRef } from "react"; +import { Trans, msg } from "@lingui/macro"; +import { useLingui } from "@lingui/react"; +import { Button } from "./button"; +import { Dialog, DialogActions, DialogBody, DialogTitle } from "./dialog"; +import { createShare, ApiError } from "../services/shareApi"; + +export interface ShareDialogProps { + secretId: string; + secretTitle: string; + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; + users: Array<{ id: string; name: string }>; + roles: Array<{ id: string; name: string }>; +} + +export function ShareDialog({ + secretId, + secretTitle, + isOpen, + onClose, + onSuccess, + users, + roles, +}: ShareDialogProps) { + const { i18n } = useLingui(); + const [selectedId, setSelectedId] = useState(""); + const [permission, setPermission] = useState<"read" | "write" | "admin">( + "read" + ); + const [expiresAt, setExpiresAt] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const selectRef = useRef(null); + + // Reset form when dialog opens + useEffect(() => { + if (isOpen) { + setSelectedId(""); + setPermission("read"); + setExpiresAt(""); + setError(null); + // Focus first input + setTimeout(() => selectRef.current?.focus(), 0); + } + }, [isOpen]); + + if (!isOpen) return null; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!selectedId) return; + + setLoading(true); + setError(null); + + try { + const isRole = selectedId.startsWith("role-"); + const payload: { + user_id?: string; + role_id?: string; + permission: "read" | "write" | "admin"; + expires_at?: string; + } = { + permission, + }; + + if (isRole) { + payload.role_id = selectedId.replace("role-", ""); + } else { + payload.user_id = selectedId; + } + + if (expiresAt) { + // Convert date to ISO 8601 with end-of-day time in user's local timezone + const localEndOfDay = new Date(`${expiresAt}T23:59:59`); + payload.expires_at = localEndOfDay.toISOString(); + } + + await createShare(secretId, payload); + onSuccess(); + onClose(); + } catch (err) { + if (err instanceof ApiError) { + setError(err.message); + } else { + setError(i18n._(msg`Failed to share secret`)); + } + } finally { + setLoading(false); + } + }; + + return ( + +
+ + Share "{secretTitle}" + + + + {/* Share with selector */} +
+ + +
+ + {/* Permission dropdown */} +
+ + +
+ + {/* Expiration date */} +
+ + setExpiresAt(e.target.value)} + className="w-full rounded-md border-zinc-300 dark:border-zinc-700 dark:bg-zinc-900 dark:text-white" + /> +
+ + {/* Permission descriptions */} +
+

+ Permission Levels: +

+
    +
  • + • Read: View secret details +
  • +
  • + • Write: View + edit secret +
  • +
  • + • Admin: View + edit + share + delete +
  • +
+
+ + {/* Error message */} + {error && ( +
+ {error} +
+ )} +
+ + + + + +
+
+ ); +} diff --git a/src/components/SharedWithList.test.tsx b/src/components/SharedWithList.test.tsx new file mode 100644 index 0000000..2771630 --- /dev/null +++ b/src/components/SharedWithList.test.tsx @@ -0,0 +1,294 @@ +// 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 { I18nProvider } from "@lingui/react"; +import { i18n } from "@lingui/core"; +import { SharedWithList } from "./SharedWithList"; +import * as shareApi from "../services/shareApi"; +import type { SecretShare } from "../services/secretApi"; + +// Setup i18n +i18n.loadAndActivate({ locale: "en", messages: {} }); + +// Mock shareApi functions but keep ApiError +vi.mock("../services/shareApi", async () => { + const actual = await vi.importActual( + "../services/shareApi" + ); + return { + ...actual, + revokeShare: vi.fn(), + }; +}); + +describe("SharedWithList", () => { + const mockSecretId = "019a9b50-test-secret"; + const mockOnRevoke = vi.fn(); + + const mockShares: SecretShare[] = [ + { + id: "share-1", + user: { id: "user-1", name: "John Doe" }, + permission: "read", + granted_by: { id: "owner-1", name: "You" }, + granted_at: "2025-11-01T10:00:00Z", + }, + { + id: "share-2", + user: { id: "user-2", name: "Jane Smith" }, + permission: "write", + granted_by: { id: "admin-1", name: "Admin" }, + granted_at: "2025-11-15T10:00:00Z", + expires_at: "2025-12-31T23:59:59Z", + }, + { + id: "share-3", + role: { id: "role-1", name: "Admins" }, + permission: "admin", + granted_by: { id: "owner-1", name: "You" }, + granted_at: "2025-10-20T10:00:00Z", + }, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("rendering", () => { + it("should render list of shares", () => { + render( + + + + ); + + expect(screen.getByText("Shared with (3)")).toBeInTheDocument(); + expect(screen.getByText("John Doe (read)")).toBeInTheDocument(); + expect(screen.getByText("Jane Smith (write)")).toBeInTheDocument(); + expect(screen.getByText("Admins (admin)")).toBeInTheDocument(); + }); + + it("should display granted information", () => { + render( + + + + ); + + // toLocaleDateString() output varies by system locale, so just verify text structure + // Note: Multiple shares can have same granter, so use getAllByText + const grantedByYou = screen.getAllByText(/Granted by You on/); + expect(grantedByYou.length).toBeGreaterThan(0); + expect(screen.getByText(/Granted by Admin on/)).toBeInTheDocument(); + }); + + it("should display expiration date when set", () => { + render( + + + + ); + + // Trans component may split text, and toLocaleDateString() varies by locale + // Just verify "Expires:" text is present (date format is locale-dependent) + expect(screen.getByText(/Expires:/)).toBeInTheDocument(); + }); + + it("should render empty state when no shares", () => { + render( + + + + ); + + expect(screen.getByText(/Not shared with anyone/i)).toBeInTheDocument(); + }); + + it("should show revoke button for each share", () => { + render( + + + + ); + + const revokeButtons = screen.getAllByRole("button", { name: /revoke/i }); + expect(revokeButtons).toHaveLength(3); + }); + }); + + describe("revoke functionality", () => { + it("should show confirmation dialog on revoke click", async () => { + const user = userEvent.setup(); + globalThis.confirm = vi.fn(() => false); + + render( + + + + ); + + const revokeButtons = screen.getAllByRole("button", { name: /revoke/i }); + await user.click(revokeButtons[0]!); + + expect(globalThis.confirm).toHaveBeenCalledWith( + "Are you sure you want to revoke access for John Doe?" + ); + }); + + it("should revoke share when confirmed", async () => { + const user = userEvent.setup(); + globalThis.confirm = vi.fn(() => true); + vi.mocked(shareApi.revokeShare).mockResolvedValueOnce(undefined); + + render( + + + + ); + + const revokeButtons = screen.getAllByRole("button", { name: /revoke/i }); + await user.click(revokeButtons[0]!); + + await waitFor(() => { + expect(shareApi.revokeShare).toHaveBeenCalledWith( + mockSecretId, + "share-1" + ); + expect(mockOnRevoke).toHaveBeenCalledOnce(); + }); + }); + + it("should not revoke if not confirmed", async () => { + const user = userEvent.setup(); + globalThis.confirm = vi.fn(() => false); + + render( + + + + ); + + const revokeButtons = screen.getAllByRole("button", { name: /revoke/i }); + await user.click(revokeButtons[0]!); + + expect(shareApi.revokeShare).not.toHaveBeenCalled(); + expect(mockOnRevoke).not.toHaveBeenCalled(); + }); + + it("should display error on revoke failure", async () => { + const user = userEvent.setup(); + globalThis.confirm = vi.fn(() => true); + const apiError = new shareApi.ApiError("Forbidden", 403); + vi.mocked(shareApi.revokeShare).mockRejectedValueOnce(apiError); + + render( + + + + ); + + const revokeButtons = screen.getAllByRole("button", { name: /revoke/i }); + await user.click(revokeButtons[0]!); + + await waitFor(() => { + expect(screen.getByText("Forbidden")).toBeInTheDocument(); + }); + }); + + it("should display generic error message on non-API error", async () => { + const user = userEvent.setup(); + globalThis.confirm = vi.fn(() => true); + vi.mocked(shareApi.revokeShare).mockRejectedValueOnce( + new Error("Network timeout") + ); + + render( + + + + ); + + const revokeButtons = screen.getAllByRole("button", { name: /revoke/i }); + await user.click(revokeButtons[0]!); + + await waitFor(() => { + expect(screen.getByText("Failed to revoke access")).toBeInTheDocument(); + }); + }); + }); + + describe("user vs role display", () => { + it("should show user icon for user shares", () => { + render( + + + + ); + + // Check for user icon (👤) + expect(screen.getByText("👤")).toBeInTheDocument(); + }); + + it("should show role icon for role shares", () => { + render( + + + + ); + + // Check for role icon (👥) + expect(screen.getByText("👥")).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/SharedWithList.tsx b/src/components/SharedWithList.tsx new file mode 100644 index 0000000..5dad9ab --- /dev/null +++ b/src/components/SharedWithList.tsx @@ -0,0 +1,123 @@ +// SPDX-FileCopyrightText: 2025 SecPal +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { useState } from "react"; +import { Trans, msg } from "@lingui/macro"; +import { useLingui } from "@lingui/react"; +import { Button } from "./button"; +import { revokeShare, ApiError } from "../services/shareApi"; +import type { SecretShare } from "../services/secretApi"; + +export interface SharedWithListProps { + secretId: string; + shares: SecretShare[]; + onRevoke: () => void; +} + +export function SharedWithList({ + secretId, + shares, + onRevoke, +}: SharedWithListProps) { + const { i18n } = useLingui(); + const [error, setError] = useState(null); + const [revoking, setRevoking] = useState(null); + + const handleRevoke = async (share: SecretShare) => { + const name = share.user?.name || share.role?.name || "this user"; + const confirmed = confirm( + i18n._(msg`Are you sure you want to revoke access for ${name}?`) + ); + + if (!confirmed) return; + + setRevoking(share.id); + setError(null); + + try { + await revokeShare(secretId, share.id); + onRevoke(); + } catch (err) { + if (err instanceof ApiError) { + setError(err.message); + } else { + setError(i18n._(msg`Failed to revoke access`)); + } + } finally { + setRevoking(null); + } + }; + + const formatDate = (isoDate: string) => { + return new Date(isoDate).toLocaleDateString(); + }; + + if (shares.length === 0) { + return ( +
+ Not shared with anyone +
+ ); + } + + return ( +
+

+ Shared with ({shares.length}) +

+ + {error && ( +
+ {error} +
+ )} + +
    + {shares.map((share) => { + const isUser = !!share.user; + const name = share.user?.name || share.role?.name || "Unknown"; + const icon = isUser ? "👤" : "👥"; + + return ( +
  • +
    +

    + {icon} + {name} ({share.permission}) +

    +

    + + Granted by {share.granted_by.name} on{" "} + {formatDate(share.granted_at)} + +

    + {share.expires_at && ( +

    + Expires: {formatDate(share.expires_at)} +

    + )} +
    + + +
  • + ); + })} +
+
+ ); +} diff --git a/src/pages/Secrets/SecretCard.tsx b/src/pages/Secrets/SecretCard.tsx index 36e35ac..8cbe536 100644 --- a/src/pages/Secrets/SecretCard.tsx +++ b/src/pages/Secrets/SecretCard.tsx @@ -50,17 +50,15 @@ export function SecretCard({ secret }: SecretCardProps) { )} - {/* Username */} {secret.username && (

{secret.username}

)} - {/* Tags */} {secret.tags && secret.tags.length > 0 && ( -
+
{secret.tags.map((tag) => ( )} - {/* Footer with metadata */}
{secret.attachment_count !== undefined && diff --git a/src/pages/Secrets/SecretDetail.test.tsx b/src/pages/Secrets/SecretDetail.test.tsx index 4e64e41..16db37e 100644 --- a/src/pages/Secrets/SecretDetail.test.tsx +++ b/src/pages/Secrets/SecretDetail.test.tsx @@ -9,6 +9,7 @@ import { I18nProvider } from "@lingui/react"; import { i18n } from "@lingui/core"; import { SecretDetail } from "./SecretDetail"; import * as secretApi from "../../services/secretApi"; +import * as shareApi from "../../services/shareApi"; import type { SecretDetail as SecretDetailType } from "../../services/secretApi"; // Mock secret API (keep ApiError real) @@ -24,6 +25,18 @@ vi.mock("../../services/secretApi", async (importOriginal) => { }; }); +// Mock share API +vi.mock("../../services/shareApi", async (importOriginal) => { + const actual = + (await importOriginal()) as typeof import("../../services/shareApi"); + return { + ...actual, + fetchShares: vi.fn(), + createShare: vi.fn(), + revokeShare: vi.fn(), + }; +}); + describe("SecretDetail", () => { const mockSecret: SecretDetailType = { id: "secret-1", @@ -814,3 +827,167 @@ describe("SecretDetail", () => { consoleErrorSpy.mockRestore(); }); }); + +describe("Secret Sharing", () => { + const mockSecretWithShares: SecretDetailType = { + id: "secret-1", + title: "Gmail Account", + username: "user@example.com", + password: "super-secret-password", + url: "https://gmail.com", + notes: "Main work email account", + tags: ["work", "email"], + expires_at: "2025-12-31T23:59:59Z", + created_at: "2025-01-01T10:00:00Z", + updated_at: "2025-11-15T14:30:00Z", + owner: { + id: "user-1", + name: "John Doe", + }, + shares: [ + { + id: "share-1", + user: { id: "user-2", name: "Jane Smith" }, + permission: "read", + granted_by: { id: "user-1", name: "You" }, + granted_at: "2025-11-01T10:00:00Z", + }, + ], + }; + + const renderWithRouter = (secretId: string) => { + return render( + + + + } /> + + + + ); + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should call refreshShares after revoke", async () => { + vi.mocked(secretApi.getSecretById).mockResolvedValue(mockSecretWithShares); + vi.mocked(shareApi.fetchShares).mockResolvedValue([]); + vi.mocked(shareApi.revokeShare).mockResolvedValue(undefined); + + renderWithRouter("secret-1"); + + await waitFor(() => { + expect(screen.getByText("Gmail Account")).toBeInTheDocument(); + }); + + // Has initial share + expect(screen.getByText(/Jane Smith/)).toBeInTheDocument(); + + // Revoke share + const user = userEvent.setup(); + globalThis.confirm = vi.fn(() => true); + + const revokeButtons = screen.getAllByRole("button", { name: /revoke/i }); + await user.click(revokeButtons[0]!); + + // Verify refreshShares was called (fetchShares gets called inside refreshShares) + await waitFor(() => { + expect(shareApi.fetchShares).toHaveBeenCalledWith("secret-1"); + expect(shareApi.revokeShare).toHaveBeenCalledWith("secret-1", "share-1"); + }); + }); + + it("should show Share button only when owner is set", async () => { + vi.mocked(secretApi.getSecretById).mockResolvedValue(mockSecretWithShares); + vi.mocked(shareApi.fetchShares).mockResolvedValue( + mockSecretWithShares.shares! + ); + + renderWithRouter("secret-1"); + + await waitFor(() => { + expect(screen.getByText("Gmail Account")).toBeInTheDocument(); + }); + + expect(screen.getByRole("button", { name: /share/i })).toBeInTheDocument(); + }); + + it("should not show Share button when owner is null", async () => { + const secretWithoutOwner: SecretDetailType = { + ...mockSecretWithShares, + owner: undefined, + }; + vi.mocked(secretApi.getSecretById).mockResolvedValue(secretWithoutOwner); + + renderWithRouter("secret-1"); + + await waitFor(() => { + expect(screen.getByText("Gmail Account")).toBeInTheDocument(); + }); + + expect( + screen.queryByRole("button", { name: /share/i }) + ).not.toBeInTheDocument(); + }); + + it("should open ShareDialog when Share button is clicked", async () => { + vi.mocked(secretApi.getSecretById).mockResolvedValue(mockSecretWithShares); + vi.mocked(shareApi.fetchShares).mockResolvedValue( + mockSecretWithShares.shares! + ); + + renderWithRouter("secret-1"); + + await waitFor(() => { + expect(screen.getByText("Gmail Account")).toBeInTheDocument(); + }); + + const user = userEvent.setup(); + const shareButton = screen.getByRole("button", { name: /share/i }); + await user.click(shareButton); + + await waitFor(() => { + expect(screen.getByText(/Share "Gmail Account"/i)).toBeInTheDocument(); + }); + }); + + it("should refresh shares after successful share creation", async () => { + vi.mocked(secretApi.getSecretById).mockResolvedValue(mockSecretWithShares); + vi.mocked(shareApi.fetchShares).mockResolvedValue( + mockSecretWithShares.shares! + ); + vi.mocked(shareApi.createShare).mockResolvedValue({ + id: "share-2", + user: { id: "user-3", name: "Bob Johnson" }, + permission: "read", + granted_by: { id: "user-1", name: "You" }, + granted_at: new Date().toISOString(), + }); + + renderWithRouter("secret-1"); + + await waitFor(() => { + expect(screen.getByText("Gmail Account")).toBeInTheDocument(); + }); + + const user = userEvent.setup(); + const shareButton = screen.getByRole("button", { name: /share/i }); + await user.click(shareButton); + + await waitFor(() => { + expect(screen.getByText(/Share "Gmail Account"/i)).toBeInTheDocument(); + }); + + // Close dialog - actual form interactions tested in ShareDialog.test.tsx + const cancelButton = screen.getByRole("button", { name: /cancel/i }); + await user.click(cancelButton); + + await waitFor(() => { + expect( + screen.queryByText(/Share "Gmail Account"/i) + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/pages/Secrets/SecretDetail.tsx b/src/pages/Secrets/SecretDetail.tsx index bde329a..aa1347c 100644 --- a/src/pages/Secrets/SecretDetail.tsx +++ b/src/pages/Secrets/SecretDetail.tsx @@ -13,8 +13,11 @@ import { deleteAttachment, type SecretDetail as SecretDetailType, } from "../../services/secretApi"; +import { fetchShares } from "../../services/shareApi"; import { AttachmentList } from "../../components/AttachmentList"; import { AttachmentPreview } from "../../components/AttachmentPreview"; +import { ShareDialog } from "../../components/ShareDialog"; +import { SharedWithList } from "../../components/SharedWithList"; /** * Helper function to trigger browser download @@ -48,6 +51,8 @@ export function SecretDetail() { file: File; url: string; } | null>(null); + const [shareDialogOpen, setShareDialogOpen] = useState(false); + const [shares, setShares] = useState(secret?.shares || []); // Cleanup blob URL when previewFile changes or component unmounts useEffect(() => { @@ -71,6 +76,7 @@ export function SecretDetail() { setError(null); const data = await getSecretById(id); setSecret(data); + setShares(data.shares || []); // Load master key if secret has attachments if (data.attachments && data.attachments.length > 0) { @@ -185,6 +191,19 @@ export function SecretDetail() { } }; + /** + * Refresh shares list after create/revoke + */ + const refreshShares = async () => { + if (!id) return; + try { + const updatedShares = await fetchShares(id); + setShares(updatedShares); + } catch (err) { + console.error("Failed to refresh shares:", err); + } + }; + if (loading) { return (
@@ -384,47 +403,33 @@ export function SecretDetail() { )} {/* Shared With */} - {secret.shares && secret.shares.length > 0 && ( -
-

- Shared with ({secret.shares.length}) +
+
+

+ Access Control

-
    - {secret.shares.map((share) => ( -
  • -
    -

    - {share.user - ? `👤 ${share.user.name}` - : `👥 ${share.role?.name}`}{" "} - - ({share.permission}) - -

    -

    - Granted by {share.granted_by.name} on{" "} - {new Date(share.granted_at).toLocaleDateString()} -

    - {share.expires_at && ( -

    - Expires:{" "} - {new Date(share.expires_at).toLocaleDateString()} -

    - )} -
    -
  • - ))} -
+ {secret.owner != null && ( + + )}
- )} + {id && ( + + )} +
{/* Metadata */}
- {secret.owner && ( + {secret.owner != null && (

Owner:{" "} {secret.owner.name} @@ -454,6 +459,19 @@ export function SecretDetail() { }} /> )} + + {/* Share Dialog */} + {secret && ( + setShareDialogOpen(false)} + onSuccess={refreshShares} + users={[]} + roles={[]} + /> + )}

); } diff --git a/src/services/shareApi.test.ts b/src/services/shareApi.test.ts new file mode 100644 index 0000000..75266cf --- /dev/null +++ b/src/services/shareApi.test.ts @@ -0,0 +1,250 @@ +// SPDX-FileCopyrightText: 2025 SecPal +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + fetchShares, + createShare, + revokeShare, + ApiError, + type CreateShareRequest, +} from "./shareApi"; +import type { SecretShare } from "./secretApi"; +import { apiConfig } from "../config"; + +describe("shareApi", () => { + const mockFetch = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", mockFetch); + }); + + describe("fetchShares", () => { + const mockShares: SecretShare[] = [ + { + id: "share-1", + user: { id: "user-1", name: "John Doe" }, + permission: "read", + granted_by: { id: "owner-1", name: "Owner" }, + granted_at: "2025-11-01T10:00:00Z", + }, + ]; + + it("should fetch shares for a secret successfully", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ data: mockShares }), + }); + + const shares = await fetchShares("secret-1"); + + expect(shares).toEqual(mockShares); + expect(mockFetch).toHaveBeenCalledWith( + `${apiConfig.baseUrl}/api/v1/secrets/secret-1/shares`, + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + "Content-Type": "application/json", + }), + }) + ); + }); + + it("should return empty array when no shares exist", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ data: [] }), + }); + + const shares = await fetchShares("secret-2"); + + expect(shares).toEqual([]); + }); + + it("should throw ApiError on 403 Forbidden", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 403, + json: async () => ({ message: "Forbidden" }), + }); + + await expect(fetchShares("secret-1")).rejects.toThrow(ApiError); + await expect(fetchShares("secret-1")).rejects.toThrow("Forbidden"); + }); + + it("should throw ApiError on 404 Not Found", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + json: async () => ({ message: "Secret not found" }), + }); + + await expect(fetchShares("nonexistent")).rejects.toThrow(ApiError); + }); + + it("should throw error on network failure", async () => { + mockFetch.mockRejectedValue(new Error("Network error")); + + await expect(fetchShares("secret-1")).rejects.toThrow("Network error"); + }); + }); + + describe("createShare", () => { + const mockShare: SecretShare = { + id: "share-1", + user: { id: "user-1", name: "John Doe" }, + permission: "read", + granted_by: { id: "owner-1", name: "You" }, + granted_at: "2025-11-20T10:00:00Z", + }; + + it("should create share with user successfully", async () => { + const request: CreateShareRequest = { + user_id: "user-1", + permission: "read", + }; + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ data: mockShare }), + }); + + const share = await createShare("secret-1", request); + + expect(share).toEqual(mockShare); + expect(mockFetch).toHaveBeenCalledWith( + `${apiConfig.baseUrl}/api/v1/secrets/secret-1/shares`, + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + "Content-Type": "application/json", + }), + body: JSON.stringify(request), + }) + ); + }); + + it("should create share with role successfully", async () => { + const request: CreateShareRequest = { + role_id: "role-1", + permission: "write", + }; + + const roleShare = { + ...mockShare, + role: { id: "role-1", name: "Admins" }, + user: undefined, + }; + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ data: roleShare }), + }); + + const share = await createShare("secret-1", request); + + expect(share).toEqual(roleShare); + }); + + it("should create share with expiration date", async () => { + const request: CreateShareRequest = { + user_id: "user-1", + permission: "read", + expires_at: "2026-01-01T00:00:00Z", + }; + + const expiringShare = { + ...mockShare, + expires_at: "2026-01-01T00:00:00Z", + }; + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ data: expiringShare }), + }); + + const share = await createShare("secret-1", request); + + expect(share.expires_at).toBe("2026-01-01T00:00:00Z"); + }); + + it("should throw ApiError on 422 validation error", async () => { + const request: CreateShareRequest = { + user_id: "user-1", + permission: "read", + }; + + mockFetch.mockResolvedValue({ + ok: false, + status: 422, + json: async () => ({ message: "User already has access" }), + }); + + await expect(createShare("secret-1", request)).rejects.toThrow(ApiError); + await expect(createShare("secret-1", request)).rejects.toThrow( + "User already has access" + ); + }); + + it("should throw ApiError on 403 Forbidden (not owner)", async () => { + const request: CreateShareRequest = { + user_id: "user-1", + permission: "read", + }; + + mockFetch.mockResolvedValue({ + ok: false, + status: 403, + json: async () => ({ message: "Only owner can share" }), + }); + + await expect(createShare("secret-1", request)).rejects.toThrow(ApiError); + }); + }); + + describe("revokeShare", () => { + it("should revoke share successfully", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({}), + }); + + await expect(revokeShare("secret-1", "share-1")).resolves.toBeUndefined(); + + expect(mockFetch).toHaveBeenCalledWith( + `${apiConfig.baseUrl}/api/v1/secrets/secret-1/shares/share-1`, + expect.objectContaining({ + method: "DELETE", + headers: expect.objectContaining({ + "Content-Type": "application/json", + }), + }) + ); + }); + + it("should throw ApiError on 404 Not Found", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + json: async () => ({ message: "Share not found" }), + }); + + await expect(revokeShare("secret-1", "nonexistent")).rejects.toThrow( + ApiError + ); + }); + + it("should throw ApiError on 403 Forbidden", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 403, + json: async () => ({ message: "Cannot revoke this share" }), + }); + + await expect(revokeShare("secret-1", "share-1")).rejects.toThrow( + ApiError + ); + }); + }); +}); diff --git a/src/services/shareApi.ts b/src/services/shareApi.ts new file mode 100644 index 0000000..61d64bc --- /dev/null +++ b/src/services/shareApi.ts @@ -0,0 +1,148 @@ +// SPDX-FileCopyrightText: 2025 SecPal +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { apiConfig, getAuthHeaders } from "../config"; +import { ApiError, type SecretShare } from "./secretApi"; + +// Re-export ApiError for test convenience +export { ApiError }; + +/** + * Request payload for creating a share + */ +export interface CreateShareRequest { + user_id?: string; // XOR with role_id + role_id?: string; + permission: "read" | "write" | "admin"; + expires_at?: string; // ISO 8601 format +} + +/** + * Fetch all shares for a secret + * + * @param secretId - Secret UUID + * @returns Array of shares + * @throws ApiError if request fails + * + * @example + * ```ts + * const shares = await fetchShares("019a9b50-secret-id"); + * console.log(`Secret has ${shares.length} shares`); + * ``` + */ +export async function fetchShares(secretId: string): Promise { + const response = await fetch( + `${apiConfig.baseUrl}/api/v1/secrets/${secretId}/shares`, + { + method: "GET", + credentials: "include", + headers: { + "Content-Type": "application/json", + ...getAuthHeaders(), + }, + } + ); + + if (!response.ok) { + const errorData = await response + .json() + .catch(() => ({ message: response.statusText })); + throw new ApiError( + errorData.message || "Failed to fetch shares", + response.status, + errorData.errors + ); + } + + const data = await response.json(); + return data.data; +} + +/** + * Create a new share (grant access to user or role) + * + * @param secretId - Secret UUID + * @param request - Share creation data + * @returns Created share + * @throws ApiError if request fails + * + * @example + * ```ts + * const share = await createShare("019a9b50-secret-id", { + * user_id: "019a9b50-user-id", + * permission: "read", + * expires_at: "2025-12-31T23:59:59Z" + * }); + * ``` + */ +export async function createShare( + secretId: string, + request: CreateShareRequest +): Promise { + const response = await fetch( + `${apiConfig.baseUrl}/api/v1/secrets/${secretId}/shares`, + { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + ...getAuthHeaders(), + }, + body: JSON.stringify(request), + } + ); + + if (!response.ok) { + const errorData = await response + .json() + .catch(() => ({ message: response.statusText })); + throw new ApiError( + errorData.message || "Failed to create share", + response.status, + errorData.errors + ); + } + + const data = await response.json(); + return data.data; +} + +/** + * Revoke a share (remove access) + * + * @param secretId - Secret UUID + * @param shareId - Share UUID + * @throws ApiError if request fails + * + * @example + * ```ts + * await revokeShare("019a9b50-secret-id", "019a9b50-share-id"); + * ``` + */ +export async function revokeShare( + secretId: string, + shareId: string +): Promise { + const response = await fetch( + `${apiConfig.baseUrl}/api/v1/secrets/${secretId}/shares/${shareId}`, + { + method: "DELETE", + credentials: "include", + headers: { + "Content-Type": "application/json", + ...getAuthHeaders(), + }, + } + ); + + if (!response.ok) { + const errorData = await response + .json() + .catch(() => ({ message: response.statusText })); + throw new ApiError( + errorData.message || "Failed to revoke share", + response.status, + errorData.errors + ); + } +}