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
+8 -2
View File
@@ -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",
);
@@ -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
// 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];
@@ -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>
);
}