Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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