Sharing UI: invite by email, roles, read-only viewer mode (#17) #36
@@ -1,33 +1,30 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { isOwnerRole, type Garden } from '@/lib/gardens'
|
||||
import 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'
|
||||
import { cardActionClass, cardDangerClass } from '@/components/ui/cardActions'
|
||||
|
||||
|
|
||||
/**
|
||||
* 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).
|
||||
* Ownership is the authoritative ownerId==me check, not the my_role hint.
|
||||
*/
|
||||
export function GardenCard({
|
||||
garden,
|
||||
currentUserId,
|
||||
onShare,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onLeave,
|
||||
}: {
|
||||
garden: Garden
|
||||
currentUserId?: number
|
||||
onShare: () => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onLeave: () => void
|
||||
}) {
|
||||
const owner = isOwnerRole(garden.myRole)
|
||||
const owner = currentUserId != null && garden.ownerId === currentUserId
|
||||
return (
|
||||
<div className="flex flex-col rounded-xl border border-border bg-surface transition-colors hover:border-accent/50">
|
||||
<Link
|
||||
|
gitea-actions
commented
🟡 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
🪰 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>
|
||||
@@ -51,18 +48,18 @@ export function GardenCard({
|
||||
<div className="flex justify-end gap-1 border-t border-border px-2 py-1.5">
|
||||
{owner ? (
|
||||
<>
|
||||
<button type="button" onClick={onShare} className={actionClass}>
|
||||
<button type="button" onClick={onShare} className={cardActionClass}>
|
||||
Share
|
||||
</button>
|
||||
<button type="button" onClick={onEdit} className={actionClass}>
|
||||
<button type="button" onClick={onEdit} className={cardActionClass}>
|
||||
Edit
|
||||
</button>
|
||||
<button type="button" onClick={onDelete} className={dangerClass}>
|
||||
<button type="button" onClick={onDelete} className={cardDangerClass}>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" onClick={onLeave} className={dangerClass}>
|
||||
<button type="button" onClick={onLeave} className={cardDangerClass}>
|
||||
Leave
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -46,8 +46,10 @@ export function ShareGardenModal({ garden, onClose }: { garden: Garden; onClose:
|
||||
}
|
||||
}
|
||||
|
||||
const onMutationError = (fallback: string) => (err: unknown) => setError(errorMessage(err, fallback))
|
||||
|
||||
|
gitea-actions
commented
🟠 Modal busy prop does not cover updateRole or remove pending states error-handling · flagged by 3 models
🪰 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 (
|
||||
<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">
|
||||
<form onSubmit={onInvite} className="flex flex-col gap-2">
|
||||
<TextField
|
||||
@@ -90,7 +92,13 @@ export function ShareGardenModal({ garden, onClose }: { garden: Garden; onClose:
|
||||
</div>
|
||||
<select
|
||||
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.') },
|
||||
)
|
||||
|
gitea-actions
commented
🔴 remove mutation has no error handling or pending UI correctness, error-handling, maintainability · flagged by 5 models
🪰 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}`}
|
||||
className={cn(fieldControlClass, 'w-auto px-2 py-1 text-sm')}
|
||||
>
|
||||
@@ -99,7 +107,10 @@ export function ShareGardenModal({ garden, onClose }: { garden: Garden; onClose:
|
||||
</select>
|
||||
<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}`}
|
||||
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"
|
||||
>
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { PlantIcon } from './PlantIcon'
|
||||
import { cardActionClass, cardDangerClass } from '@/components/ui/cardActions'
|
||||
import { CATEGORY_LABELS, isBuiltin, type Plant } from '@/lib/plants'
|
||||
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,
|
||||
* category + mature spacing (unit-aware), and actions. Built-ins are badged and
|
||||
@@ -53,16 +47,16 @@ export function PlantCard({
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
</button>
|
||||
{!builtin && (
|
||||
<button type="button" onClick={onEdit} className={actionClass}>
|
||||
<button type="button" onClick={onEdit} className={cardActionClass}>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
{!builtin && (
|
||||
<button type="button" onClick={onDelete} className={dangerClass}>
|
||||
<button type="button" onClick={onDelete} className={cardDangerClass}>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -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'
|
||||
@@ -66,8 +66,10 @@ export function Inspector({
|
||||
setColor(object.color ?? DEFAULT_COLOR)
|
||||
}, [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 })
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -58,8 +58,10 @@ export function PlopInspector({
|
||||
setPlanted(p.plantedAt ?? '')
|
||||
}, [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 })
|
||||
}
|
||||
|
||||
const u = spacingUnitLabel(unit)
|
||||
const derived = plant ? computeDerivedCount(p.radiusCm, plant.spacingCm) : p.derivedCount
|
||||
|
||||
@@ -31,16 +31,17 @@ export function sharesQueryOptions(gardenId: number) {
|
||||
})
|
||||
}
|
||||
|
||||
/** The shares on a garden (owner-only endpoint; pass enabled=false to skip). */
|
||||
export function useShares(gardenId: number, enabled = true) {
|
||||
return useQuery({ ...sharesQueryOptions(gardenId), enabled })
|
||||
/** The shares on a garden (owner-only endpoint). */
|
||||
export function useShares(gardenId: number) {
|
||||
|
gitea-actions
commented
⚪ useShares maintainability · flagged by 1 model
🪰 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))
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
|
gitea-actions
commented
🔴 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> =>
|
||||
shareSchema.parse(await api.post(`/gardens/${gardenId}/shares`, input)),
|
||||
mutationFn: (input: { email: string; role: ShareRole }) => api.post(`/gardens/${gardenId}/shares`, input),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: sharesKey(gardenId) }),
|
||||
})
|
||||
}
|
||||
@@ -48,8 +49,8 @@ export function useAddShare(gardenId: number) {
|
||||
export function useUpdateShareRole(gardenId: number) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({ userId, role }: { userId: number; role: ShareRole }): Promise<Share> =>
|
||||
shareSchema.parse(await api.patch(`/gardens/${gardenId}/shares/${userId}`, { role })),
|
||||
mutationFn: ({ userId, role }: { userId: number; role: ShareRole }) =>
|
||||
|
gitea-actions
commented
🔴 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>
|
||||
api.patch(`/gardens/${gardenId}/shares/${userId}`, { role }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: sharesKey(gardenId) }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { kindDef } from '@/editor/kinds'
|
||||
import { useEditorStore } from '@/editor/store'
|
||||
import type { EditorGarden } from '@/editor/types'
|
||||
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 { toEditorPlanting } from '@/lib/plantings'
|
||||
|
||||
@@ -23,6 +23,7 @@ export function GardenEditorPage() {
|
||||
const { focus } = routeApi.useSearch()
|
||||
const navigate = routeApi.useNavigate()
|
||||
const full = useGardenFull(gid)
|
||||
const me = useMe()
|
||||
|
||||
const selectedId = useEditorStore((s) => s.selectedId)
|
||||
const select = useEditorStore((s) => s.select)
|
||||
@@ -123,9 +124,11 @@ export function GardenEditorPage() {
|
||||
heightCm: g.heightCm,
|
||||
unitPref: g.unitPref,
|
||||
}
|
||||
// Role-driven gating from the server's my_role — never guessed client-side.
|
||||
const canEdit = canEditRole(g.myRole)
|
||||
const isOwner = isOwnerRole(g.myRole)
|
||||
// Role-driven gating. Ownership is the authoritative ownerId==me check (so a
|
||||
|
gitea-actions
commented
🟠 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
🪰 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>
|
||||
// missing my_role can never lock an owner out); edit access is owner or an
|
||||
// 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 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
|
||||
|
||||
function onPickPlant(plantId: number) {
|
||||
if (!canEdit) return // defense in depth: viewers can't reach the picker anyway
|
||||
const plant = plantsById.get(plantId)
|
||||
if (!plant) return
|
||||
if (picker === 'change' && selectedPlop) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { GardenCard } from '@/components/gardens/GardenCard'
|
||||
import { GardenFormModal } from '@/components/gardens/GardenFormModal'
|
||||
import { LeaveGardenModal } from '@/components/gardens/LeaveGardenModal'
|
||||
import { ShareGardenModal } from '@/components/gardens/ShareGardenModal'
|
||||
import { useMe } from '@/lib/auth'
|
||||
import { useGardens, type Garden } from '@/lib/gardens'
|
||||
|
||||
// Which modal is open, if any. edit/delete/share/leave carry the target garden.
|
||||
@@ -19,6 +20,7 @@ type Dialog =
|
||||
|
||||
export function GardensPage() {
|
||||
const gardens = useGardens()
|
||||
const me = useMe()
|
||||
const [dialog, setDialog] = useState<Dialog>(null)
|
||||
const close = () => setDialog(null)
|
||||
const hasGardens = !!gardens.data && gardens.data.length > 0
|
||||
@@ -50,6 +52,7 @@ export function GardensPage() {
|
||||
<GardenCard
|
||||
key={g.id}
|
||||
garden={g}
|
||||
currentUserId={me.data?.id}
|
||||
onShare={() => setDialog({ kind: 'share', garden: g })}
|
||||
onEdit={() => setDialog({ kind: 'edit', garden: g })}
|
||||
onDelete={() => setDialog({ kind: 'delete', garden: g })}
|
||||
|
||||
🟡 actionClass/dangerClass duplicated verbatim from PlantCard — should be shared
maintainability · flagged by 3 models
actionClass/dangerClassconstants —web/src/components/gardens/GardenCard.tsx:5-10defines the exact same two class strings asweb/src/components/plants/PlantCard.tsx:5-10(verified identical character-for-character). This is copy-paste that should be shared — e.g. extract toweb/src/components/ui/button.tsor acardActionshelper. As more card components appear, this drift will diverge.🪰 Gadfly · advisory