Files
GearBox/.planning/phases/32-setup-sharing-system/32-03-PLAN.md
Jean-Luc Makiola 81a654085d docs(32): create phase plans for setup sharing system
4 plans in 3 waves:
- Wave 1: Schema migration (isPublic→visibility) + shares table
- Wave 2: Share link service + API routes
- Wave 3: Share modal UI + shared setup viewer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 17:05:36 +02:00

14 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
32-setup-sharing-system 03 execute 3
01
02
src/client/components/ShareModal.tsx
src/client/hooks/useShares.ts
src/client/routes/setups/$setupId.tsx
true
TBD
truths artifacts key_links
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
path provides exports
src/client/components/ShareModal.tsx Share modal with visibility picker, link creation, and link management
ShareModal
path provides exports
src/client/hooks/useShares.ts React Query hooks for share link CRUD
useShareLinks
useCreateShareLink
useRevokeShareLink
from to via pattern
src/client/components/ShareModal.tsx src/client/hooks/useShares.ts Share CRUD mutations useShareLinks|useCreateShareLink|useRevokeShareLink
from to via pattern
src/client/routes/setups/$setupId.tsx src/client/components/ShareModal.tsx Modal open state and render 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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.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<Share>
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:

export function apiGet<T>(url: string): Promise<T>;
export function apiPost<T>(url: string, body: unknown): Promise<T>;
export function apiDelete<T>(url: string): Promise<T>;

From src/client/lib/iconData.tsx:

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:

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`:
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<ShareLink[]>(`/api/setups/${setupId}/shares`),
    enabled: !!setupId,
  });
}

export function useCreateShareLink(setupId: number) {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (data: { expiresInDays: number | null }) =>
      apiPost<ShareLink>(`/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<ShareLink>(`/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:

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:

<button
  type="button"
  onClick={() => setShareModalOpen(true)}
  className={`hidden md:inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
    setup.visibility === "public"
      ? "text-green-700 bg-green-50 hover:bg-green-100"
      : setup.visibility === "link"
        ? "text-blue-600 bg-blue-50 hover:bg-blue-100"
        : "text-gray-500 bg-gray-50 hover:bg-gray-100"
  }`}
>
  <LucideIcon name={setup.visibility === "public" ? "globe" : setup.visibility === "link" ? "link" : "lock"} size={16} />
  Share
</button>

Mobile variant:

<button
  type="button"
  onClick={() => setShareModalOpen(true)}
  className={`md:hidden inline-flex items-center justify-center min-w-[44px] min-h-[44px] p-2 rounded-lg transition-colors ${
    setup.visibility === "public"
      ? "text-green-700 bg-green-50 hover:bg-green-100"
      : setup.visibility === "link"
        ? "text-blue-600 bg-blue-50 hover:bg-blue-100"
        : "text-gray-500 bg-gray-50 hover:bg-gray-100"
  }`}
  aria-label="Share settings"
  title="Share settings"
>
  <LucideIcon name={setup.visibility === "public" ? "globe" : setup.visibility === "link" ? "link" : "lock"} size={16} />
</button>
  1. Render ShareModal:
{shareModalOpen && (
  <ShareModal
    isOpen={shareModalOpen}
    onClose={() => setShareModalOpen(false)}
    setupId={numericId}
    currentVisibility={setup.visibility}
    onVisibilityChange={(v) => updateSetup.mutate({ visibility: v })}
  />
)}
  1. 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" <acceptance_criteria>
    • 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 </acceptance_criteria> Share modal fully functional with visibility management, link creation, copy, and revoke

<threat_model>

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
</threat_model>
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

<success_criteria>

  • 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) </success_criteria>
After completion, create `.planning/phases/32-setup-sharing-system/32-03-SUMMARY.md`