Sharing UI: invite by email, roles, read-only viewer mode (#17) #36

Merged
steve merged 2 commits from phase-6-sharing-ui into main 2026-07-19 04:14:30 +00:00
9 changed files with 196 additions and 172 deletions
Showing only changes of commit 85c355ce4d - Show all commits
+10 -13
View File
@@ -1,33 +1,30 @@
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { isOwnerRole, type Garden } from '@/lib/gardens' import type { Garden } from '@/lib/gardens'
import { formatDimensions } from '@/lib/units' import { formatDimensions } from '@/lib/units'
import { cardActionClass, cardDangerClass } from '@/components/ui/cardActions'
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'
Review

🟡 actionClass/dangerClass duplicated verbatim from PlantCard — should be shared

maintainability · flagged by 3 models

  • Duplicated actionClass / dangerClass constantsweb/src/components/gardens/GardenCard.tsx:5-10 defines the exact same two class strings as web/src/components/plants/PlantCard.tsx:5-10 (verified identical character-for-character). This is copy-paste that should be shared — e.g. extract to web/src/components/ui/button.ts or a cardActions helper. As more card components appear, this drift will diverge.

🪰 Gadfly · advisory

🟡 **actionClass/dangerClass duplicated verbatim from PlantCard — should be shared** _maintainability · flagged by 3 models_ - **Duplicated `actionClass` / `dangerClass` constants** — `web/src/components/gardens/GardenCard.tsx:5-10` defines the exact same two class strings as `web/src/components/plants/PlantCard.tsx:5-10` (verified identical character-for-character). This is copy-paste that should be shared — e.g. extract to `web/src/components/ui/button.ts` or a `cardActions` helper. As more card components appear, this drift will diverge. <sub>🪰 Gadfly · advisory</sub>
/** /**
* One garden as a card: the body links into the editor. The footer differs by * 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" * 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). * badge and a Leave action (garden metadata edit + sharing are owner-only).
* Ownership is the authoritative ownerId==me check, not the my_role hint.
*/ */
export function GardenCard({ export function GardenCard({
garden, garden,
currentUserId,
onShare, onShare,
onEdit, onEdit,
onDelete, onDelete,
onLeave, onLeave,
}: { }: {
garden: Garden garden: Garden
currentUserId?: number
onShare: () => void onShare: () => void
onEdit: () => void onEdit: () => void
onDelete: () => void onDelete: () => void
onLeave: () => void onLeave: () => void
}) { }) {
const owner = isOwnerRole(garden.myRole) const owner = currentUserId != null && garden.ownerId === currentUserId
return ( return (
<div className="flex flex-col rounded-xl border border-border bg-surface transition-colors hover:border-accent/50"> <div className="flex flex-col rounded-xl border border-border bg-surface transition-colors hover:border-accent/50">
<Link <Link
Outdated
Review

🟡 Garden with undefined myRole is treated as non-owner and shown a Leave (mutating) button with no badge; fail-closed direction is wrong for unknown role

correctness · flagged by 1 model

  • web/src/components/gardens/GardenCard.tsx:30,40,64 — a garden with myRole === undefined is treated as non-owner and gets a Leave button with no badge. isOwnerRole(undefined) is false (confirmed in gardens.ts:37), so the footer falls into the !owner branch and renders Leave; the badge is gated on !owner && garden.myRole (truthy), so a missing role yields a card with no role label and a Leave button that calls remove.mutateAsync(me.data.id) — a self-share delete that like…

🪰 Gadfly · advisory

🟡 **Garden with undefined myRole is treated as non-owner and shown a Leave (mutating) button with no badge; fail-closed direction is wrong for unknown role** _correctness · flagged by 1 model_ - **`web/src/components/gardens/GardenCard.tsx:30,40,64` — a garden with `myRole === undefined` is treated as non-owner and gets a `Leave` button with no badge.** `isOwnerRole(undefined)` is false (confirmed in `gardens.ts:37`), so the footer falls into the `!owner` branch and renders `Leave`; the badge is gated on `!owner && garden.myRole` (truthy), so a missing role yields a card with no role label and a `Leave` button that calls `remove.mutateAsync(me.data.id)` — a self-share delete that like… <sub>🪰 Gadfly · advisory</sub>
1
@@ -51,18 +48,18 @@ export function GardenCard({
<div className="flex justify-end gap-1 border-t border-border px-2 py-1.5"> <div className="flex justify-end gap-1 border-t border-border px-2 py-1.5">
{owner ? ( {owner ? (
<> <>
<button type="button" onClick={onShare} className={actionClass}> <button type="button" onClick={onShare} className={cardActionClass}>
Share Share
</button> </button>
<button type="button" onClick={onEdit} className={actionClass}> <button type="button" onClick={onEdit} className={cardActionClass}>
Edit Edit
</button> </button>
<button type="button" onClick={onDelete} className={dangerClass}> <button type="button" onClick={onDelete} className={cardDangerClass}>
Delete Delete
</button> </button>
</> </>
) : ( ) : (
<button type="button" onClick={onLeave} className={dangerClass}> <button type="button" onClick={onLeave} className={cardDangerClass}>
Leave Leave
</button> </button>
)} )}
@@ -46,8 +46,10 @@ export function ShareGardenModal({ garden, onClose }: { garden: Garden; onClose:
} }
} }
const onMutationError = (fallback: string) => (err: unknown) => setError(errorMessage(err, fallback))
Outdated
Review

🟠 Modal busy prop does not cover updateRole or remove pending states

error-handling · flagged by 3 models

  • web/src/components/gardens/ShareGardenModal.tsx:50 — The Modal’s busy prop only tracks add.isPending, so Escape / backdrop-click can dismiss the dialog while an in-flight updateRole or remove is still pending. That buries any eventual error and leaves the user unsure whether the action completed. Fix: Pass busy={add.isPending || updateRole.isPending || remove.isPending}.

🪰 Gadfly · advisory

🟠 **Modal busy prop does not cover updateRole or remove pending states** _error-handling · flagged by 3 models_ * **`web/src/components/gardens/ShareGardenModal.tsx:50`** — The Modal’s `busy` prop only tracks `add.isPending`, so Escape / backdrop-click can dismiss the dialog while an in-flight `updateRole` or `remove` is still pending. That buries any eventual error and leaves the user unsure whether the action completed. **Fix:** Pass `busy={add.isPending || updateRole.isPending || remove.isPending}`. <sub>🪰 Gadfly · advisory</sub>
return ( return (
<Modal title="Share garden" onClose={onClose} busy={add.isPending}> <Modal title="Share garden" onClose={onClose} busy={add.isPending || updateRole.isPending || remove.isPending}>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<form onSubmit={onInvite} className="flex flex-col gap-2"> <form onSubmit={onInvite} className="flex flex-col gap-2">
<TextField <TextField
2
@@ -90,7 +92,13 @@ export function ShareGardenModal({ garden, onClose }: { garden: Garden; onClose:
</div> </div>
<select <select
value={sh.role} value={sh.role}
onChange={(e) => updateRole.mutate({ userId: sh.userId, role: e.target.value as ShareRole })} onChange={(e) => {
setError(null)
updateRole.mutate(
{ userId: sh.userId, role: e.target.value as ShareRole },
{ onError: onMutationError('Could not change that role.') },
)
Outdated
Review

🔴 remove mutation has no error handling or pending UI

correctness, error-handling, maintainability · flagged by 5 models

  • web/src/components/gardens/ShareGardenModal.tsx:102remove.mutate(sh.userId) is fire-and-forget with no error surface. useRemoveShare (lib/shares.ts:60-69) defines only onSuccess, no onError, and the call site has no try/catch. If the DELETE fails, the ✕ button appears to do nothing and the user gets no feedback. Same fix applies.

🪰 Gadfly · advisory

🔴 **remove mutation has no error handling or pending UI** _correctness, error-handling, maintainability · flagged by 5 models_ - **`web/src/components/gardens/ShareGardenModal.tsx:102`** — `remove.mutate(sh.userId)` is fire-and-forget with no error surface. `useRemoveShare` (`lib/shares.ts:60-69`) defines only `onSuccess`, no `onError`, and the call site has no try/catch. If the DELETE fails, the ✕ button appears to do nothing and the user gets no feedback. Same fix applies. <sub>🪰 Gadfly · advisory</sub>
}}
aria-label={`Role for ${sh.displayName}`} aria-label={`Role for ${sh.displayName}`}
className={cn(fieldControlClass, 'w-auto px-2 py-1 text-sm')} className={cn(fieldControlClass, 'w-auto px-2 py-1 text-sm')}
> >
@@ -99,7 +107,10 @@ export function ShareGardenModal({ garden, onClose }: { garden: Garden; onClose:
</select> </select>
<button <button
type="button" type="button"
onClick={() => remove.mutate(sh.userId)} onClick={() => {
setError(null)
remove.mutate(sh.userId, { onError: onMutationError('Could not remove that person.') })
}}
aria-label={`Remove ${sh.displayName}`} aria-label={`Remove ${sh.displayName}`}
className="rounded-md px-2 py-1 text-sm text-muted transition-colors hover:bg-red-500/10 hover:text-red-600 dark:hover:text-red-400" className="rounded-md px-2 py-1 text-sm text-muted transition-colors hover:bg-red-500/10 hover:text-red-600 dark:hover:text-red-400"
> >
+4 -10
View File
@@ -1,14 +1,8 @@
import { PlantIcon } from './PlantIcon' import { PlantIcon } from './PlantIcon'
import { cardActionClass, cardDangerClass } from '@/components/ui/cardActions'
import { CATEGORY_LABELS, isBuiltin, type Plant } from '@/lib/plants' import { CATEGORY_LABELS, isBuiltin, type Plant } from '@/lib/plants'
import { formatSpacing, type UnitPref } from '@/lib/units' import { formatSpacing, type UnitPref } 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 catalog plant as a card: icon tile tinted with the plant's color, name, * One catalog plant as a card: icon tile tinted with the plant's color, name,
* category + mature spacing (unit-aware), and actions. Built-ins are badged and * category + mature spacing (unit-aware), and actions. Built-ins are badged and
@@ -53,16 +47,16 @@ export function PlantCard({
/> />
</div> </div>
<div className="flex justify-end gap-1 border-t border-border px-2 py-1.5"> <div className="flex justify-end gap-1 border-t border-border px-2 py-1.5">
<button type="button" onClick={onDuplicate} className={actionClass}> <button type="button" onClick={onDuplicate} className={cardActionClass}>
Duplicate Duplicate
</button> </button>
{!builtin && ( {!builtin && (
<button type="button" onClick={onEdit} className={actionClass}> <button type="button" onClick={onEdit} className={cardActionClass}>
Edit Edit
</button> </button>
)} )}
{!builtin && ( {!builtin && (
<button type="button" onClick={onDelete} className={dangerClass}> <button type="button" onClick={onDelete} className={cardDangerClass}>
Delete Delete
</button> </button>
)} )}
+10
View File
@@ -0,0 +1,10 @@
// Shared footer-action button styling for list cards (garden/plant), so the
// verbatim class strings don't drift between them.
export const cardActionClass =
'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'
export const cardDangerClass =
'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'
+103 -101
View File
@@ -66,8 +66,10 @@ export function Inspector({
setColor(object.color ?? DEFAULT_COLOR) setColor(object.color ?? DEFAULT_COLOR)
}, [object.version, object.widthCm, object.heightCm, object.xCm, object.yCm, object.rotationDeg, object.name, object.notes, object.color, unit]) }, [object.version, object.widthCm, object.heightCm, object.xCm, object.yCm, object.rotationDeg, object.name, object.notes, object.color, unit])
const patch = (fields: Partial<Omit<EditorObject, 'id' | 'version'>>) => const patch = (fields: Partial<Omit<EditorObject, 'id' | 'version'>>) => {
if (readOnly) return // a blur mustn't fire a mutation if the role changed mid-edit
update.mutate({ id: object.id, version: object.version, ...fields }) update.mutate({ id: object.id, version: object.version, ...fields })
}
// Commit a dimension/position field. `positive` gates width/height (must be // Commit a dimension/position field. `positive` gates width/height (must be
// ≥ the server minimum) but not x/y, which may be zero or negative. Compare at // ≥ the server minimum) but not x/y, which may be zero or negative. Compare at
3
@@ -119,111 +121,111 @@ export function Inspector({
onBlur={() => name !== object.name && patch({ name })} onBlur={() => name !== object.name && patch({ name })}
/> />
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<TextField <TextField
label={`Width (${u})`} label={`Width (${u})`}
name="width" name="width"
type="number" type="number"
inputMode="decimal" inputMode="decimal"
step="any" step="any"
value={width} value={width}
onChange={(e) => setWidth(e.target.value)} onChange={(e) => setWidth(e.target.value)}
onBlur={() => commitDim(width, object.widthCm, (cm) => patch({ widthCm: cm }), true)} onBlur={() => commitDim(width, object.widthCm, (cm) => patch({ widthCm: cm }), true)}
/> />
<TextField <TextField
label={`Height (${u})`} label={`Height (${u})`}
name="height" name="height"
type="number" type="number"
inputMode="decimal" inputMode="decimal"
step="any" step="any"
value={height} value={height}
onChange={(e) => setHeight(e.target.value)} onChange={(e) => setHeight(e.target.value)}
onBlur={() => commitDim(height, object.heightCm, (cm) => patch({ heightCm: cm }), true)} onBlur={() => commitDim(height, object.heightCm, (cm) => patch({ heightCm: cm }), true)}
/> />
<TextField <TextField
label={`X (${u})`} label={`X (${u})`}
name="x" name="x"
type="number" type="number"
inputMode="decimal" inputMode="decimal"
step="any" step="any"
value={x} value={x}
onChange={(e) => setX(e.target.value)} onChange={(e) => setX(e.target.value)}
onBlur={() => commitDim(x, object.xCm, (cm) => patch({ xCm: cm }))} onBlur={() => commitDim(x, object.xCm, (cm) => patch({ xCm: cm }))}
/> />
<TextField <TextField
label={`Y (${u})`} label={`Y (${u})`}
name="y" name="y"
type="number" type="number"
inputMode="decimal" inputMode="decimal"
step="any" step="any"
value={y} value={y}
onChange={(e) => setY(e.target.value)} onChange={(e) => setY(e.target.value)}
onBlur={() => commitDim(y, object.yCm, (cm) => patch({ yCm: cm }))} onBlur={() => commitDim(y, object.yCm, (cm) => patch({ yCm: cm }))}
/>
</div>
<TextField
label="Rotation (°)"
name="rotation"
type="number"
inputMode="numeric"
step="1"
value={rotation}
onChange={(e) => setRotation(e.target.value)}
onBlur={() => {
const v = parseFloat(rotation)
if (Number.isFinite(v) && v !== object.rotationDeg) patch({ rotationDeg: v })
}}
/>
<div className="flex items-end gap-2">
<div className="flex flex-col gap-1.5">
<label htmlFor="obj-color" className="text-sm font-medium text-fg">
Color
</label>
<input
id="obj-color"
type="color"
value={color}
// onChange fires continuously while dragging in the native picker;
// track it locally for the live swatch and commit one PATCH on blur.
onChange={(e: ChangeEvent<HTMLInputElement>) => setColor(e.target.value)}
onBlur={() => color !== (object.color ?? DEFAULT_COLOR) && patch({ color })}
className="h-9 w-14 cursor-pointer rounded-md border border-border bg-surface"
/> />
</div> </div>
{object.color && (
<Button
variant="ghost"
className="px-2 py-1.5 text-xs"
onClick={() => {
setColor(DEFAULT_COLOR)
patch({ color: null })
}}
>
Clear
</Button>
)}
</div>
<label className="flex items-center gap-2 text-sm text-fg"> <TextField
<input label="Rotation (°)"
type="checkbox" name="rotation"
checked={object.plantable} type="number"
onChange={(e) => patch({ plantable: e.target.checked })} inputMode="numeric"
className="h-4 w-4 rounded border-border" step="1"
value={rotation}
onChange={(e) => setRotation(e.target.value)}
onBlur={() => {
const v = parseFloat(rotation)
if (Number.isFinite(v) && v !== object.rotationDeg) patch({ rotationDeg: v })
}}
/> />
Plantable
</label>
<TextArea <div className="flex items-end gap-2">
label="Notes" <div className="flex flex-col gap-1.5">
name="notes" <label htmlFor="obj-color" className="text-sm font-medium text-fg">
rows={2} Color
value={notes} </label>
onChange={(e) => setNotes(e.target.value)} <input
onBlur={() => notes !== object.notes && patch({ notes })} id="obj-color"
/> type="color"
value={color}
// onChange fires continuously while dragging in the native picker;
// track it locally for the live swatch and commit one PATCH on blur.
onChange={(e: ChangeEvent<HTMLInputElement>) => setColor(e.target.value)}
onBlur={() => color !== (object.color ?? DEFAULT_COLOR) && patch({ color })}
className="h-9 w-14 cursor-pointer rounded-md border border-border bg-surface"
/>
</div>
{object.color && (
<Button
variant="ghost"
className="px-2 py-1.5 text-xs"
onClick={() => {
setColor(DEFAULT_COLOR)
patch({ color: null })
}}
>
Clear
</Button>
)}
</div>
<label className="flex items-center gap-2 text-sm text-fg">
<input
type="checkbox"
checked={object.plantable}
onChange={(e) => patch({ plantable: e.target.checked })}
className="h-4 w-4 rounded border-border"
/>
Plantable
</label>
<TextArea
label="Notes"
name="notes"
rows={2}
value={notes}
onChange={(e) => setNotes(e.target.value)}
onBlur={() => notes !== object.notes && patch({ notes })}
/>
</fieldset> </fieldset>
{!readOnly && {!readOnly &&
+36 -34
View File
@@ -58,8 +58,10 @@ export function PlopInspector({
setPlanted(p.plantedAt ?? '') setPlanted(p.plantedAt ?? '')
}, [p.version, p.radiusCm, p.count, p.label, p.plantedAt, unit]) }, [p.version, p.radiusCm, p.count, p.label, p.plantedAt, unit])
const patch = (fields: Omit<Parameters<typeof update.mutate>[0], 'id' | 'version'>) => const patch = (fields: Omit<Parameters<typeof update.mutate>[0], 'id' | 'version'>) => {
if (readOnly) return // a blur mustn't fire a mutation if the role changed mid-edit
update.mutate({ id: plop.id, version: plop.version, ...fields }) update.mutate({ id: plop.id, version: plop.version, ...fields })
}
const u = spacingUnitLabel(unit) const u = spacingUnitLabel(unit)
const derived = plant ? computeDerivedCount(p.radiusCm, plant.spacingCm) : p.derivedCount const derived = plant ? computeDerivedCount(p.radiusCm, plant.spacingCm) : p.derivedCount
@@ -125,40 +127,40 @@ export function PlopInspector({
</div> </div>
Outdated
Review

🟠 Inconsistent indentation inside fieldset wrapper makes JSX hierarchy unreadable

maintainability · flagged by 5 models

  • web/src/editor/PlopInspector.tsx:127 — Same indentation problem: the <fieldset> tag and its first child <div> (line 128) are at the same indentation level, while the inner fields vary between +0 and +2. Uniformly indent all children.

🪰 Gadfly · advisory

🟠 **Inconsistent indentation inside fieldset wrapper makes JSX hierarchy unreadable** _maintainability · flagged by 5 models_ - `web/src/editor/PlopInspector.tsx:127` — Same indentation problem: the `<fieldset>` tag and its first child `<div>` (line 128) are at the same indentation level, while the inner fields vary between +0 and +2. Uniformly indent all children. <sub>🪰 Gadfly · advisory</sub>
<fieldset disabled={readOnly} className="flex min-w-0 flex-col gap-3 border-0 p-0"> <fieldset disabled={readOnly} className="flex min-w-0 flex-col gap-3 border-0 p-0">
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<TextField <TextField
label={`Radius (${u})`} label={`Radius (${u})`}
name="radius" name="radius"
type="number" type="number"
inputMode="decimal" inputMode="decimal"
step="any" step="any"
min="0" min="0"
value={radius} value={radius}
onChange={(e) => setRadius(e.target.value)} onChange={(e) => setRadius(e.target.value)}
onBlur={commitRadius} onBlur={commitRadius}
/> />
<TextField <TextField
label="Count" label="Count"
name="count" name="count"
type="number" type="number"
inputMode="numeric" inputMode="numeric"
step="1" step="1"
min="1" min="1"
placeholder={`${derived} (auto)`} placeholder={`${derived} (auto)`}
value={count} value={count}
onChange={(e) => setCount(e.target.value)} onChange={(e) => setCount(e.target.value)}
onBlur={commitCount} onBlur={commitCount}
hint={p.count != null ? 'Override clear for auto' : 'Auto from area ÷ spacing²'} hint={p.count != null ? 'Override clear for auto' : 'Auto from area ÷ spacing²'}
/> />
</div> </div>
<TextField <TextField
label="Label (optional)" label="Label (optional)"
name="label" name="label"
value={label} value={label}
onChange={(e) => setLabel(e.target.value)} onChange={(e) => setLabel(e.target.value)}
onBlur={() => label !== (p.label ?? '') && patch({ label: label.trim() || null })} onBlur={() => label !== (p.label ?? '') && patch({ label: label.trim() || null })}
/> />
<TextField <TextField
label="Planted" label="Planted"
+8 -7
View File
@@ -31,16 +31,17 @@ export function sharesQueryOptions(gardenId: number) {
}) })
} }
/** The shares on a garden (owner-only endpoint; pass enabled=false to skip). */ /** The shares on a garden (owner-only endpoint). */
export function useShares(gardenId: number, enabled = true) { export function useShares(gardenId: number) {
Outdated
Review

useShares enabled parameter has no caller; dead surface

maintainability · flagged by 1 model

  • web/src/editor/Inspector.tsx:113-227 & PlopInspector.tsx:127-171 — fieldset children not re-indented. The new <fieldset> wrappers enclose a large block of pre-existing JSX, but the children inside were left at their original indentation (e.g. <div className="grid grid-cols-2 gap-2"> at Inspector:122 sits at the same indent as the <fieldset> that owns it; same in PlopInspector:128). The closing </fieldset> at 227 is likewise dedented past the <TextArea> it contains. This flatt…

🪰 Gadfly · advisory

⚪ **useShares `enabled` parameter has no caller; dead surface** _maintainability · flagged by 1 model_ - **`web/src/editor/Inspector.tsx:113-227` & `PlopInspector.tsx:127-171` — fieldset children not re-indented.** The new `<fieldset>` wrappers enclose a large block of pre-existing JSX, but the children inside were left at their original indentation (e.g. `<div className="grid grid-cols-2 gap-2">` at Inspector:122 sits at the same indent as the `<fieldset>` that owns it; same in PlopInspector:128). The closing `</fieldset>` at 227 is likewise dedented past the `<TextArea>` it contains. This flatt… <sub>🪰 Gadfly · advisory</sub>
return useQuery({ ...sharesQueryOptions(gardenId), enabled }) return useQuery(sharesQueryOptions(gardenId))
} }
// The add/update responses are a bare GardenShare (no email/displayName), so we
// don't parse them into the list's Share shape; onSuccess refetches the list.
export function useAddShare(gardenId: number) { export function useAddShare(gardenId: number) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
Review

🔴 useAddShare parses the POST /shares response with a schema requiring email/displayName, but the backend's AddShare returns a bare GardenShare without them — every successful invite throws and shows a false 'Could not share the garden' error

correctness · flagged by 1 model

🪰 Gadfly · advisory

🔴 **useAddShare parses the POST /shares response with a schema requiring email/displayName, but the backend's AddShare returns a bare GardenShare without them — every successful invite throws and shows a false 'Could not share the garden' error** _correctness · flagged by 1 model_ <sub>🪰 Gadfly · advisory</sub>
mutationFn: async (input: { email: string; role: ShareRole }): Promise<Share> => mutationFn: (input: { email: string; role: ShareRole }) => api.post(`/gardens/${gardenId}/shares`, input),
shareSchema.parse(await api.post(`/gardens/${gardenId}/shares`, input)),
onSuccess: () => qc.invalidateQueries({ queryKey: sharesKey(gardenId) }), onSuccess: () => qc.invalidateQueries({ queryKey: sharesKey(gardenId) }),
}) })
} }
@@ -48,8 +49,8 @@ export function useAddShare(gardenId: number) {
export function useUpdateShareRole(gardenId: number) { export function useUpdateShareRole(gardenId: number) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: async ({ userId, role }: { userId: number; role: ShareRole }): Promise<Share> => mutationFn: ({ userId, role }: { userId: number; role: ShareRole }) =>
Outdated
Review

🔴 useUpdateShareRole parses the PATCH /shares/:userId response with the same over-strict schema; the backend's UpdateShareRole also omits email/displayName, so every successful role change throws and is swallowed silently (no onError in ShareGardenModal), leaving the UI reverted to the stale role

correctness · flagged by 1 model

🪰 Gadfly · advisory

🔴 **useUpdateShareRole parses the PATCH /shares/:userId response with the same over-strict schema; the backend's UpdateShareRole also omits email/displayName, so every successful role change throws and is swallowed silently (no onError in ShareGardenModal), leaving the UI reverted to the stale role** _correctness · flagged by 1 model_ <sub>🪰 Gadfly · advisory</sub>
shareSchema.parse(await api.patch(`/gardens/${gardenId}/shares/${userId}`, { role })), api.patch(`/gardens/${gardenId}/shares/${userId}`, { role }),
onSuccess: () => qc.invalidateQueries({ queryKey: sharesKey(gardenId) }), onSuccess: () => qc.invalidateQueries({ queryKey: sharesKey(gardenId) }),
}) })
} }
1
+8 -4
View File
@@ -11,7 +11,7 @@ import { kindDef } from '@/editor/kinds'
import { useEditorStore } from '@/editor/store' import { useEditorStore } from '@/editor/store'
import type { EditorGarden } from '@/editor/types' import type { EditorGarden } from '@/editor/types'
import { ShareGardenModal } from '@/components/gardens/ShareGardenModal' import { ShareGardenModal } from '@/components/gardens/ShareGardenModal'
import { canEditRole, isOwnerRole } from '@/lib/gardens' import { useMe } from '@/lib/auth'
import { toEditorObject, useGardenFull, useUpdatePlanting } from '@/lib/objects' import { toEditorObject, useGardenFull, useUpdatePlanting } from '@/lib/objects'
import { toEditorPlanting } from '@/lib/plantings' import { toEditorPlanting } from '@/lib/plantings'
@@ -23,6 +23,7 @@ export function GardenEditorPage() {
const { focus } = routeApi.useSearch() const { focus } = routeApi.useSearch()
const navigate = routeApi.useNavigate() const navigate = routeApi.useNavigate()
const full = useGardenFull(gid) const full = useGardenFull(gid)
const me = useMe()
const selectedId = useEditorStore((s) => s.selectedId) const selectedId = useEditorStore((s) => s.selectedId)
const select = useEditorStore((s) => s.select) const select = useEditorStore((s) => s.select)
@@ -123,9 +124,11 @@ export function GardenEditorPage() {
heightCm: g.heightCm, heightCm: g.heightCm,
unitPref: g.unitPref, unitPref: g.unitPref,
} }
// Role-driven gating from the server's my_role — never guessed client-side. // Role-driven gating. Ownership is the authoritative ownerId==me check (so a
Outdated
Review

🟠 canEdit/isOwner false on missing myRole can lock an owner out of their own garden with no error surfaced

error-handling · flagged by 2 models

  • web/src/pages/GardenEditorPage.tsx:127canEdit/isOwner are false when myRole is absent, which can lock an owner out of their own garden with no error surfaced. Verified in web/src/lib/gardens.ts: canEditRole(undefined) (line 32-34) and isOwnerRole(undefined) (line 37-39) both return false, and myRole is declared gardenRoleSchema.optional() (line 27) "defensively." GardenEditorPage.tsx:127-128 gates all UI on these. If the server ever omits my_role (backend bug,…

🪰 Gadfly · advisory

🟠 **canEdit/isOwner false on missing myRole can lock an owner out of their own garden with no error surfaced** _error-handling · flagged by 2 models_ - **`web/src/pages/GardenEditorPage.tsx:127` — `canEdit`/`isOwner` are `false` when `myRole` is absent, which can lock an owner out of their own garden with no error surfaced.** Verified in `web/src/lib/gardens.ts`: `canEditRole(undefined)` (line 32-34) and `isOwnerRole(undefined)` (line 37-39) both return `false`, and `myRole` is declared `gardenRoleSchema.optional()` (line 27) "defensively." `GardenEditorPage.tsx:127-128` gates all UI on these. If the server ever omits `my_role` (backend bug,… <sub>🪰 Gadfly · advisory</sub>
const canEdit = canEditRole(g.myRole) // missing my_role can never lock an owner out); edit access is owner or an
const isOwner = isOwnerRole(g.myRole) // editor share.
const isOwner = me.data != null && g.ownerId === me.data.id
const canEdit = isOwner || g.myRole === 'editor'
const selectedObject = objects.find((o) => o.id === selectedId) ?? null const selectedObject = objects.find((o) => o.id === selectedId) ?? null
const focusedObject = focusedObjectId != null ? objects.find((o) => o.id === focusedObjectId) ?? null : null const focusedObject = focusedObjectId != null ? objects.find((o) => o.id === focusedObjectId) ?? null : null
@@ -133,6 +136,7 @@ export function GardenEditorPage() {
const selectedPlop = plantings.find((p) => p.id === selectedPlantingId) ?? null const selectedPlop = plantings.find((p) => p.id === selectedPlantingId) ?? null
function onPickPlant(plantId: number) { function onPickPlant(plantId: number) {
if (!canEdit) return // defense in depth: viewers can't reach the picker anyway
const plant = plantsById.get(plantId) const plant = plantsById.get(plantId)
if (!plant) return if (!plant) return
if (picker === 'change' && selectedPlop) { if (picker === 'change' && selectedPlop) {
+3
View File
@@ -6,6 +6,7 @@ import { GardenCard } from '@/components/gardens/GardenCard'
import { GardenFormModal } from '@/components/gardens/GardenFormModal' import { GardenFormModal } from '@/components/gardens/GardenFormModal'
import { LeaveGardenModal } from '@/components/gardens/LeaveGardenModal' import { LeaveGardenModal } from '@/components/gardens/LeaveGardenModal'
import { ShareGardenModal } from '@/components/gardens/ShareGardenModal' import { ShareGardenModal } from '@/components/gardens/ShareGardenModal'
import { useMe } from '@/lib/auth'
import { useGardens, type Garden } from '@/lib/gardens' import { useGardens, type Garden } from '@/lib/gardens'
// Which modal is open, if any. edit/delete/share/leave carry the target garden. // Which modal is open, if any. edit/delete/share/leave carry the target garden.
@@ -19,6 +20,7 @@ type Dialog =
export function GardensPage() { export function GardensPage() {
const gardens = useGardens() const gardens = useGardens()
const me = useMe()
const [dialog, setDialog] = useState<Dialog>(null) const [dialog, setDialog] = useState<Dialog>(null)
const close = () => setDialog(null) const close = () => setDialog(null)
const hasGardens = !!gardens.data && gardens.data.length > 0 const hasGardens = !!gardens.data && gardens.data.length > 0
@@ -50,6 +52,7 @@ export function GardensPage() {
<GardenCard <GardenCard
key={g.id} key={g.id}
garden={g} garden={g}
currentUserId={me.data?.id}
onShare={() => setDialog({ kind: 'share', garden: g })} onShare={() => setDialog({ kind: 'share', garden: g })}
onEdit={() => setDialog({ kind: 'edit', garden: g })} onEdit={() => setDialog({ kind: 'edit', garden: g })}
onDelete={() => setDialog({ kind: 'delete', garden: g })} onDelete={() => setDialog({ kind: 'delete', garden: g })}