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:
338
.planning/phases/32-setup-sharing-system/32-03-PLAN.md
Normal file
338
.planning/phases/32-setup-sharing-system/32-03-PLAN.md
Normal 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>
|
||||
Reference in New Issue
Block a user