Public read-only share link (no login / no OIDC) (#41) (#43)
Build image / build-and-push (push) Successful in 8s

Per-garden public read-only link: unauthenticated GET /api/v1/public/gardens/:token (no requireAuth, no OIDC), owner-only enable/rotate/disable, and a /g/$token page rendering GardenCanvas read-only. Review fixes: redact plant owner ids, Cache-Control: no-store, 400 on malformed body, shared resetTransient store action.

Closes #41.

Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #43.
This commit is contained in:
2026-07-19 06:18:31 +00:00
committed by steve
parent 5cdc2779d7
commit 9968c06243
15 changed files with 782 additions and 9 deletions
+72
View File
@@ -0,0 +1,72 @@
import { useEffect, useMemo } from 'react'
import { getRouteApi } from '@tanstack/react-router'
import { Alert } from '@/components/ui/Alert'
import { GardenCanvas } from '@/editor/GardenCanvas'
import { useEditorStore } from '@/editor/store'
import type { EditorGarden } from '@/editor/types'
import { toEditorObject } from '@/lib/objects'
import { toEditorPlanting } from '@/lib/plantings'
import { usePublicGarden } from '@/lib/publicGarden'
import { usePageTitle } from '@/lib/usePageTitle'
const routeApi = getRouteApi('/g/$token')
/**
* The public, read-only garden view behind a share token. Rendered on the
* unauthenticated `/g/$token` route (no auth guard), so a logged-out visitor
* never hits /login or OIDC. Reuses GardenCanvas with canEdit=false — pan/zoom
* to explore, but no editing chrome.
*/
export function PublicGardenPage() {
const { token } = routeApi.useParams()
const full = usePublicGarden(token)
usePageTitle(full.data?.garden.name ?? 'Shared garden')
// Start from a clean canvas: if a logged-in user reaches here from their own
// editor, stale focus/selection state would otherwise dim or highlight things.
useEffect(() => {
useEditorStore.getState().resetTransient()
}, [token])
const objects = useMemo(() => full.data?.objects.map(toEditorObject) ?? [], [full.data?.objects])
const plantings = useMemo(() => full.data?.plantings.map(toEditorPlanting) ?? [], [full.data?.plantings])
const plants = useMemo(() => full.data?.plants ?? [], [full.data?.plants])
const plantsById = useMemo(() => new Map(plants.map((p) => [p.id, p])), [plants])
if (full.isPending) return <p className="p-6 text-sm text-muted">Loading garden</p>
if (full.isError)
return (
<div className="p-6">
<Alert>This shared link isnt available. The owner may have turned it off or changed it.</Alert>
</div>
)
const g = full.data.garden
const garden: EditorGarden = {
id: g.id,
name: g.name,
widthCm: g.widthCm,
heightCm: g.heightCm,
unitPref: g.unitPref,
}
return (
<div className="flex h-[calc(100vh-8rem)] flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<h1 className="truncate text-lg font-semibold tracking-tight" title={garden.name}>
{garden.name}
</h1>
<span className="rounded-md bg-border/40 px-2 py-1 text-xs text-muted">👁 Shared · read-only</span>
</div>
<div className="min-h-0 flex-1">
<GardenCanvas
garden={garden}
objects={objects}
plantings={plantings}
plantsById={plantsById}
canEdit={false}
/>
</div>
</div>
)
}