From be5ed18db0447845377cb6e7e9754523237e12e4 Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sat, 18 Jul 2026 23:57:59 -0400 Subject: [PATCH] Add sharing UI: invite by email, roles, read-only viewer mode (#17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/shares.ts: zod Share shape + hooks (useShares/useAddShare/ useUpdateShareRole/useRemoveShare over /gardens/:id/shares). - lib/gardens.ts: myRole on the garden schema + canEditRole/isOwnerRole helpers (role always comes from the server's my_role, never guessed). - ShareGardenModal (owner only): invite an existing user by email as viewer/ editor, change a share's role inline, remove; surfaces the backend's friendly "no account with that email". Reachable from the garden card and the editor. - GardenCard: "shared · viewer/editor" badge on non-owned gardens; owner gets Share/Edit/Delete, a recipient gets Leave (LeaveGardenModal removes their own share). - Read-only viewer mode in the editor, gated on a single canEdit flag: viewers get no palette, no select/move/resize/rotate handles, no plop overlay, no placement, and read-only inspectors (a disabled
makes every field read-only in one shot; Change/Delete/Remove/Plant-here hidden), plus a "View only" indicator. Defense in depth: the canvas create/place handlers also check canEdit, so a missed gate can't fire a mutation that would 403. tsc --noEmit clean; 24/24 vitest; production build green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi --- web/src/components/gardens/GardenCard.tsx | 59 ++++++--- .../components/gardens/LeaveGardenModal.tsx | 47 +++++++ .../components/gardens/ShareGardenModal.tsx | 121 ++++++++++++++++++ web/src/editor/GardenCanvas.tsx | 16 ++- web/src/editor/Inspector.tsx | 85 ++++++------ web/src/editor/PlopInspector.tsx | 56 ++++---- web/src/lib/gardens.ts | 16 +++ web/src/lib/shares.ts | 69 ++++++++++ web/src/pages/GardenEditorPage.tsx | 45 +++++-- web/src/pages/GardensPage.tsx | 16 ++- 10 files changed, 433 insertions(+), 97 deletions(-) create mode 100644 web/src/components/gardens/LeaveGardenModal.tsx create mode 100644 web/src/components/gardens/ShareGardenModal.tsx create mode 100644 web/src/lib/shares.ts diff --git a/web/src/components/gardens/GardenCard.tsx b/web/src/components/gardens/GardenCard.tsx index 61828f5..56f6f06 100644 --- a/web/src/components/gardens/GardenCard.tsx +++ b/web/src/components/gardens/GardenCard.tsx @@ -1,20 +1,33 @@ import { Link } from '@tanstack/react-router' -import type { Garden } from '@/lib/gardens' +import { isOwnerRole, type Garden } from '@/lib/gardens' import { formatDimensions } from '@/lib/units' +const actionClass = + 'rounded-md px-2.5 py-1 text-sm font-medium text-muted outline-none transition-colors ' + + 'hover:bg-border/50 hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40' +const dangerClass = + 'rounded-md px-2.5 py-1 text-sm font-medium text-muted outline-none transition-colors ' + + 'hover:bg-red-500/10 hover:text-red-600 focus-visible:ring-2 focus-visible:ring-red-500/40 dark:hover:text-red-400' + /** - * One garden as a card: the body links into the editor (/gardens/:id); the - * footer has edit/delete actions (kept out of the link so they don't navigate). + * One garden as a card: the body links into the editor. The footer differs by + * role — the owner gets Share / Edit / Delete; a recipient sees a "shared · role" + * badge and a Leave action (garden metadata edit + sharing are owner-only). */ export function GardenCard({ garden, + onShare, onEdit, onDelete, + onLeave, }: { garden: Garden + onShare: () => void onEdit: () => void onDelete: () => void + onLeave: () => void }) { + const owner = isOwnerRole(garden.myRole) return (
-

{garden.name}

+
+

{garden.name}

+ {!owner && garden.myRole && ( + + shared · {garden.myRole} + + )} +

{formatDimensions(garden.widthCm, garden.heightCm, garden.unitPref)}

{garden.notes &&

{garden.notes}

}
- - + {owner ? ( + <> + + + + + ) : ( + + )}
) diff --git a/web/src/components/gardens/LeaveGardenModal.tsx b/web/src/components/gardens/LeaveGardenModal.tsx new file mode 100644 index 0000000..85c9dce --- /dev/null +++ b/web/src/components/gardens/LeaveGardenModal.tsx @@ -0,0 +1,47 @@ +import { useState } from 'react' +import { Modal } from '@/components/ui/Modal' +import { Alert } from '@/components/ui/Alert' +import { Button } from '@/components/ui/Button' +import { errorMessage } from '@/lib/api' +import { useMe } from '@/lib/auth' +import type { Garden } from '@/lib/gardens' +import { useRemoveShare } from '@/lib/shares' + +/** Confirmation for a recipient leaving a garden shared with them (removes their + * own share). */ +export function LeaveGardenModal({ garden, onClose }: { garden: Garden; onClose: () => void }) { + const me = useMe() + const remove = useRemoveShare(garden.id) + const [error, setError] = useState(null) + + async function onConfirm() { + if (!me.data) return + setError(null) + try { + await remove.mutateAsync(me.data.id) + onClose() + } catch (err) { + setError(errorMessage(err, 'Could not leave the garden.')) + } + } + + return ( + +
+

+ Leave {garden.name}? You'll lose access until the owner + shares it with you again. +

+ {error && {error}} +
+ + +
+
+
+ ) +} diff --git a/web/src/components/gardens/ShareGardenModal.tsx b/web/src/components/gardens/ShareGardenModal.tsx new file mode 100644 index 0000000..c9c6bf1 --- /dev/null +++ b/web/src/components/gardens/ShareGardenModal.tsx @@ -0,0 +1,121 @@ +import { useState, type FormEvent } from 'react' +import { Modal } from '@/components/ui/Modal' +import { Alert } from '@/components/ui/Alert' +import { Button } from '@/components/ui/Button' +import { Select } from '@/components/ui/Select' +import { TextField } from '@/components/ui/TextField' +import { cn } from '@/lib/cn' +import { fieldControlClass } from '@/components/ui/field' +import { errorMessage } from '@/lib/api' +import type { Garden } from '@/lib/gardens' +import { useAddShare, useRemoveShare, useShares, useUpdateShareRole, type ShareRole } from '@/lib/shares' + +const roleOptions = [ + { value: 'viewer', label: 'Viewer (read-only)' }, + { value: 'editor', label: 'Editor (can edit)' }, +] + +/** + * Owner-only dialog to manage a garden's shares: invite an existing user by + * email as viewer/editor, change a share's role, or remove it. Targets existing + * accounts only (v1 has no invitation emails) — an unknown email surfaces a + * friendly "no account with that email". + */ +export function ShareGardenModal({ garden, onClose }: { garden: Garden; onClose: () => void }) { + const shares = useShares(garden.id) + const add = useAddShare(garden.id) + const updateRole = useUpdateShareRole(garden.id) + const remove = useRemoveShare(garden.id) + + const [email, setEmail] = useState('') + const [role, setRole] = useState('viewer') + const [error, setError] = useState(null) + + async function onInvite(e: FormEvent) { + e.preventDefault() + setError(null) + if (!email.trim()) { + setError('Enter an email address.') + return + } + try { + await add.mutateAsync({ email: email.trim(), role }) + setEmail('') + } catch (err) { + setError(errorMessage(err, 'Could not share the garden.')) + } + } + + return ( + +
+
+ setEmail(e.target.value)} + /> +
+ updateRole.mutate({ userId: sh.userId, role: e.target.value as ShareRole })} + aria-label={`Role for ${sh.displayName}`} + className={cn(fieldControlClass, 'w-auto px-2 py-1 text-sm')} + > + + + + + + ))} + +
+ +
+ +
+
+
+ ) +} diff --git a/web/src/editor/GardenCanvas.tsx b/web/src/editor/GardenCanvas.tsx index 5b27758..ca23c68 100644 --- a/web/src/editor/GardenCanvas.tsx +++ b/web/src/editor/GardenCanvas.tsx @@ -34,11 +34,13 @@ export function GardenCanvas({ objects, plantings, plantsById, + canEdit, }: { garden: EditorGarden objects: EditorObject[] plantings: EditorPlanting[] plantsById: Map + canEdit: boolean }) { const svgRef = useRef(null) const containerRef = useRef(null) @@ -108,7 +110,9 @@ export function GardenCanvas({ // or exit focus mode, or just deselect. function onCanvasPointerDown(e: ReactPointerEvent) { const armed = useEditorStore.getState().armedKind - if (armed) { + // Defense in depth: a viewer can't place objects even if a stale armed kind + // slipped through (the palette isn't rendered for them). + if (armed && canEdit) { useEditorStore.getState().setArmedKind(null) const def = kindDef(armed) const rect = svgRef.current?.getBoundingClientRect() @@ -134,7 +138,7 @@ export function GardenCanvas({ // armed for repeat-placement until Escape / Done). function onPlace(e: ReactPointerEvent) { e.stopPropagation() - if (!focusedObject || !armedPlant || !focusedObject.plantable) return + if (!canEdit || !focusedObject || !armedPlant || !focusedObject.plantable) return const rect = svgRef.current?.getBoundingClientRect() if (!rect) return const world = screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top }, viewport) @@ -188,14 +192,16 @@ export function GardenCanvas({ onSelectPlop={selectPlanting} /> - {selectedObject && } - {selectedPlop && selectedPlopObject && ( + {/* Edit handles are mounted only for editors/owners; viewers can still + select to inspect (read-only), but never move/resize. */} + {canEdit && selectedObject && } + {canEdit && selectedPlop && selectedPlopObject && ( )} {/* Placement capture: a transparent sheet over the focused object while a plant is armed, so taps drop plops instead of selecting the object. */} - {focusedObject && armedPlant && focusedObject.plantable && ( + {canEdit && focusedObject && armedPlant && focusedObject.plantable && ( void + readOnly?: boolean }) { const update = useUpdateObject(gardenId) const del = useDeleteObject(gardenId) @@ -96,19 +98,26 @@ export function Inspector({ - {object.plantable && onFocus && ( + {readOnly && ( +

View only — you can't edit this garden.

+ )} + + {!readOnly && object.plantable && onFocus && ( )} - setName(e.target.value)} - onBlur={() => name !== object.name && patch({ name })} - /> + {/* A disabled fieldset makes every control below read-only for viewers in + one shot (no per-input disabled). */} +
+ setName(e.target.value)} + onBlur={() => name !== object.name && patch({ name })} + />
-