Public read-only share link (#41)
A per-garden public link anyone can open read-only, with no login and no OIDC. Backend: - Migration 0004 adds gardens.public_token (nullable, unique over non-NULL). - Owner-only management: GET/POST/DELETE /gardens/:id/share-link (state / enable-or-rotate / disable). The token is stored raw (it must be shown back to the owner) and never selected into the normal garden payload. - Unauthenticated GET /api/v1/public/gardens/:token returns the read-only /full payload — the token is the capability, so no requireAuth and no redirect. The public view drops MyRole and OwnerID to minimize what an anonymous viewer learns. Unknown/rotated/disabled tokens are masked as 404. - assembleFull is extracted from GardenFull so both reads return one shape. Frontend: - /g/$token route with NO auth guard (SPA fallback already serves it), rendering GardenCanvas read-only via usePublicGarden. - Share dialog gains a Public link section: create, copy, regenerate, turn off. Tests: service lifecycle + ACL (owner-only, non-owner masked, rotate/disable invalidation) and the HTTP surface incl. the cookieless public read. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
This commit is contained in:
@@ -8,7 +8,16 @@ 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'
|
||||
import {
|
||||
useAddShare,
|
||||
useDisableShareLink,
|
||||
useEnableShareLink,
|
||||
useRemoveShare,
|
||||
useShareLink,
|
||||
useShares,
|
||||
useUpdateShareRole,
|
||||
type ShareRole,
|
||||
} from '@/lib/shares'
|
||||
|
||||
const roleOptions = [
|
||||
{ value: 'viewer', label: 'Viewer (read-only)' },
|
||||
@@ -121,6 +130,8 @@ export function ShareGardenModal({ garden, onClose }: { garden: Garden; onClose:
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<PublicLinkSection gardenId={garden.id} />
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Done
|
||||
@@ -130,3 +141,87 @@ export function ShareGardenModal({ garden, onClose }: { garden: Garden; onClose:
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/** The public read-only link controls: create, copy, regenerate, turn off. */
|
||||
function PublicLinkSection({ gardenId }: { gardenId: number }) {
|
||||
const link = useShareLink(gardenId)
|
||||
const enable = useEnableShareLink(gardenId)
|
||||
const disable = useDisableShareLink(gardenId)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const token = link.data?.enabled ? link.data.token : undefined
|
||||
const url = token ? `${window.location.origin}/g/${token}` : ''
|
||||
const busy = link.isPending || enable.isPending || disable.isPending
|
||||
|
||||
const run = (p: Promise<unknown>, fallback: string) => {
|
||||
setError(null)
|
||||
p.catch((err) => setError(errorMessage(err, fallback)))
|
||||
}
|
||||
|
||||
async function copy() {
|
||||
if (!url) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 1500)
|
||||
} catch {
|
||||
// Clipboard API may be unavailable (e.g. non-secure context); the field is
|
||||
// selectable so the user can still copy manually.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t border-border pt-4">
|
||||
<h3 className="mb-1 text-sm font-medium text-fg">Public link</h3>
|
||||
<p className="mb-2 text-xs text-muted">
|
||||
Anyone with the link can view this garden read-only — no account needed.
|
||||
</p>
|
||||
|
||||
{link.isError && <Alert>Could not load the public link.</Alert>}
|
||||
{error && <Alert>{error}</Alert>}
|
||||
|
||||
{link.isSuccess && !link.data.enabled && (
|
||||
<Button onClick={() => run(enable.mutateAsync({}), 'Could not create the link.')} disabled={busy}>
|
||||
{enable.isPending ? 'Creating…' : 'Create public link'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{link.isSuccess && link.data.enabled && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
readOnly
|
||||
value={url}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
aria-label="Public link URL"
|
||||
className={cn(fieldControlClass, 'min-w-0 flex-1 text-sm')}
|
||||
/>
|
||||
<Button variant="ghost" onClick={copy} disabled={!url}>
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={() => run(enable.mutateAsync({ rotate: true }), 'Could not regenerate the link.')}
|
||||
disabled={busy}
|
||||
title="Issue a new link and invalidate the old one"
|
||||
>
|
||||
{enable.isPending ? 'Working…' : 'Regenerate'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1 text-xs text-red-600 dark:text-red-400"
|
||||
onClick={() => run(disable.mutateAsync(), 'Could not turn off the link.')}
|
||||
disabled={busy}
|
||||
>
|
||||
Turn off
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// Public read-only garden data layer: the unauthenticated /full payload behind a
|
||||
// share token. Shares the FullGarden shape with the editor's own load, so the
|
||||
// public page can reuse toEditorObject / toEditorPlanting and GardenCanvas.
|
||||
|
||||
import { queryOptions, useQuery } from '@tanstack/react-query'
|
||||
import { api } from './api'
|
||||
import { fullGardenSchema, type FullGarden } from './objects'
|
||||
|
||||
export function publicGardenQueryOptions(token: string) {
|
||||
return queryOptions({
|
||||
queryKey: ['public-garden', token] as const,
|
||||
queryFn: async (): Promise<FullGarden> =>
|
||||
fullGardenSchema.parse(await api.get(`/public/gardens/${encodeURIComponent(token)}`)),
|
||||
// A disabled/rotated token is a 404, not a transient failure — don't retry.
|
||||
retry: false,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function usePublicGarden(token: string) {
|
||||
return useQuery(publicGardenQueryOptions(token))
|
||||
}
|
||||
@@ -68,3 +68,49 @@ export function useRemoveShare(gardenId: number) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// --- public share link (owner-only management) -----------------------------
|
||||
// The read-only public link that anyone (no account) can open. token is present
|
||||
// only when enabled; the page builds the URL as `${origin}/g/${token}`.
|
||||
|
||||
export const shareLinkSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
token: z.string().optional(),
|
||||
})
|
||||
export type ShareLinkState = z.infer<typeof shareLinkSchema>
|
||||
|
||||
const shareLinkKey = (gardenId: number) => ['share-link', gardenId] as const
|
||||
|
||||
export function shareLinkQueryOptions(gardenId: number) {
|
||||
return queryOptions({
|
||||
queryKey: shareLinkKey(gardenId),
|
||||
queryFn: async (): Promise<ShareLinkState> =>
|
||||
shareLinkSchema.parse(await api.get(`/gardens/${gardenId}/share-link`)),
|
||||
})
|
||||
}
|
||||
|
||||
/** The garden's public-link state (owner-only endpoint). */
|
||||
export function useShareLink(gardenId: number) {
|
||||
return useQuery(shareLinkQueryOptions(gardenId))
|
||||
}
|
||||
|
||||
/** Enable the public link, or with { rotate: true } issue a fresh token that
|
||||
* invalidates the old URL. Writes the returned state straight into the cache. */
|
||||
export function useEnableShareLink(gardenId: number) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({ rotate = false }: { rotate?: boolean }): Promise<ShareLinkState> =>
|
||||
shareLinkSchema.parse(await api.post(`/gardens/${gardenId}/share-link`, { rotate })),
|
||||
onSuccess: (state) => qc.setQueryData(shareLinkKey(gardenId), state),
|
||||
})
|
||||
}
|
||||
|
||||
/** Disable the public link. */
|
||||
export function useDisableShareLink(gardenId: number) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (): Promise<ShareLinkState> =>
|
||||
shareLinkSchema.parse(await api.delete(`/gardens/${gardenId}/share-link`)),
|
||||
onSuccess: (state) => qc.setQueryData(shareLinkKey(gardenId), state),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
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(() => {
|
||||
const s = useEditorStore.getState()
|
||||
s.setFocusedObject(null)
|
||||
s.select(null)
|
||||
s.selectPlanting(null)
|
||||
s.setArmedPlant(null)
|
||||
s.setArmedKind(null)
|
||||
s.setLiveObject(null)
|
||||
s.setLivePlanting(null)
|
||||
}, [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 isn’t 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>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { LoginPage } from '@/pages/LoginPage'
|
||||
import { RegisterPage } from '@/pages/RegisterPage'
|
||||
import { GardensPage } from '@/pages/GardensPage'
|
||||
import { GardenEditorPage } from '@/pages/GardenEditorPage'
|
||||
import { PublicGardenPage } from '@/pages/PublicGardenPage'
|
||||
import { PlantsPage } from '@/pages/PlantsPage'
|
||||
import { meQueryOptions } from '@/lib/auth'
|
||||
import { queryClient } from '@/lib/queryClient'
|
||||
@@ -108,6 +109,15 @@ const plantsRoute = createRoute({
|
||||
component: PlantsPage,
|
||||
})
|
||||
|
||||
// Public read-only garden by share token. Deliberately has NO beforeLoad auth
|
||||
// guard, so a logged-out visitor viewing a shared link is never redirected to
|
||||
// /login or OIDC.
|
||||
const publicGardenRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: 'g/$token',
|
||||
component: PublicGardenPage,
|
||||
})
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
loginRoute,
|
||||
@@ -115,6 +125,7 @@ const routeTree = rootRoute.addChildren([
|
||||
gardensRoute,
|
||||
gardenEditorRoute,
|
||||
plantsRoute,
|
||||
publicGardenRoute,
|
||||
])
|
||||
|
||||
export const router = createRouter({
|
||||
|
||||
Reference in New Issue
Block a user