Address code review findings across API, web and deployment
release gate / check (push) Failing after 6m43s
release gate / check (push) Failing after 6m43s
High severity: - wire TRUSTED_PROXY through Compose/deploy/docs and warn at web startup - bucket public rate limits on effective client IP, add per-bookmark budget, and forward the signed browser identity from the share page - disable first-run bootstrap in server deployments and document seeding - serialize last-admin demotion in one advisory-locked transaction Medium/low: - cap and batch share recipient inserts - add a shared outbound budget for preview/CardDAV/IMAP/signal work - pin CardDAV dials to 443 and bound IMAP logout - fix rightward kanban column drags and normalize cards on column delete - classify backend outages in auth guards, add request timeouts and an error boundary, remove the dead login route, surface throttling - enforce pinned/unpinned page reorder grouping server-side - avoid preview work for stale bookmark versions; drop duplicate dialog fetch - sequence-guard collection/tag/calendar/quick-find loads - validate property config and fix MULTI_SELECT/DATE filter inputs - document index maintenance; apply same-origin check to sign-out - trim signup names, unique shared dates and kanban keys (migration 010), reset reference cache on identity changes, hide OIDC issuer, return 413 for oversized direct bodies, escape Markdown link destinations - remove dead template files and duplicate test helper Verified with go build/vet, full PostgreSQL race suite, db convergence, tsc, both ESLint configs and a Next production build.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -109,7 +109,7 @@ function plainValue(prop: CollectionProperty, value: unknown): string {
|
||||
if (prop.type === 'DATE' && typeof value === 'string' && value) {
|
||||
if (prop.config.mode === 'date') return value.slice(0, 10)
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString()
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('en-US', { timeZone: 'UTC' })
|
||||
}
|
||||
if (value === null || value === undefined || value === '') return '—'
|
||||
return String(value)
|
||||
@@ -246,6 +246,12 @@ export function CollectionsClient() {
|
||||
const [shareQuery, setShareQuery] = useState('')
|
||||
const [shareResults, setShareResults] = useState<{ id: string; name: string | null; email: string }[]>([])
|
||||
|
||||
// Review M-11: switching collections/views quickly used to let an older
|
||||
// response overwrite the newer selection. Each loader has a request
|
||||
// sequence and only the latest may apply its result.
|
||||
const detailSeq = useRef(0)
|
||||
const itemsSeq = useRef(0)
|
||||
|
||||
const loadCollections = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/collections')
|
||||
@@ -254,27 +260,33 @@ export function CollectionsClient() {
|
||||
}, [])
|
||||
|
||||
const loadDetail = useCallback(async (id: string) => {
|
||||
const seq = ++detailSeq.current
|
||||
try {
|
||||
const res = await fetch(`/api/collections/${id}`)
|
||||
if (seq !== detailSeq.current) return
|
||||
if (!res.ok) { setDetail(null); return }
|
||||
const data: CollectionDetail = await res.json()
|
||||
if (seq !== detailSeq.current) return
|
||||
setDetail(data)
|
||||
setNameDraft(data.name)
|
||||
setViewId((current) => (current && data.views.some((v) => v.id === current) ? current : data.views[0]?.id ?? null))
|
||||
} catch { toast.error('Could not load collection') }
|
||||
} catch { if (seq === detailSeq.current) toast.error('Could not load collection') }
|
||||
}, [])
|
||||
|
||||
const loadItems = useCallback(async (id: string, view: string | null, append?: string | null) => {
|
||||
const seq = ++itemsSeq.current
|
||||
try {
|
||||
const params = new URLSearchParams({ limit: '200' })
|
||||
if (view) params.set('viewId', view)
|
||||
if (append) params.set('cursor', append)
|
||||
const res = await fetch(`/api/collections/${id}/items?${params}`)
|
||||
if (seq !== itemsSeq.current) return
|
||||
if (!res.ok) return
|
||||
const rows: CollectionItem[] = await res.json()
|
||||
if (seq !== itemsSeq.current) return
|
||||
setItems((current) => (append ? [...current, ...rows] : rows))
|
||||
setCursor(res.headers.get('X-Next-Cursor'))
|
||||
} catch { toast.error('Could not load items') }
|
||||
} catch { if (seq === itemsSeq.current) toast.error('Could not load items') }
|
||||
}, [])
|
||||
|
||||
useEffect(() => { const t = setTimeout(loadCollections, 0); return () => clearTimeout(t) }, [loadCollections])
|
||||
@@ -286,14 +298,14 @@ export function CollectionsClient() {
|
||||
}, [collections, activeId])
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
if (!activeId) { setDetail(null); return }
|
||||
if (!activeId) { detailSeq.current++; setDetail(null); return }
|
||||
loadDetail(activeId)
|
||||
}, 0)
|
||||
return () => clearTimeout(t)
|
||||
}, [activeId, loadDetail])
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
if (!activeId) { setItems([]); return }
|
||||
if (!activeId) { itemsSeq.current++; setItems([]); return }
|
||||
loadItems(activeId, viewId)
|
||||
}, 0)
|
||||
return () => clearTimeout(t)
|
||||
@@ -529,7 +541,12 @@ export function CollectionsClient() {
|
||||
if (!['empty', 'notEmpty'].includes(fOp)) {
|
||||
if (prop.type === 'NUMBER') value = Number(fValue)
|
||||
else if (prop.type === 'CHECKBOX') value = fValue === 'true'
|
||||
else value = fValue
|
||||
else if (prop.type === 'DATE' && prop.config?.mode === 'datetime') {
|
||||
// Review M-14: datetime-local emits a local string; the server stores
|
||||
// fixed-width UTC.
|
||||
const parsed = new Date(fValue)
|
||||
value = Number.isNaN(parsed.getTime()) ? '' : parsed.toISOString()
|
||||
} else value = fValue
|
||||
if (fValue === '' || (prop.type === 'NUMBER' && Number.isNaN(value))) { toast.error('Filter value required'); return }
|
||||
}
|
||||
setClause([...filterClauses, value === undefined ? { propertyId: fProp, op: fOp } : { propertyId: fProp, op: fOp, value }])
|
||||
@@ -539,7 +556,9 @@ export function CollectionsClient() {
|
||||
const prop = detail?.properties.find((p) => p.id === c.propertyId)
|
||||
if (!prop) return 'unknown'
|
||||
let shown = c.value
|
||||
if (prop.type === 'SELECT' && typeof c.value === 'string') shown = prop.config.options?.find((o) => o.id === c.value)?.label ?? c.value
|
||||
// Review M-14: MULTI_SELECT filters also carry an option id; show its
|
||||
// label like SELECT instead of the raw id.
|
||||
if ((prop.type === 'SELECT' || prop.type === 'MULTI_SELECT') && typeof c.value === 'string') shown = prop.config.options?.find((o) => o.id === c.value)?.label ?? c.value
|
||||
if (prop.type === 'CHECKBOX') shown = c.value === true ? 'checked' : 'unchecked'
|
||||
return `${prop.name} ${OP_LABELS[c.op] ?? c.op}${shown !== undefined ? ` ${shown}` : ''}`
|
||||
}
|
||||
@@ -637,6 +656,9 @@ export function CollectionsClient() {
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
const item = items.find((i) => i.id === draggedItemId)
|
||||
// This is a drop handler, not render; the compiler rule cannot
|
||||
// see through the async helper's captured state.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
if (item) void moveItemToGroup(item, col.key === NONE ? null : col.key)
|
||||
setDraggedItemId(null)
|
||||
setBoardDropKey(null)
|
||||
@@ -824,12 +846,18 @@ export function CollectionsClient() {
|
||||
</Select>
|
||||
{!['empty', 'notEmpty'].includes(fOp) && (() => {
|
||||
const p = detail.properties.find((x) => x.id === fProp)
|
||||
if (p?.type === 'SELECT') return (
|
||||
if (p?.type === 'SELECT' || p?.type === 'MULTI_SELECT') return (
|
||||
<Select value={fValue || undefined} onValueChange={setFValue}>
|
||||
<SelectTrigger aria-label="Filter value"><SelectValue placeholder="Value" /></SelectTrigger>
|
||||
<SelectContent>{(p.config.options ?? []).map((o) => <SelectItem key={o.id} value={o.id}>{o.label}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
// Review M-14: DATE filters used to render a plain
|
||||
// text box, so a normal date always failed server
|
||||
// validation. Match the property editor's inputs.
|
||||
if (p?.type === 'DATE') return p.config?.mode === 'datetime'
|
||||
? <Input type="datetime-local" value={fValue} onChange={(e) => setFValue(e.target.value)} aria-label="Filter value" className="h-8 text-sm" />
|
||||
: <Input type="date" value={fValue} onChange={(e) => setFValue(e.target.value)} aria-label="Filter value" className="h-8 text-sm" />
|
||||
if (p?.type === 'CHECKBOX') return (
|
||||
<Select value={fValue || undefined} onValueChange={setFValue}>
|
||||
<SelectTrigger aria-label="Filter value"><SelectValue placeholder="Value" /></SelectTrigger>
|
||||
|
||||
Reference in New Issue
Block a user