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>
This commit is contained in:
2026-04-13 17:05:36 +02:00
parent 9965e356de
commit 81a654085d
5 changed files with 1212 additions and 2 deletions

View File

@@ -0,0 +1,338 @@
---
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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<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
<interfaces>
<!-- Key types and contracts from Plans 01 and 02. -->
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:
```typescript
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:
```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;
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create share hooks for React Query</name>
<files>src/client/hooks/useShares.ts</files>
<read_first>src/client/hooks/useSetups.ts, src/client/lib/api.ts</read_first>
<action>
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<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] });
},
});
}
```
</action>
<verify>
<automated>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"</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>Share hooks created with query and mutation patterns</done>
</task>
<task type="auto">
<name>Task 2: Create ShareModal component and wire into setup detail page</name>
<files>src/client/components/ShareModal.tsx, src/client/routes/setups/$setupId.tsx</files>
<read_first>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</read_first>
<action>
**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
<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:**
```tsx
<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>
```
4. Render ShareModal:
```tsx
{shareModalOpen && (
<ShareModal
isOpen={shareModalOpen}
onClose={() => 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).
</action>
<verify>
<automated>grep -q "ShareModal" src/client/components/ShareModal.tsx && grep -q "ShareModal" src/client/routes/setups/\$setupId.tsx && bun run lint && echo "PASS" || echo "FAIL"</automated>
</verify>
<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>
<done>Share modal fully functional with visibility management, link creation, copy, and revoke</done>
</task>
</tasks>
<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>
<verification>
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
</verification>
<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>
<output>
After completion, create `.planning/phases/32-setup-sharing-system/32-03-SUMMARY.md`
</output>