@cytario/web 2.1.8 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +1 -1
  2. package/app/.server/auth/README.md +0 -1
  3. package/app/.server/auth/authMiddleware.ts +6 -28
  4. package/app/.server/auth/getSessionCredentials.ts +11 -1
  5. package/app/.server/auth/sessionPolicy.ts +69 -0
  6. package/app/.server/corsPreflight.ts +136 -0
  7. package/app/.server/csp.ts +34 -0
  8. package/app/components/.client/ImageViewer/README.md +2 -2
  9. package/app/components/DirectoryView/DirectoryViewGrid.tsx +2 -11
  10. package/app/components/DirectoryView/DirectoryViewTree.tsx +18 -26
  11. package/app/components/DirectoryView/buildDirectoryTree.ts +89 -42
  12. package/app/components/DirectoryView/filterNodes.ts +4 -19
  13. package/app/components/DirectoryView/useLazyTreeNodes.ts +120 -0
  14. package/app/components/GlobalSearch/GlobalSearch.tsx +8 -3
  15. package/app/components/GlobalSearch/Suggestions.tsx +21 -3
  16. package/app/config.ts +0 -7
  17. package/app/entry.server.tsx +9 -4
  18. package/app/hooks/useInitConnections.ts +3 -3
  19. package/app/root.tsx +11 -6
  20. package/app/routes/connections/connection.form.tsx +9 -3
  21. package/app/routes/connections/connection.schema.ts +60 -11
  22. package/app/routes/connections/connections.clientLoader.ts +91 -0
  23. package/app/routes/connections/connections.loader.ts +19 -39
  24. package/app/routes/connections/connections.route.tsx +5 -0
  25. package/app/routes/connections/createConnection.action.ts +39 -13
  26. package/app/routes/connections/deleteConnection.action.ts +5 -1
  27. package/app/routes/connections/updateConnection.action.ts +50 -13
  28. package/app/routes/home/home.route.tsx +5 -1
  29. package/app/routes/layouts/protected.layout.tsx +15 -1
  30. package/app/routes/objects/objects.clientLoader.ts +73 -0
  31. package/app/routes/objects/objects.loader.ts +58 -92
  32. package/app/routes/objects/objects.route.tsx +41 -40
  33. package/app/routes/search.route.tsx +133 -28
  34. package/app/routes.ts +0 -4
  35. package/app/utils/connectionsStore/useConnectionsStore.ts +25 -62
  36. package/app/utils/credentialsRefresh.ts +53 -0
  37. package/app/utils/db/convertCsvToParquet.ts +24 -16
  38. package/app/utils/db/createDatabase.ts +25 -33
  39. package/app/utils/db/duckdbBundles.ts +42 -0
  40. package/app/utils/db/ensureSpatialLoaded.ts +28 -0
  41. package/app/utils/db/escapeSqlString.ts +4 -0
  42. package/app/utils/db/getBlobFromObjectNode.ts +15 -36
  43. package/app/utils/db/getTileDataWasm.ts +4 -5
  44. package/app/utils/db/sqlQueries.ts +6 -1
  45. package/app/utils/filterObjects.ts +2 -18
  46. package/app/utils/limitConcurrency.ts +28 -0
  47. package/app/utils/listObjectsClient.ts +318 -0
  48. package/app/utils/listingLimits.ts +7 -0
  49. package/app/utils/loadConnectionLevel.ts +50 -0
  50. package/app/utils/localFilesStore/useFileStore.ts +1 -6
  51. package/app/utils/pathUtils.ts +65 -0
  52. package/app/utils/resourceId.ts +14 -29
  53. package/app/utils/s3HostAllowlist.ts +116 -0
  54. package/app/utils/signedFetch.ts +159 -24
  55. package/package.json +2 -2
  56. package/prisma/seed.ts +3 -3
  57. package/public/duckdb-extensions/checksums.json +12 -0
  58. package/scripts/download-duckdb-extensions.mjs +188 -0
  59. package/scripts/prebuild.mjs +18 -10
  60. package/app/.server/auth/getPresignedUrl.ts +0 -21
  61. package/app/.server/auth/getS3Client.ts +0 -93
  62. package/app/routes/presign.route.tsx +0 -42
  63. package/app/utils/getObjects.ts +0 -24
