Public read-only share link (no login / no OIDC) (#41) (#43)
Build image / build-and-push (push) Successful in 8s
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user