--- phase: 32-setup-sharing-system plan: 03 type: execute wave: 3 depends_on: [01, 02] files_modified: - src/client/components/ShareModal.tsx - src/client/hooks/useShares.ts - src/client/routes/setups/$setupId.tsx autonomous: true requirements: - TBD must_haves: truths: - "Share button on setup detail page reflects current visibility state (lock/link/globe icon with state color)" - "Clicking share button opens the share modal" - "Share modal shows visibility picker with three options (private/link/public)" - "Changing visibility in modal immediately updates via API" - "Share modal shows create link form when visibility is link or public" - "Creating a share link auto-copies to clipboard and shows in links list" - "Each share link has copy and revoke actions" - "Switching to private shows deactivation warning" - "Share modal works on both desktop and mobile" artifacts: - path: "src/client/components/ShareModal.tsx" provides: "Share modal with visibility picker, link creation, and link management" exports: ["ShareModal"] - path: "src/client/hooks/useShares.ts" provides: "React Query hooks for share link CRUD" exports: ["useShareLinks", "useCreateShareLink", "useRevokeShareLink"] key_links: - from: "src/client/components/ShareModal.tsx" to: "src/client/hooks/useShares.ts" via: "Share CRUD mutations" pattern: "useShareLinks|useCreateShareLink|useRevokeShareLink" - from: "src/client/routes/setups/$setupId.tsx" to: "src/client/components/ShareModal.tsx" via: "Modal open state and render" pattern: "ShareModal|shareModalOpen" --- Create the share modal component and wire it into the setup detail page, replacing the temporary visibility badge from Plan 01 with a full share button that opens a Google Docs-style share dialog. Purpose: This implements the primary user-facing share UX (D-13 through D-16). Users manage visibility and share links from a single modal. Output: ShareModal component, share hooks, and updated setup detail page. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/32-setup-sharing-system/32-CONTEXT.md @.planning/phases/32-setup-sharing-system/32-UI-SPEC.md @.planning/phases/32-setup-sharing-system/32-01-SUMMARY.md @.planning/phases/32-setup-sharing-system/32-02-SUMMARY.md Share API endpoints (from Plan 02): ``` POST /api/setups/:id/shares → { id, setupId, token, permission, expiresAt, createdAt, revokedAt } GET /api/setups/:id/shares → Array DELETE /api/setups/:id/shares/:shareId → { id, setupId, token, ..., revokedAt } ``` Setup update endpoint (from Plan 01): ``` PUT /api/setups/:id → accepts { name, visibility } → returns updated setup ``` From src/client/lib/api.ts: ```typescript export function apiGet(url: string): Promise; export function apiPost(url: string, body: unknown): Promise; export function apiDelete(url: string): Promise; ``` From src/client/lib/iconData.tsx: ```typescript export function LucideIcon({ name, size, className }: { name: string; size?: number; className?: string }): JSX.Element; // Available icons: lock, link, globe, copy, check, x, alert-triangle, share-2, plus ``` From src/client/hooks/useSetups.ts: ```typescript export function useUpdateSetup(setupId: number): UseMutationResult; ``` Task 1: Create share hooks for React Query src/client/hooks/useShares.ts src/client/hooks/useSetups.ts, src/client/lib/api.ts Create `src/client/hooks/useShares.ts` following existing hook patterns in `useSetups.ts`: ```typescript import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiDelete, apiGet, apiPost } from "../lib/api"; interface ShareLink { id: number; setupId: number; token: string; permission: string; expiresAt: string | null; createdAt: string; revokedAt: string | null; } export function useShareLinks(setupId: number | null) { return useQuery({ queryKey: ["shares", setupId], queryFn: () => apiGet(`/api/setups/${setupId}/shares`), enabled: !!setupId, }); } export function useCreateShareLink(setupId: number) { const queryClient = useQueryClient(); return useMutation({ mutationFn: (data: { expiresInDays: number | null }) => apiPost(`/api/setups/${setupId}/shares`, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["shares", setupId] }); }, }); } export function useRevokeShareLink(setupId: number) { const queryClient = useQueryClient(); return useMutation({ mutationFn: (shareId: number) => apiDelete(`/api/setups/${setupId}/shares/${shareId}`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["shares", setupId] }); }, }); } ``` grep -q "useShareLinks" src/client/hooks/useShares.ts && grep -q "useCreateShareLink" src/client/hooks/useShares.ts && grep -q "useRevokeShareLink" src/client/hooks/useShares.ts && echo "PASS" || echo "FAIL" - `src/client/hooks/useShares.ts` exports `useShareLinks`, `useCreateShareLink`, `useRevokeShareLink` - Hooks follow same patterns as `useSetups.ts` (React Query, apiGet/apiPost/apiDelete) - Query invalidation on mutations targets `["shares", setupId]` key Share hooks created with query and mutation patterns Task 2: Create ShareModal component and wire into setup detail page src/client/components/ShareModal.tsx, src/client/routes/setups/$setupId.tsx src/client/routes/setups/$setupId.tsx, src/client/components/ConfirmDialog.tsx, src/client/components/CreateThreadModal.tsx, src/client/hooks/useSetups.ts, .planning/phases/32-setup-sharing-system/32-UI-SPEC.md **Create `src/client/components/ShareModal.tsx`** following the 32-UI-SPEC.md contract exactly: Props: ```typescript interface ShareModalProps { isOpen: boolean; onClose: () => void; setupId: number; currentVisibility: "private" | "link" | "public"; onVisibilityChange: (visibility: "private" | "link" | "public") => void; } ``` Component structure (all per 32-UI-SPEC.md): 1. **Overlay:** `fixed inset-0 z-50 bg-black/50 flex items-center justify-center`. Click overlay to close. Listen for Escape key. 2. **Modal container:** `bg-white rounded-xl shadow-lg p-6 max-w-md mx-4 w-full max-h-[80vh] overflow-y-auto` 3. **Header:** "Share Setup" in `text-lg font-semibold text-gray-900`, close X button top-right. 4. **Visibility Picker:** Three radio-style buttons in vertical stack with `gap-2`: - Each: `flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors` - Unselected: `border-gray-200 hover:border-gray-300` - Selected: `border-{state-color}-200 bg-{state-color}-50` - Private: lock icon (gray-500), "Private", "Only you can access" - Link: link icon (blue-600), "Link sharing", "Anyone with the link" - Public: globe icon (green-700), "Public", "Visible on your profile" - On click: call `onVisibilityChange(newVisibility)` (immediate API call) 5. **Deactivation warning** (show when selecting private while links exist): - `flex items-start gap-2 p-3 bg-amber-50 border border-amber-200 rounded-lg mt-2` - alert-triangle icon in text-amber-500 - "Switching to private will deactivate all share links. They can be reactivated by switching back." 6. **Share Links Section** (visible when visibility is "link" or "public"): - Divider: `border-t border-gray-100 pt-4 mt-4` - Label: "Share Links" in `text-sm font-medium text-gray-700 mb-3` - Create row: `flex items-center gap-2` - Expiration select: `px-3 py-2 text-sm border border-gray-200 rounded-lg bg-white` - Options: "7 days", "14 days" (default selected), "30 days", "No expiration" - Create button: `px-4 py-2 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg` - Text: "Create Link" - On click: call `createShareLink.mutate({ expiresInDays })`, on success copy the generated URL to clipboard 7. **Active Links List:** For each non-revoked share from `useShareLinks`: - `flex items-center gap-2 p-3 bg-gray-50 rounded-lg mb-2` - URL display: `text-sm text-gray-600 truncate flex-1` showing short URL `{origin}/s/{token.slice(0,8)}...` - Expiration badge: `text-xs text-gray-400` — "Expires {formatted date}" or "No expiration" - Copy button: `p-1.5 text-gray-400 hover:text-gray-600 rounded` with copy icon (16px) - On click: copy full share URL to clipboard, swap icon to check (green-500) for 2 seconds - Revoke button: `p-1.5 text-gray-400 hover:text-red-500 rounded` with x icon (16px) - On click: call `revokeShareLink.mutate(shareId)` 8. **Empty state** (no active links): "No share links yet" in `text-sm text-gray-400 text-center py-4` **Clipboard helper:** Use `navigator.clipboard.writeText(url)`. Construct full URL as `${window.location.origin}/s/${share.token}`. **Update `src/client/routes/setups/$setupId.tsx`:** 1. Add import: `import { ShareModal } from "../../components/ShareModal";` 2. Add state: `const [shareModalOpen, setShareModalOpen] = useState(false);` 3. Replace the temporary visibility badge (from Plan 01) with the share button per UI-SPEC: **Desktop variant:** ```tsx ``` **Mobile variant:** ```tsx ``` 4. Render ShareModal: ```tsx {shareModalOpen && ( setShareModalOpen(false)} setupId={numericId} currentVisibility={setup.visibility} onVisibilityChange={(v) => updateSetup.mutate({ visibility: v })} /> )} ``` 5. Only show share button when `isAuthenticated` (same guard as current toggle). grep -q "ShareModal" src/client/components/ShareModal.tsx && grep -q "ShareModal" src/client/routes/setups/\$setupId.tsx && bun run lint && echo "PASS" || echo "FAIL" - `src/client/components/ShareModal.tsx` renders: visibility picker with 3 options, create link form, active links list - Visibility picker options use correct icons: lock (private), link (link), globe (public) - Visibility picker colors match UI-SPEC: gray-500/gray-50, blue-600/blue-50, green-700/green-50 - Create link form has expiration dropdown with options: 7 days, 14 days, 30 days, No expiration - Copy button copies `${origin}/s/${token}` to clipboard and shows check icon for 2s - Revoke button calls delete mutation - Deactivation warning shows when selecting private with active links - `src/client/routes/setups/$setupId.tsx` renders share button with visibility-state icon/color - Share button opens ShareModal on click - `bun run lint` passes Share modal fully functional with visibility management, link creation, copy, and revoke ## Trust Boundaries | Boundary | Description | |----------|-------------| | client->clipboard | Share URL written to system clipboard | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-32-08 | Information Disclosure | clipboard copy | accept | Share URLs are intentionally shareable — copying to clipboard is the feature's purpose | 1. `bun run lint` passes 2. Share button visible on setup detail page with correct icon/color per visibility state 3. Modal opens, visibility picker works, create link generates copyable URL 4. Revoking a link removes it from the list 5. Switching to private shows warning and deactivates links - Share modal is the single UI for managing visibility and share links (per D-13) - Share icon button replaces old globe toggle (per D-14) - Modal contains visibility picker, create link, and active links list (per D-15) - Works on both desktop and mobile (per D-16) After completion, create `.planning/phases/32-setup-sharing-system/32-03-SUMMARY.md`