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
11 changes: 2 additions & 9 deletions frontend/src/components/OrganizationPaperFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
type SearchableSelectOption,
} from "@/components/ui/searchable-select";
import { Skeleton } from "@/components/ui/skeleton";
import { useIntersectionObserver } from "@/hooks/useIntersectionObserver";
import { useInfiniteScroll } from "@/hooks/useInfiniteScroll";
import { useInfiniteOrganizationPapers } from "@/hooks/usePapers";
import { catalogApi } from "@/lib/api/catalog";
import { displayTaskName, transformPaperForCard } from "@/lib/transform";
Expand Down Expand Up @@ -66,15 +66,8 @@ export const OrganizationPaperFeed: React.FC<{
end_date: dateRange.endDate,
enabled: !!organizationSlug,
});
const { targetRef, isIntersecting } = useIntersectionObserver({
rootMargin: "200px",
});

useEffect(() => {
if (isIntersecting && papersQ.hasNextPage && !papersQ.isFetchingNextPage) {
papersQ.fetchNextPage();
}
}, [isIntersecting, papersQ.hasNextPage, papersQ.isFetchingNextPage]); // eslint-disable-line react-hooks/exhaustive-deps
const targetRef = useInfiniteScroll(papersQ);

useEffect(() => {
setLogoFailed(false);
Expand Down
17 changes: 9 additions & 8 deletions frontend/src/components/paper-detail/EditTasksDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,8 @@ export function EditTasksDialog({
// Apply the new task list to the paper detail cache *before* the network
// round-trip so the chip the user just unchecked disappears immediately.
// The dialog also closes here, then the canonical server response
// reconciles the cache in `onSuccess` (or rolls back on `onError`).
// reconciles the cache and confirms in `onSuccess` (or rolls back on
// `onError`).
onMutate: async ({ taskIds }) => {
if (submitMode === "pending") {
// Pending submissions don't touch live data, so skip the optimistic
Expand Down Expand Up @@ -205,23 +206,23 @@ export function EditTasksDialog({
});
});

toast({
title: "Tasks updated",
description: `Saved ${taskIds.length} task${
taskIds.length === 1 ? "" : "s"
} for this paper.`,
});
onSuccess?.();
onOpenChange(false);

return { snapshots };
},
onSuccess: (updatedPaper) => {
onSuccess: (updatedPaper, { taskIds }) => {
if (submitMode === "pending") {
onSuccess?.();
onOpenChange(false);
return;
}
toast({
title: "Tasks updated",
description: `Saved ${taskIds.length} task${
taskIds.length === 1 ? "" : "s"
} for this paper.`,
});
if (updatedPaper) {
refreshPaperQueries(queryClient, updatedPaper, [paperId, arxivId]);
}
Expand Down
14 changes: 4 additions & 10 deletions frontend/src/hooks/useBookmarks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import { paperQueryKeys } from "@/lib/paperQueryKeys";
* Save button flips state without a round-trip.
*
* If the user is not signed in, `toggle()` redirects to the HF OAuth login
* (same pattern as `HfSignInButton`) so the user lands back on the page they
* came from.
* with Saved Papers authorization and resumes the save when the user lands
* back on the page they came from.
*/

import React from "react";
Expand All @@ -18,7 +18,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { accountApi } from "@/lib/api/account";
import type { BookmarkIdsResponse } from "@/lib/api/account.types";
import { ApiError } from "@/lib/apiTransport";
import { apiUrl, isAuthDisabled } from "@/lib/apiUrl";
import { isAuthDisabled } from "@/lib/apiUrl";
import { useAuth } from "@/lib/authContext";
import {
SAVED_PAPERS_AUTHORIZED_PARAM,
Expand Down Expand Up @@ -132,13 +132,7 @@ export function useToggleBookmark() {
(paperId: string | number, isBookmarked: boolean) => {
if (!user) {
if (isAuthDisabled()) return;
const returnTo =
typeof window !== "undefined"
? window.location.pathname + window.location.search
: "/";
window.location.href = apiUrl(
`/api/v1/auth/hf/login?return_to=${encodeURIComponent(returnTo)}`,
);
authorizeSavedPapers({ paperId, isBookmarked });
return;
}
mutation.mutate({ paperId, isBookmarked });
Expand Down
23 changes: 23 additions & 0 deletions frontend/src/hooks/useInfiniteScroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useEffect } from "react";

import { useIntersectionObserver } from "./useIntersectionObserver";

interface PageableQuery {
data: unknown;
hasNextPage: boolean;
isFetchingNextPage: boolean;
fetchNextPage: () => Promise<unknown>;
}

/** Fetch the next page while the returned sentinel ref is near the viewport. */
export function useInfiniteScroll(query: PageableQuery, rootMargin = "200px") {
const { targetRef, isIntersecting } = useIntersectionObserver({ rootMargin });
const { data, hasNextPage, isFetchingNextPage, fetchNextPage } = query;

// Also rerun when results change: a filter change can leave the sentinel visible.
useEffect(() => {
if (isIntersecting && hasNextPage && !isFetchingNextPage) void fetchNextPage();
}, [isIntersecting, hasNextPage, isFetchingNextPage, fetchNextPage, data]);

return targetRef;
}
17 changes: 0 additions & 17 deletions frontend/src/lib/adminAuth.ts

This file was deleted.

11 changes: 4 additions & 7 deletions frontend/src/lib/apiTransport.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

import { setStoredAdminToken } from "./adminAuth";
import { ApiError, ApiTransport, getApiErrorMessage, requestJson } from "./apiTransport";

describe("API transport", () => {
beforeEach(() => {
vi.restoreAllMocks();
setStoredAdminToken("");
});

it("constructs the URL and parses a successful JSON response", async () => {
Expand Down Expand Up @@ -57,8 +55,8 @@ describe("API transport", () => {
expect(new Headers(options?.headers).get("Accept")).toBe("text/event-stream");
});

it("includes browser credentials and the stored admin credential", async () => {
setStoredAdminToken("secret-admin-token");
it("includes browser credentials and never sends an admin token", async () => {
window.localStorage.setItem("cronAdminToken", "secret-admin-token");
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ ok: true }), { status: 200 }),
);
Expand All @@ -67,9 +65,8 @@ describe("API transport", () => {

const options = fetchMock.mock.calls[0][1];
expect(options?.credentials).toBe("include");
expect(new Headers(options?.headers).get("X-Admin-Token")).toBe(
"secret-admin-token",
);
expect(new Headers(options?.headers).has("X-Admin-Token")).toBe(false);
window.localStorage.removeItem("cronAdminToken");
});

it("returns a typed error without applying browser policy", async () => {
Expand Down
5 changes: 0 additions & 5 deletions frontend/src/lib/apiTransport.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { getStoredAdminToken } from "./adminAuth";

const LOG_API_REQUESTS = import.meta.env.DEV;

Expand Down Expand Up @@ -85,14 +84,10 @@ export class ApiTransport {
const { headers, ...requestOptions } = options ?? {};
const method = (requestOptions.method ?? "GET").toUpperCase();
const defaultCache: RequestCache = method === "GET" ? "default" : "no-store";
const adminToken = getStoredAdminToken();
const requestHeaders = new Headers(headers);
if (!requestHeaders.has("Content-Type")) {
requestHeaders.set("Content-Type", "application/json");
}
if (adminToken && !requestHeaders.has("X-Admin-Token")) {
requestHeaders.set("X-Admin-Token", adminToken);
}
const response = await fetch(url, {
...requestOptions,
cache: requestOptions.cache ?? defaultCache,
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/lib/authContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import React from "react";
import posthog from "posthog-js";
import { useQueryClient } from "@tanstack/react-query";

import { authApi, type AuthUser } from "./api/auth";
import { authUrl } from "./apiUrl";
Expand Down Expand Up @@ -41,6 +42,8 @@ function consumeSignupMarker(): boolean {
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = React.useState<AuthUser | null>(null);
const [isLoading, setIsLoading] = React.useState(true);
const queryClient = useQueryClient();
const previousUserRef = React.useRef<AuthUser | null>(null);

const refresh = React.useCallback(async () => {
setIsLoading(true);
Expand Down Expand Up @@ -83,6 +86,12 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
}
}, [user]);

// Drop the signed-out user's cached data (bookmarks, follows, sessions).
React.useEffect(() => {
if (previousUserRef.current && !user) void queryClient.resetQueries();
previousUserRef.current = user;
}, [queryClient, user]);

React.useEffect(() => {
const handler = () => {
setUser(null);
Expand Down
1 change: 1 addition & 0 deletions frontend/src/pages/AutonomousOperations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Activity, ArrowLeft, ShieldAlert } from "lucide-react";
import { Link } from "react-router-dom";
import "./CronDashboard.css";

import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
Expand Down
37 changes: 0 additions & 37 deletions frontend/src/pages/SubmissionsDashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -315,43 +315,6 @@
color: var(--ink);
font-weight: 500;
}
.submission-loading,
.submission-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 240px;
padding: 32px;
text-align: center;
color: var(--ink-faint);
}
.submission-loading {
min-height: 160px;
flex-direction: row;
font-family: var(--mono);
font-size: 12px;
}
.submission-empty h1,
.submission-empty h2 {
font-family: var(--serif);
font-weight: 500;
color: var(--ink);
}
.submission-empty p {
max-width: 460px;
color: var(--ink-soft);
}
.submission-empty a {
color: var(--accent-text);
text-decoration: underline;
}
.submission-empty-warn {
border: 1px solid var(--hairline);
border-radius: 8px;
background: var(--bg-elevated);
}
.submission-pagination {
display: flex;
flex-direction: column;
Expand Down
33 changes: 33 additions & 0 deletions frontend/src/pages/SubmissionsDashboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,39 @@ beforeEach(() => {
});

describe("SubmissionsDashboard", () => {
it("pages every status by offset instead of growing the request limit", async () => {
const rowsFor = (status: "pending" | "approved" | "rejected", parity: number) =>
Array.from({ length: status === "rejected" ? 0 : 30 }, (_, index) => ({
...externalPaperSubmission,
id: 2 * index + parity + 1,
status,
created_at: new Date(Date.UTC(2026, 7, 10) - (2 * index + parity) * 60_000).toISOString(),
}));
const rows = { pending: rowsFor("pending", 0), approved: rowsFor("approved", 1), rejected: rowsFor("rejected", 0) };
listPendingPaperEdits.mockImplementation(
async ({ status, limit, offset }: { status: keyof typeof rows; limit: number; offset: number }) => ({
count: rows[status].length,
results: rows[status].slice(offset, offset + limit),
}),
);

renderDashboard();
const [allStatuses] = await screen.findAllByRole("button", { name: /^all/ });
await userEvent.click(allStatuses);
expect(await screen.findByText("Showing 25 of 60 submissions")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Load more" }));
expect(await screen.findByText("Showing 50 of 60 submissions")).toBeInTheDocument();

const pageCalls = listPendingPaperEdits.mock.calls.map(([params]) => params).filter((params) => params.limit !== 1);
expect(pageCalls.every((params) => params.limit === 25)).toBe(true);
const nextPage = pageCalls.slice(-3);
expect(nextPage.map((params) => [params.status, params.offset])).toEqual([
["pending", 13],
["approved", 12],
["rejected", 0],
]);
});

it("shows a controlled error when pending-edit data is malformed", async () => {
listPendingPaperEdits.mockRejectedValue(
new Error("Malformed pending-edit payload: proposed_payload.task_ids: Required"),
Expand Down
Loading
Loading