diff --git a/apps/web/app/admin/audit/[id]/page.tsx b/apps/web/app/admin/audit/[id]/page.tsx index fbc2634a..e48e4130 100644 --- a/apps/web/app/admin/audit/[id]/page.tsx +++ b/apps/web/app/admin/audit/[id]/page.tsx @@ -9,6 +9,7 @@ import { formatAdminTimestamp, } from "@/components/admin-ui"; import { requireOwnerUser } from "@/lib/auth"; +import { isRouteUuid } from "@/lib/input-validation"; type AdminAuditDetailPageProps = { params: Promise<{ id: string }>; @@ -50,6 +51,9 @@ export default async function AdminAuditDetailPage({ }: AdminAuditDetailPageProps) { const ownerUser = await requireOwnerUser(); const { id } = await params; + if (!isRouteUuid(id)) { + notFound(); + } const event = await getAdminAuditEventById(ownerUser.id, id); if (!event) { diff --git a/apps/web/app/admin/barcodes/[id]/page.tsx b/apps/web/app/admin/barcodes/[id]/page.tsx index 8c8b60b6..34994996 100644 --- a/apps/web/app/admin/barcodes/[id]/page.tsx +++ b/apps/web/app/admin/barcodes/[id]/page.tsx @@ -14,6 +14,7 @@ import { updateAdminBarcodeProductAction, } from "@/lib/admin-actions"; import { requireAdminUser } from "@/lib/auth"; +import { isRouteUuid } from "@/lib/input-validation"; type AdminBarcodeDetailPageProps = { params: Promise<{ id: string }>; @@ -29,6 +30,9 @@ export default async function AdminBarcodeDetailPage({ }: AdminBarcodeDetailPageProps) { const adminUser = await requireAdminUser(); const [{ id }, query] = await Promise.all([params, searchParams]); + if (!isRouteUuid(id)) { + notFound(); + } const [barcode, audit] = await Promise.all([ getAdminBarcodeProductById(adminUser.id, id), listAdminAuditEvents(adminUser.id, { diff --git a/apps/web/app/admin/users/[userId]/page.tsx b/apps/web/app/admin/users/[userId]/page.tsx index 253669cc..9de2680b 100644 --- a/apps/web/app/admin/users/[userId]/page.tsx +++ b/apps/web/app/admin/users/[userId]/page.tsx @@ -13,6 +13,7 @@ import { import { ConfirmSubmitButton } from "@/components/confirm-delete-button"; import { changeUserRoleAction } from "@/lib/admin-actions"; import { requireAdminUser } from "@/lib/auth"; +import { isRouteUuid } from "@/lib/input-validation"; import { getTemplateMacroTotals } from "@/lib/template-macros"; type AdminUserDetailPageProps = { @@ -46,6 +47,9 @@ export default async function AdminUserDetailPage({ }: AdminUserDetailPageProps) { const adminUser = await requireAdminUser(); const [{ userId }, query] = await Promise.all([params, searchParams]); + if (!isRouteUuid(userId)) { + notFound(); + } const detail = await getAdminUserDetail(adminUser.id, userId); if (!detail) { diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index ec34fab7..bd0e628c 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -8,6 +8,7 @@ import { LayoutNav } from "@/components/layout-nav"; import { OfflineBanner } from "@/components/offline-banner"; import { OrientationLock } from "@/components/orientation-lock"; import { ServiceWorkerRegister } from "@/components/service-worker-register"; +import { getRequestTodayFromHeaders } from "@/lib/server-timezone"; import { TIMEZONE_COOKIE_MAX_AGE_SECONDS, TIMEZONE_COOKIE_NAME, @@ -83,7 +84,11 @@ export default async function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { - const nonce = (await headers()).get(NONCE_HEADER) ?? undefined; + const requestHeaders = await headers(); + const nonce = requestHeaders.get(NONCE_HEADER) ?? undefined; + // Stable per-request day snapshot for the bottom nav so SSR and the + // first client render agree; the browser corrects post-hydration. + const today = getRequestTodayFromHeaders(requestHeaders); return ( - + {children} diff --git a/apps/web/app/library/page.tsx b/apps/web/app/library/page.tsx index 3fc0dc2a..615242ed 100644 --- a/apps/web/app/library/page.tsx +++ b/apps/web/app/library/page.tsx @@ -1,24 +1,59 @@ -import { getRecipeSummaries, getTemplateSummaries, searchFoodProducts } from "@macro-tracker/db"; +import { + BackendError, + getRecipeSummaries, + getTemplateSummaries, + searchFoodProducts, + type BrowserFoodProduct, +} from "@macro-tracker/db"; import { LibraryShell } from "@/components/library-shell"; import { toBrowserFoodProducts } from "@/lib/browser-product"; +import { validateSearchQuery } from "@/lib/input-validation"; import { loadOnboardedPageContext } from "@/lib/page-context"; type LibraryPageProps = { searchParams: Promise<{ q?: string; date?: string }>; }; +function isSearchValidationError(error: unknown) { + const code = (error as { code?: string } | null)?.code; + const name = (error as { name?: string } | null)?.name; + return ( + code === "bad_request" && + (error instanceof BackendError || name === "BackendError") + ); +} + export default async function LibraryPage({ searchParams }: LibraryPageProps) { const { params, sessionUser, selectedDate, userEmail, canAccessAdmin, today} = await loadOnboardedPageContext(searchParams); const query = params.q ?? ""; - const [templates, recipes, productRows] = await Promise.all([ + const [templates, recipes] = await Promise.all([ getTemplateSummaries(sessionUser.userId), getRecipeSummaries(sessionUser.userId), - query.trim() ? searchFoodProducts(sessionUser.userId, query) : Promise.resolve([]), ]); - // SEC-06: strip internal provenance before the rows are serialized into client props. - const products = toBrowserFoodProducts(productRows); + + // Invalid input stays on the page as an actionable message with the query + // preserved; only genuine backend outages reach the error boundary. + // Successful rows are projected to the browser-safe shape (SEC-06). + let products: BrowserFoodProduct[] = []; + let searchError: string | null = null; + if (query.trim()) { + searchError = validateSearchQuery(query); + if (!searchError) { + try { + products = toBrowserFoodProducts( + await searchFoodProducts(sessionUser.userId, query), + ); + } catch (error) { + if (isSearchValidationError(error)) { + searchError = (error as Error).message; + } else { + throw error; + } + } + } + } return ( ); } diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx index a62ae315..87e9fd40 100644 --- a/apps/web/app/login/page.tsx +++ b/apps/web/app/login/page.tsx @@ -3,6 +3,7 @@ import { redirect } from "next/navigation"; import { ShooLoginButton } from "@/components/shoo-login-button"; import { ThemeToggle } from "@/components/theme-toggle"; import { getCurrentSessionUser } from "@/lib/auth"; +import { getLoginErrorMessage } from "@/lib/login-errors"; type LoginPageProps = { searchParams: Promise<{ @@ -11,13 +12,6 @@ type LoginPageProps = { }>; }; -const ERROR_MESSAGES: Record = { - login_failed: "Sign-in did not complete. Please try again.", - missing_email: "Google did not provide an email address for this account.", - invalid_token: "The Shoo token could not be verified.", - session_expired: "Your local session expired. Please sign in again.", -}; - const shooBaseUrl = process.env.NEXT_PUBLIC_SHOO_BASE_URL ?? process.env.SHOO_BASE_URL ?? @@ -31,7 +25,7 @@ export default async function LoginPage({ searchParams }: LoginPageProps) { const params = await searchParams; const clearIdentityOnMount = params.loggedOut === "1"; - const errorMessage = params.error ? ERROR_MESSAGES[params.error] : null; + const errorMessage = getLoginErrorMessage(params.error); const loggedOut = clearIdentityOnMount; return ( diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index f437e2b1..b0b8fa6c 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -33,6 +33,7 @@ export default async function HomePage({ searchParams }: HomePageProps) { canAccessAdmin={canAccessAdmin} selectedDate={selectedDate} todayStr={today} + initialHourUtc={new Date().getUTCHours()} dailySummary={dailySummary} goals={goals} quickAddCandidates={quickAddCandidates} diff --git a/apps/web/app/recipes/[id]/edit/page.tsx b/apps/web/app/recipes/[id]/edit/page.tsx index 8a9c6898..4f5d999e 100644 --- a/apps/web/app/recipes/[id]/edit/page.tsx +++ b/apps/web/app/recipes/[id]/edit/page.tsx @@ -2,6 +2,7 @@ import { getRecipeById, getTemplates } from "@macro-tracker/db"; import { notFound } from "next/navigation"; import { RecipeBuilderShell } from "@/components/recipe-builder-shell"; +import { isRouteUuid } from "@/lib/input-validation"; import { loadOnboardedPageContext } from "@/lib/page-context"; type EditRecipePageProps = { @@ -21,6 +22,12 @@ export default async function EditRecipePage({ ]); const { sessionUser, selectedDate, today, userEmail, canAccessAdmin } = pageContext; + // Malformed ids are a 404 after auth; only genuine backend failures reach + // the error boundary. Well-formed-but-missing ids 404 on the null below. + if (!isRouteUuid(routeParams.id)) { + notFound(); + } + const [recipe, templates] = await Promise.all([ getRecipeById(sessionUser.userId, routeParams.id), getTemplates(sessionUser.userId), diff --git a/apps/web/components/admin-shell.tsx b/apps/web/components/admin-shell.tsx index 61ea014c..dffb3146 100644 --- a/apps/web/components/admin-shell.tsx +++ b/apps/web/components/admin-shell.tsx @@ -15,6 +15,29 @@ function getRoleLabel(role: AdminRole) { return role === "owner" ? "Owner" : role === "admin" ? "Admin" : "User"; } +export type AdminNavLink = { href: string; label: string }; + +function isNavMatch(pathname: string, href: string) { + return ( + pathname === href || (href !== "/admin" && pathname.startsWith(`${href}/`)) + ); +} + +// Flat navigation shows one active item: the longest matching href wins, so +// /admin/barcodes/review highlights only the Review Queue, not Barcodes too. +export function getActiveAdminHref(pathname: string, links: AdminNavLink[]) { + let active: string | null = null; + for (const link of links) { + if ( + isNavMatch(pathname, link.href) && + (active === null || link.href.length > active.length) + ) { + active = link.href; + } + } + return active; +} + export function AdminShell({ userEmail, role, children }: AdminShellProps) { const pathname = usePathname(); const links = [ @@ -57,16 +80,14 @@ export function AdminShell({ userEmail, role, children }: AdminShellProps) {