mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
The step confirmed one key, the active application key. A restore needs more: off-host copies are sealed under BACKUP_ENCRYPTION_KEY, a copy written before a rotation needs the retired application key it was written under, and every session, token and share link hangs off API_TOKEN_HMAC_KEY. An operator could do everything the step asked and still be unable to read a single backup. The recovery kit lists every key a restore of this server needs, by id and fingerprint, never key material: - each id in ENCRYPTION_KEYS, plus any retired id a stored copy (innerKeyIds) or an off-host object inside the bucket's retention (OffhostBackupKeyUse) still lists, also once the server dropped it; - BACKUP_ENCRYPTION_KEY and each BACKUP_ENCRYPTION_PREVIOUS_KEYS entry, named by the id their objects record; - API_TOKEN_HMAC_KEY. Confirming records the set the admin was shown and refuses a stale one with 409 recovery.kit.stale. The kit is due again as soon as a key it lists is not in that set (a rotation, a new backup key, a re-keyed install); a key that merely drops out does not make it due. "Check my copy" now checks any key in the kit. Migration 0487 carries a confirmation given under the old step forward as a kit holding that key, so that part stays confirmed, and drops the four app_settings columns. It also creates the two tables the readiness evaluation uses (restore_drill_outcomes, recovery_readiness_notices). The routes move to /api/admin/recovery-kit; the reader of the keys the backups still need moves out of the encryption status route into src/lib/recovery/backup-key-needs.ts so the kit and the status view share one definition.
190 lines
8.2 KiB
TypeScript
190 lines
8.2 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useState } from "react";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
|
|
import { useDashboardChartReveal } from "@/components/dashboard/chart-reveal";
|
|
import { DashboardHeader } from "@/components/dashboard/dashboard-header";
|
|
import { EnvironmentChip } from "@/components/dashboard/environment-chip";
|
|
import { RecoveryKitBanner } from "@/components/dashboard/recovery-kit-banner";
|
|
import {
|
|
QuickEntrySheets,
|
|
type QuickEntryDialog,
|
|
} from "@/components/dashboard/quick-entry-sheets";
|
|
import {
|
|
buildChartEntries,
|
|
revealChartIds,
|
|
visibleChartTiles,
|
|
} from "@/components/dashboard/tiles/chart-tiles";
|
|
import { DashboardTiles } from "@/components/dashboard/tiles/dashboard-tiles";
|
|
import { buildStripEntries } from "@/components/dashboard/tiles/strip-tiles";
|
|
import { useDashboardTileContext } from "@/components/dashboard/tiles/tile-context";
|
|
import { useDashboardData } from "@/components/dashboard/tiles/use-dashboard-data";
|
|
import { TodayHero } from "@/components/daily/today-hero";
|
|
import { TodayHeroSkeleton } from "@/components/daily/today-hero-skeleton";
|
|
import { GettingStartedChecklist } from "@/components/onboarding/getting-started-checklist";
|
|
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
|
|
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
|
import { useAccountOnceMounted, useAuth } from "@/hooks/use-auth";
|
|
import { useMounted } from "@/hooks/use-mounted";
|
|
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
|
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
|
import type { BatchWindow } from "@/lib/dashboard/batch-chart-types";
|
|
import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item";
|
|
|
|
/**
|
|
* The dashboard: the header, the Today hero, and the tiles and charts the
|
|
* layout switches on. Which tiles exist, what each reads, when each shows and
|
|
* where it sits is the registry under `components/dashboard/tiles/`; this
|
|
* component only composes.
|
|
*/
|
|
export default function DashboardPageClient({
|
|
batchWindow,
|
|
demoMode = false,
|
|
}: {
|
|
/**
|
|
* The batched-series ISO window computed server-side from the PROFILE time
|
|
* zone, present only when the RSC prefetch ran. Adopted verbatim so the
|
|
* `chartSeriesBatch` key matches the key the RSC dehydrated under.
|
|
*/
|
|
batchWindow?: BatchWindow;
|
|
/**
|
|
* True on a `DEMO_MODE` instance. The demo is one shared, read-only
|
|
* account a visitor cannot set up, so the setup checklist has nothing to
|
|
* offer there, as the shell already leaves out the tour.
|
|
*/
|
|
demoMode?: boolean;
|
|
} = {}) {
|
|
const { isAuthenticated } = useAuth();
|
|
const mounted = useMounted();
|
|
// The account payload is withheld until this boundary has mounted, so the
|
|
// hydration render paints exactly what the server streamed. Every
|
|
// account-derived branch (the BMI chart's height gate, the personalised
|
|
// bands, the module gates, the unit labels) would otherwise render one
|
|
// thing on the server and another here, and with the RSC prefetch on that
|
|
// costs the whole streamed dashboard (React #418). See
|
|
// `useAccountOnceMounted`.
|
|
const user = useAccountOnceMounted();
|
|
const queryClient = useQueryClient();
|
|
const { canWriteDomain } = useRecordCapabilities();
|
|
const canAddMeasurement = canWriteDomain("measurements");
|
|
const [quickEntryDialog, setQuickEntryDialog] =
|
|
useState<QuickEntryDialog>(null);
|
|
|
|
// Pull to refresh: a PWA resumed from the home screen never fires a focus
|
|
// event, and the dashboard fans out into a dozen independent reads, so
|
|
// refetching every active query is the one tractable "refresh everything
|
|
// visible".
|
|
const refreshDashboard = useCallback(
|
|
() => queryClient.refetchQueries({ type: "active" }),
|
|
[queryClient],
|
|
);
|
|
const pull = usePullToRefresh({ onRefresh: refreshDashboard });
|
|
|
|
const dashboard = useDashboardData({
|
|
isAuthenticated,
|
|
mounted,
|
|
user,
|
|
batchWindowProp: batchWindow,
|
|
});
|
|
const { digestQuery, layout, primaryLoading } = dashboard;
|
|
const tileContext = useDashboardTileContext({ dashboard, user });
|
|
|
|
const renderFilteredHeroAllClear =
|
|
(layout.enabledHeroItemKinds ?? PRIORITY_ITEM_KINDS).length <
|
|
PRIORITY_ITEM_KINDS.length;
|
|
|
|
// Every data-backed chart mounts as soon as its gate flips (so the chart
|
|
// reads fan out in parallel), but the cells hold their skeletons until
|
|
// every gated chart reported its data settled, or the reveal timeout fired
|
|
// (see `chart-reveal.tsx`), so the row swaps in one frame.
|
|
const chartTiles = visibleChartTiles(tileContext);
|
|
const { revealed: chartsRevealed, markReady: markChartReady } =
|
|
useDashboardChartReveal(revealChartIds(chartTiles));
|
|
const stripEntries = buildStripEntries(tileContext);
|
|
const chartEntries = buildChartEntries(chartTiles, {
|
|
...tileContext,
|
|
preloadedSeries: dashboard.preloadedSeries,
|
|
markChartReady,
|
|
});
|
|
|
|
return (
|
|
<div
|
|
className="space-y-6"
|
|
// The switch in the layout settings, for the e2e check: the card
|
|
// itself renders nothing on a day without a digest.
|
|
data-today-card={layout.todayCardVisible === false ? "off" : "on"}
|
|
>
|
|
<PullToRefreshIndicator {...pull} />
|
|
<DashboardHeader onQuickEntry={setQuickEntryDialog} />
|
|
<RecoveryKitBanner isAdmin={user?.role === "ADMIN"} />
|
|
|
|
{/* Records other people share with you are reached from the account
|
|
switcher in the top bar and the sidebar, not from a card here. The
|
|
dashboard is this account's own day. */}
|
|
|
|
{
|
|
// The Today hero above the tile strip: the day's read, the score and
|
|
// freshness face, and the worth-a-look rail. DATA-FIRST, so the SSR
|
|
// pass paints the populated hero (the LCP element). That is
|
|
// hydration-safe ONLY because the digest is server-dehydrated
|
|
// (`page.tsx` prefetch), so `digestQuery.data` is present on both the
|
|
// SSR pass and the first hydration render, and because `["daily", …]`
|
|
// is NOT in the persist allowlist (`PERSIST_ALLOWLIST_HEADS` in
|
|
// `@/lib/pwa/query-persister`), so it never rehydrates from disk. Do
|
|
// not add it there, or render the hero from any client-only source,
|
|
// without revisiting this gate. A failed read is an error card with
|
|
// retry, distinct from the honest empty degrade (the hero returns
|
|
// null). The card can be switched off in the layout settings; only an
|
|
// explicit `false` hides it, its skeleton and its error card.
|
|
layout.todayCardVisible === false ? null : digestQuery.data ? (
|
|
<TodayHero
|
|
digest={digestQuery.data}
|
|
renderFilteredAllClear={renderFilteredHeroAllClear}
|
|
// The server-persisted hero choice rides the same resolved layout
|
|
// as the widget visibility, so SSR and hydration agree.
|
|
primaryContent={layout.hero ?? "score"}
|
|
/>
|
|
) : !mounted || digestQuery.isLoading ? (
|
|
<TodayHeroSkeleton />
|
|
) : digestQuery.isError ? (
|
|
<QueryErrorCard
|
|
error={digestQuery.error}
|
|
onRetry={() => digestQuery.refetch()}
|
|
/>
|
|
) : null
|
|
}
|
|
|
|
{/* A quiet note about the newest stored environment day (high pollen, a
|
|
hot night, very poor air). Nothing unless one applies, and nothing
|
|
before mount (the module map and the overview are client reads).
|
|
Not a layout widget. */}
|
|
{mounted && <EnvironmentChip />}
|
|
|
|
{/* Bottom sheet below `md`, centred dialog from `md` up. */}
|
|
<QuickEntrySheets
|
|
open={quickEntryDialog}
|
|
onClose={() => setQuickEntryDialog(null)}
|
|
/>
|
|
|
|
<DashboardTiles
|
|
stripEntries={stripEntries}
|
|
chartEntries={chartEntries}
|
|
chartsRevealed={chartsRevealed}
|
|
layout={layout}
|
|
primaryLoading={primaryLoading}
|
|
mounted={mounted}
|
|
user={user}
|
|
canAddMeasurement={canAddMeasurement}
|
|
onAddMeasurement={() => setQuickEntryDialog("measurement")}
|
|
/>
|
|
|
|
{/* The getting-started checklist sits below the tiles and charts: it
|
|
mounts only once the snapshot resolves, and above the strip its late
|
|
arrival pushed every tile down. It gates itself on onboarding state
|
|
and disappears once dismissed or complete. */}
|
|
{demoMode ? null : <GettingStartedChecklist />}
|
|
</div>
|
|
);
|
|
}
|