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 { 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'
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
* 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
1
@@ -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))
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
2
@@ -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.') },
)
}}
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"
>
+4 -10
View File
@@ -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>
)}
+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'
+3 -1
View File
@@ -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
2
+3 -1
View File
@@ -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
+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). */
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) {
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({
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> =>
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 }) =>
api.patch(`/gardens/${gardenId}/shares/${userId}`, { role }),
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 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
// 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) {
+3
View File
@@ -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 })}