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:
James Brunton
2026-05-28 11:05:30 +00:00
committed by GitHub
parent 76840d8a57
commit 44fbf8c587
22 changed files with 313 additions and 798 deletions
@@ -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;
@@ -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<string, EndpointAvailabilityDetails> = {};
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<string | null>(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<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",
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<string, boolean>,
details: {} as Record<string, EndpointAvailabilityDetails>,
},
);
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<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(
(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<string, boolean>,
details: {} as Record<string, EndpointAvailabilityDetails>,
},
);
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,