Skip to content

Commit d61f661

Browse files
committed
Merge remote-tracking branch 'origin/main' into sync-main
# Conflicts: # brand/designer-brand-pack.html
2 parents e9e619a + e73259d commit d61f661

61 files changed

Lines changed: 2739 additions & 159 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/audits/2026-05-15-user-list-phantom-leaks-investigation.md

Lines changed: 613 additions & 0 deletions
Large diffs are not rendered by default.

frontend/src/app/__tests__/purchases-page.misc-filter.test.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,13 @@ vi.mock("@/hooks/useAccountType", () => ({
9292
useAccountType: () => "member",
9393
}));
9494

95+
// The awaiting-approval chip renders only in lab mode (showApprovalFilter =
96+
// useIsLabMode() === true). This suite tests the chip in a lab-mode context
97+
// where a member is waiting on the lab head for approval.
98+
vi.mock("@/hooks/useIsLabMode", () => ({
99+
useIsLabMode: () => true,
100+
}));
101+
95102
// Purchases UX fix Bug 3 (2026-05-24): the new banner CTA uses
96103
// `useRouter().push("/lab-overview")`. The misc-filter suite never
97104
// triggers it (account_type is "member"), but the hook still has to

frontend/src/app/__tests__/supplies-page.approvals-lens.test.tsx

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,119 @@ describe("SuppliesPage — Orders & approvals lens (lab-head gating)", () => {
267267
);
268268
});
269269

