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
2 changes: 1 addition & 1 deletion apps/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"@superset/trpc": "workspace:*",
"@superset/ui": "workspace:*",
"@t3-oss/env-nextjs": "^0.13.8",
"@tanstack/react-query": "^5.90.19",
"@tanstack/react-query": "^5.100.9",
"@tanstack/react-query-devtools": "^5.90.10",
"@trpc/client": "^11.7.1",
"@trpc/server": "^11.7.1",
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,10 @@
"@tanstack/electric-db-collection": "0.3.3",
"@tanstack/electron-db-sqlite-persistence": "0.1.9",
"@tanstack/node-db-sqlite-persistence": "0.1.9",
"@tanstack/query-async-storage-persister": "^5.100.9",
"@tanstack/react-db": "0.1.83",
"@tanstack/react-query": "^5.90.19",
"@tanstack/react-query": "^5.100.9",
"@tanstack/react-query-persist-client": "^5.100.9",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"@tanstack/react-router": "^1.147.3",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.18",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,32 @@ import { LuImageOff } from "react-icons/lu";
/**
* Check if an image source is safe to load.
*
* Uses strict ALLOWLIST approach - only data: URLs are safe.
*
* ALLOWED:
* - data: URLs (embedded base64 images)
* - https:// URLs (GitHub user-attachments, avatars, etc.)
*
* BLOCKED (everything else):
* - http://, https:// (tracking pixels, privacy leak)
* - http:// (cleartext / mixed-content)
* - file:// URLs (arbitrary local file access)
* - Absolute paths /... or \... (become file:// in Electron)
* - Relative paths with .. (can escape repo boundary)
* - UNC paths //server/share (Windows NTLM credential leak)
* - Empty or malformed sources
*
* Security context: In Electron production, renderer loads via file://
* protocol. Any non-data: image src could access local filesystem or
* trigger network requests to attacker-controlled servers.
* Trade-off: https sources can phone home (tracking pixels). Acceptable
* here because the markdown comes from trusted sources (GitHub PR/issue
* bodies, user-authored task descriptions) where image embedding is part
* of the expected UX.
*/
function isSafeImageSrc(src: string | undefined): boolean {
if (!src) return false;
const trimmed = src.trim();
if (trimmed.length === 0) return false;
const lower = trimmed.toLowerCase();

// Only allow data: URLs (embedded images)
// These are self-contained and can't access external resources
return trimmed.toLowerCase().startsWith("data:");
if (lower.startsWith("data:")) return true;
if (lower.startsWith("https://")) return true;
return false;
}

