mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
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
This commit is contained in:
@@ -178,7 +178,6 @@ venv.bak/
|
|||||||
|
|
||||||
# Env files (secrets / local overrides). Subproject .gitignore files whitelist any committed defaults.
|
# Env files (secrets / local overrides). Subproject .gitignore files whitelist any committed defaults.
|
||||||
.env*
|
.env*
|
||||||
!.env.saas.example
|
|
||||||
|
|
||||||
# VS Code
|
# VS Code
|
||||||
/.vscode/**/*
|
/.vscode/**/*
|
||||||
|
|||||||
@@ -40,10 +40,10 @@ tasks:
|
|||||||
platforms: [linux, darwin]
|
platforms: [linux, darwin]
|
||||||
|
|
||||||
dev:saas:
|
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
|
# `dotenv:` reads from the root Taskfile's directory (".") because this
|
||||||
# subtaskfile is included with `dir: .`. Drop the file at the repo root.
|
# subtaskfile is included with `dir: .`.
|
||||||
dotenv: ['.env.saas.local']
|
dotenv: ['app/.env.saas.local', 'app/.env.saas']
|
||||||
ignore_error: true
|
ignore_error: true
|
||||||
vars:
|
vars:
|
||||||
PORT: '{{.PORT | default "8080"}}'
|
PORT: '{{.PORT | default "8080"}}'
|
||||||
|
|||||||
@@ -58,6 +58,23 @@ tasks:
|
|||||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||||
OPEN: "true"
|
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:
|
dev:all:
|
||||||
desc: "Start backend + frontend + engine concurrently on free ports"
|
desc: "Start backend + frontend + engine concurrently on free ports"
|
||||||
vars:
|
vars:
|
||||||
|
|||||||
@@ -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.
|
# This file is committed and provides non-secret defaults loaded by
|
||||||
# Loaded by `task backend:dev:saas` via Taskfile's `dotenv:` directive, then
|
# `task backend:dev:saas`. Put real values for secrets (passwords, project
|
||||||
# read by Spring Boot's `${...}` placeholders in application-saas.properties
|
# refs, edge function secrets) in `.env.saas.local` - any variable set there
|
||||||
# and application-dev.properties.
|
# 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 ----------
|
# ---------- Supabase project ----------
|
||||||
# Project reference (the subdomain part of <ref>.supabase.co). Required.
|
# Project reference (the subdomain part of <ref>.supabase.co). Required.
|
||||||
# Example dev project:
|
# Set in .env.saas.local.
|
||||||
SAAS_DB_PROJECT_REF=
|
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=
|
SUPABASE_EDGE_FUNCTION_SECRET=
|
||||||
|
|
||||||
# ---------- Database (saas profile) ----------
|
# ---------- Database (saas profile) ----------
|
||||||
@@ -28,7 +28,7 @@ SAAS_DB_PASSWORD=
|
|||||||
# ---------- Database (dev profile overrides) ----------
|
# ---------- Database (dev profile overrides) ----------
|
||||||
# Used when `--spring.profiles.include=dev` is active. The dev profile
|
# Used when `--spring.profiles.include=dev` is active. The dev profile
|
||||||
# defaults the URL/username to the shared dev Supabase project, but the
|
# 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_URL=
|
||||||
SAAS_DEV_DB_USERNAME=postgres
|
SAAS_DEV_DB_USERNAME=postgres
|
||||||
SAAS_DEV_DB_PASSWORD=
|
SAAS_DEV_DB_PASSWORD=
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Whitelist committed env defaults. `.env.saas.local` (and any other .env*)
|
||||||
|
# stays ignored via the root .gitignore.
|
||||||
|
!.env.saas
|
||||||
@@ -2794,6 +2794,7 @@ selectBaseFirst = "Select original PDF first"
|
|||||||
filesMissing = "Unable to locate the selected files. Please re-select them."
|
filesMissing = "Unable to locate the selected files. Please re-select them."
|
||||||
generic = "Unable to compare these files."
|
generic = "Unable to compare these files."
|
||||||
selectRequired = "Select a original and edited document."
|
selectRequired = "Select a original and edited document."
|
||||||
|
title = "Comparison failed"
|
||||||
|
|
||||||
[compare.large.file]
|
[compare.large.file]
|
||||||
message = "One or Both of the provided documents are too large to process"
|
message = "One or Both of the provided documents are too large to process"
|
||||||
@@ -2859,6 +2860,7 @@ title = "Still working…"
|
|||||||
|
|
||||||
[compare.status]
|
[compare.status]
|
||||||
complete = "Comparison ready"
|
complete = "Comparison ready"
|
||||||
|
error = "Comparison failed"
|
||||||
extracting = "Extracting text..."
|
extracting = "Extracting text..."
|
||||||
processing = "Analysing differences..."
|
processing = "Analysing differences..."
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const DismissAllErrorsButton: React.FC<DismissAllErrorsButtonProps> = ({
|
|||||||
onClick={handleDismissAllErrors}
|
onClick={handleDismissAllErrors}
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: "1rem",
|
top: "calc(48px + 1rem)", // Sit below the 48px-tall WorkbenchBar (top toolbar)
|
||||||
right: "1rem",
|
right: "1rem",
|
||||||
zIndex: Z_INDEX_TOAST,
|
zIndex: Z_INDEX_TOAST,
|
||||||
pointerEvents: "auto",
|
pointerEvents: "auto",
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ import {
|
|||||||
} from "@app/contexts/NavigationContext";
|
} from "@app/contexts/NavigationContext";
|
||||||
import { useViewer } from "@app/contexts/ViewerContext";
|
import { useViewer } from "@app/contexts/ViewerContext";
|
||||||
import { useFileHandler } from "@app/hooks/useFileHandler";
|
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 { accountService } from "@app/services/accountService";
|
||||||
import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons";
|
import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons";
|
||||||
import { Wordmark } from "@app/components/shared/Wordmark";
|
import { Wordmark } from "@app/components/shared/Wordmark";
|
||||||
@@ -143,9 +146,10 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
|||||||
}, [indexedDB, state.files.ids, state.files.byId]);
|
}, [indexedDB, state.files.ids, state.files.byId]);
|
||||||
|
|
||||||
// Refresh on mount, workbench changes, or external IndexedDB writes
|
// Refresh on mount, workbench changes, or external IndexedDB writes
|
||||||
|
const indexedDBRevision = useIndexedDBRevision();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshStubs();
|
refreshStubs();
|
||||||
}, [refreshStubs, indexedDB.revision]);
|
}, [refreshStubs, indexedDBRevision]);
|
||||||
|
|
||||||
// Once a pending file lands in state, open it in the viewer.
|
// Once a pending file lands in state, open it in the viewer.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export const VALID_NAV_KEYS = [
|
|||||||
"adminGeneral",
|
"adminGeneral",
|
||||||
"adminSecurity",
|
"adminSecurity",
|
||||||
"adminConnections",
|
"adminConnections",
|
||||||
"adminTelegram",
|
|
||||||
"adminPrivacy",
|
"adminPrivacy",
|
||||||
"adminDatabase",
|
"adminDatabase",
|
||||||
"adminAdvanced",
|
"adminAdvanced",
|
||||||
|
|||||||
@@ -185,29 +185,34 @@ const CompareTextWorkbenchView = ({ data }: CompareTextWorkbenchViewProps) => {
|
|||||||
const comparisonTitle =
|
const comparisonTitle =
|
||||||
comparisonStub?.name || result?.comparison?.fileName || "";
|
comparisonStub?.name || result?.comparison?.fileName || "";
|
||||||
|
|
||||||
// During diff processing, show compact spinners in the dropdown badges
|
// During diff processing, show compact spinners in the dropdown badges.
|
||||||
const baseDropdownPlaceholder =
|
// If the operation finished without a result (e.g. errored — the toast
|
||||||
isOperationLoading || !result ? (
|
// already told the user why), show the bare label so the dropdowns don't
|
||||||
<span className="inline-flex flex-row items-center gap-1">
|
// spin forever waiting for a result that will never arrive.
|
||||||
{t("compare.dropdown.deletionsLabel", "Deletions")}{" "}
|
const baseDropdownPlaceholder = isOperationLoading ? (
|
||||||
<Loader size="xs" color="currentColor" />
|
<span className="inline-flex flex-row items-center gap-1">
|
||||||
</span>
|
{t("compare.dropdown.deletionsLabel", "Deletions")}{" "}
|
||||||
) : (
|
<Loader size="xs" color="currentColor" />
|
||||||
t("compare.dropdown.deletions", "Deletions ({{count}})", {
|
</span>
|
||||||
count: baseWordChanges.length,
|
) : result ? (
|
||||||
})
|
t("compare.dropdown.deletions", "Deletions ({{count}})", {
|
||||||
);
|
count: baseWordChanges.length,
|
||||||
const comparisonDropdownPlaceholder =
|
})
|
||||||
isOperationLoading || !result ? (
|
) : (
|
||||||
<span className="inline-flex flex-row items-center gap-1">
|
t("compare.dropdown.deletionsLabel", "Deletions")
|
||||||
{t("compare.dropdown.additionsLabel", "Additions")}{" "}
|
);
|
||||||
<Loader size="xs" color="currentColor" />
|
const comparisonDropdownPlaceholder = isOperationLoading ? (
|
||||||
</span>
|
<span className="inline-flex flex-row items-center gap-1">
|
||||||
) : (
|
{t("compare.dropdown.additionsLabel", "Additions")}{" "}
|
||||||
t("compare.dropdown.additions", "Additions ({{count}})", {
|
<Loader size="xs" color="currentColor" />
|
||||||
count: comparisonWordChanges.length,
|
</span>
|
||||||
})
|
) : result ? (
|
||||||
);
|
t("compare.dropdown.additions", "Additions ({{count}})", {
|
||||||
|
count: comparisonWordChanges.length,
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
t("compare.dropdown.additionsLabel", "Additions")
|
||||||
|
);
|
||||||
|
|
||||||
const workbenchBarButtons = useCompareWorkbenchBarButtons({
|
const workbenchBarButtons = useCompareWorkbenchBarButtons({
|
||||||
layout,
|
layout,
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ import { fileStorage } from "@app/services/fileStorage";
|
|||||||
import { folderSyncService } from "@app/services/folderSyncService";
|
import { folderSyncService } from "@app/services/folderSyncService";
|
||||||
import { uploadHistoryChain } from "@app/services/serverStorageUpload";
|
import { uploadHistoryChain } from "@app/services/serverStorageUpload";
|
||||||
import { reconcileServerFiles } from "@app/services/fileSyncService";
|
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 { useFileActions } from "@app/contexts/file/fileHooks";
|
||||||
import { useFolders } from "@app/contexts/FolderContext";
|
import { useFolders } from "@app/contexts/FolderContext";
|
||||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||||
@@ -134,6 +137,7 @@ const FilesPageContext = createContext<FilesPageContextValue | null>(null);
|
|||||||
export function FilesPageProvider({ children }: { children: React.ReactNode }) {
|
export function FilesPageProvider({ children }: { children: React.ReactNode }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const indexedDB = useIndexedDB();
|
const indexedDB = useIndexedDB();
|
||||||
|
const indexedDBRevision = useIndexedDBRevision();
|
||||||
const folders = useFolders();
|
const folders = useFolders();
|
||||||
const { actions: fileActions } = useFileActions();
|
const { actions: fileActions } = useFileActions();
|
||||||
const { config: appConfig } = useAppConfig();
|
const { config: appConfig } = useAppConfig();
|
||||||
@@ -182,7 +186,7 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refresh();
|
void refresh();
|
||||||
}, [refresh, indexedDB.revision]);
|
}, [refresh, indexedDBRevision]);
|
||||||
|
|
||||||
const fileMap = useMemo(() => {
|
const fileMap = useMemo(() => {
|
||||||
const map = new Map<FileId, StirlingFileStub>();
|
const map = new Map<FileId, StirlingFileStub>();
|
||||||
|
|||||||
@@ -59,13 +59,19 @@ interface IndexedDBContextValue {
|
|||||||
) => Promise<FileId[]>;
|
) => Promise<FileId[]>;
|
||||||
clearFolderForFiles: (folderIds: FolderId[]) => Promise<number>;
|
clearFolderForFiles: (folderIds: FolderId[]) => Promise<number>;
|
||||||
|
|
||||||
// Incremented after any write or delete - subscribe to trigger re-reads
|
// Bumped after any write or delete. Subscribe to changes via
|
||||||
revision: number;
|
// `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;
|
bumpRevision: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const IndexedDBContext = createContext<IndexedDBContextValue | null>(null);
|
const IndexedDBContext = createContext<IndexedDBContextValue | null>(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<number>(0);
|
||||||
|
|
||||||
interface IndexedDBProviderProps {
|
interface IndexedDBProviderProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
@@ -271,8 +277,14 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
|||||||
[bumpRevision],
|
[bumpRevision],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Memoise so identity-based context propagation doesn't re-render every
|
// Memoize the context value so consumers' useIndexedDB() reference stays
|
||||||
// downstream consumer on each parent render.
|
// 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<IndexedDBContextValue>(
|
const value = useMemo<IndexedDBContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
saveFile,
|
saveFile,
|
||||||
@@ -288,7 +300,6 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
|||||||
markFileAsProcessed,
|
markFileAsProcessed,
|
||||||
moveFilesToFolder,
|
moveFilesToFolder,
|
||||||
clearFolderForFiles,
|
clearFolderForFiles,
|
||||||
revision,
|
|
||||||
bumpRevision,
|
bumpRevision,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
@@ -305,14 +316,15 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
|||||||
markFileAsProcessed,
|
markFileAsProcessed,
|
||||||
moveFilesToFolder,
|
moveFilesToFolder,
|
||||||
clearFolderForFiles,
|
clearFolderForFiles,
|
||||||
revision,
|
|
||||||
bumpRevision,
|
bumpRevision,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IndexedDBContext.Provider value={value}>
|
<IndexedDBContext.Provider value={value}>
|
||||||
{children}
|
<IndexedDBRevisionContext.Provider value={revision}>
|
||||||
|
{children}
|
||||||
|
</IndexedDBRevisionContext.Provider>
|
||||||
</IndexedDBContext.Provider>
|
</IndexedDBContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -324,3 +336,11 @@ export function useIndexedDB() {
|
|||||||
}
|
}
|
||||||
return context;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -609,24 +609,34 @@ export const useCompareOperation = (): CompareOperationHook => {
|
|||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error("[compare] operation failed", error);
|
console.error("[compare] operation failed", error);
|
||||||
const errorCode = getWorkerErrorCode(error);
|
const errorCode = getWorkerErrorCode(error);
|
||||||
|
let resolvedMessage: string;
|
||||||
if (errorCode === "EMPTY_TEXT") {
|
if (errorCode === "EMPTY_TEXT") {
|
||||||
setErrorMessage(
|
resolvedMessage =
|
||||||
warningMessages.emptyTextMessage ??
|
warningMessages.emptyTextMessage ??
|
||||||
t("compare.error.generic", "Unable to compare these files."),
|
t("compare.error.generic", "Unable to compare these files.");
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
const fallbackMessage = t(
|
const fallbackMessage = t(
|
||||||
"compare.error.generic",
|
"compare.error.generic",
|
||||||
"Unable to compare these files.",
|
"Unable to compare these files.",
|
||||||
);
|
);
|
||||||
if (error instanceof Error && error.message) {
|
if (error instanceof Error && error.message) {
|
||||||
setErrorMessage(error.message);
|
resolvedMessage = error.message;
|
||||||
} else if (typeof error === "string" && error.trim().length > 0) {
|
} else if (typeof error === "string" && error.trim().length > 0) {
|
||||||
setErrorMessage(error);
|
resolvedMessage = error;
|
||||||
} else {
|
} 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 {
|
} finally {
|
||||||
const duration = performance.now() - operationStart;
|
const duration = performance.now() - operationStart;
|
||||||
setStatusDetailMs(Math.round(duration));
|
setStatusDetailMs(Math.round(duration));
|
||||||
@@ -696,7 +706,9 @@ export const useCompareOperation = (): CompareOperationHook => {
|
|||||||
? t("compare.status.complete", "Comparison ready")
|
? t("compare.status.complete", "Comparison ready")
|
||||||
: statusState === "cancelled"
|
: statusState === "cancelled"
|
||||||
? t("operationCancelled", "Operation cancelled")
|
? t("operationCancelled", "Operation cancelled")
|
||||||
: "";
|
: statusState === "error"
|
||||||
|
? t("compare.status.error", "Comparison failed")
|
||||||
|
: "";
|
||||||
if (label && statusDetailMs != null)
|
if (label && statusDetailMs != null)
|
||||||
return `${label} (${statusDetailMs} ms)`;
|
return `${label} (${statusDetailMs} ms)`;
|
||||||
return label;
|
return label;
|
||||||
|
|||||||
@@ -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 apiClient from "@app/services/apiClient";
|
||||||
|
import { useJwtConfigSync } from "@app/hooks/useJwtConfigSync";
|
||||||
import type { EndpointAvailabilityDetails } from "@app/types/endpointAvailability";
|
import type { EndpointAvailabilityDetails } from "@app/types/endpointAvailability";
|
||||||
|
|
||||||
// Track whether we've done the global fetch to prevent duplicate requests
|
// Track whether we've done the global fetch to prevent duplicate requests
|
||||||
let globalFetchDone = false;
|
let globalFetchDone = false;
|
||||||
const globalEndpointCache: Record<string, EndpointAvailabilityDetails> = {};
|
const globalEndpointCache: Record<string, EndpointAvailabilityDetails> = {};
|
||||||
|
|
||||||
|
function resetGlobalCache() {
|
||||||
|
globalFetchDone = false;
|
||||||
|
Object.keys(globalEndpointCache).forEach(
|
||||||
|
(key) => delete globalEndpointCache[key],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to check if a specific endpoint is enabled
|
* Hook to check if a specific endpoint is enabled
|
||||||
* This wraps the context for single endpoint checks
|
* This wraps the context for single endpoint checks
|
||||||
@@ -78,89 +87,116 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const fetchAllEndpointStatuses = async (force = false) => {
|
const fetchAllEndpointStatuses = useCallback(
|
||||||
// Skip if already fetched globally and not forced
|
async (force = false) => {
|
||||||
if (!force && globalFetchDone) {
|
// Skip if already fetched globally and not forced
|
||||||
console.debug("[useEndpointConfig] Using global cache");
|
if (!force && globalFetchDone) {
|
||||||
const cached = endpoints.reduce(
|
console.debug("[useEndpointConfig] Using global cache");
|
||||||
(acc, endpoint) => {
|
const cached = endpoints.reduce(
|
||||||
const cachedDetails = globalEndpointCache[endpoint];
|
(acc, endpoint) => {
|
||||||
if (cachedDetails) {
|
const cachedDetails = globalEndpointCache[endpoint];
|
||||||
acc.status[endpoint] = cachedDetails.enabled;
|
if (cachedDetails) {
|
||||||
acc.details[endpoint] = cachedDetails;
|
acc.status[endpoint] = cachedDetails.enabled;
|
||||||
} else {
|
acc.details[endpoint] = cachedDetails;
|
||||||
acc.status[endpoint] = true;
|
} else {
|
||||||
}
|
acc.status[endpoint] = true;
|
||||||
return acc;
|
}
|
||||||
},
|
return acc;
|
||||||
{
|
},
|
||||||
status: {} as Record<string, boolean>,
|
{
|
||||||
details: {} as Record<string, EndpointAvailabilityDetails>,
|
status: {} as Record<string, boolean>,
|
||||||
},
|
details: {} as Record<string, EndpointAvailabilityDetails>,
|
||||||
);
|
},
|
||||||
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<string, EndpointAvailabilityDetails>
|
|
||||||
>(`/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<string, boolean>,
|
|
||||||
details: {} as Record<string, EndpointAvailabilityDetails>,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
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",
|
|
||||||
);
|
);
|
||||||
endpoints.forEach((endpoint) => {
|
setEndpointStatus(cached.status);
|
||||||
globalEndpointCache[endpoint] = { enabled: true, reason: null };
|
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<string, EndpointAvailabilityDetails>
|
||||||
|
>(`/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<string, boolean>,
|
||||||
|
details: {} as Record<string, EndpointAvailabilityDetails>,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
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<string, boolean>,
|
||||||
|
details: {} as Record<string, EndpointAvailabilityDetails>,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
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(
|
const optimisticStatus = endpoints.reduce(
|
||||||
(acc, endpoint) => {
|
(acc, endpoint) => {
|
||||||
acc.status[endpoint] = true;
|
acc.status[endpoint] = true;
|
||||||
@@ -177,55 +213,29 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
|||||||
...prev,
|
...prev,
|
||||||
...optimisticStatus.details,
|
...optimisticStatus.details,
|
||||||
}));
|
}));
|
||||||
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
const errorMessage =
|
[endpoints.join(",")],
|
||||||
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<string, boolean>,
|
|
||||||
details: {} as Record<string, EndpointAvailabilityDetails>,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
setEndpointStatus(optimisticStatus.status);
|
|
||||||
setEndpointDetails((prev) => ({ ...prev, ...optimisticStatus.details }));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAllEndpointStatuses();
|
fetchAllEndpointStatuses();
|
||||||
}, [endpoints.join(",")]); // Re-run when endpoints array changes
|
}, [fetchAllEndpointStatuses]);
|
||||||
|
|
||||||
// Listen for JWT availability (triggered on login/signup)
|
// Re-fetch when auth state changes. Core implementation listens for the
|
||||||
useEffect(() => {
|
// proprietary `jwt-available` event; the SaaS no-op override means the
|
||||||
const handleJwtAvailable = () => {
|
// cache simply isn't invalidated on Supabase auth changes (today's behavior).
|
||||||
console.debug(
|
// If SaaS later needs that, wire it up inside saas/hooks/useJwtConfigSync.ts.
|
||||||
"[useEndpointConfig] JWT available event - clearing cache for refetch with auth",
|
const handleAuthChange = useCallback(() => {
|
||||||
);
|
console.debug(
|
||||||
globalFetchDone = false;
|
"[useEndpointConfig] Auth changed - clearing cache for refetch",
|
||||||
Object.keys(globalEndpointCache).forEach(
|
);
|
||||||
(key) => delete globalEndpointCache[key],
|
resetGlobalCache();
|
||||||
);
|
fetchAllEndpointStatuses(true);
|
||||||
fetchAllEndpointStatuses(true);
|
}, [fetchAllEndpointStatuses]);
|
||||||
};
|
useJwtConfigSync(handleAuthChange);
|
||||||
|
|
||||||
window.addEventListener("jwt-available", handleJwtAvailable);
|
|
||||||
return () =>
|
|
||||||
window.removeEventListener("jwt-available", handleJwtAvailable);
|
|
||||||
}, [endpoints.join(",")]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
endpointStatus,
|
endpointStatus,
|
||||||
|
|||||||
@@ -230,11 +230,28 @@ const Compare = (props: BaseToolProps) => {
|
|||||||
});
|
});
|
||||||
return;
|
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,
|
base.operation.isLoading,
|
||||||
baseSlot,
|
baseSlot,
|
||||||
clearCustomWorkbenchViewData,
|
clearCustomWorkbenchViewData,
|
||||||
compSlot,
|
compSlot,
|
||||||
|
operation.errorMessage,
|
||||||
operation.result,
|
operation.result,
|
||||||
params.baseFileId,
|
params.baseFileId,
|
||||||
params.comparisonFileId,
|
params.comparisonFileId,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import DescriptionIcon from "@mui/icons-material/DescriptionOutlined";
|
|||||||
|
|
||||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||||
import {
|
import {
|
||||||
|
useAllFiles,
|
||||||
useFileSelection,
|
useFileSelection,
|
||||||
useFileManagement,
|
useFileManagement,
|
||||||
useFileContext,
|
useFileContext,
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
useNavigationActions,
|
useNavigationActions,
|
||||||
useNavigationState,
|
useNavigationState,
|
||||||
} from "@app/contexts/NavigationContext";
|
} from "@app/contexts/NavigationContext";
|
||||||
|
import { useViewer } from "@app/contexts/ViewerContext";
|
||||||
import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers";
|
import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers";
|
||||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||||
import { getDefaultWorkbench } from "@app/types/workbench";
|
import { getDefaultWorkbench } from "@app/types/workbench";
|
||||||
@@ -382,6 +384,25 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
|||||||
[t],
|
[t],
|
||||||
);
|
);
|
||||||
const { selectedFiles } = useFileSelection();
|
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(
|
const resetToDocument = useCallback(
|
||||||
(
|
(
|
||||||
@@ -1917,7 +1938,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
|||||||
}, [isLazyMode, loadedDocument, selectedPage, loadImagesForPage]);
|
}, [isLazyMode, loadedDocument, selectedPage, loadImagesForPage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedFiles.length === 0) {
|
if (!autoLoadFile) {
|
||||||
autoLoadKeyRef.current = null;
|
autoLoadKeyRef.current = null;
|
||||||
sourceFileIdRef.current = null;
|
sourceFileIdRef.current = null;
|
||||||
return;
|
return;
|
||||||
@@ -1927,21 +1948,16 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const file = selectedFiles[0];
|
const fileKey = getAutoLoadKey(autoLoadFile);
|
||||||
if (!file) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileKey = getAutoLoadKey(file);
|
|
||||||
if (autoLoadKeyRef.current === fileKey) {
|
if (autoLoadKeyRef.current === fileKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
autoLoadKeyRef.current = fileKey;
|
autoLoadKeyRef.current = fileKey;
|
||||||
// Capture the source file ID for save-to-workbench functionality
|
// Capture the source file ID for save-to-workbench functionality
|
||||||
sourceFileIdRef.current = (file as any).fileId ?? null;
|
sourceFileIdRef.current = (autoLoadFile as any).fileId ?? null;
|
||||||
void handleLoadFile(file);
|
void handleLoadFile(autoLoadFile);
|
||||||
}, [selectedFiles, navigationState.selectedTool, handleLoadFile]);
|
}, [autoLoadFile, navigationState.selectedTool, handleLoadFile]);
|
||||||
|
|
||||||
// Auto-navigate to workbench when tool is selected
|
// Auto-navigate to workbench when tool is selected
|
||||||
const hasAutoOpenedWorkbenchRef = useRef(false);
|
const hasAutoOpenedWorkbenchRef = useRef(false);
|
||||||
|
|||||||
@@ -550,7 +550,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
} else if (event === "SIGNED_IN") {
|
} else if (event === "SIGNED_IN") {
|
||||||
console.debug("[Auth Debug] User signed in successfully");
|
console.debug("[Auth Debug] User signed in successfully");
|
||||||
if (newSession?.user) {
|
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)
|
// Sync OAuth avatar in background (don't block other fetches)
|
||||||
syncOAuthAvatar(newSession.user).catch((err) => {
|
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
|
// Use a small delay to allow avatar sync to finish if it's quick
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
fetchProfilePicture(newSession).finally(() => {
|
fetchProfilePicture(newSession).finally(() => {
|
||||||
setLoading(false);
|
|
||||||
console.debug(
|
console.debug(
|
||||||
"[Auth Debug] User data fully loaded after sign in",
|
"[Auth Debug] User data fully loaded after sign in",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 <Outlet />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for both auth bootstrap and auto-anon to finish
|
|
||||||
if (loading || isAutoAuthenticating) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="animate-spin h-6 w-6 mx-auto mb-3 border-2 border-gray-300 border-t-transparent rounded-full" />
|
|
||||||
<p className="text-gray-600">Preparing your session…</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
// Change the URL to /login
|
|
||||||
return <Navigate to={fallbackPath} replace state={{ from: location }} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render protected routes
|
|
||||||
return <Outlet />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default RequireAuth;
|
|
||||||
@@ -1,69 +1,8 @@
|
|||||||
// Re-export from core for compatibility
|
import { VALID_NAV_KEYS as CORE_NAV_KEYS } from "@core/components/shared/config/types";
|
||||||
// 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;
|
|
||||||
|
|
||||||
// Extend NavKey to include saas-specific keys
|
// SaaS adds an "overview" account section. All other keys (including ones
|
||||||
export type NavKey =
|
// SaaS doesn't render today) come from core - subtracting them here would
|
||||||
| "overview"
|
// just break the type union without affecting runtime nav.
|
||||||
| "password-security"
|
export const VALID_NAV_KEYS = [...CORE_NAV_KEYS, "overview"] as const;
|
||||||
| "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";
|
|
||||||
|
|
||||||
// some of these are not used yet, but appear in figma designs
|
export type NavKey = (typeof VALID_NAV_KEYS)[number];
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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<string>();
|
|
||||||
const globalEndpointCache: Record<string, EndpointAvailabilityDetails> = {};
|
|
||||||
/**
|
|
||||||
* 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<void>;
|
|
||||||
} {
|
|
||||||
const [enabled, setEnabled] = useState<boolean | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const fetchEndpointStatus = async () => {
|
|
||||||
if (!endpoint) {
|
|
||||||
setEnabled(null);
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const response = await apiClient.get<boolean>(
|
|
||||||
`/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<string, boolean>;
|
|
||||||
endpointDetails: Record<string, EndpointAvailabilityDetails>;
|
|
||||||
loading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
refetch: () => Promise<void>;
|
|
||||||
} {
|
|
||||||
const [endpointStatus, setEndpointStatus] = useState<Record<string, boolean>>(
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
const [endpointDetails, setEndpointDetails] = useState<
|
|
||||||
Record<string, EndpointAvailabilityDetails>
|
|
||||||
>({});
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(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<string, boolean>,
|
|
||||||
details: {} as Record<string, EndpointAvailabilityDetails>,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
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<string, boolean>,
|
|
||||||
details: {} as Record<string, EndpointAvailabilityDetails>,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
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<string, EndpointAvailabilityDetails>
|
|
||||||
>(
|
|
||||||
`/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<string, boolean>,
|
|
||||||
details: {} as Record<string, EndpointAvailabilityDetails>,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
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<string, boolean>,
|
|
||||||
details: {} as Record<string, EndpointAvailabilityDetails>,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
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<string, boolean>,
|
|
||||||
details: {} as Record<string, EndpointAvailabilityDetails>,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { useTranslation } from "@app/hooks/useTranslation";
|
|
||||||
|
|
||||||
export default function LoadingState() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
minHeight: "100vh",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
backgroundColor: "#f3f4f6",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ textAlign: "center" }}>
|
|
||||||
<div style={{ fontSize: "32px", marginBottom: "16px" }}>⏳</div>
|
|
||||||
<p style={{ color: "#6b7280" }}>{t("loading")}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user