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]>
228 lines
8.1 KiB
TypeScript
228 lines
8.1 KiB
TypeScript
import { useState, type FormEvent } from 'react'
|
|
import { Modal } from '@/components/ui/Modal'
|
|
import { Alert } from '@/components/ui/Alert'
|
|
import { Button } from '@/components/ui/Button'
|
|
import { Select } from '@/components/ui/Select'
|
|
import { TextField } from '@/components/ui/TextField'
|
|
import { cn } from '@/lib/cn'
|
|
import { fieldControlClass } from '@/components/ui/field'
|
|
import { errorMessage } from '@/lib/api'
|
|
import type { Garden } from '@/lib/gardens'
|
|
import {
|
|
useAddShare,
|
|
useDisableShareLink,
|
|
useEnableShareLink,
|
|
useRemoveShare,
|
|
useShareLink,
|
|
useShares,
|
|
useUpdateShareRole,
|
|
type ShareRole,
|
|
} from '@/lib/shares'
|
|
|
|
const roleOptions = [
|
|
{ value: 'viewer', label: 'Viewer (read-only)' },
|
|
{ value: 'editor', label: 'Editor (can edit)' },
|
|
]
|
|
|
|
/**
|
|
* Owner-only dialog to manage a garden's shares: invite an existing user by
|
|
* email as viewer/editor, change a share's role, or remove it. Targets existing
|
|
* accounts only (v1 has no invitation emails) — an unknown email surfaces a
|
|
* friendly "no account with that email".
|
|
*/
|
|
export function ShareGardenModal({ garden, onClose }: { garden: Garden; onClose: () => void }) {
|
|
const shares = useShares(garden.id)
|
|
const add = useAddShare(garden.id)
|
|
const updateRole = useUpdateShareRole(garden.id)
|
|
const remove = useRemoveShare(garden.id)
|
|
|
|
const [email, setEmail] = useState('')
|
|
const [role, setRole] = useState<ShareRole>('viewer')
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
async function onInvite(e: FormEvent) {
|
|
e.preventDefault()
|
|
setError(null)
|
|
if (!email.trim()) {
|
|
setError('Enter an email address.')
|
|
return
|
|
}
|
|
try {
|
|
await add.mutateAsync({ email: email.trim(), role })
|
|
setEmail('')
|
|
} catch (err) {
|
|
setError(errorMessage(err, 'Could not share the garden.'))
|
|
}
|
|
}
|
|
|
|
const onMutationError = (fallback: string) => (err: unknown) => setError(errorMessage(err, fallback))
|
|
|
|
return (
|
|
<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
|
|
label="Invite by email"
|
|
name="email"
|
|
type="email"
|
|
placeholder="[email protected]"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
/>
|
|
<div className="flex items-end gap-2">
|
|
<Select
|
|
label="Role"
|
|
name="role"
|
|
value={role}
|
|
onChange={(e) => setRole(e.target.value as ShareRole)}
|
|
options={roleOptions}
|
|
className="flex-1"
|
|
/>
|
|
<Button type="submit" disabled={add.isPending}>
|
|
{add.isPending ? 'Sharing…' : 'Share'}
|
|
</Button>
|
|
</div>
|
|
{error && <Alert>{error}</Alert>}
|
|
</form>
|
|
|
|
<div>
|
|
<h3 className="mb-2 text-sm font-medium text-fg">Shared with</h3>
|
|
{shares.isPending && <p className="text-sm text-muted">Loading…</p>}
|
|
{shares.isError && <Alert>Could not load who this garden is shared with.</Alert>}
|
|
{shares.isSuccess && shares.data.length === 0 && (
|
|
<p className="text-sm text-muted">Not shared with anyone yet.</p>
|
|
)}
|
|
<ul className="flex flex-col gap-2">
|
|
{shares.data?.map((sh) => (
|
|
<li key={sh.userId} className="flex items-center gap-2">
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-sm font-medium text-fg">{sh.displayName}</p>
|
|
<p className="truncate text-xs text-muted">{sh.email}</p>
|
|
</div>
|
|
<select
|
|
value={sh.role}
|
|
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')}
|
|
>
|
|
<option value="viewer">Viewer</option>
|
|
<option value="editor">Editor</option>
|
|
</select>
|
|
<button
|
|
type="button"
|
|
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"
|
|
>
|
|
✕
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
|
|
<PublicLinkSection gardenId={garden.id} />
|
|
|
|
<div className="flex justify-end">
|
|
<Button variant="ghost" onClick={onClose}>
|
|
Done
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</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>
|
|
)
|
|
}
|