@@ -6,42 +6,30 @@ import { isZarrPath } from "~/utils/zarrUtils";
6
6
  export type TreeNodeType = "bucket" | "directory" | "file";
7
7
 
8
8
  /**
9
- * A node in the storage directory tree.
10
- *
11
- * Structurally extends `@cytario/design` `TreeNode` (`{ id, name, children? }`)
12
- * so it can be passed directly to the design system `<Tree>` component without
13
- * conversion. Callbacks like `onActivate` return the full `TreeNode`,
14
- * eliminating the need for reverse-lookup helpers.
15
- *
16
- * Connection-level metadata (`provider`, `bucketName`, etc.) is not stored on
17
- * every node — look it up from the connections store via `connectionName`.
9
+ * A node in the storage directory tree. Shape matches `@cytario/design`
10
+ * `TreeNode` so it can be passed straight to the design-system `<Tree>`.
18
11
  */
19
12
  export interface TreeNode {
20
13
  /** Globally unique identifier: `connectionName/pathName`. */
21
14
  id: string;
22
15
  /** Name of the connection config this node belongs to. */
23
16
  connectionName: string;
24
- /**
25
- * Display name — the last segment of the path (e.g. `"output.ome.tif"`).
26
- *
27
- * For `type: "bucket"` nodes, this equals `connectionName` by convention —
28
- * loaders set it that way (`connections.loader.ts`, `search.route.tsx`) so
29
- * `DirectoryViewTableConnection` can use `node.name` as the row's stable
30
- * id and URL segment without a separate lookup.
31
- */
17
+ /** Display name. For `type: "bucket"` this equals `connectionName`. */
32
18
  name: string;
33
19
  type: TreeNodeType;
34
- /**
35
- * Full path relative to the connection root, including trailing `/` for
36
- * directories. Empty string `""` for bucket root nodes.
37
- *
38
- * Built by `buildDirectoryTree` as the concatenation of ancestor `name`
39
- * segments. Used for routing via `buildConnectionPath()`.
40
- */
20
+ /** Full path relative to connection root (trailing `/` for dirs, `""` for bucket root). */
41
21
  pathName: string;
42
- children: TreeNode[];
43
- /** Original S3 object metadata. Present for files; for directories, holds the first image object (used for previews). */
22
+ /** `undefined` signals a leaf (react-arborist hides the chevron); `[]` is expandable-empty. */
23
+ children?: TreeNode[];
24
+ /** S3 metadata. On directories: first image inside, used for previews. */
44
25
  _Object?: _Object;
26
+ /** Hints from `buildLevelTree` for one-level listings. */
27
+ hasChildren?: boolean;
28
+ isLeaf?: boolean;
29
+ loadState?: "idle" | "loading" | "loaded" | "error";
30
+ /** Populated for bucket nodes by the per-connection preview probe. */
31
+ connectionStatus?: "connected" | "error";
32
+ connectionErrorMessage?: string;
45
33
  }
46
34
 
