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.
164 lines
6.0 KiB
TypeScript
164 lines
6.0 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
import { useSession } from 'next-auth/react'
|
|
import { usePathname, useRouter } from 'next/navigation'
|
|
import { Sidebar } from '@/components/sidebar'
|
|
import { TopBar } from '@/components/top-bar'
|
|
import { AddBookmarkDialog } from '@/components/add-bookmark-dialog'
|
|
import { QuickFind } from '@/components/quick-find'
|
|
import { resetReferenceCache } from '@/lib/reference-data'
|
|
|
|
export type Bookmark = {
|
|
id: string
|
|
url: string
|
|
title: string | null
|
|
description: string | null
|
|
favicon: string | null
|
|
ogImage: string | null
|
|
ogTitle: string | null
|
|
ogDescription: string | null
|
|
notes: string | null
|
|
dueDate: string | null
|
|
// Owner-only fields (review PRIV-02): shared readers receive a narrowed
|
|
// projection without contacts, recipients, alerts or mailbox watches.
|
|
alertAt?: string | null
|
|
alertSent?: boolean
|
|
kanbanStatus: string
|
|
kanbanColumnId?: string | null
|
|
// Owner-only board order within a column. Shared-reader projections omit it.
|
|
kanbanPosition?: number
|
|
imapWatchEnabled?: boolean
|
|
imapQuery?: string | null
|
|
isPublic: boolean
|
|
ownerId: string
|
|
contactId?: string | null
|
|
// Optimistic concurrency revision (review CR-04): every owner-driven PUT
|
|
// must send the version it edited from; 409 means a newer edit exists.
|
|
version?: number
|
|
contact?: any | null
|
|
tags: { tag: { id: string; name: string; color: string; ownerId?: string } }[]
|
|
sharedWith?: any[]
|
|
owner?: any
|
|
createdAt: string
|
|
updatedAt: string
|
|
}
|
|
|
|
export type TagWithCount = {
|
|
id: string
|
|
name: string
|
|
color: string
|
|
ownerId: string
|
|
_count: { bookmarks: number }
|
|
sharedWith?: any[]
|
|
}
|
|
|
|
export function AppShell({ children }: { children: React.ReactNode }) {
|
|
const { data: session } = useSession()
|
|
const router = useRouter()
|
|
const pathname = usePathname()
|
|
const [sidebarOpen, setSidebarOpen] = useState(true)
|
|
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
|
const [quickFindOpen, setQuickFindOpen] = useState(false)
|
|
const [alerts, setAlerts] = useState<any[]>([])
|
|
const [me, setMe] = useState<{ isAdmin: boolean; viewingAs: { name: string | null; email: string } | null } | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (!session?.user) return
|
|
fetch('/api/me')
|
|
.then((r) => (r.ok ? r.json() : null))
|
|
.then(setMe)
|
|
.catch((e) => console.error('Failed to load account info', e))
|
|
}, [session?.user])
|
|
|
|
const exitViewAs = useCallback(async () => {
|
|
try {
|
|
await fetch('/api/admin/view-as', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId: null }) })
|
|
} catch { /* navigating to /admin surfaces any persistent failure */ }
|
|
// Review L-6: leaving view-as restores the admin identity.
|
|
resetReferenceCache()
|
|
window.location.href = '/admin'
|
|
}, [])
|
|
|
|
// Poll alerts. The API returns every un-dismissed alert on each poll, so
|
|
// track already-notified IDs per tab to avoid re-firing every 5 minutes.
|
|
const notifiedAlerts = useRef<Set<string>>(new Set())
|
|
useEffect(() => {
|
|
if (!session?.user) return
|
|
const check = async () => {
|
|
try {
|
|
const res = await fetch('/api/alerts/check')
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setAlerts(data ?? [])
|
|
if (data?.length && typeof Notification !== 'undefined' && Notification.permission === 'granted') {
|
|
for (const a of data) {
|
|
const id = String(a?.id ?? a?.bookmarkId ?? '')
|
|
if (!id || notifiedAlerts.current.has(id)) continue
|
|
notifiedAlerts.current.add(id)
|
|
new Notification('computer alert', { body: `${a?.title ?? 'Bookmark'} is due!`, icon: '/favicon.svg' })
|
|
}
|
|
}
|
|
}
|
|
} catch { /* ignore */ }
|
|
}
|
|
check()
|
|
const interval = setInterval(check, 5 * 60 * 1000)
|
|
return () => clearInterval(interval)
|
|
}, [session?.user])
|
|
|
|
// Review L-5: notification permission is requested from the alert bell on
|
|
// an explicit user action (top-bar.tsx), not on every app mount.
|
|
|
|
const refreshPage = useCallback(() => {
|
|
window.dispatchEvent(new Event('computer:bookmarks-changed'))
|
|
router.refresh()
|
|
}, [router])
|
|
|
|
const dismissAlert = useCallback(async (bookmarkId: string) => {
|
|
try {
|
|
const res = await fetch(`/api/alerts/${bookmarkId}`, { method: 'DELETE' })
|
|
if (!res.ok) return
|
|
} catch { return }
|
|
setAlerts((prev: any[]) => (prev ?? []).filter((a: any) => a?.id !== bookmarkId))
|
|
}, [])
|
|
|
|
if (!session?.user) return null
|
|
|
|
return (
|
|
<div className="flex h-screen overflow-hidden bg-background">
|
|
<Sidebar open={sidebarOpen} onToggle={() => setSidebarOpen(!sidebarOpen)} onQuickFind={() => setQuickFindOpen(true)} pathname={pathname} isAdmin={!!me?.isAdmin} />
|
|
<div className="flex flex-1 flex-col overflow-hidden">
|
|
{me?.viewingAs && (
|
|
<div className="flex items-center justify-center gap-3 bg-amber-500 px-4 py-1.5 text-sm font-medium text-amber-950">
|
|
<span>Admin view — viewing as {me.viewingAs.name || me.viewingAs.email}</span>
|
|
<button onClick={exitViewAs} className="rounded bg-amber-950/15 px-2 py-0.5 text-xs font-semibold hover:bg-amber-950/25">Exit</button>
|
|
</div>
|
|
)}
|
|
<TopBar
|
|
user={session.user as any}
|
|
alerts={alerts}
|
|
onDismissAlert={dismissAlert}
|
|
onAddBookmark={() => setAddDialogOpen(true)}
|
|
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
|
|
onQuickFind={() => setQuickFindOpen(true)}
|
|
/>
|
|
<main className="flex-1 overflow-auto p-6">
|
|
{children}
|
|
</main>
|
|
</div>
|
|
<AddBookmarkDialog
|
|
open={addDialogOpen}
|
|
onClose={() => setAddDialogOpen(false)}
|
|
onCreated={() => { setAddDialogOpen(false); refreshPage() }}
|
|
/>
|
|
<QuickFind
|
|
open={quickFindOpen}
|
|
onOpenChange={setQuickFindOpen}
|
|
isAdmin={!!me?.isAdmin}
|
|
onAddBookmark={() => setAddDialogOpen(true)}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|