interface SafeImageProps {
Expand All @@ -37,15 +38,11 @@ interface SafeImageProps {
}

/**
* Safe image component for untrusted markdown content.
*
* Only renders embedded data: URLs. All other sources are blocked
* to prevent local file access, network requests, and path traversal
* attacks from malicious repository content.
* Safe image component for markdown content.
*
* Future: Could add opt-in support for repo-relative images via a
* secure loader that validates paths through secureFs and serves
* as blob: URLs.
* Renders data: and http(s):// images. file://, absolute paths, UNC paths,
* and traversal sources are blocked to prevent local-filesystem access from
* malicious markdown content.
*/
export function SafeImage({ src, alt, className }: SafeImageProps) {
if (!isSafeImageSrc(src)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ export const tufteConfig: MarkdownStyleConfig = {
{children}
</CodeBlock>
),
// Block external images for privacy (tracking pixels, etc.)
img: ({ src, alt }) => <SafeImage src={src} alt={alt} />,
},
};
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
import {
defaultShouldDehydrateQuery,
QueryClient,
} from "@tanstack/react-query";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { del, get, set } from "idb-keyval";
import { electronTrpc } from "renderer/lib/electron-trpc";
import { electronReactClient } from "../../lib/trpc-client";

// Bump when query response shapes change — invalidates the persisted cache.
const PERSIST_BUSTER = "v1";

// Shared QueryClient for tRPC hooks and router loaders
const queryClient = new QueryClient({
defaultOptions: {
Expand All @@ -16,10 +25,30 @@ const queryClient = new QueryClient({
},
});

/**
* Provider for Electron IPC tRPC client.
* QueryClient is shared with router context for loader prefetching.
*/
// IndexedDB-backed persister. localStorage is too small (~5MB) for the
// volume of PR/issue rows we cache. idb-keyval uses a single object store
// keyed by the persister's `key` below.
const persister = createAsyncStoragePersister({
storage: {
getItem: async (key) => (await get<string>(key)) ?? null,
setItem: async (key, value) => {
await set(key, value);
},
removeItem: async (key) => {
await del(key);
},
},
key: "superset-rq-cache",
});

// Whitelist of queryKey prefixes worth persisting — anything else (auth
// tokens, ephemeral host state, transient mutations) is left in memory only.
const PERSIST_KEY_PREFIXES = new Set([
"tasks", // PR/issue list infinite queries
"pull-request-detail",
"issue-detail",
]);

export function ElectronTRPCProvider({
children,
}: {
Expand All @@ -30,7 +59,23 @@ export function ElectronTRPCProvider({
client={electronReactClient}
queryClient={queryClient}
>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister,
maxAge: 24 * 60 * 60 * 1000, // 24h
buster: PERSIST_BUSTER,
dehydrateOptions: {
shouldDehydrateQuery: (query) => {
if (!defaultShouldDehydrateQuery(query)) return false;
const head = query.queryKey[0];
return typeof head === "string" && PERSIST_KEY_PREFIXES.has(head);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
},
},
}}
>
{children}
</PersistQueryClientProvider>
</electronTrpc.Provider>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider";
import { useTasksFilterStore } from "../../stores/tasks-filter-state";
import { BoardContent } from "./components/BoardContent";
import { GitHubIssuesContent } from "./components/GitHubIssuesContent";
import { LinearCTA } from "./components/LinearCTA";
import { PullRequestsContent } from "./components/PullRequestsContent";
import { TableContent } from "./components/TableContent";
import { type TabValue, TasksTopBar } from "./components/TasksTopBar";
import type { TaskWithStatus } from "./hooks/useTasksData";
Expand All @@ -13,41 +15,77 @@ interface TasksViewProps {
initialTab?: "all" | "active" | "backlog";
initialAssignee?: string;
initialSearch?: string;
initialType?: "tasks" | "prs" | "issues";
initialProject?: string;
}

export function TasksView({
initialTab,
initialAssignee,
initialSearch,
initialType,
initialProject,
}: TasksViewProps) {
const navigate = useNavigate();
const collections = useCollections();
const currentTab: TabValue = initialTab ?? "all";
const [searchQuery, setSearchQuery] = useState(initialSearch ?? "");
const assigneeFilter = initialAssignee ?? null;
const typeTab = initialType ?? "tasks";
const projectFilter = initialProject ?? null;

const {
setTab: storeSetTab,
setAssignee: storeSetAssignee,
setSearch: storeSetSearch,
setTypeTab: storeSetTypeTab,
setProjectFilter: storeSetProjectFilter,
viewMode,
setViewMode,
} = useTasksFilterStore();

const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);

const buildSearch = useCallback(
(overrides: {
tab?: TabValue;
assignee?: string | null;
search?: string;
type?: "tasks" | "prs" | "issues";
project?: string | null;
}) => {
const tab = overrides.tab ?? currentTab;
const assignee =
overrides.assignee !== undefined ? overrides.assignee : assigneeFilter;
const query =
overrides.search !== undefined ? overrides.search : searchQuery;
const type = overrides.type ?? typeTab;
const project =
overrides.project !== undefined ? overrides.project : projectFilter;

const search: Record<string, string> = {};
if (tab !== "all") search.tab = tab;
if (assignee) search.assignee = assignee;
if (query) search.search = query;
if (type !== "tasks") search.type = type;
if (project) search.project = project;
return search;
},
[currentTab, assigneeFilter, searchQuery, typeTab, projectFilter],
);

const syncSearchToUrl = useCallback(
(query: string) => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
const search: Record<string, string> = {};
if (currentTab !== "all") search.tab = currentTab;
if (assigneeFilter) search.assignee = assigneeFilter;
if (query) search.search = query;
navigate({ to: "/tasks", search, replace: true });
navigate({
to: "/tasks",
search: buildSearch({ search: query }),
replace: true,
});
}, 300);
},
[navigate, currentTab, assigneeFilter],
[navigate, buildSearch],
);

useEffect(() => {
Expand Down Expand Up @@ -77,6 +115,14 @@ export function TasksView({
storeSetSearch(searchQuery);
}, [searchQuery, storeSetSearch]);

useEffect(() => {
storeSetTypeTab(typeTab);
}, [typeTab, storeSetTypeTab]);

useEffect(() => {
storeSetProjectFilter(projectFilter);
}, [projectFilter, storeSetProjectFilter]);

const { data: integrations } = useLiveQuery(
(q) =>
q
Expand All @@ -91,19 +137,23 @@ export function TasksView({
integrations?.some((i) => i.provider === "linear") ?? false;

const handleTabChange = (tab: TabValue) => {
const search: Record<string, string> = {};
if (tab !== "all") search.tab = tab;
if (assigneeFilter) search.assignee = assigneeFilter;
if (searchQuery) search.search = searchQuery;
navigate({ to: "/tasks", search, replace: true });
navigate({ to: "/tasks", search: buildSearch({ tab }), replace: true });
};

const handleAssigneeFilterChange = (assignee: string | null) => {
const search: Record<string, string> = {};
if (currentTab !== "all") search.tab = currentTab;
if (assignee) search.assignee = assignee;
if (searchQuery) search.search = searchQuery;
navigate({ to: "/tasks", search, replace: true });
navigate({
to: "/tasks",
search: buildSearch({ assignee }),
replace: true,
});
};

const handleTypeTabChange = (type: "tasks" | "prs" | "issues") => {
navigate({ to: "/tasks", search: buildSearch({ type }), replace: true });
};

const handleProjectFilterChange = (project: string | null) => {
navigate({ to: "/tasks", search: buildSearch({ project }), replace: true });
};

const [selectedTasks, setSelectedTasks] = useState<TaskWithStatus[]>([]);
Expand All @@ -122,18 +172,19 @@ export function TasksView({
}, []);

const handleTaskClick = (task: TaskWithStatus) => {
const search: Record<string, string> = {};
if (currentTab !== "all") search.tab = currentTab;
if (assigneeFilter) search.assignee = assigneeFilter;
if (searchQuery) search.search = searchQuery;
navigate({
to: "/tasks/$taskId",
params: { taskId: task.id },
search,
search: buildSearch({}),
});
};

const showLinearCTA = integrations !== undefined && !isLinearConnected;
const showLinearCTA =
integrations !== undefined && !isLinearConnected && typeTab === "tasks";

const showTasks = typeTab === "tasks";
const showPRs = typeTab === "prs";
const showIssues = typeTab === "issues";

return (
<div className="flex-1 flex flex-col min-h-0 min-w-0 overflow-hidden">
Expand All @@ -149,26 +200,47 @@ export function TasksView({
onClearSelection={handleClearSelection}
viewMode={viewMode}
onViewModeChange={setViewMode}
typeTab={typeTab}
onTypeTabChange={handleTypeTabChange}
projectFilter={projectFilter}
onProjectFilterChange={handleProjectFilterChange}
/>
)}

{showLinearCTA ? (
<LinearCTA />
) : viewMode === "board" ? (
<BoardContent
filterTab={currentTab}
searchQuery={searchQuery}
assigneeFilter={assigneeFilter}
onTaskClick={handleTaskClick}
/>
) : (
<TableContent
filterTab={currentTab}
searchQuery={searchQuery}
assigneeFilter={assigneeFilter}
onTaskClick={handleTaskClick}
onSelectionChange={handleSelectionChange}
/>
<div className="flex-1 flex flex-col min-h-0 min-w-0 overflow-hidden">
{showTasks &&
(viewMode === "board" ? (
<BoardContent
filterTab={currentTab}
searchQuery={searchQuery}
assigneeFilter={assigneeFilter}
onTaskClick={handleTaskClick}
/>
) : (
<TableContent
filterTab={currentTab}
searchQuery={searchQuery}
assigneeFilter={assigneeFilter}
onTaskClick={handleTaskClick}
onSelectionChange={handleSelectionChange}
/>
))}
{showPRs && (
<PullRequestsContent
projectFilter={projectFilter}
searchQuery={searchQuery}
/>
)}
{showIssues && (
<GitHubIssuesContent
projectFilter={projectFilter}
searchQuery={searchQuery}
/>
)}
</div>
)}
</div>
);
Expand Down
Loading
Loading