47
35
  function buildDirectoryTreeRecursive(
@@ -56,9 +44,7 @@ function buildDirectoryTreeRecursive(
56
44
  if (keyParts.length > 1) pathName += "/";
57
45
 
58
46
  if (keyParts.length === 1) {
59
- // Skip empty-name leaf nodes produced by S3 folder markers (keys ending
60
- // in "/"). The parent directory was already created by the recursive
61
- // call, so there is nothing to add.
47
+ // Skip empty-name leaves from S3 folder marker keys (ending in "/").
62
48
  if (name === "") return;
63
49
 
64
50
  currentDir.push({
@@ -71,8 +57,7 @@ function buildDirectoryTreeRecursive(
71
57
  _Object: obj,
72
58
  });
73
59
  } else {
74
- // Zarr directories are images — treat as leaf nodes, skip recursion
75
- // into the thousands of internal chunk files.
60
+ // Zarr directories are images — treat as leaf, skip the thousands of chunks.
76
61
  if (isZarrPath(name)) {
77
62
  if (!currentDir.find((child) => child.name === name)) {
78
63
  currentDir.push({
@@ -104,6 +89,7 @@ function buildDirectoryTreeRecursive(
104
89
  existingDir._Object = obj;
105
90
  }
106
91
 
92
+ if (!existingDir.children) existingDir.children = [];
107
93
  buildDirectoryTreeRecursive(
108
94
  existingDir.children,
109
95
  keyParts.slice(1),
@@ -114,11 +100,10 @@ function buildDirectoryTreeRecursive(
114
100
  }
115
101
  }
116
102
 
117
- /** Depth-first search for a node by its `id`. */
118
103
  export function findNodeById(nodes: TreeNode[], id: string): TreeNode | undefined {
119
104
  for (const node of nodes) {
120
105
  if (node.id === id) return node;
121
- if (node.children.length > 0) {
106
+ if (node.children && node.children.length > 0) {
122
107
  const found = findNodeById(node.children, id);
123
108
  if (found) return found;
124
109
  }
@@ -126,19 +111,19 @@ export function findNodeById(nodes: TreeNode[], id: string): TreeNode | undefine
126
111
  return undefined;
127
112
  }
128
113
 
129
- /** Total size of all files under a directory node. */
130
114
  export function computeDirectorySize(node: TreeNode): number {
131
115
  if (node.type === "file") return node._Object?.Size ?? 0;
132
- if (node.children.length === 0) return node._Object?.Size ?? 0;
116
+ if (!node.children || node.children.length === 0) {
117
+ return node._Object?.Size ?? 0;
118
+ }
133
119
  return node.children.reduce((sum, child) => sum + computeDirectorySize(child), 0);
134
120
  }
135
121
 
136
- /** Latest LastModified timestamp under a directory node. */
137
122
  export function computeDirectoryLastModified(node: TreeNode): number {
138
123
  if (node.type === "file") {
139
124
  return node._Object?.LastModified ? new Date(node._Object.LastModified).getTime() : 0;
140
125
  }
141
- if (node.children.length === 0) {
126
+ if (!node.children || node.children.length === 0) {
142
127
  return node._Object?.LastModified ? new Date(node._Object.LastModified).getTime() : 0;
143
128
  }
144
129
  return node.children.reduce(
@@ -147,13 +132,75 @@ export function computeDirectoryLastModified(node: TreeNode): number {
147
132
  );
148
133
  }
149
134
 
135
+ interface BuildLevelTreeArgs {
136
+ contents: _Object[];
137
+ commonPrefixes: string[];
138
+ connectionName: string;
139
+ /** Listing prefix to strip from keys to derive node names. Empty at bucket root. */
140
+ prefix?: string;
141
+ /** Path relative to connection root, prepended to `pathName`. */
142
+ urlPath?: string;
143
+ }
144
+
150
145
  /**
151
- * Build a directory tree from S3 objects.
152
- *
153
- * @param prefix S3 listing prefix to strip from object keys
154
- * @param urlPath Path relative to the connection root (prepended to node
155
- * pathNames so they stay routable via `/connections/:connectionName/*`)
146
+ * Build a single-level tree from a paginated S3 listing with `Delimiter: "/"`.
147
+ * Zarr `CommonPrefixes` collapse into a single leaf node.
156
148
  */
149
+ export function buildLevelTree({
150
+ contents,
151
+ commonPrefixes,
152
+ connectionName,
153
+ prefix,
154
+ urlPath,
155
+ }: BuildLevelTreeArgs): TreeNode[] {
156
+ const basePath = urlPath ? (urlPath.endsWith("/") ? urlPath : `${urlPath}/`) : "";
157
+ const stripPrefix = prefix ?? "";
158
+ const nodes: TreeNode[] = [];
159
+
160
+ for (const cp of commonPrefixes) {
161
+ const relative = cp.startsWith(stripPrefix) ? cp.slice(stripPrefix.length) : cp;
162
+ const name = relative.replace(/\/$/, "");
163
+ if (!name) continue;
164
+ const pathName = `${basePath}${name}/`;
165
+ const isZarr = isZarrPath(name);
166
+
167
+ nodes.push({
168
+ id: `${connectionName}/${pathName}`,
169
+ connectionName,
170
+ type: isZarr ? "file" : "directory",
171
+ name,
172
+ pathName,
173
+ // Empty array → chevron + lazy expansion; `undefined` → no chevron.
174
+ children: isZarr ? undefined : [],
175
+ hasChildren: !isZarr,
176
+ isLeaf: isZarr,
177
+ loadState: isZarr ? undefined : "idle",
178
+ });
179
+ }
180
+
181
+ for (const obj of contents) {
182
+ if (!obj.Key) continue;
183
+ const relative = obj.Key.startsWith(stripPrefix) ? obj.Key.slice(stripPrefix.length) : obj.Key;
184
+ if (!relative || relative.endsWith("/")) continue;
185
+ const name = relative.split("/").pop()!;
186
+ if (!name) continue;
187
+ const pathName = `${basePath}${relative}`;
188
+
189
+ nodes.push({
190
+ id: `${connectionName}/${pathName}`,
191
+ connectionName,
192
+ type: "file",
193
+ name,
194
+ pathName,
195
+ isLeaf: true,
196
+ _Object: obj,
197
+ });
198
+ }
199
+
200
+ return nodes;
201
+ }
202
+
203
+ /** Build a recursive directory tree from a flat S3 object listing. */
157
204
  export function buildDirectoryTree(
158
205
  objects: _Object[],
159
206
  connectionName: string,
@@ -6,16 +6,6 @@ import type { ColumnConfig } from "~/components/Table/types";
6
6
  import type { Connection } from "~/utils/connectionsStore/useConnectionsStore";
7
7
  import { getFileType } from "~/utils/fileType";
8
8
 
9
- // Applies a `ColumnFiltersState` to a `TreeNode[]` so every downstream view
10
- // (Grid, Table, Tree) receives pre-filtered data. The `ColumnFiltersState`
11
- // shape is produced by both the Table's column-filter UI and the FilterBar,
12
- // both writing to the same per-tableId Zustand store.
13
- //
14
- // allNodes -> filteredNodes -> DirectoryView -> (Grid | Table | Tree)
15
- //
16
- // Filters only the current level (top-level `nodes`). Tree's hierarchical
17
- // expansion shows descendants unfiltered; name-matching inside the tree
18
- // relies on the design-system Tree's `searchTerm` + `searchMatch`.
19
9
  type NodeAccessor = (node: TreeNode) => string;
20
10
 
21
11
  const fileAccessors: Record<string, NodeAccessor> = {
@@ -42,27 +32,22 @@ export function getNodeAccessors(
42
32
  return kind === "connections" ? makeConnectionAccessors(connections) : fileAccessors;
43
33
  }
44
34
 
45
- /**
46
- * Filters hidden files (names starting with ".") unless showHidden is true.
47
- * Filtering is applied recursively so that hidden children inside visible
48
- * directories are also removed (required for the tree view).
49
- */
35
+ /** Filter hidden files (names starting with ".") recursively. */
50
36
  export function filterHiddenNodes(nodes: TreeNode[], showHidden: boolean): TreeNode[] {
51
37
  if (showHidden) return nodes;
52
38
 
53
39
  return nodes
54
40
  .filter((node) => !node.name.startsWith("."))
55
41
  .map((node) =>
56
- node.children.length > 0
42
+ node.children && node.children.length > 0
57
43
  ? { ...node, children: filterHiddenNodes(node.children, false) }
58
44
  : node,
59
45
  );
60
46
  }
61
47
 
62
48
  /**
63
- * Filters TreeNode[] using the same column filter semantics as the Table.
64
- * Text filters use case-insensitive substring matching; select filters use
65
- * exact match. Columns without a matching accessor are skipped.
49
+ * Filter `TreeNode[]` using the same column-filter semantics as the Table.
50
+ * Text filters use case-insensitive substring; select filters use exact match.
66
51
  */
67
52
  export function filterNodes(
68
53
  nodes: TreeNode[],
@@ -0,0 +1,120 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+
3
+ import { TreeNode } from "./buildDirectoryTree";
4
+ import { toastBridge, toToastVariant } from "~/toast-bridge";
5
+ import { select } from "~/utils/connectionsStore/selectors";
6
+ import { useConnectionsStore } from "~/utils/connectionsStore/useConnectionsStore";
7
+ import { formatTruncationMessage } from "~/utils/listingLimits";
8
+ import { loadConnectionLevel } from "~/utils/loadConnectionLevel";
9
+
10
+ /**
11
+ * Lazily-expanding tree backed by direct browser → S3 listings.
12
+ * `loadChildren` is idempotent and dedupes concurrent calls; each
13
+ * `initialNodes` cohort owns an `AbortController` so stale responses
14
+ * cannot overwrite a fresh tree after navigation.
15
+ */
16
+ export function useLazyTreeNodes(initialNodes: TreeNode[]) {
17
+ const [nodes, setNodes] = useState<TreeNode[]>(initialNodes);
18
+ const inflightRef = useRef<Map<string, Promise<TreeNode[]>>>(new Map());
19
+ const abortRef = useRef<AbortController>(new AbortController());
20
+
21
+ useEffect(() => {
22
+ const controller = new AbortController();
23
+ abortRef.current = controller;
24
+ inflightRef.current = new Map();
25
+ setNodes(initialNodes);
26
+ return () => {
27
+ controller.abort();
28
+ };
29
+ }, [initialNodes]);
30
+
31
+ const loadChildren = useCallback(async (node: TreeNode): Promise<TreeNode[]> => {
32
+ if (
33
+ !node.connectionName ||
34
+ node.isLeaf ||
35
+ node.hasChildren === false ||
36
+ node.loadState === "loaded"
37
+ ) {
38
+ return node.children ?? [];
39
+ }
40
+ const connectionName = node.connectionName;
41
+ const key = `${connectionName} ${node.pathName}`;
42
+ const existing = inflightRef.current.get(key);
43
+ if (existing) return existing;
44
+
45
+ const connection = select.connection(connectionName)(useConnectionsStore.getState());
46
+ if (!connection) {
47
+ throw new Error(`No connection in store for "${connectionName}"`);
48
+ }
49
+ const { connectionConfig, credentials } = connection;
50
+
51
+ const controller = abortRef.current;
52
+ const { signal } = controller;
53
+ const inflightMap = inflightRef.current;
54
+
55
+ setNodes((prev) =>
56
+ replaceNodeById(prev, node.id, (n) => ({
57
+ ...n,
58
+ loadState: "loading",
59
+ })),
60
+ );
61
+
62
+ const promise = (async () => {
63
+ try {
64
+ const { nodes: children, isCapped } = await loadConnectionLevel({
65
+ connectionConfig,
66
+ credentials,
67
+ connectionName,
68
+ urlPath: node.pathName,
69
+ signal,
70
+ });
71
+ if (signal.aborted) return [];
72
+
73
+ if (isCapped) {
74
+ toastBridge.emit({
75
+ variant: toToastVariant("warning"),
76
+ message: formatTruncationMessage(node.name),
77
+ });
78
+ }
79
+ setNodes((prev) =>
80
+ replaceNodeById(prev, node.id, (n) => ({
81
+ ...n,
82
+ children,
83
+ loadState: "loaded",
84
+ })),
85
+ );
86
+ return children;
87
+ } catch (error) {
88
+ if (signal.aborted) return [];
89
+ setNodes((prev) =>
90
+ replaceNodeById(prev, node.id, (n) => ({
91
+ ...n,
92
+ loadState: "error",
93
+ })),
94
+ );
95
+ throw error;
96
+ } finally {
97
+ if (inflightMap === inflightRef.current) {
98
+ inflightMap.delete(key);
99
+ }
100
+ }
101
+ })();
102
+
103
+ inflightRef.current.set(key, promise);
104
+ return promise;
105
+ }, []);
106
+
107
+ return { nodes, loadChildren };
108
+ }
109
+
110
+ function replaceNodeById(
111
+ nodes: TreeNode[],
112
+ id: string,
113
+ update: (n: TreeNode) => TreeNode,
114
+ ): TreeNode[] {
115
+ return nodes.map((n) => {
116
+ if (n.id === id) return update(n);
117
+ if (!n.children || n.children.length === 0) return n;
118
+ return { ...n, children: replaceNodeById(n.children, id, update) };
119
+ });
120
+ }
@@ -18,12 +18,17 @@ export const GlobalSearch = () => {
18
18
  const [query, setQuery] = useState(searchQuery);
19
19
  const [showResults, setShowResults] = useState(false);
20
20
 
21
- // Derive nodes from fetcher data
22
21
  const nodes: TreeNode[] = fetcher.data?.nodes ?? [];
23
22
 
23
+ // Treat the debounce window as "loading" too — otherwise the dropdown
24
+ // flashes "No results" between keystrokes.
25
+ const lastResultsQuery = fetcher.data?.searchQuery ?? "";
26
+ const trimmedQuery = query.trim();
27
+ const isLoading =
28
+ trimmedQuery.length > 0 && (fetcher.state !== "idle" || trimmedQuery !== lastResultsQuery);
29
+
24
30
  const handleSubmit = async (value: string) => {
25
31
  setSearchQuery(value);
26
- // Trigger fetcher only if there's input
27
32
  if (value.trim()) {
28
33
  fetcher.submit({ query: value }, { method: "get", action: "/search" });
29
34
  setShowResults(true);
@@ -63,7 +68,7 @@ export const GlobalSearch = () => {
63
68
  }}
64
69
  />
65
70
 
66
- <Suggestions nodes={nodes} showResults={showResults} />
71
+ <Suggestions nodes={nodes} showResults={showResults} isLoading={isLoading} />
67
72
  </div>
68
73
  );
69
74
  };
@@ -1,5 +1,5 @@
1
1
  import { EmptyState, H2 } from "@cytario/design";
2
- import { SearchX } from "lucide-react";
2
+ import { Loader2, SearchX } from "lucide-react";
3
3
  import { AnimatePresence, motion } from "motion/react";
4
4
 
5
5
  import { TreeNode } from "../DirectoryView/buildDirectoryTree";
@@ -8,8 +8,12 @@ import { DirectoryTree } from "../DirectoryView/DirectoryViewTree";
8
8
  interface SuggestionsProps {
9
9
  nodes: TreeNode[];
10
10
  showResults: boolean;
11
+ isLoading: boolean;
11
12
  }
12
- export const Suggestions = ({ nodes, showResults }: SuggestionsProps) => {
13
+
14
+ // Render order: prefer stale nodes over the empty state while a new query is in
15
+ // flight — otherwise the dropdown flashes "No results" on every keystroke.
16
+ export const Suggestions = ({ nodes, showResults, isLoading }: SuggestionsProps) => {
13
17
  return (
14
18
  <AnimatePresence>
15
19
  {showResults && (
@@ -28,16 +32,30 @@ export const Suggestions = ({ nodes, showResults }: SuggestionsProps) => {
28
32
  text-black
29
33
  shadow-lg rounded-sm border border-slate-200
30
34
  `}
35
+ aria-busy={isLoading}
31
36
  >
32
37
  {nodes.length > 0 ? (
33
38
  <>
34
- <header className="flex-shrink-0 p-4 border-b border-slate-200">
39
+ <header className="flex-shrink-0 p-4 border-b border-slate-200 flex items-center justify-between">
35
40
  <H2>All Results</H2>
41
+ {isLoading && (
42
+ <Loader2
43
+ size={16}
44
+ className="animate-spin text-slate-500"
45
+ aria-label="Searching"
46
+ />
47
+ )}
36
48
  </header>
37
49
  <div className="overflow-y-auto flex-1">
38
50
  <DirectoryTree nodes={nodes} />
39
51
  </div>
40
52
  </>
53
+ ) : isLoading ? (
54
+ <EmptyState
55
+ icon={Loader2}
56
+ title="Searching…"
57
+ description="Looking across your connections."
58
+ />
41
59
  ) : (
42
60
  <EmptyState
43
61
  icon={SearchX}
package/app/config.ts CHANGED
@@ -1,9 +1,6 @@
1
1
  import { CookieOptions } from "react-router";
2
2
 
3
3
  interface CytarioConfig {
4
- setup: {
5
- allowedFiles: string;
6
- };
7
4
  endpoints: {
8
5
  webapp: string;
9
6
  };
@@ -32,7 +29,6 @@ interface CytarioConfig {
32
29
  }
33
30
 
34
31
  const {
35
- ALLOWED_FILES,
36
32
  BASE_URL,
37
33
  CLIENT_ID,
38
34
  CLIENT_SECRET,
@@ -50,9 +46,6 @@ const {
50
46
  } = process.env;
51
47
 
52
48
  export const cytarioConfig: Readonly<CytarioConfig> = {
53
- setup: {
54
- allowedFiles: ALLOWED_FILES ?? ".*",
55
- },
56
49
  endpoints: {
57
50
  webapp: WEB_HOST!,
58
51
  },
@@ -5,14 +5,13 @@ import { renderToPipeableStream } from "react-dom/server";
5
5
  import type { AppLoadContext, EntryContext } from "react-router";
6
6
  import { ServerRouter } from "react-router";
7
7
 
8
+ import { buildContentSecurityPolicy } from "./.server/csp";
8
9
  import { bootstrapPlugins } from "./plugins.generated";
9
10
 
10
11
  const ABORT_DELAY = 5_000;
11
12
 
12
- // Server-side bootstrap registers only platform plugins from CYTARIO_PLUGINS.
13
- // Built-ins live behind viv/geotiff (browser-only) and register on the
14
- // client. `handleRequest` awaits this so a request cannot resolve the
15
- // registry before the plugin's async `register()` has completed.
13
+ // `handleRequest` awaits this so a request cannot resolve the plugin registry
14
+ // before async `register()` calls have completed.
16
15
  const bootstrapPromise: Promise<void> = bootstrapPlugins({
17
16
  debug: (msg, fields) => console.debug("[plugin-bootstrap]", msg, fields ?? {}),
18
17
  info: (msg, fields) => console.info("[plugin-bootstrap]", msg, fields ?? {}),
@@ -56,6 +55,9 @@ function handleBotRequest(
56
55
  const stream = createReadableStreamFromReadable(body);
57
56
 
58
57
  responseHeaders.set("Content-Type", "text/html");
58
+ // Only attached to the HTML document; `.data` and action JSON responses
59
+ // inherit the document-level policy from the hydrated page.
60
+ responseHeaders.set("Content-Security-Policy", buildContentSecurityPolicy());
59
61
 
60
62
  resolve(
61
63
  new Response(stream, {
@@ -102,6 +104,9 @@ function handleBrowserRequest(
102
104
  const stream = createReadableStreamFromReadable(body);
103
105
 
104
106
  responseHeaders.set("Content-Type", "text/html");
107
+ // Only attached to the HTML document; `.data` and action JSON responses
108
+ // inherit the document-level policy from the hydrated page.
109
+ responseHeaders.set("Content-Security-Policy", buildContentSecurityPolicy());
105
110
 
106
111
  resolve(
107
112
  new Response(stream, {
@@ -6,9 +6,9 @@ import { select } from "~/utils/connectionsStore/selectors";
6
6
  import { useConnectionsStore } from "~/utils/connectionsStore/useConnectionsStore";
7
7
 
8
8
  /**
9
- * Replaces the client store with the authoritative set of connections from
10
- * the auth context. Runs on every route that calls it — missing connections
11
- * (deleted server-side) are pruned, new ones added. Safe to call repeatedly.
9
+ * Replace the client connections store with the auth-context set. Uses an
10
+ * effect rather than render-time mutation: subscribed descendants would
11
+ * otherwise trip React's "cannot update during render" warning.
12
12
  */
13
13
  export function useInitConnections(
14
14
  connectionConfigs: ConnectionConfig[],
package/app/root.tsx CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  useNavigation,
14
14
  useRouteError,
15
15
  useRouteLoaderData,
16
+ type ClientLoaderFunctionArgs,
16
17
  type LinksFunction,
17
18
  type MiddlewareFunction,
18
19
  type LoaderFunctionArgs,
@@ -62,7 +63,7 @@ export const shouldRevalidate: ShouldRevalidateFunction = ({
62
63
  formAction,
63
64
  defaultShouldRevalidate,
64
65
  }) => {
65
- // Only revalidate after form submissions (to pick up session notifications)
66
+ // Only revalidate after form submissions so flash notifications surface.
66
67
  if (formAction) return defaultShouldRevalidate;
67
68
  return false;
68
69
  };
@@ -83,7 +84,6 @@ export const loader = async ({ context }: LoaderFunctionArgs): Promise<RootLoade
83
84
  await sessionStorage.commitSession(session);
84
85
  }
85
86
 
86
- // Build Keycloak account settings URL server-side
87
87
  const accountSettingsUrl = user
88
88
  ? `${cytarioConfig.auth.baseUrl}/account?referrer=${cytarioConfig.auth.clientId}&referrer_uri=${cytarioConfig.endpoints.webapp}`
89
89
  : undefined;
@@ -91,6 +91,12 @@ export const loader = async ({ context }: LoaderFunctionArgs): Promise<RootLoade
91
91
  return { user, notification, accountSettingsUrl };
92
92
  };
93
93
 
94
+ // Identity clientLoader. Forces this route off RR's bulk-fetch single-fetch
95
+ // path, which is short-circuited during initial hydrate when a descendant
96
+ // route opts into `clientLoader.hydrate = true` (RR issue #13873).
97
+ export const clientLoader = ({ serverLoader }: ClientLoaderFunctionArgs) =>
98
+ serverLoader<typeof loader>();
99
+
94
100
  export function Layout({ children }: { children: React.ReactNode }) {
95
101
  const data = useRouteLoaderData<RootLoaderResponse>("root");
96
102
  const location = useLocation();
@@ -105,12 +111,11 @@ export function Layout({ children }: { children: React.ReactNode }) {
105
111
  }
106
112
  }, [data?.notification]);
107
113
 
108
- // Hydrate file store from IndexedDB on mount
109
114
  useEffect(() => {
110
115
  useFileStore.getState().hydrate();
111
116
  }, []);
112
117
 
113
- // Move focus to main content on route change (skip initial render)
118
+ // Move focus to main content on route change (skip initial render).
114
119
  useEffect(() => {
115
120
  if (isInitialRender.current) {
116
121
  isInitialRender.current = false;
@@ -185,8 +190,8 @@ export function ErrorBoundary() {
185
190
  title = `${error.status} ${error.statusText}`;
186
191
  message = error.data ?? "An error occurred while processing your request.";
187
192
  } else if (error instanceof Error) {
188
- // Log full error server-side but only show generic message to client
189
- // to avoid leaking internal details (session IDs, endpoints, stack traces)
193
+ // Log full error but show only the generic message to avoid leaking
194
+ // session IDs, endpoints, or stack traces to the client.
190
195
  console.error("Unhandled error:", error);
191
196
  }
192
197
 
@@ -1,4 +1,5 @@
1
1
  import {
2
+ Banner,
2
3
  Field,
3
4
  Fieldset,
4
5
  FormWizard,
@@ -78,15 +79,15 @@ export const ConnectionForm = ({
78
79
  const submit = useSubmit();
79
80
  const actionData = useActionData<{
80
81
  errors?: Record<string, string[]>;
82
+ formError?: string;
81
83
  status?: string;
82
84
  }>();
83
85
  const navigation = useNavigation();
84
86
 
85
87
  const serverErrors = actionData?.status === "error" ? actionData.errors : undefined;
88
+ const formError = actionData?.status === "error" ? actionData.formError : undefined;
86
89
  const isSubmitting = navigation.state === "submitting";
87
90
 
88
- // Compute the initial step from server errors so we navigate to the
89
- // correct page without needing setState inside an effect.
90
91
  const initialStep = serverErrors
91
92
  ? Object.keys(serverErrors).reduce<number>((acc, field) => {
92
93
  const step = FIELD_TO_STEP[field];
@@ -115,7 +116,6 @@ export const ConnectionForm = ({
115
116
  mode: "onTouched",
116
117
  });
117
118
 
118
- // Surface server-side errors (e.g. unique name constraint) in the form
119
119
  useEffect(() => {
120
120
  if (!serverErrors) return;
121
121
  for (const [field, messages] of Object.entries(serverErrors)) {
@@ -198,6 +198,12 @@ export const ConnectionForm = ({
198
198
  <div className="flex flex-col gap-(--spacing-6)">
199
199
  <FormWizardProgress labels={STEP_LABELS} />
200
200
 
201
+ {formError && (
202
+ <Banner variant="danger" title="Could not save the connection">
203
+ {formError}
204
+ </Banner>
205
+ )}
206
+
201
207
  {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
202
208
  <form
203
209
  className="flex flex-col gap-(--spacing-6)"