From 44fbf8c587b4ca02244a4d37495fea3edb7927c8 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Thu, 28 May 2026 12:05:30 +0100 Subject: [PATCH] Various bug fixes found while testing SaaS build (#6459) # Description of Changes Various fixes and improvements I made while testing the SaaS code: - Changes the new `.env.saas` file to live in `app/` and match the semantics of the other `.env` files - Adds top-level `task dev:saas` command to spawn SaaS frontend & backend - Deletes dead SaaS code and improves some overriding logic - Fixes refreshing issue when coming back to the tab - Fix the Compare tool's selection logic - Make Compare handle error cases properly - Fixes the location of the "Dismiss All Errors" button (was rendering on top of the top-bar with a transparent background previously so it looked rubbish) - Fixes file selection in PDF Editor --- .gitignore | 1 - .taskfiles/backend.yml | 6 +- Taskfile.yml | 17 ++ .env.saas.example => app/.env.saas | 18 +- app/.gitignore | 3 + .../public/locales/en-GB/translation.toml | 2 + .../shared/DismissAllErrorsButton.tsx | 2 +- .../core/components/shared/FileSidebar.tsx | 8 +- .../core/components/shared/config/types.ts | 1 - .../tools/compare/CompareWorkbenchView.tsx | 51 ++-- .../src/core/contexts/FilesPageContext.tsx | 8 +- .../src/core/contexts/IndexedDBContext.tsx | 34 ++- .../tools/compare/useCompareOperation.ts | 26 +- .../src/core/hooks/useEndpointConfig.ts | 260 +++++++++--------- frontend/editor/src/core/tools/Compare.tsx | 17 ++ .../tools/pdfTextEditor/PdfTextEditor.tsx | 36 ++- frontend/editor/src/saas/auth/UseSession.tsx | 10 +- .../src/saas/components/auth/RequireAuth.tsx | 52 ---- .../saas/components/shared/config/types.ts | 73 +---- .../saas/components/toast/ToastRenderer.css | 209 -------------- .../src/saas/hooks/useEndpointConfig.ts | 255 ----------------- .../src/saas/routes/login/LoadingState.tsx | 22 -- 22 files changed, 313 insertions(+), 798 deletions(-) rename .env.saas.example => app/.env.saas (63%) create mode 100644 app/.gitignore delete mode 100644 frontend/editor/src/saas/components/auth/RequireAuth.tsx delete mode 100644 frontend/editor/src/saas/components/toast/ToastRenderer.css delete mode 100644 frontend/editor/src/saas/hooks/useEndpointConfig.ts delete mode 100644 frontend/editor/src/saas/routes/login/LoadingState.tsx diff --git a/.gitignore b/.gitignore index f4558af4c..1b7e69c49 100644 --- a/.gitignore +++ b/.gitignore @@ -178,7 +178,6 @@ venv.bak/ # Env files (secrets / local overrides). Subproject .gitignore files whitelist any committed defaults. .env* -!.env.saas.example # VS Code /.vscode/**/* diff --git a/.taskfiles/backend.yml b/.taskfiles/backend.yml index ccf3a7775..472258fdb 100644 --- a/.taskfiles/backend.yml +++ b/.taskfiles/backend.yml @@ -40,10 +40,10 @@ tasks: platforms: [linux, darwin] dev:saas: - desc: "Start backend in SaaS flavor against Supabase (loads .env.saas.local)" + desc: "Start backend in SaaS flavor against Supabase" # `dotenv:` reads from the root Taskfile's directory (".") because this - # subtaskfile is included with `dir: .`. Drop the file at the repo root. - dotenv: ['.env.saas.local'] + # subtaskfile is included with `dir: .`. + dotenv: ['app/.env.saas.local', 'app/.env.saas'] ignore_error: true vars: PORT: '{{.PORT | default "8080"}}' diff --git a/Taskfile.yml b/Taskfile.yml index 7edf1e9b4..5b40ac4fe 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -58,6 +58,23 @@ tasks: BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' OPEN: "true" + dev:saas: + desc: "Start SaaS backend + frontend concurrently on free ports" + vars: + PORTS: + sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173{{end}}' + BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}' + FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 1}}' + deps: + - task: backend:dev:saas + vars: + PORT: '{{.BACKEND_PORT}}' + - task: frontend:dev:saas + vars: + PORT: '{{.FRONTEND_PORT}}' + BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' + OPEN: "true" + dev:all: desc: "Start backend + frontend + engine concurrently on free ports" vars: diff --git a/.env.saas.example b/app/.env.saas similarity index 63% rename from .env.saas.example rename to app/.env.saas index d9d787fe0..fb5feec55 100644 --- a/.env.saas.example +++ b/app/.env.saas @@ -1,20 +1,20 @@ ############################################################################### -# Stirling-PDF SaaS local environment template. +# Stirling-PDF SaaS environment defaults. # -# Copy this file to `.env.saas.local` (gitignored) and fill in real values. -# Loaded by `task backend:dev:saas` via Taskfile's `dotenv:` directive, then -# read by Spring Boot's `${...}` placeholders in application-saas.properties -# and application-dev.properties. +# This file is committed and provides non-secret defaults loaded by +# `task backend:dev:saas`. Put real values for secrets (passwords, project +# refs, edge function secrets) in `.env.saas.local` - any variable set there +# takes precedence over what's defined here. # -# DO NOT commit `.env.saas.local`. Only `.env.saas.example` is checked in. +# DO NOT commit `.env.saas.local`. Only `.env.saas` is checked in. ############################################################################### # ---------- Supabase project ---------- # Project reference (the subdomain part of .supabase.co). Required. -# Example dev project: +# Set in .env.saas.local. SAAS_DB_PROJECT_REF= -# Edge function secret used by billing/license rollup calls. +# Edge function secret used by billing/license rollup calls. Set in .env.saas.local. SUPABASE_EDGE_FUNCTION_SECRET= # ---------- Database (saas profile) ---------- @@ -28,7 +28,7 @@ SAAS_DB_PASSWORD= # ---------- Database (dev profile overrides) ---------- # Used when `--spring.profiles.include=dev` is active. The dev profile # defaults the URL/username to the shared dev Supabase project, but the -# password must still be provided here. +# password must still be provided in .env.saas.local. SAAS_DEV_DB_URL= SAAS_DEV_DB_USERNAME=postgres SAAS_DEV_DB_PASSWORD= diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 000000000..e2a86ce4c --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,3 @@ +# Whitelist committed env defaults. `.env.saas.local` (and any other .env*) +# stays ignored via the root .gitignore. +!.env.saas diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index 547a6cd67..80cf7fd58 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -2794,6 +2794,7 @@ selectBaseFirst = "Select original PDF first" filesMissing = "Unable to locate the selected files. Please re-select them." generic = "Unable to compare these files." selectRequired = "Select a original and edited document." +title = "Comparison failed" [compare.large.file] message = "One or Both of the provided documents are too large to process" @@ -2859,6 +2860,7 @@ title = "Still working…" [compare.status] complete = "Comparison ready" +error = "Comparison failed" extracting = "Extracting text..." processing = "Analysing differences..." diff --git a/frontend/editor/src/core/components/shared/DismissAllErrorsButton.tsx b/frontend/editor/src/core/components/shared/DismissAllErrorsButton.tsx index 659429235..3bcc53daf 100644 --- a/frontend/editor/src/core/components/shared/DismissAllErrorsButton.tsx +++ b/frontend/editor/src/core/components/shared/DismissAllErrorsButton.tsx @@ -39,7 +39,7 @@ const DismissAllErrorsButton: React.FC = ({ onClick={handleDismissAllErrors} style={{ position: "absolute", - top: "1rem", + top: "calc(48px + 1rem)", // Sit below the 48px-tall WorkbenchBar (top toolbar) right: "1rem", zIndex: Z_INDEX_TOAST, pointerEvents: "auto", diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx index 08529f5ed..6fd3ea9a6 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx @@ -18,7 +18,10 @@ import { } from "@app/contexts/NavigationContext"; import { useViewer } from "@app/contexts/ViewerContext"; import { useFileHandler } from "@app/hooks/useFileHandler"; -import { useIndexedDB } from "@app/contexts/IndexedDBContext"; +import { + useIndexedDB, + useIndexedDBRevision, +} from "@app/contexts/IndexedDBContext"; import { accountService } from "@app/services/accountService"; import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons"; import { Wordmark } from "@app/components/shared/Wordmark"; @@ -143,9 +146,10 @@ const FileSidebar = forwardRef( }, [indexedDB, state.files.ids, state.files.byId]); // Refresh on mount, workbench changes, or external IndexedDB writes + const indexedDBRevision = useIndexedDBRevision(); useEffect(() => { refreshStubs(); - }, [refreshStubs, indexedDB.revision]); + }, [refreshStubs, indexedDBRevision]); // Once a pending file lands in state, open it in the viewer. useEffect(() => { diff --git a/frontend/editor/src/core/components/shared/config/types.ts b/frontend/editor/src/core/components/shared/config/types.ts index 110c7801e..32081c5c4 100644 --- a/frontend/editor/src/core/components/shared/config/types.ts +++ b/frontend/editor/src/core/components/shared/config/types.ts @@ -18,7 +18,6 @@ export const VALID_NAV_KEYS = [ "adminGeneral", "adminSecurity", "adminConnections", - "adminTelegram", "adminPrivacy", "adminDatabase", "adminAdvanced", diff --git a/frontend/editor/src/core/components/tools/compare/CompareWorkbenchView.tsx b/frontend/editor/src/core/components/tools/compare/CompareWorkbenchView.tsx index 2034dcaf7..1df0f1c14 100644 --- a/frontend/editor/src/core/components/tools/compare/CompareWorkbenchView.tsx +++ b/frontend/editor/src/core/components/tools/compare/CompareWorkbenchView.tsx @@ -185,29 +185,34 @@ const CompareTextWorkbenchView = ({ data }: CompareTextWorkbenchViewProps) => { const comparisonTitle = comparisonStub?.name || result?.comparison?.fileName || ""; - // During diff processing, show compact spinners in the dropdown badges - const baseDropdownPlaceholder = - isOperationLoading || !result ? ( - - {t("compare.dropdown.deletionsLabel", "Deletions")}{" "} - - - ) : ( - t("compare.dropdown.deletions", "Deletions ({{count}})", { - count: baseWordChanges.length, - }) - ); - const comparisonDropdownPlaceholder = - isOperationLoading || !result ? ( - - {t("compare.dropdown.additionsLabel", "Additions")}{" "} - - - ) : ( - t("compare.dropdown.additions", "Additions ({{count}})", { - count: comparisonWordChanges.length, - }) - ); + // During diff processing, show compact spinners in the dropdown badges. + // If the operation finished without a result (e.g. errored — the toast + // already told the user why), show the bare label so the dropdowns don't + // spin forever waiting for a result that will never arrive. + const baseDropdownPlaceholder = isOperationLoading ? ( + + {t("compare.dropdown.deletionsLabel", "Deletions")}{" "} + + + ) : result ? ( + t("compare.dropdown.deletions", "Deletions ({{count}})", { + count: baseWordChanges.length, + }) + ) : ( + t("compare.dropdown.deletionsLabel", "Deletions") + ); + const comparisonDropdownPlaceholder = isOperationLoading ? ( + + {t("compare.dropdown.additionsLabel", "Additions")}{" "} + + + ) : result ? ( + t("compare.dropdown.additions", "Additions ({{count}})", { + count: comparisonWordChanges.length, + }) + ) : ( + t("compare.dropdown.additionsLabel", "Additions") + ); const workbenchBarButtons = useCompareWorkbenchBarButtons({ layout, diff --git a/frontend/editor/src/core/contexts/FilesPageContext.tsx b/frontend/editor/src/core/contexts/FilesPageContext.tsx index 378d8136b..cccf50108 100644 --- a/frontend/editor/src/core/contexts/FilesPageContext.tsx +++ b/frontend/editor/src/core/contexts/FilesPageContext.tsx @@ -18,7 +18,10 @@ import { fileStorage } from "@app/services/fileStorage"; import { folderSyncService } from "@app/services/folderSyncService"; import { uploadHistoryChain } from "@app/services/serverStorageUpload"; import { reconcileServerFiles } from "@app/services/fileSyncService"; -import { useIndexedDB } from "@app/contexts/IndexedDBContext"; +import { + useIndexedDB, + useIndexedDBRevision, +} from "@app/contexts/IndexedDBContext"; import { useFileActions } from "@app/contexts/file/fileHooks"; import { useFolders } from "@app/contexts/FolderContext"; import { useAppConfig } from "@app/contexts/AppConfigContext"; @@ -134,6 +137,7 @@ const FilesPageContext = createContext(null); export function FilesPageProvider({ children }: { children: React.ReactNode }) { const { t } = useTranslation(); const indexedDB = useIndexedDB(); + const indexedDBRevision = useIndexedDBRevision(); const folders = useFolders(); const { actions: fileActions } = useFileActions(); const { config: appConfig } = useAppConfig(); @@ -182,7 +186,7 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) { useEffect(() => { void refresh(); - }, [refresh, indexedDB.revision]); + }, [refresh, indexedDBRevision]); const fileMap = useMemo(() => { const map = new Map(); diff --git a/frontend/editor/src/core/contexts/IndexedDBContext.tsx b/frontend/editor/src/core/contexts/IndexedDBContext.tsx index 94040711f..5dc8f78bc 100644 --- a/frontend/editor/src/core/contexts/IndexedDBContext.tsx +++ b/frontend/editor/src/core/contexts/IndexedDBContext.tsx @@ -59,13 +59,19 @@ interface IndexedDBContextValue { ) => Promise; clearFolderForFiles: (folderIds: FolderId[]) => Promise; - // Incremented after any write or delete - subscribe to trigger re-reads - revision: number; + // Bumped after any write or delete. Subscribe to changes via + // `useIndexedDBRevision()` - exposing the value here would force the + // entire API context to invalidate on every write, cascading downstream + // useCallback deps and creating refetch loops. bumpRevision: () => void; } const IndexedDBContext = createContext(null); +// Separate context for the revision number so consumers that only need the +// stable method API are not re-rendered when revision bumps. +const IndexedDBRevisionContext = createContext(0); + interface IndexedDBProviderProps { children: React.ReactNode; } @@ -271,8 +277,14 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) { [bumpRevision], ); - // Memoise so identity-based context propagation doesn't re-render every - // downstream consumer on each parent render. + // Memoize the context value so consumers' useIndexedDB() reference stays + // stable across renders. Without this, every IndexedDBProvider render + // (e.g. on bumpRevision after a thumbnail write) hands every consumer a + // brand-new object, invalidating downstream useCallback deps that include + // `indexedDB` (notably useFileManager.loadRecentFiles), which can cascade + // into infinite refetch loops in components that depend on those callbacks. + // `revision` is intentionally NOT a dep here - consume it via + // `useIndexedDBRevision()` so revision bumps don't churn the API value. const value = useMemo( () => ({ saveFile, @@ -288,7 +300,6 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) { markFileAsProcessed, moveFilesToFolder, clearFolderForFiles, - revision, bumpRevision, }), [ @@ -305,14 +316,15 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) { markFileAsProcessed, moveFilesToFolder, clearFolderForFiles, - revision, bumpRevision, ], ); return ( - {children} + + {children} + ); } @@ -324,3 +336,11 @@ export function useIndexedDB() { } return context; } + +/** + * Subscribe to IndexedDB write revisions. The number increments after any + * write or delete; use it as a `useEffect` dep to refetch stubs etc. + */ +export function useIndexedDBRevision(): number { + return useContext(IndexedDBRevisionContext); +} diff --git a/frontend/editor/src/core/hooks/tools/compare/useCompareOperation.ts b/frontend/editor/src/core/hooks/tools/compare/useCompareOperation.ts index cee12c851..7a43a443e 100644 --- a/frontend/editor/src/core/hooks/tools/compare/useCompareOperation.ts +++ b/frontend/editor/src/core/hooks/tools/compare/useCompareOperation.ts @@ -609,24 +609,34 @@ export const useCompareOperation = (): CompareOperationHook => { } catch (error: unknown) { console.error("[compare] operation failed", error); const errorCode = getWorkerErrorCode(error); + let resolvedMessage: string; if (errorCode === "EMPTY_TEXT") { - setErrorMessage( + resolvedMessage = warningMessages.emptyTextMessage ?? - t("compare.error.generic", "Unable to compare these files."), - ); + t("compare.error.generic", "Unable to compare these files."); } else { const fallbackMessage = t( "compare.error.generic", "Unable to compare these files.", ); if (error instanceof Error && error.message) { - setErrorMessage(error.message); + resolvedMessage = error.message; } else if (typeof error === "string" && error.trim().length > 0) { - setErrorMessage(error); + resolvedMessage = error; } else { - setErrorMessage(fallbackMessage); + resolvedMessage = fallbackMessage; } } + setErrorMessage(resolvedMessage); + setStatusState("error"); + // Surface the failure to the user. Without this, the error only goes + // to the console and the custom workbench just keeps spinning. + alert({ + alertType: "warning", + title: t("compare.error.title", "Comparison failed"), + body: resolvedMessage, + location: "bottom-right" as ToastLocation, + }); } finally { const duration = performance.now() - operationStart; setStatusDetailMs(Math.round(duration)); @@ -696,7 +706,9 @@ export const useCompareOperation = (): CompareOperationHook => { ? t("compare.status.complete", "Comparison ready") : statusState === "cancelled" ? t("operationCancelled", "Operation cancelled") - : ""; + : statusState === "error" + ? t("compare.status.error", "Comparison failed") + : ""; if (label && statusDetailMs != null) return `${label} (${statusDetailMs} ms)`; return label; diff --git a/frontend/editor/src/core/hooks/useEndpointConfig.ts b/frontend/editor/src/core/hooks/useEndpointConfig.ts index 227a42524..9bd5f1712 100644 --- a/frontend/editor/src/core/hooks/useEndpointConfig.ts +++ b/frontend/editor/src/core/hooks/useEndpointConfig.ts @@ -1,11 +1,20 @@ -import { useState, useEffect } from "react"; +import { useCallback, useEffect, useState } from "react"; +import { isAxiosError } from "axios"; import apiClient from "@app/services/apiClient"; +import { useJwtConfigSync } from "@app/hooks/useJwtConfigSync"; import type { EndpointAvailabilityDetails } from "@app/types/endpointAvailability"; // Track whether we've done the global fetch to prevent duplicate requests let globalFetchDone = false; const globalEndpointCache: Record = {}; +function resetGlobalCache() { + globalFetchDone = false; + Object.keys(globalEndpointCache).forEach( + (key) => delete globalEndpointCache[key], + ); +} + /** * Hook to check if a specific endpoint is enabled * This wraps the context for single endpoint checks @@ -78,89 +87,116 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const fetchAllEndpointStatuses = async (force = false) => { - // Skip if already fetched globally and not forced - if (!force && globalFetchDone) { - console.debug("[useEndpointConfig] Using global cache"); - const cached = endpoints.reduce( - (acc, endpoint) => { - const cachedDetails = globalEndpointCache[endpoint]; - if (cachedDetails) { - acc.status[endpoint] = cachedDetails.enabled; - acc.details[endpoint] = cachedDetails; - } else { - acc.status[endpoint] = true; - } - return acc; - }, - { - status: {} as Record, - details: {} as Record, - }, - ); - setEndpointStatus(cached.status); - setEndpointDetails((prev) => ({ ...prev, ...cached.details })); - setLoading(false); - return; - } - - if (!endpoints || endpoints.length === 0) { - setEndpointStatus({}); - setEndpointDetails({}); - setLoading(false); - return; - } - - try { - setLoading(true); - setError(null); - console.debug( - "[useEndpointConfig] Fetching all endpoint statuses from server", - ); - - // Fetch all endpoints at once - no query params needed - const response = await apiClient.get< - Record - >(`/api/v1/config/endpoints-availability`); - - // Populate global cache with all results - Object.entries(response.data).forEach(([endpoint, details]) => { - globalEndpointCache[endpoint] = { - enabled: details?.enabled ?? true, - reason: details?.reason ?? null, - }; - }); - globalFetchDone = true; - - // Return status for the requested endpoints - const fullStatus = endpoints.reduce( - (acc, endpoint) => { - const cachedDetails = globalEndpointCache[endpoint]; - if (cachedDetails) { - acc.status[endpoint] = cachedDetails.enabled; - acc.details[endpoint] = cachedDetails; - } else { - acc.status[endpoint] = true; - } - return acc; - }, - { - status: {} as Record, - details: {} as Record, - }, - ); - - setEndpointStatus(fullStatus.status); - setEndpointDetails((prev) => ({ ...prev, ...fullStatus.details })); - } catch (err: any) { - // On 401 (auth error), use optimistic fallback instead of disabling - if (err.response?.status === 401) { - console.warn( - "[useEndpointConfig] 401 error - using optimistic fallback", + const fetchAllEndpointStatuses = useCallback( + async (force = false) => { + // Skip if already fetched globally and not forced + if (!force && globalFetchDone) { + console.debug("[useEndpointConfig] Using global cache"); + const cached = endpoints.reduce( + (acc, endpoint) => { + const cachedDetails = globalEndpointCache[endpoint]; + if (cachedDetails) { + acc.status[endpoint] = cachedDetails.enabled; + acc.details[endpoint] = cachedDetails; + } else { + acc.status[endpoint] = true; + } + return acc; + }, + { + status: {} as Record, + details: {} as Record, + }, ); - endpoints.forEach((endpoint) => { - globalEndpointCache[endpoint] = { enabled: true, reason: null }; + setEndpointStatus(cached.status); + setEndpointDetails((prev) => ({ ...prev, ...cached.details })); + setLoading(false); + return; + } + + if (!endpoints || endpoints.length === 0) { + setEndpointStatus({}); + setEndpointDetails({}); + setLoading(false); + return; + } + + try { + setLoading(true); + setError(null); + console.debug( + "[useEndpointConfig] Fetching all endpoint statuses from server", + ); + + // Fetch all endpoints at once - no query params needed + const response = await apiClient.get< + Record + >(`/api/v1/config/endpoints-availability`); + + // Populate global cache with all results + Object.entries(response.data).forEach(([endpoint, details]) => { + globalEndpointCache[endpoint] = { + enabled: details?.enabled ?? true, + reason: details?.reason ?? null, + }; }); + globalFetchDone = true; + + // Return status for the requested endpoints + const fullStatus = endpoints.reduce( + (acc, endpoint) => { + const cachedDetails = globalEndpointCache[endpoint]; + if (cachedDetails) { + acc.status[endpoint] = cachedDetails.enabled; + acc.details[endpoint] = cachedDetails; + } else { + acc.status[endpoint] = true; + } + return acc; + }, + { + status: {} as Record, + details: {} as Record, + }, + ); + + setEndpointStatus(fullStatus.status); + setEndpointDetails((prev) => ({ ...prev, ...fullStatus.details })); + } catch (err: unknown) { + // On 401 (auth error), use optimistic fallback instead of disabling + if (isAxiosError(err) && err.response?.status === 401) { + console.warn( + "[useEndpointConfig] 401 error - using optimistic fallback", + ); + endpoints.forEach((endpoint) => { + globalEndpointCache[endpoint] = { enabled: true, reason: null }; + }); + const optimisticStatus = endpoints.reduce( + (acc, endpoint) => { + acc.status[endpoint] = true; + acc.details[endpoint] = { enabled: true, reason: null }; + return acc; + }, + { + status: {} as Record, + details: {} as Record, + }, + ); + setEndpointStatus(optimisticStatus.status); + setEndpointDetails((prev) => ({ + ...prev, + ...optimisticStatus.details, + })); + setLoading(false); + return; + } + + const errorMessage = + err instanceof Error ? err.message : "Unknown error occurred"; + setError(errorMessage); + console.error("[EndpointConfig] Failed to check endpoints:", err); + + // Fallback: assume all endpoints are enabled on error (optimistic) const optimisticStatus = endpoints.reduce( (acc, endpoint) => { acc.status[endpoint] = true; @@ -177,55 +213,29 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { ...prev, ...optimisticStatus.details, })); + } finally { setLoading(false); - return; } - - const errorMessage = - err instanceof Error ? err.message : "Unknown error occurred"; - setError(errorMessage); - console.error("[EndpointConfig] Failed to check endpoints:", err); - - // Fallback: assume all endpoints are enabled on error (optimistic) - const optimisticStatus = endpoints.reduce( - (acc, endpoint) => { - acc.status[endpoint] = true; - acc.details[endpoint] = { enabled: true, reason: null }; - return acc; - }, - { - status: {} as Record, - details: {} as Record, - }, - ); - setEndpointStatus(optimisticStatus.status); - setEndpointDetails((prev) => ({ ...prev, ...optimisticStatus.details })); - } finally { - setLoading(false); - } - }; + }, + [endpoints.join(",")], + ); useEffect(() => { fetchAllEndpointStatuses(); - }, [endpoints.join(",")]); // Re-run when endpoints array changes + }, [fetchAllEndpointStatuses]); - // Listen for JWT availability (triggered on login/signup) - useEffect(() => { - const handleJwtAvailable = () => { - console.debug( - "[useEndpointConfig] JWT available event - clearing cache for refetch with auth", - ); - globalFetchDone = false; - Object.keys(globalEndpointCache).forEach( - (key) => delete globalEndpointCache[key], - ); - fetchAllEndpointStatuses(true); - }; - - window.addEventListener("jwt-available", handleJwtAvailable); - return () => - window.removeEventListener("jwt-available", handleJwtAvailable); - }, [endpoints.join(",")]); + // Re-fetch when auth state changes. Core implementation listens for the + // proprietary `jwt-available` event; the SaaS no-op override means the + // cache simply isn't invalidated on Supabase auth changes (today's behavior). + // If SaaS later needs that, wire it up inside saas/hooks/useJwtConfigSync.ts. + const handleAuthChange = useCallback(() => { + console.debug( + "[useEndpointConfig] Auth changed - clearing cache for refetch", + ); + resetGlobalCache(); + fetchAllEndpointStatuses(true); + }, [fetchAllEndpointStatuses]); + useJwtConfigSync(handleAuthChange); return { endpointStatus, diff --git a/frontend/editor/src/core/tools/Compare.tsx b/frontend/editor/src/core/tools/Compare.tsx index 8f3ca6131..81cf263de 100644 --- a/frontend/editor/src/core/tools/Compare.tsx +++ b/frontend/editor/src/core/tools/Compare.tsx @@ -230,11 +230,28 @@ const Compare = (props: BaseToolProps) => { }); return; } + + // Operation finished without producing a fresh result (e.g. it errored). + // Without this branch, `prepareWorkbenchForRun` leaves `isLoading: true` + // and the workbench spins forever. + const previous = lastWorkbenchDataRef.current; + if (previous?.isLoading) { + updateWorkbenchData({ + ...previous, + baseFileId, + comparisonFileId, + baseLocalFile: baseSlot?.stirlingFile ?? previous.baseLocalFile ?? null, + comparisonLocalFile: + compSlot?.stirlingFile ?? previous.comparisonLocalFile ?? null, + isLoading: false, + }); + } }, [ base.operation.isLoading, baseSlot, clearCustomWorkbenchViewData, compSlot, + operation.errorMessage, operation.result, params.baseFileId, params.comparisonFileId, diff --git a/frontend/editor/src/core/tools/pdfTextEditor/PdfTextEditor.tsx b/frontend/editor/src/core/tools/pdfTextEditor/PdfTextEditor.tsx index 3c74799a6..bf9628f5b 100644 --- a/frontend/editor/src/core/tools/pdfTextEditor/PdfTextEditor.tsx +++ b/frontend/editor/src/core/tools/pdfTextEditor/PdfTextEditor.tsx @@ -4,6 +4,7 @@ import DescriptionIcon from "@mui/icons-material/DescriptionOutlined"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import { + useAllFiles, useFileSelection, useFileManagement, useFileContext, @@ -12,6 +13,7 @@ import { useNavigationActions, useNavigationState, } from "@app/contexts/NavigationContext"; +import { useViewer } from "@app/contexts/ViewerContext"; import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers"; import { BaseToolProps, ToolComponent } from "@app/types/tool"; import { getDefaultWorkbench } from "@app/types/workbench"; @@ -382,6 +384,25 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { [t], ); const { selectedFiles } = useFileSelection(); + const { files: allFiles } = useAllFiles(); + const { activeFileId } = useViewer(); + + // The file the tool should auto-load: prefer the sidebar selection, then + // whatever the viewer is currently showing (so opening PDF Editor from the + // viewer picks up that file), then the single workbench file if there is + // only one. Returns null if the choice is ambiguous (no selection, no + // viewer file, and multiple files in the workbench). + const autoLoadFile = useMemo(() => { + if (selectedFiles[0]) return selectedFiles[0]; + if (activeFileId) { + const viewerFile = allFiles.find( + (f) => (f.fileId as string) === activeFileId, + ); + if (viewerFile) return viewerFile; + } + if (allFiles.length === 1) return allFiles[0]; + return null; + }, [selectedFiles, activeFileId, allFiles]); const resetToDocument = useCallback( ( @@ -1917,7 +1938,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { }, [isLazyMode, loadedDocument, selectedPage, loadImagesForPage]); useEffect(() => { - if (selectedFiles.length === 0) { + if (!autoLoadFile) { autoLoadKeyRef.current = null; sourceFileIdRef.current = null; return; @@ -1927,21 +1948,16 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { return; } - const file = selectedFiles[0]; - if (!file) { - return; - } - - const fileKey = getAutoLoadKey(file); + const fileKey = getAutoLoadKey(autoLoadFile); if (autoLoadKeyRef.current === fileKey) { return; } autoLoadKeyRef.current = fileKey; // Capture the source file ID for save-to-workbench functionality - sourceFileIdRef.current = (file as any).fileId ?? null; - void handleLoadFile(file); - }, [selectedFiles, navigationState.selectedTool, handleLoadFile]); + sourceFileIdRef.current = (autoLoadFile as any).fileId ?? null; + void handleLoadFile(autoLoadFile); + }, [autoLoadFile, navigationState.selectedTool, handleLoadFile]); // Auto-navigate to workbench when tool is selected const hasAutoOpenedWorkbenchRef = useRef(false); diff --git a/frontend/editor/src/saas/auth/UseSession.tsx b/frontend/editor/src/saas/auth/UseSession.tsx index e6d59d045..744cb43fb 100644 --- a/frontend/editor/src/saas/auth/UseSession.tsx +++ b/frontend/editor/src/saas/auth/UseSession.tsx @@ -550,7 +550,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { } else if (event === "SIGNED_IN") { console.debug("[Auth Debug] User signed in successfully"); if (newSession?.user) { - setLoading(true); + // Note: we deliberately do NOT toggle `loading` here. Supabase + // also fires SIGNED_IN on tab visibility / token-refresh wakeups + // (per its docs: "SIGNED_IN is fired when a user signs in OR + // when the access token is refreshed"), and gating the UI on + // `loading` would unmount Landing -> HomePage every time the + // user switches tabs back. Initial-mount loading is handled by + // `initializeAuth` above; downstream fetches expose their own + // null/loading states. // Sync OAuth avatar in background (don't block other fetches) syncOAuthAvatar(newSession.user).catch((err) => { @@ -568,7 +575,6 @@ export function AuthProvider({ children }: { children: ReactNode }) { // Use a small delay to allow avatar sync to finish if it's quick setTimeout(() => { fetchProfilePicture(newSession).finally(() => { - setLoading(false); console.debug( "[Auth Debug] User data fully loaded after sign in", ); diff --git a/frontend/editor/src/saas/components/auth/RequireAuth.tsx b/frontend/editor/src/saas/components/auth/RequireAuth.tsx deleted file mode 100644 index 3ce0fd8d1..000000000 --- a/frontend/editor/src/saas/components/auth/RequireAuth.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Navigate, Outlet, useLocation } from "react-router-dom"; -import { useAuth } from "@app/auth/UseSession"; -import { useAutoAnonymousAuth } from "@app/hooks/useAutoAnonymousAuth"; - -interface RequireAuthProps { - fallbackPath?: string; -} - -export function RequireAuth({ fallbackPath = "/login" }: RequireAuthProps) { - const { session, loading } = useAuth(); - const location = useLocation(); - const { isAutoAuthenticating } = useAutoAnonymousAuth(); - - // Safe development-only auth bypass - const isLocalhost = - typeof window !== "undefined" && - /^(localhost|127\.0\.0\.1)$/i.test(window.location.hostname); - const devBypassEnabled = Boolean( - import.meta.env.DEV && - isLocalhost && - import.meta.env.VITE_DEV_BYPASS_AUTH === "true", - ); - - if (devBypassEnabled) { - console.warn( - "[RequireAuth] DEV BYPASS ACTIVE — allowing access without session on localhost", - ); - return ; - } - - // Wait for both auth bootstrap and auto-anon to finish - if (loading || isAutoAuthenticating) { - return ( -
-
-
-

Preparing your session…

-
-
- ); - } - - if (!session) { - // Change the URL to /login - return ; - } - - // Render protected routes - return ; -} - -export default RequireAuth; diff --git a/frontend/editor/src/saas/components/shared/config/types.ts b/frontend/editor/src/saas/components/shared/config/types.ts index a17a80947..e39944212 100644 --- a/frontend/editor/src/saas/components/shared/config/types.ts +++ b/frontend/editor/src/saas/components/shared/config/types.ts @@ -1,69 +1,8 @@ -// Re-export from core for compatibility -// Override VALID_NAV_KEYS to include saas-specific keys -export const VALID_NAV_KEYS = [ - "overview", - "password-security", - "preferences", - "notifications", - "connections", - "general", - "people", - "teams", - "security", - "identity", - "plan", - "payments", - "requests", - "developer", - "api-keys", - "hotkeys", - "adminGeneral", - "adminSecurity", - "adminConnections", - "adminPrivacy", - "adminDatabase", - "adminAdvanced", - "adminLegal", - "adminPremium", - "adminFeatures", - "adminPlan", - "adminAudit", - "adminUsage", - "adminEndpoints", - "help", -] as const; +import { VALID_NAV_KEYS as CORE_NAV_KEYS } from "@core/components/shared/config/types"; -// Extend NavKey to include saas-specific keys -export type NavKey = - | "overview" - | "password-security" - | "preferences" - | "notifications" - | "connections" - | "general" - | "people" - | "teams" - | "security" - | "identity" - | "plan" - | "payments" - | "requests" - | "developer" - | "api-keys" - | "hotkeys" - | "adminGeneral" - | "adminSecurity" - | "adminConnections" - | "adminPrivacy" - | "adminDatabase" - | "adminAdvanced" - | "adminLegal" - | "adminPremium" - | "adminFeatures" - | "adminPlan" - | "adminAudit" - | "adminUsage" - | "adminEndpoints" - | "help"; +// SaaS adds an "overview" account section. All other keys (including ones +// SaaS doesn't render today) come from core - subtracting them here would +// just break the type union without affecting runtime nav. +export const VALID_NAV_KEYS = [...CORE_NAV_KEYS, "overview"] as const; -// some of these are not used yet, but appear in figma designs +export type NavKey = (typeof VALID_NAV_KEYS)[number]; diff --git a/frontend/editor/src/saas/components/toast/ToastRenderer.css b/frontend/editor/src/saas/components/toast/ToastRenderer.css deleted file mode 100644 index b19cefd92..000000000 --- a/frontend/editor/src/saas/components/toast/ToastRenderer.css +++ /dev/null @@ -1,209 +0,0 @@ -/* Toast Container Styles */ -.toast-container { - position: fixed; - z-index: 1200; - display: flex; - gap: 12px; - pointer-events: none; -} - -.toast-container--top-left { - top: 16px; - left: 16px; - flex-direction: column; -} - -.toast-container--top-right { - top: 16px; - right: 16px; - flex-direction: column; -} - -.toast-container--bottom-left { - bottom: 16px; - left: 16px; - flex-direction: column-reverse; -} - -.toast-container--bottom-right { - bottom: 16px; - right: 16px; - flex-direction: column-reverse; -} - -/* Toast Item Styles */ -.toast-item { - min-width: 320px; - max-width: 560px; - box-shadow: var(--shadow-lg); - border-radius: 16px; - padding: 16px; - display: flex; - flex-direction: column; - gap: 8px; - pointer-events: auto; -} - -/* Toast Alert Type Colors */ -.toast-item--success { - background: var(--color-green-100); - color: var(--text-primary); - border: 1px solid var(--color-green-400); -} - -.toast-item--error { - background: var(--color-red-100); - color: var(--text-primary); - border: 1px solid var(--color-red-400); -} - -.toast-item--warning { - background: var(--color-orange-100); /* deeper orange background */ - color: var(--text-primary); - border: 1px solid var(--color-orange-300); /* deeper orange border */ -} - -.toast-item--neutral { - background: var(--bg-surface); - color: var(--text-primary); - border: 1px solid var(--border-default); -} - -/* Toast Header Row */ -.toast-header { - display: flex; - align-items: center; - gap: 12px; -} - -.toast-icon { - width: 24px; - height: 24px; - display: flex; - align-items: center; - justify-content: center; -} - -.toast-title-container { - font-weight: 700; - flex: 1; - display: flex; - align-items: center; - gap: 8px; -} - -.toast-count-badge { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 20px; - height: 20px; - padding: 0 6px; - border-radius: 999px; - background: rgba(0, 0, 0, 0.08); - color: inherit; - font-size: 12px; - font-weight: 700; -} - -.toast-controls { - display: flex; - gap: 4px; - align-items: center; -} - -.toast-button { - width: 28px; - height: 28px; - border-radius: 999px; - border: none; - background: transparent; - color: var(--text-secondary); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; -} - -.toast-expand-button { - transform: rotate(0deg); - transition: transform 160ms ease; -} - -.toast-expand-button--expanded { - transform: rotate(180deg); -} - -/* Progress Bar */ -.toast-progress-container { - margin-top: 8px; - height: 6px; - background: var(--bg-muted); - border-radius: 999px; - overflow: hidden; -} - -.toast-progress-bar { - height: 100%; - transition: width 160ms ease; -} - -.toast-progress-bar--success { - background: var(--color-green-500); -} - -.toast-progress-bar--error { - background: var(--color-red-500); -} - -.toast-progress-bar--warning { - background: var(--color-orange-400); /* deeper orange for progress */ -} - -.toast-progress-bar--neutral { - background: var(--color-gray-500); -} - -/* Toast Body */ -.toast-body { - font-size: 14px; - opacity: 0.9; - margin-top: 8px; -} - -/* Toast Action Button */ -.toast-action-container { - margin-top: 12px; - display: flex; - justify-content: flex-start; -} - -.toast-action-button { - padding: 8px 12px; - border-radius: 12px; - border: 1px solid; - background: transparent; - font-weight: 600; - cursor: pointer; - margin-left: auto; -} - -.toast-action-button--success { - color: var(--text-primary); - border-color: var(--color-green-400); -} - -.toast-action-button--error { - color: var(--text-primary); - border-color: var(--color-red-400); -} - -.toast-action-button--warning { - color: var(--text-primary); - border-color: var(--color-orange-300); -} - -.toast-action-button--neutral { - color: var(--text-primary); - border-color: var(--border-default); -} diff --git a/frontend/editor/src/saas/hooks/useEndpointConfig.ts b/frontend/editor/src/saas/hooks/useEndpointConfig.ts deleted file mode 100644 index 590cb49cf..000000000 --- a/frontend/editor/src/saas/hooks/useEndpointConfig.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { useState, useEffect } from "react"; -import { isAxiosError } from "axios"; -import apiClient from "@app/services/apiClient"; -import type { EndpointAvailabilityDetails } from "@app/types/endpointAvailability"; - -// Track globally fetched endpoint sets to prevent duplicate fetches across components -const globalFetchedSets = new Set(); -const globalEndpointCache: Record = {}; -/** - * Hook to check if a specific endpoint is enabled - * This wraps the context for single endpoint checks - */ -export function useEndpointEnabled(endpoint: string): { - enabled: boolean | null; - loading: boolean; - error: string | null; - refetch: () => Promise; -} { - const [enabled, setEnabled] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const fetchEndpointStatus = async () => { - if (!endpoint) { - setEnabled(null); - setLoading(false); - return; - } - - try { - setLoading(true); - setError(null); - - const response = await apiClient.get( - `/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`, - ); - - const isEnabled = response.data; - setEnabled(isEnabled); - } catch (err) { - const errorMessage = - err instanceof Error ? err.message : "Unknown error occurred"; - setError(errorMessage); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - fetchEndpointStatus(); - }, [endpoint]); - - return { - enabled, - loading, - error, - refetch: fetchEndpointStatus, - }; -} - -/** - * Hook to check multiple endpoints at once using batch API - * Returns a map of endpoint -> enabled status - */ -export function useMultipleEndpointsEnabled(endpoints: string[]): { - endpointStatus: Record; - endpointDetails: Record; - loading: boolean; - error: string | null; - refetch: () => Promise; -} { - const [endpointStatus, setEndpointStatus] = useState>( - {}, - ); - const [endpointDetails, setEndpointDetails] = useState< - Record - >({}); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const fetchAllEndpointStatuses = async (force = false) => { - const endpointsKey = [...endpoints].sort().join(","); - - // Skip if we already fetched these exact endpoints globally - if (!force && globalFetchedSets.has(endpointsKey)) { - console.debug( - "[useEndpointConfig] Already fetched these endpoints globally, using cache", - ); - const cached = endpoints.reduce( - (acc, endpoint) => { - const cachedDetails = globalEndpointCache[endpoint]; - if (cachedDetails) { - acc.status[endpoint] = cachedDetails.enabled; - acc.details[endpoint] = cachedDetails; - } - return acc; - }, - { - status: {} as Record, - details: {} as Record, - }, - ); - setEndpointStatus(cached.status); - setEndpointDetails((prev) => ({ ...prev, ...cached.details })); - setLoading(false); - return; - } - if (!endpoints || endpoints.length === 0) { - setEndpointStatus({}); - setEndpointDetails({}); - setLoading(false); - return; - } - - try { - setLoading(true); - setError(null); - - // Check which endpoints we haven't fetched yet - const newEndpoints = endpoints.filter( - (ep) => !(ep in globalEndpointCache), - ); - if (newEndpoints.length === 0) { - console.debug( - "[useEndpointConfig] All endpoints already in global cache", - ); - const cached = endpoints.reduce( - (acc, endpoint) => { - const cachedDetails = globalEndpointCache[endpoint]; - if (cachedDetails) { - acc.status[endpoint] = cachedDetails.enabled; - acc.details[endpoint] = cachedDetails; - } - return acc; - }, - { - status: {} as Record, - details: {} as Record, - }, - ); - setEndpointStatus(cached.status); - setEndpointDetails((prev) => ({ ...prev, ...cached.details })); - globalFetchedSets.add(endpointsKey); - setLoading(false); - return; - } - // Use batch API for efficiency - only fetch new endpoints - const endpointsParam = newEndpoints.join(","); - - const response = await apiClient.get< - Record - >( - `/api/v1/config/endpoints-availability?endpoints=${encodeURIComponent(endpointsParam)}`, - ); - const statusMap = response.data; - - // Update global cache with new results - Object.entries(statusMap).forEach(([endpoint, details]) => { - globalEndpointCache[endpoint] = { - enabled: details?.enabled ?? true, - reason: details?.reason ?? null, - }; - }); - // Get all requested endpoints from cache (including previously cached ones) - const fullStatus = endpoints.reduce( - (acc, endpoint) => { - const cachedDetails = globalEndpointCache[endpoint]; - if (cachedDetails) { - acc.status[endpoint] = cachedDetails.enabled; - acc.details[endpoint] = cachedDetails; - } else { - acc.status[endpoint] = true; - } - return acc; - }, - { - status: {} as Record, - details: {} as Record, - }, - ); - setEndpointStatus(fullStatus.status); - setEndpointDetails((prev) => ({ ...prev, ...fullStatus.details })); - globalFetchedSets.add(endpointsKey); - } catch (err: unknown) { - // On 401 (auth error), use optimistic fallback instead of disabling - if (isAxiosError(err) && err.response?.status === 401) { - console.warn( - "[useEndpointConfig] 401 error - using optimistic fallback", - ); - const optimisticStatus = endpoints.reduce( - (acc, endpoint) => { - const optimisticDetails: EndpointAvailabilityDetails = { - enabled: true, - reason: null, - }; - acc.status[endpoint] = true; - acc.details[endpoint] = optimisticDetails; - globalEndpointCache[endpoint] = optimisticDetails; - return acc; - }, - { - status: {} as Record, - details: {} as Record, - }, - ); - setEndpointStatus(optimisticStatus.status); - setEndpointDetails((prev) => ({ - ...prev, - ...optimisticStatus.details, - })); - setLoading(false); - return; - } - const errorMessage = - err instanceof Error ? err.message : "Unknown error occurred"; - setError(errorMessage); - console.error( - "[EndpointConfig] Failed to check multiple endpoints:", - err, - ); - - // Fallback: assume all endpoints are enabled on error (optimistic) - const optimisticStatus = endpoints.reduce( - (acc, endpoint) => { - const optimisticDetails: EndpointAvailabilityDetails = { - enabled: true, - reason: null, - }; - acc.status[endpoint] = true; - acc.details[endpoint] = optimisticDetails; - return acc; - }, - { - status: {} as Record, - details: {} as Record, - }, - ); - setEndpointStatus(optimisticStatus.status); - setEndpointDetails((prev) => ({ ...prev, ...optimisticStatus.details })); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - fetchAllEndpointStatuses(); - }, [endpoints.join(",")]); // Re-run when endpoints array changes - - return { - endpointStatus, - endpointDetails, - loading, - error, - refetch: () => fetchAllEndpointStatuses(true), - }; -} diff --git a/frontend/editor/src/saas/routes/login/LoadingState.tsx b/frontend/editor/src/saas/routes/login/LoadingState.tsx deleted file mode 100644 index c419fe696..000000000 --- a/frontend/editor/src/saas/routes/login/LoadingState.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { useTranslation } from "@app/hooks/useTranslation"; - -export default function LoadingState() { - const { t } = useTranslation(); - - return ( -
-
-
-

{t("loading")}

-
-
- ); -}