mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-17 11:45:05 +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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user