Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/web/app/admin/audit/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>;
Expand Down Expand Up @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions apps/web/app/admin/barcodes/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>;
Expand All @@ -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, {
Expand Down
4 changes: 4 additions & 0 deletions apps/web/app/admin/users/[userId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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) {
Expand Down
9 changes: 7 additions & 2 deletions apps/web/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<html
Expand All @@ -102,7 +107,7 @@ export default async function RootLayout({
<ServiceWorkerRegister />
<OfflineBanner />
<Suspense fallback={null}>
<LayoutNav />
<LayoutNav todayStr={today} />
</Suspense>
{children}
</body>
Expand Down
46 changes: 41 additions & 5 deletions apps/web/app/library/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<LibraryShell
Expand All @@ -30,6 +65,7 @@ export default async function LibraryPage({ searchParams }: LibraryPageProps) {
products={products}
templates={templates}
recipes={recipes}
searchError={searchError}
/>
);
}
10 changes: 2 additions & 8 deletions apps/web/app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand All @@ -11,13 +12,6 @@ type LoginPageProps = {
}>;
};

const ERROR_MESSAGES: Record<string, string> = {
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 ??
Expand All @@ -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 (
Expand Down
1 change: 1 addition & 0 deletions apps/web/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
7 changes: 7 additions & 0 deletions apps/web/app/recipes/[id]/edit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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),
Expand Down
29 changes: 25 additions & 4 deletions apps/web/components/admin-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -57,16 +80,14 @@ export function AdminShell({ userEmail, role, children }: AdminShellProps) {
<div className="mt-5 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<nav className="flex flex-wrap gap-2">
{links.map((link) => {
const active =
pathname === link.href ||
(link.href !== "/admin" &&
pathname.startsWith(`${link.href}/`));
const active = getActiveAdminHref(pathname, links) === link.href;

return (
<Link
prefetch={false}
key={link.href}
href={link.href}
aria-current={active ? "page" : undefined}
className={`rounded-full px-4 py-2 text-sm font-semibold transition ${
active
? "bg-[var(--color-accent)] text-white"
Expand Down
46 changes: 40 additions & 6 deletions apps/web/components/api-settings-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
} from "@/lib/api-token-actions";
import type { ApiScope, ApiTokenRecord } from "@macro-tracker/db";

import { useHydrated } from "@/lib/use-hydrated";

import { ConfirmSubmitButton } from "./confirm-delete-button";

type ApiSettingsClientProps = {
Expand Down Expand Up @@ -99,18 +101,46 @@ export function getVisibleApiTokens(
return [stateRecord, ...tokens];
}

// Built once rather than per token row; Intl constructors are costly.
const tokenDateFormat = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
// Stable server-agreeing render first: the same instant formats 10:00 in UTC
// and 12:00 in Europe/Amsterdam, so rendering local time during SSR can
// disagree with hydration. The initial markup uses a pinned en-US/UTC
// rendering; the browser-local rendering is applied after hydration.
// The stable UTC formatter is built once (Intl constructors are costly).
// The local formatter is built per call so it always reflects the viewer's
// current zone instead of the zone pinned at module load.
// dateStyle/timeStyle cannot be combined with timeZoneName, so the fields
// are spelled out to keep the same shape with an explicit zone suffix.
const stableTokenDateFormat = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
timeZone: "UTC",
timeZoneName: "short",
});

function formatDate(value: string | null) {
export function formatStableTokenDate(value: string | null) {
if (!value) {
return "Never";
}

return stableTokenDateFormat.format(new Date(value));
}

function formatLocalTokenDate(value: string | null) {
if (!value) {
return "Never";
}

return tokenDateFormat.format(new Date(value));
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
timeZoneName: "short",
}).format(new Date(value));
}

export function ApiSettingsClient({ tokens, scopes }: ApiSettingsClientProps) {
Expand All @@ -121,6 +151,10 @@ export function ApiSettingsClient({ tokens, scopes }: ApiSettingsClientProps) {
const [tokenName, setTokenName] = useState("");
const [expires, setExpires] = useState<"90" | "never">("90");
const [selectedScopes, setSelectedScopes] = useState<ApiScope[]>(scopes);
// False during SSR and the first client render (which must match SSR);
// flips after hydration so dates can show in the viewer's own zone.
const hydrated = useHydrated();
const formatDate = hydrated ? formatLocalTokenDate : formatStableTokenDate;
const visibleTokens = useMemo(
() => getVisibleApiTokens(tokens, state.record),
[state.record, tokens],
Expand Down
17 changes: 15 additions & 2 deletions apps/web/components/dashboard-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ type DashboardShellProps = {
initialPresetTemplateKind?: PresetTemplateKind | null;
// Server-resolved in the user's timezone so the client and server renders agree on hydration.
todayStr?: string;
// Server-captured UTC hour so SSR and the first client render rank
// quick-add identically; the live hour applies after hydration.
initialHourUtc?: number;
};

type ErrorState = Record<string, string | null>;
Expand Down Expand Up @@ -440,6 +443,7 @@ export function DashboardShell({
initialComposeAction = null,
initialPresetTemplateKind = null,
todayStr: todayStrProp,
initialHourUtc,
}: DashboardShellProps) {
const router = useRouter();
const composeHandledRef = useRef<string | null>(null);
Expand Down Expand Up @@ -560,14 +564,23 @@ export function DashboardShell({
selectedDate > todayStr ? "planned" : "eaten";

// Single unified quick-add list: ranked by routine signals, not macro fit.
// The hour is a stable snapshot for SSR/first render; the live clock
// only takes over after hydration so an hour boundary in flight cannot
// reorder the initial markup.
const [currentHourUtc, setCurrentHourUtc] = useState(
() => initialHourUtc ?? new Date().getUTCHours(),
);
useEffect(() => {
setCurrentHourUtc(new Date().getUTCHours());
}, []);
const quickAddItems = useMemo(
() =>
rankCandidates(quickAddCandidates, {
limit: 10,
currentHourUtc: new Date().getUTCHours(),
currentHourUtc,
referenceDate: todayStr,
}),
[quickAddCandidates, todayStr],
[quickAddCandidates, todayStr, currentHourUtc],
);

function clearDraftError(clientId: string) {
Expand Down
Loading