270+
it("double-clicking Approve fires setPurchaseApproval exactly once (in-flight guard)", async () => {
271+
// Simulate the stress-test regression: a rapid double-click on Approve must
272+
// not call setPurchaseApproval twice. The inFlightRef guard blocks the second
273+
// invocation synchronously before React re-renders with busy=true.
274+
let resolveApproval!: () => void;
275+
setPurchaseApproval.mockImplementationOnce(
276+
() =>
277+
new Promise<{ ok: true; value: object }>((res) => {
278+
resolveApproval = () => res({ ok: true, value: {} });
279+
}),
280+
);
281+
282+
labApi.getAllPurchaseItems.mockResolvedValue([
283+
makeItem({ id: 21, task_id: 9, username: "mira", item_name: "Enzyme mix" }),
284+
]);
285+
labApi.getTasks.mockResolvedValue([
286+
{ id: 9, name: "Buffer prep", username: "mira" },
287+
] as never);
288+
289+
renderPage();
290+
291+
const chip = await screen.findByRole("tab", { name: /Awaiting approval/i });
292+
fireEvent.click(chip);
293+
await screen.findByText("Enzyme mix");
294+
295+
const [approveBtn] = screen.getAllByTestId("lab-head-purchase-approval-toggle");
296+
297+
// Rapid double-click: both events fire before React can re-render.
298+
fireEvent.click(approveBtn);
299+
fireEvent.click(approveBtn);
300+
301+
// Only one call should have been made.
302+
expect(setPurchaseApproval).toHaveBeenCalledTimes(1);
303+
304+
// Let the in-flight promise resolve so the component settles cleanly.
305+
resolveApproval();
306+
await waitFor(() => expect(setPurchaseApproval).toHaveBeenCalledTimes(1));
307+
});
308+
309+
it("double-clicking Decline fires declinePurchase exactly once (in-flight guard)", async () => {
310+
let resolveDecline!: () => void;
311+
declinePurchase.mockImplementationOnce(
312+
() =>
313+
new Promise<{ ok: true; value: object }>((res) => {
314+
resolveDecline = () => res({ ok: true, value: {} });
315+
}),
316+
);
317+
318+
labApi.getAllPurchaseItems.mockResolvedValue([
319+
makeItem({ id: 22, task_id: 9, username: "mira", item_name: "Loading dye" }),
320+
]);
321+
labApi.getTasks.mockResolvedValue([
322+
{ id: 9, name: "Buffer prep", username: "mira" },
323+
] as never);
324+
325+
renderPage();
326+
327+
const chip = await screen.findByRole("tab", { name: /Awaiting approval/i });
328+
fireEvent.click(chip);
329+
await screen.findByText("Loading dye");
330+
331+
const [declineBtn] = screen.getAllByTestId("lab-head-purchase-decline-button");
332+
333+
fireEvent.click(declineBtn);
334+
fireEvent.click(declineBtn);
335+
336+
expect(declinePurchase).toHaveBeenCalledTimes(1);
337+
338+
resolveDecline();
339+
await waitFor(() => expect(declinePurchase).toHaveBeenCalledTimes(1));
340+
});
341+
342+
it("approving item A does not disable item B's Approve button (per-item guard)", async () => {
343+
// The guard is scoped per-component instance; approving one item must not
344+
// block a concurrent approval of a different item in the same order group.
345+
let resolveA!: () => void;
346+
setPurchaseApproval
347+
.mockImplementationOnce(
348+
() =>
349+
new Promise<{ ok: true; value: object }>((res) => {
350+
resolveA = () => res({ ok: true, value: {} });
351+
}),
352+
)
353+
.mockResolvedValueOnce({ ok: true, value: {} });
354+
355+
labApi.getAllPurchaseItems.mockResolvedValue([
356+
makeItem({ id: 31, task_id: 10, username: "mira", item_name: "Item A" }),
357+
makeItem({ id: 32, task_id: 10, username: "mira", item_name: "Item B" }),
358+
]);
359+
labApi.getTasks.mockResolvedValue([
360+
{ id: 10, name: "Parallel order", username: "mira" },
361+
] as never);
362+
363+
renderPage();
364+
365+
const chip = await screen.findByRole("tab", { name: /Awaiting approval/i });
366+
fireEvent.click(chip);
367+
await screen.findByText("Item A");
368+
369+
const [btnA, btnB] = screen.getAllByTestId("lab-head-purchase-approval-toggle");
370+
371+
// Click A (in-flight, not resolved yet).
372+
fireEvent.click(btnA);
373+
expect(setPurchaseApproval).toHaveBeenCalledTimes(1);
374+
375+
// Click B independently — should also fire immediately.
376+
fireEvent.click(btnB);
377+
expect(setPurchaseApproval).toHaveBeenCalledTimes(2);
378+
379+
resolveA();
380+
await waitFor(() => expect(setPurchaseApproval).toHaveBeenCalledTimes(2));
381+
});
382+
270383
it("mounts SpendingDashboard in the drawer when View spending is clicked", async () => {
271384
renderPage();
272385

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Operator-only CLEAR CLOUD SETTINGS (POST /api/admin/accounts/clear-settings).
2+
//
3+
// Body: { ownerKey?, email?, confirm: true }. Deletes ONE identity's
4+
// account_settings row (the E2E account-scoped settings blob). A plain
5+
// server-side row delete keyed by owner_key, no E2E key needed, the row is just
6+
// ciphertext we cannot read anyway. After this the user falls back to
7+
// folder-local settings, exactly as if they had never lifted, which un-sticks an
8+
// account whose blob got polluted (the Owen lift misfire). Unlike the full wipe
9+
// this touches ONLY account_settings, nothing else. Gated on ADMIN_EMAILS.
10+
// Idempotent (0 rows when the user had none).
11+
//
12+
// House style: no em-dashes, no emojis, no mid-sentence colons.
13+
14+
import { requireOperator } from "@/lib/sharing/operator-access";
15+
import { isSharingEnabled, json } from "@/lib/sharing/directory/guard";
16+
import { ownerKeyForEmailSafe } from "@/lib/billing/owner";
17+
import {
18+
deleteAccountSettings,
19+
ensureAccountSettingsSchema,
20+
} from "@/lib/account/account-settings-db";
21+
22+
export const runtime = "nodejs";
23+
24+
export async function POST(req: Request): Promise<Response> {
25+
if (!isSharingEnabled()) {
26+
return json(404, { error: "not found" });
27+
}
28+
29+
const blocked = await requireOperator();
30+
if (blocked) return blocked;
31+
32+
let body: unknown;
33+
try {
34+
body = await req.json();
35+
} catch {
36+
return json(400, { error: "invalid json body" });
37+
}
38+
const input = (body ?? {}) as Record<string, unknown>;
39+
40+
if (input.confirm !== true) {
41+
return json(400, { error: "confirm must be true to clear cloud settings" });
42+
}
43+
44+
// Resolve the target identity. An explicit ownerKey wins, otherwise hash the
45+
// email with the same key billing + the directory use.
46+
let ownerKey: string | null =
47+
typeof input.ownerKey === "string" && input.ownerKey.trim()
48+
? input.ownerKey.trim()
49+
: null;
50+
if (!ownerKey && typeof input.email === "string" && input.email.trim()) {
51+
ownerKey = ownerKeyForEmailSafe(input.email.trim());
52+
}
53+
if (!ownerKey) {
54+
return json(400, { error: "an ownerKey or email is required" });
55+
}
56+
57+
try {
58+
await ensureAccountSettingsSchema();
59+
const cleared = await deleteAccountSettings(ownerKey);
60+
return json(200, { ok: true, ownerKey, cleared });
61+
} catch {
62+
return json(500, { error: "clear failed" });
63+
}
64+
}

frontend/src/app/calendar/page.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -966,10 +966,7 @@ function CreateEventModal({
966966
setError("new-event-start-date", "Start date is required.");
967967
ok = false;
968968
}
969-
if (!endDate) {
970-
setError("new-event-end-date", "End date is required.");
971-
ok = false;
972-
} else if (startDate && endDate < startDate) {
969+
if (endDate && startDate && endDate < startDate) {
973970
setError("new-event-end-date", "End date must be on or after the start date.");
974971
ok = false;
975972
}
@@ -981,11 +978,13 @@ function CreateEventModal({
981978
focusFirstError();
982979
return;
983980
}
981+
// If the user left end date blank, default it to the start date (single-day event).
982+
const effectiveEndDate = endDate || startDate;
984983
onCreate({
985984
title,
986985
event_type: eventType,
987986
start_date: startDate,
988-
end_date: endDate || null,
987+
end_date: effectiveEndDate || null,
989988
start_time: startTime || null,
990989
end_time: endTime || null,
991990
location: location || null,

frontend/src/app/datahub/page.tsx

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ import {
3030
} from "@/lib/datahub/bigtable";
3131
import { ingestToDatasetLane } from "@/lib/datahub/bigtable/ingest";
3232
import { init as initBigTableEngine } from "@/lib/datahub/bigtable/duckdb-client";
33+
import {
34+
getViewModePref,
35+
setViewModePref,
36+
getLinkedDatasetId,
37+
setLinkedDatasetId,
38+
} from "@/lib/datahub/bigtable/view-mode-pref";
3339
import DatasetView from "@/components/datahub/bigtable/DatasetView";
3440
import TransformBuilder from "@/components/datahub/bigtable/TransformBuilder";
3541
import ManualSwitchControl from "@/components/datahub/bigtable/ManualSwitchControl";
@@ -531,6 +537,47 @@ export default function DataHubPage() {
531537
setConfirmDeleteTableId(null);
532538
}, [selectedTableId]);
533539

540+
// Restore the last-viewed mode when a table is opened. If the user previously
541+
// converted this table to a dataset and was last viewing the dataset, reopen
542+
// the dataset view automatically. The preference is stored in localStorage
543+
// keyed by owner+tableId, so it persists across nav-away and back without any
544+
// on-disk data-shape change. A missing or "editable" preference is a no-op.
545+
// This runs after the table-switch clear above so it is not overwritten.
546+
//
547+
// Tracking: restorePendingId captures a tableId that has a "dataset" pref but
548+
// whose catalog entry has not loaded yet. A follow-up datasets-change fires the
549+
// restore once the linked dataset appears. Cleared as soon as we act on it.
550+
const restorePendingId = useRef<string | null>(null);
551+
useEffect(() => {
552+
if (!bigTableOn || !currentUser || selectedTableId == null) return;
553+
const pref = getViewModePref(currentUser, selectedTableId);
554+
if (pref !== "dataset") return;
555+
const linkedId = getLinkedDatasetId(currentUser, selectedTableId);
556+
if (!linkedId) return;
557+
if (datasets.some((d) => d.id === linkedId)) {
558+
// Dataset catalog is already loaded: restore now.
559+
setSelectedTableId(null);
560+
setSelectedDatasetId(linkedId);
561+
restorePendingId.current = null;
562+
} else {
563+
// Dataset catalog not yet loaded: park the request so the datasets effect
564+
// below can complete the restore once the catalog arrives.
565+
restorePendingId.current = selectedTableId;
566+
}
567+
}, [selectedTableId, currentUser, bigTableOn, datasets]);
568+
569+
// Complete a deferred mode restore once the dataset catalog has loaded.
570+
useEffect(() => {
571+
const tableId = restorePendingId.current;
572+
if (!tableId || !currentUser || !bigTableOn || datasets.length === 0) return;
573+
const linkedId = getLinkedDatasetId(currentUser, tableId);
574+
if (!linkedId) return;
575+
if (!datasets.some((d) => d.id === linkedId)) return;
576+
restorePendingId.current = null;
577+
setSelectedTableId(null);
578+
setSelectedDatasetId(linkedId);
579+
}, [datasets, currentUser, bigTableOn]);
580+
534581
// Apply a pending `?analysis=<id>` deep link once the deep-linked doc's content
535582
// has loaded. This runs after the table-switch clear above, so the analysis the
536583
// run navigated to is what wins, and the user lands on its result sheet rather
@@ -1441,7 +1488,13 @@ export default function DataHubPage() {
14411488
// Success: flip to the dataset view and clear the loader.
14421489
setConvertBoot(null);
14431490
setSelectedTableId(null);
1444-
if (newId) setSelectedDatasetId(newId);
1491+
if (newId) {
1492+
// Persist both the table->dataset link and the view-mode preference so
1493+
// that nav-back reopens the dataset view rather than the editable grid.
1494+
setLinkedDatasetId(owner, meta.id, newId);
1495+
setViewModePref(owner, meta.id, "dataset");
1496+
setSelectedDatasetId(newId);
1497+
}
14451498
} catch {
14461499
// The error phase was already emitted to convertBoot; the loader shows a
14471500
// retry. Leave convertBoot set so the user sees it (cleared on retry/cancel).
@@ -1450,6 +1503,33 @@ export default function DataHubPage() {
14501503
}
14511504
}, [bigTableOn, currentUser, openContent, selectedMeta, queryClient]);
14521505

1506+
// Switch back from the dataset view to the editable grid for the table that was
1507+
// previously converted. Reads the linked tableId from localStorage and restores
1508+
// it. Sets the view-mode preference to "editable" so nav-back stays on the grid.
1509+
// The editable cell store was never touched, so switching back is non-destructive.
1510+
const handleSwitchBack = useCallback(() => {
1511+
if (!currentUser || !selectedDatasetId) return;
1512+
// Find the editable tableId that links to this dataset.
1513+
const table = allTables.find(
1514+
(t) => getLinkedDatasetId(currentUser, t.id) === selectedDatasetId,
1515+
);
1516+
if (!table) return;
1517+
setViewModePref(currentUser, table.id, "editable");
1518+
setSelectedDatasetId(null);
1519+
setSelectedTableId(table.id);
1520+
}, [currentUser, selectedDatasetId, allTables]);
1521+
1522+
// Whether the currently-open dataset has a linked editable table (i.e. the user
1523+
// can switch back). Used to show/hide the switch-back affordance.
1524+
const linkedEditableTable = useMemo(() => {
1525+
if (!currentUser || !selectedDatasetId) return null;
1526+
return (
1527+
allTables.find(
1528+
(t) => getLinkedDatasetId(currentUser, t.id) === selectedDatasetId,
1529+
) ?? null
1530+
);
1531+
}, [currentUser, selectedDatasetId, allTables]);
1532+
14531533
// The derived-table banner inputs. When the open table is derived, resolve its
14541534
// source meta from the catalog so the banner can name it and offer a jump to
14551535
// it. isDerived comes from the open content's link (the recompute path sets it),
@@ -2371,6 +2451,9 @@ export default function DataHubPage() {
23712451
});
23722452
setSelectedDatasetId(null);
23732453
}}
2454+
onSwitchToEditable={
2455+
linkedEditableTable ? handleSwitchBack : undefined
2456+
}
23742457
/>
23752458
)
23762459
) : tablesInCollection.length === 0 && datasets.length === 0 ? (

frontend/src/app/globals.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
--surface-raised: #ffffff; /* cards, panels */
2929
--surface-overlay: #ffffff; /* popups, modals */
3030
--surface-sunken: #f1f5f9; /* input wells, insets (slate-100) */
31-
--foreground-muted: #6b7280; /* secondary text, gray-500 */
31+
--foreground-muted: #6b7080; /* secondary text — nudged 2 pts blue from gray-500 to clear WCAG AA on --surface-sunken */
3232
--border-subtle: #e5e7eb; /* hairlines, gray-200 */
3333

3434
/* Interactive accent (links, active nav, selected states). brand-action on

frontend/src/components/AppNavBar.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,9 @@ export default function AppNavBar({
483483
onPointerDown={(e) =>
484484
onTabPointerDown(e, item.href, "inline")
485485
}
486+
{...(item.newTab
487+
? { target: "_blank", rel: "noopener noreferrer" }
488+
: {})}
486489
className={tabClass(item, active)}
487490
>
488491
{item.label}
@@ -597,6 +600,9 @@ export default function AppNavBar({
597600
href={item.href}
598601
onClick={() => setMoreOpen(false)}
599602
role="menuitem"
603+
{...(item.newTab
604+
? { target: "_blank", rel: "noopener noreferrer" }
605+
: {})}
600606
className={`flex items-center gap-2 px-2.5 py-2 text-[13px] font-semibold rounded-lg ${
601607
active
602608
? "text-accent bg-accent-soft"

0 commit comments

Comments
 (0)