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>
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 |
|
|
true |
|
|
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.mdShare 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;
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] });
},
});
}
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):
-
Overlay:
fixed inset-0 z-50 bg-black/50 flex items-center justify-center. Click overlay to close. Listen for Escape key. -
Modal container:
bg-white rounded-xl shadow-lg p-6 max-w-md mx-4 w-full max-h-[80vh] overflow-y-auto -
Header: "Share Setup" in
text-lg font-semibold text-gray-900, close X button top-right. -
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)
- Each:
-
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."
-
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
- Expiration select:
- Divider:
-
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-1showing 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 roundedwith 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 roundedwith x icon (16px)- On click: call
revokeShareLink.mutate(shareId)
- On click: call
-
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:
- Add import:
import { ShareModal } from "../../components/ShareModal"; - Add state:
const [shareModalOpen, setShareModalOpen] = useState(false); - 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>
- Render ShareModal:
{shareModalOpen && (
<ShareModal
isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)}
setupId={numericId}
currentVisibility={setup.visibility}
onVisibilityChange={(v) => updateSetup.mutate({ visibility: v })}
/>
)}
- 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.tsxrenders: 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.tsxrenders share button with visibility-state icon/color- Share button opens ShareModal on click
bun run lintpasses </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> |
<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>