mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Merge branch 'feat/graceful-degradation' into feat/on-demand-ai-features
This commit is contained in:
+104
-44
@@ -1,39 +1,50 @@
|
||||
import { Component, type ErrorInfo, lazy, type ReactNode, Suspense } from "react";
|
||||
import { Component, type ErrorInfo, type ReactNode, Suspense } from "react";
|
||||
import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { Toaster } from "sonner";
|
||||
import { ConnectionBanner } from "./components/common/connection-banner";
|
||||
import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider";
|
||||
import { useAuth } from "./hooks/use-auth";
|
||||
import { useConnectionMonitor } from "./hooks/use-connection-monitor";
|
||||
import { isChunkError, lazyWithRetry } from "./lib/lazy-with-retry";
|
||||
|
||||
// Lazy-load all pages so each page's JS (and its icons/deps) is only
|
||||
// downloaded when the user navigates there, shrinking the main bundle.
|
||||
const AutomatePage = lazy(() =>
|
||||
// Lazy-load all pages with automatic retry so chunk failures from
|
||||
// deployments are recovered transparently instead of white-screening.
|
||||
const AutomatePage = lazyWithRetry(() =>
|
||||
import("./pages/automate-page").then((m) => ({ default: m.AutomatePage })),
|
||||
);
|
||||
const ChangePasswordPage = lazy(() =>
|
||||
const ChangePasswordPage = lazyWithRetry(() =>
|
||||
import("./pages/change-password-page").then((m) => ({ default: m.ChangePasswordPage })),
|
||||
);
|
||||
const FilesPage = lazy(() => import("./pages/files-page").then((m) => ({ default: m.FilesPage })));
|
||||
const FullscreenGridPage = lazy(() =>
|
||||
const FilesPage = lazyWithRetry(() =>
|
||||
import("./pages/files-page").then((m) => ({ default: m.FilesPage })),
|
||||
);
|
||||
const FullscreenGridPage = lazyWithRetry(() =>
|
||||
import("./pages/fullscreen-grid-page").then((m) => ({ default: m.FullscreenGridPage })),
|
||||
);
|
||||
const HomePage = lazy(() => import("./pages/home-page").then((m) => ({ default: m.HomePage })));
|
||||
const LoginPage = lazy(() => import("./pages/login-page").then((m) => ({ default: m.LoginPage })));
|
||||
const PrivacyPolicyPage = lazy(() =>
|
||||
const HomePage = lazyWithRetry(() =>
|
||||
import("./pages/home-page").then((m) => ({ default: m.HomePage })),
|
||||
);
|
||||
const LoginPage = lazyWithRetry(() =>
|
||||
import("./pages/login-page").then((m) => ({ default: m.LoginPage })),
|
||||
);
|
||||
const PrivacyPolicyPage = lazyWithRetry(() =>
|
||||
import("./pages/privacy-policy-page").then((m) => ({ default: m.PrivacyPolicyPage })),
|
||||
);
|
||||
const ToolPage = lazy(() => import("./pages/tool-page").then((m) => ({ default: m.ToolPage })));
|
||||
const ToolPage = lazyWithRetry(() =>
|
||||
import("./pages/tool-page").then((m) => ({ default: m.ToolPage })),
|
||||
);
|
||||
|
||||
class ErrorBoundary extends Component<
|
||||
{ children: ReactNode },
|
||||
{ hasError: boolean; error: Error | null }
|
||||
{ hasError: boolean; error: Error | null; isChunkError: boolean }
|
||||
> {
|
||||
constructor(props: { children: ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
this.state = { hasError: false, error: null, isChunkError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { hasError: true, error };
|
||||
return { hasError: true, error, isChunkError: isChunkError(error) };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
@@ -42,6 +53,43 @@ class ErrorBoundary extends Component<
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.state.isChunkError) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background text-foreground">
|
||||
<div className="text-center space-y-4 max-w-md px-6">
|
||||
<div className="mx-auto h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<svg
|
||||
className="h-6 w-6 text-primary"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
role="img"
|
||||
aria-label="Refresh icon"
|
||||
>
|
||||
<title>Refresh</title>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold">Update Available</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
A new version of ashim has been deployed.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background text-foreground">
|
||||
<div className="text-center space-y-4 max-w-md px-6">
|
||||
@@ -52,7 +100,7 @@ class ErrorBoundary extends Component<
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
this.setState({ hasError: false, error: null, isChunkError: false });
|
||||
window.location.href = "/";
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium"
|
||||
@@ -112,36 +160,48 @@ function PageLoader() {
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionMonitor() {
|
||||
useConnectionMonitor();
|
||||
return null;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Toaster position="bottom-right" />
|
||||
<BrowserRouter>
|
||||
<KeyboardShortcutProvider>
|
||||
<AuthGuard>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/change-password" element={<ChangePasswordPage />} />
|
||||
<Route path="/automate" element={<AutomatePage />} />
|
||||
<Route path="/files" element={<FilesPage />} />
|
||||
<Route path="/fullscreen" element={<FullscreenGridPage />} />
|
||||
<Route path="/privacy" element={<PrivacyPolicyPage />} />
|
||||
{/* Redirects: old color tools consolidated into adjust-colors */}
|
||||
<Route
|
||||
path="/brightness-contrast"
|
||||
element={<Navigate to="/adjust-colors" replace />}
|
||||
/>
|
||||
<Route path="/saturation" element={<Navigate to="/adjust-colors" replace />} />
|
||||
<Route path="/color-channels" element={<Navigate to="/adjust-colors" replace />} />
|
||||
<Route path="/color-effects" element={<Navigate to="/adjust-colors" replace />} />
|
||||
<Route path="/:toolId" element={<ToolPage />} />
|
||||
<Route path="/" element={<HomePage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</AuthGuard>
|
||||
</KeyboardShortcutProvider>
|
||||
</BrowserRouter>
|
||||
</ErrorBoundary>
|
||||
<>
|
||||
<ConnectionMonitor />
|
||||
<ConnectionBanner />
|
||||
<ErrorBoundary>
|
||||
<Toaster position="bottom-right" />
|
||||
<BrowserRouter>
|
||||
<KeyboardShortcutProvider>
|
||||
<AuthGuard>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/change-password" element={<ChangePasswordPage />} />
|
||||
<Route path="/automate" element={<AutomatePage />} />
|
||||
<Route path="/files" element={<FilesPage />} />
|
||||
<Route path="/fullscreen" element={<FullscreenGridPage />} />
|
||||
<Route path="/privacy" element={<PrivacyPolicyPage />} />
|
||||
{/* Redirects: old color tools consolidated into adjust-colors */}
|
||||
<Route
|
||||
path="/brightness-contrast"
|
||||
element={<Navigate to="/adjust-colors" replace />}
|
||||
/>
|
||||
<Route path="/saturation" element={<Navigate to="/adjust-colors" replace />} />
|
||||
<Route
|
||||
path="/color-channels"
|
||||
element={<Navigate to="/adjust-colors" replace />}
|
||||
/>
|
||||
<Route path="/color-effects" element={<Navigate to="/adjust-colors" replace />} />
|
||||
<Route path="/:toolId" element={<ToolPage />} />
|
||||
<Route path="/" element={<HomePage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</AuthGuard>
|
||||
</KeyboardShortcutProvider>
|
||||
</BrowserRouter>
|
||||
</ErrorBoundary>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { CheckCircle2, Loader2, WifiOff } from "lucide-react";
|
||||
import { useConnectionStore } from "@/stores/connection-store";
|
||||
|
||||
export function ConnectionBanner() {
|
||||
const status = useConnectionStore((s) => s.status);
|
||||
|
||||
if (status === "connected") return null;
|
||||
|
||||
const config = {
|
||||
disconnected: {
|
||||
bg: "bg-amber-500 dark:bg-amber-600",
|
||||
text: "text-amber-950 dark:text-amber-50",
|
||||
icon: <Loader2 className="h-4 w-4 animate-spin" />,
|
||||
message: "Reconnecting to server\u2026",
|
||||
},
|
||||
offline: {
|
||||
bg: "bg-amber-500 dark:bg-amber-600",
|
||||
text: "text-amber-950 dark:text-amber-50",
|
||||
icon: <WifiOff className="h-4 w-4" />,
|
||||
message: "You\u2019re offline",
|
||||
},
|
||||
reconnected: {
|
||||
bg: "bg-emerald-500 dark:bg-emerald-600",
|
||||
text: "text-emerald-950 dark:text-emerald-50",
|
||||
icon: <CheckCircle2 className="h-4 w-4" />,
|
||||
message: "Connected",
|
||||
},
|
||||
}[status];
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={`fixed top-0 left-0 right-0 z-[60] flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium transition-transform duration-300 ${config.bg} ${config.text}`}
|
||||
>
|
||||
{config.icon}
|
||||
{config.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Link } from "react-router-dom";
|
||||
import { useMobile } from "@/hooks/use-mobile";
|
||||
import { apiGet } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useConnectionStore } from "@/stores/connection-store";
|
||||
import { Dropzone } from "../common/dropzone";
|
||||
import { GemLogo } from "../common/gem-logo";
|
||||
import { HelpDialog } from "../help/help-dialog";
|
||||
@@ -25,6 +26,8 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
const isMobile = useMobile();
|
||||
const [customLogo, setCustomLogo] = useState(false);
|
||||
const connectionStatus = useConnectionStore((s) => s.status);
|
||||
const bannerVisible = connectionStatus !== "connected";
|
||||
|
||||
useEffect(() => {
|
||||
apiGet<{ settings: Record<string, string> }>("/v1/settings")
|
||||
@@ -33,7 +36,12 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background text-foreground overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-screen bg-background text-foreground overflow-hidden",
|
||||
bannerVisible && "pt-9",
|
||||
)}
|
||||
>
|
||||
{/* Desktop sidebar */}
|
||||
{!isMobile && (
|
||||
<Sidebar
|
||||
@@ -92,7 +100,12 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
|
||||
|
||||
{/* Mobile top bar */}
|
||||
{isMobile && (
|
||||
<div className="fixed top-0 left-0 right-0 z-30 bg-background/95 backdrop-blur-sm border-b border-border px-3 py-2 flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"fixed left-0 right-0 z-30 bg-background/95 backdrop-blur-sm border-b border-border px-3 py-2 flex items-center gap-3",
|
||||
bannerVisible ? "top-9" : "top-0",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileSidebarOpen(true)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
import { useConnectionStore } from "@/stores/connection-store";
|
||||
|
||||
interface AuthState {
|
||||
loading: boolean;
|
||||
@@ -36,35 +37,37 @@ export function useAuth() {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function checkAuth() {
|
||||
try {
|
||||
// Check if auth is enabled
|
||||
const configRes = await fetch("/api/v1/config/auth");
|
||||
const config = await configRes.json();
|
||||
|
||||
if (!config.authEnabled) {
|
||||
setState({
|
||||
loading: false,
|
||||
authEnabled: false,
|
||||
isAuthenticated: true,
|
||||
mustChangePassword: false,
|
||||
role: "admin",
|
||||
permissions: ALL_PERMISSIONS,
|
||||
});
|
||||
if (!cancelled)
|
||||
setState({
|
||||
loading: false,
|
||||
authEnabled: false,
|
||||
isAuthenticated: true,
|
||||
mustChangePassword: false,
|
||||
role: "admin",
|
||||
permissions: ALL_PERMISSIONS,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Auth is enabled — check if we have a valid session
|
||||
const token = localStorage.getItem("ashim-token");
|
||||
if (!token) {
|
||||
setState({
|
||||
loading: false,
|
||||
authEnabled: true,
|
||||
isAuthenticated: false,
|
||||
mustChangePassword: false,
|
||||
role: null,
|
||||
permissions: [],
|
||||
});
|
||||
if (!cancelled)
|
||||
setState({
|
||||
loading: false,
|
||||
authEnabled: true,
|
||||
isAuthenticated: false,
|
||||
mustChangePassword: false,
|
||||
role: null,
|
||||
permissions: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -75,39 +78,45 @@ export function useAuth() {
|
||||
if (sessionRes.ok) {
|
||||
const session = await sessionRes.json();
|
||||
const mustChange = session.user?.mustChangePassword === true;
|
||||
setState({
|
||||
loading: false,
|
||||
authEnabled: true,
|
||||
isAuthenticated: true,
|
||||
mustChangePassword: mustChange,
|
||||
role: session.user?.role ?? null,
|
||||
permissions: session.user?.permissions ?? [],
|
||||
});
|
||||
if (!cancelled)
|
||||
setState({
|
||||
loading: false,
|
||||
authEnabled: true,
|
||||
isAuthenticated: true,
|
||||
mustChangePassword: mustChange,
|
||||
role: session.user?.role ?? null,
|
||||
permissions: session.user?.permissions ?? [],
|
||||
});
|
||||
} else {
|
||||
localStorage.removeItem("ashim-token");
|
||||
setState({
|
||||
loading: false,
|
||||
authEnabled: true,
|
||||
isAuthenticated: false,
|
||||
mustChangePassword: false,
|
||||
role: null,
|
||||
permissions: [],
|
||||
});
|
||||
if (!cancelled)
|
||||
setState({
|
||||
loading: false,
|
||||
authEnabled: true,
|
||||
isAuthenticated: false,
|
||||
mustChangePassword: false,
|
||||
role: null,
|
||||
permissions: [],
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Can't reach API — assume no auth needed (dev mode)
|
||||
setState({
|
||||
loading: false,
|
||||
authEnabled: false,
|
||||
isAuthenticated: true,
|
||||
mustChangePassword: false,
|
||||
role: "admin",
|
||||
permissions: ALL_PERMISSIONS,
|
||||
});
|
||||
// API unreachable — stay in loading state.
|
||||
// ConnectionBanner explains the outage. AuthGuard shows spinner.
|
||||
}
|
||||
}
|
||||
|
||||
checkAuth();
|
||||
|
||||
const unsubscribe = useConnectionStore.subscribe((curr, prev) => {
|
||||
if (prev.status !== "reconnected" && curr.status === "reconnected") {
|
||||
checkAuth();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const hasPermission = (permission: string) => state.permissions.includes(permission);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useEffect } from "react";
|
||||
import { useConnectionStore } from "@/stores/connection-store";
|
||||
|
||||
export function useConnectionMonitor() {
|
||||
useEffect(() => {
|
||||
const store = useConnectionStore;
|
||||
|
||||
const handleOffline = () => store.getState().setOffline();
|
||||
const handleOnline = () => {
|
||||
const prev = store.getState().status;
|
||||
store.getState().setOnline();
|
||||
if (prev === "offline") {
|
||||
store.getState().startPolling();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("offline", handleOffline);
|
||||
window.addEventListener("online", handleOnline);
|
||||
|
||||
store.getState().checkHealth();
|
||||
|
||||
const unsubscribe = store.subscribe((state, prev) => {
|
||||
if (state.status === prev.status) return;
|
||||
|
||||
if (state.status === "disconnected") {
|
||||
store.getState().startPolling();
|
||||
}
|
||||
|
||||
if (state.status === "reconnected") {
|
||||
store.getState().stopPolling();
|
||||
store
|
||||
.getState()
|
||||
.refreshStaleData()
|
||||
.finally(() => {
|
||||
setTimeout(() => {
|
||||
if (store.getState().status === "reconnected") {
|
||||
store.setState({ status: "connected" });
|
||||
}
|
||||
}, 2500);
|
||||
});
|
||||
}
|
||||
|
||||
if (state.status === "offline") {
|
||||
store.getState().stopPolling();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("offline", handleOffline);
|
||||
window.removeEventListener("online", handleOnline);
|
||||
store.getState().stopPolling();
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -261,7 +261,7 @@ export function useToolProcessor(toolId: string) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
setError("Network error - check your connection");
|
||||
setError("Processing was interrupted \u2014 retry when reconnected");
|
||||
setProcessing(false);
|
||||
setProgress(IDLE_PROGRESS);
|
||||
};
|
||||
|
||||
+101
-35
@@ -1,3 +1,5 @@
|
||||
import { useConnectionStore } from "@/stores/connection-store";
|
||||
|
||||
const API_BASE = "/api";
|
||||
|
||||
export interface FeatureNotInstalledError {
|
||||
@@ -74,9 +76,17 @@ async function throwWithMessage(res: Response): Promise<never> {
|
||||
}
|
||||
|
||||
export async function apiGet<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
headers: formatHeaders(),
|
||||
});
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${API_BASE}${path}`, {
|
||||
headers: formatHeaders(),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof TypeError) {
|
||||
useConnectionStore.getState().setDisconnected();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!res.ok) await throwWithMessage(res);
|
||||
return res.json();
|
||||
}
|
||||
@@ -84,30 +94,54 @@ export async function apiGet<T>(path: string): Promise<T> {
|
||||
export async function apiPost<T>(path: string, body?: unknown): Promise<T> {
|
||||
const headers =
|
||||
body !== undefined ? formatHeaders({ "Content-Type": "application/json" }) : formatHeaders();
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${API_BASE}${path}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof TypeError) {
|
||||
useConnectionStore.getState().setDisconnected();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!res.ok) await throwWithMessage(res);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function apiPut<T>(path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
method: "PUT",
|
||||
headers: formatHeaders({ "Content-Type": "application/json" }),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${API_BASE}${path}`, {
|
||||
method: "PUT",
|
||||
headers: formatHeaders({ "Content-Type": "application/json" }),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof TypeError) {
|
||||
useConnectionStore.getState().setDisconnected();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!res.ok) await throwWithMessage(res);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function apiDelete<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
method: "DELETE",
|
||||
headers: formatHeaders(),
|
||||
});
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${API_BASE}${path}`, {
|
||||
method: "DELETE",
|
||||
headers: formatHeaders(),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof TypeError) {
|
||||
useConnectionStore.getState().setDisconnected();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!res.ok) await throwWithMessage(res);
|
||||
return res.json();
|
||||
}
|
||||
@@ -128,11 +162,19 @@ export async function apiUpload(files: File[]): Promise<{
|
||||
}> {
|
||||
const formData = new FormData();
|
||||
for (const f of files) formData.append("files", f);
|
||||
const res = await fetch("/api/v1/upload", {
|
||||
method: "POST",
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch("/api/v1/upload", {
|
||||
method: "POST",
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof TypeError) {
|
||||
useConnectionStore.getState().setDisconnected();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
@@ -190,21 +232,37 @@ export async function apiUploadUserFiles(
|
||||
): Promise<{ files: Array<{ id: string; originalName: string; size: number; version: number }> }> {
|
||||
const formData = new FormData();
|
||||
for (const f of files) formData.append("files", f);
|
||||
const res = await fetch("/api/v1/files/upload", {
|
||||
method: "POST",
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch("/api/v1/files/upload", {
|
||||
method: "POST",
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof TypeError) {
|
||||
useConnectionStore.getState().setDisconnected();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function apiDeleteUserFiles(ids: string[]): Promise<{ deleted: number }> {
|
||||
const res = await fetch("/api/v1/files", {
|
||||
method: "DELETE",
|
||||
headers: formatHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch("/api/v1/files", {
|
||||
method: "DELETE",
|
||||
headers: formatHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof TypeError) {
|
||||
useConnectionStore.getState().setDisconnected();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!res.ok) throw new Error(`Delete failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
@@ -218,9 +276,17 @@ export function getFileDownloadUrl(id: string): string {
|
||||
}
|
||||
|
||||
export async function apiDownloadBlob(jobId: string, filename: string): Promise<Blob> {
|
||||
const res = await fetch(getDownloadUrl(jobId, filename), {
|
||||
headers: formatHeaders(),
|
||||
});
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(getDownloadUrl(jobId, filename), {
|
||||
headers: formatHeaders(),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof TypeError) {
|
||||
useConnectionStore.getState().setDisconnected();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
|
||||
return res.blob();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { type ComponentType, lazy } from "react";
|
||||
import { useConnectionStore } from "@/stores/connection-store";
|
||||
|
||||
export function isChunkError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
const msg = error.message.toLowerCase();
|
||||
return (
|
||||
msg.includes("dynamically imported module") ||
|
||||
msg.includes("loading chunk") ||
|
||||
msg.includes("loading css chunk") ||
|
||||
msg.includes("failed to fetch") ||
|
||||
msg.includes("unable to preload")
|
||||
);
|
||||
}
|
||||
|
||||
export async function retryDynamicImport<T>(
|
||||
importFn: () => Promise<T>,
|
||||
retries = 3,
|
||||
delay = 1000,
|
||||
): Promise<T> {
|
||||
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||
try {
|
||||
return await importFn();
|
||||
} catch (error) {
|
||||
if (!isChunkError(error) || attempt === retries) {
|
||||
if (isChunkError(error)) {
|
||||
useConnectionStore.getState().setDisconnected();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
|
||||
export function lazyWithRetry<T extends ComponentType<unknown>>(
|
||||
importFn: () => Promise<{ default: T }>,
|
||||
): React.LazyExoticComponent<T> {
|
||||
return lazy(() => retryDynamicImport(importFn));
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
type ConnectionStatus = "connected" | "disconnected" | "reconnected" | "offline";
|
||||
|
||||
interface ConnectionState {
|
||||
status: ConnectionStatus;
|
||||
failedSince: number | null;
|
||||
lastHealthCheck: number | null;
|
||||
|
||||
setDisconnected: () => void;
|
||||
setOffline: () => void;
|
||||
setOnline: () => void;
|
||||
checkHealth: () => Promise<void>;
|
||||
startPolling: () => void;
|
||||
stopPolling: () => void;
|
||||
refreshStaleData: () => Promise<void>;
|
||||
}
|
||||
|
||||
let pollingInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
export const useConnectionStore = create<ConnectionState>((set, get) => ({
|
||||
status: "connected",
|
||||
failedSince: null,
|
||||
lastHealthCheck: null,
|
||||
|
||||
setDisconnected: () => {
|
||||
const current = get();
|
||||
if (current.status === "disconnected") return;
|
||||
set({
|
||||
status: "disconnected",
|
||||
failedSince: current.failedSince ?? Date.now(),
|
||||
});
|
||||
},
|
||||
|
||||
setOffline: () => {
|
||||
set({ status: "offline", failedSince: get().failedSince ?? Date.now() });
|
||||
},
|
||||
|
||||
setOnline: () => {
|
||||
if (get().status !== "offline") return;
|
||||
set({ status: "disconnected" });
|
||||
},
|
||||
|
||||
checkHealth: async () => {
|
||||
try {
|
||||
const res = await fetch("/api/v1/health");
|
||||
if (res.ok) {
|
||||
const current = get().status;
|
||||
if (current === "disconnected" || current === "offline") {
|
||||
set({ status: "reconnected", lastHealthCheck: Date.now(), failedSince: null });
|
||||
} else {
|
||||
set({ lastHealthCheck: Date.now() });
|
||||
}
|
||||
} else {
|
||||
if (get().status === "connected") {
|
||||
get().setDisconnected();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (get().status === "connected") {
|
||||
get().setDisconnected();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
startPolling: () => {
|
||||
if (pollingInterval) return;
|
||||
pollingInterval = setInterval(() => {
|
||||
get().checkHealth();
|
||||
}, 3000);
|
||||
},
|
||||
|
||||
stopPolling: () => {
|
||||
if (pollingInterval) {
|
||||
clearInterval(pollingInterval);
|
||||
pollingInterval = null;
|
||||
}
|
||||
},
|
||||
|
||||
refreshStaleData: async () => {
|
||||
const { useSettingsStore } = await import("@/stores/settings-store");
|
||||
const { useFeaturesStore } = await import("@/stores/features-store");
|
||||
|
||||
useSettingsStore.setState({ loaded: false });
|
||||
await Promise.allSettled([
|
||||
useSettingsStore.getState().fetch(),
|
||||
useFeaturesStore.getState().refresh(),
|
||||
]);
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,49 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
|
||||
import { useConnectionStore } from "@/stores/connection-store";
|
||||
|
||||
describe("ConnectionBanner", () => {
|
||||
afterEach(() => {
|
||||
useConnectionStore.setState({ status: "connected", failedSince: null, lastHealthCheck: null });
|
||||
});
|
||||
|
||||
it("renders nothing when connected", async () => {
|
||||
const { renderBanner } = await setupRender();
|
||||
useConnectionStore.setState({ status: "connected" });
|
||||
const { container } = renderBanner();
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("renders amber banner when disconnected", async () => {
|
||||
const { renderBanner } = await setupRender();
|
||||
useConnectionStore.setState({ status: "disconnected" });
|
||||
const { container } = renderBanner();
|
||||
expect(container.textContent).toContain("Reconnecting to server");
|
||||
});
|
||||
|
||||
it("renders offline banner when offline", async () => {
|
||||
const { renderBanner } = await setupRender();
|
||||
useConnectionStore.setState({ status: "offline" });
|
||||
const { container } = renderBanner();
|
||||
expect(container.textContent).toContain("offline");
|
||||
});
|
||||
|
||||
it("renders green banner when reconnected", async () => {
|
||||
const { renderBanner } = await setupRender();
|
||||
useConnectionStore.setState({ status: "reconnected" });
|
||||
const { container } = renderBanner();
|
||||
expect(container.textContent).toContain("Connected");
|
||||
});
|
||||
});
|
||||
|
||||
async function setupRender() {
|
||||
const React = await import("react");
|
||||
const { render } = await import("@testing-library/react");
|
||||
const { ConnectionBanner } = await import("@/components/common/connection-banner");
|
||||
return {
|
||||
renderBanner: () => render(React.createElement(ConnectionBanner)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
import { useConnectionStore } from "@/stores/connection-store";
|
||||
|
||||
function okHealth() {
|
||||
return Promise.resolve(new Response(JSON.stringify({ status: "healthy" }), { status: 200 }));
|
||||
}
|
||||
|
||||
function failHealth() {
|
||||
return Promise.reject(new TypeError("Failed to fetch"));
|
||||
}
|
||||
|
||||
function nonOkHealth() {
|
||||
return Promise.resolve(new Response(null, { status: 503 }));
|
||||
}
|
||||
|
||||
describe("connection-store", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
useConnectionStore.setState({
|
||||
status: "connected",
|
||||
failedSince: null,
|
||||
lastHealthCheck: null,
|
||||
});
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useConnectionStore.getState().stopPolling();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("starts in connected state", () => {
|
||||
expect(useConnectionStore.getState().status).toBe("connected");
|
||||
});
|
||||
|
||||
it("transitions to disconnected on setDisconnected", () => {
|
||||
useConnectionStore.getState().setDisconnected();
|
||||
const state = useConnectionStore.getState();
|
||||
expect(state.status).toBe("disconnected");
|
||||
expect(state.failedSince).toBeTypeOf("number");
|
||||
});
|
||||
|
||||
it("does not overwrite failedSince on repeated setDisconnected calls", () => {
|
||||
useConnectionStore.getState().setDisconnected();
|
||||
const first = useConnectionStore.getState().failedSince;
|
||||
useConnectionStore.getState().setDisconnected();
|
||||
expect(useConnectionStore.getState().failedSince).toBe(first);
|
||||
});
|
||||
|
||||
it("transitions to offline on setOffline", () => {
|
||||
useConnectionStore.getState().setOffline();
|
||||
expect(useConnectionStore.getState().status).toBe("offline");
|
||||
});
|
||||
|
||||
it("transitions from offline to disconnected on setOnline", () => {
|
||||
useConnectionStore.getState().setOffline();
|
||||
useConnectionStore.getState().setOnline();
|
||||
expect(useConnectionStore.getState().status).toBe("disconnected");
|
||||
});
|
||||
|
||||
it("checkHealth transitions disconnected → reconnected on success", async () => {
|
||||
fetchMock.mockImplementation(okHealth);
|
||||
useConnectionStore.getState().setDisconnected();
|
||||
await useConnectionStore.getState().checkHealth();
|
||||
expect(useConnectionStore.getState().status).toBe("reconnected");
|
||||
expect(useConnectionStore.getState().lastHealthCheck).toBeTypeOf("number");
|
||||
});
|
||||
|
||||
it("checkHealth stays disconnected on failure", async () => {
|
||||
fetchMock.mockImplementation(failHealth);
|
||||
useConnectionStore.getState().setDisconnected();
|
||||
await useConnectionStore.getState().checkHealth();
|
||||
expect(useConnectionStore.getState().status).toBe("disconnected");
|
||||
});
|
||||
|
||||
it("checkHealth is a no-op when already connected", async () => {
|
||||
fetchMock.mockImplementation(okHealth);
|
||||
await useConnectionStore.getState().checkHealth();
|
||||
expect(useConnectionStore.getState().status).toBe("connected");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("checkHealth transitions connected → disconnected on non-ok response", async () => {
|
||||
fetchMock.mockImplementation(nonOkHealth);
|
||||
expect(useConnectionStore.getState().status).toBe("connected");
|
||||
await useConnectionStore.getState().checkHealth();
|
||||
expect(useConnectionStore.getState().status).toBe("disconnected");
|
||||
});
|
||||
|
||||
it("checkHealth transitions connected → disconnected on fetch failure", async () => {
|
||||
fetchMock.mockImplementation(failHealth);
|
||||
expect(useConnectionStore.getState().status).toBe("connected");
|
||||
await useConnectionStore.getState().checkHealth();
|
||||
expect(useConnectionStore.getState().status).toBe("disconnected");
|
||||
expect(useConnectionStore.getState().failedSince).toBeTypeOf("number");
|
||||
});
|
||||
|
||||
it("checkHealth transitions offline → reconnected on success", async () => {
|
||||
fetchMock.mockImplementation(okHealth);
|
||||
useConnectionStore.getState().setOffline();
|
||||
expect(useConnectionStore.getState().status).toBe("offline");
|
||||
await useConnectionStore.getState().checkHealth();
|
||||
expect(useConnectionStore.getState().status).toBe("reconnected");
|
||||
expect(useConnectionStore.getState().failedSince).toBeNull();
|
||||
expect(useConnectionStore.getState().lastHealthCheck).toBeTypeOf("number");
|
||||
});
|
||||
|
||||
it("startPolling is idempotent", () => {
|
||||
useConnectionStore.getState().setDisconnected();
|
||||
useConnectionStore.getState().startPolling();
|
||||
useConnectionStore.getState().startPolling();
|
||||
fetchMock.mockImplementation(failHealth);
|
||||
vi.advanceTimersByTime(3000);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stopPolling clears the interval", () => {
|
||||
useConnectionStore.getState().setDisconnected();
|
||||
fetchMock.mockImplementation(failHealth);
|
||||
useConnectionStore.getState().startPolling();
|
||||
useConnectionStore.getState().stopPolling();
|
||||
vi.advanceTimersByTime(6000);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { retryDynamicImport } from "@/lib/lazy-with-retry";
|
||||
|
||||
describe("retryDynamicImport", () => {
|
||||
it("resolves on first success", async () => {
|
||||
const mod = { default: () => null };
|
||||
const importFn = vi.fn().mockResolvedValue(mod);
|
||||
const result = await retryDynamicImport(importFn);
|
||||
expect(result).toBe(mod);
|
||||
expect(importFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("retries on failure and resolves on eventual success", async () => {
|
||||
const mod = { default: () => null };
|
||||
const importFn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new TypeError("Failed to fetch dynamically imported module"))
|
||||
.mockRejectedValueOnce(new TypeError("Failed to fetch dynamically imported module"))
|
||||
.mockResolvedValue(mod);
|
||||
const result = await retryDynamicImport(importFn, 3, 0);
|
||||
expect(result).toBe(mod);
|
||||
expect(importFn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("rejects after all retries exhausted", async () => {
|
||||
const err = new TypeError("Failed to fetch dynamically imported module");
|
||||
const importFn = vi.fn().mockRejectedValue(err);
|
||||
await expect(retryDynamicImport(importFn, 3, 0)).rejects.toThrow(
|
||||
"Failed to fetch dynamically imported module",
|
||||
);
|
||||
expect(importFn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("retries CSS preload errors", async () => {
|
||||
const mod = { default: () => null };
|
||||
const importFn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(
|
||||
new TypeError("Unable to preload CSS for /assets/tool-page-DDbXBANV.css"),
|
||||
)
|
||||
.mockResolvedValue(mod);
|
||||
const result = await retryDynamicImport(importFn, 3, 0);
|
||||
expect(result).toBe(mod);
|
||||
expect(importFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("only retries chunk-related errors, not other errors", async () => {
|
||||
const err = new Error("Some other error");
|
||||
const importFn = vi.fn().mockRejectedValue(err);
|
||||
await expect(retryDynamicImport(importFn, 3, 0)).rejects.toThrow("Some other error");
|
||||
expect(importFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -741,6 +741,40 @@ describe("API lib", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -- api network error → connection store ---------------------------------
|
||||
|
||||
describe("api network error → connection store", () => {
|
||||
it("triggers disconnected state on TypeError from fetch", async () => {
|
||||
const { useConnectionStore } = await import("@/stores/connection-store");
|
||||
useConnectionStore.setState({
|
||||
status: "connected",
|
||||
failedSince: null,
|
||||
lastHealthCheck: null,
|
||||
});
|
||||
|
||||
fetchMock.mockRejectedValueOnce(new TypeError("Failed to fetch"));
|
||||
await expect(apiGet("/v1/test")).rejects.toThrow("Failed to fetch");
|
||||
|
||||
expect(useConnectionStore.getState().status).toBe("disconnected");
|
||||
});
|
||||
|
||||
it("does NOT trigger disconnected on HTTP errors", async () => {
|
||||
const { useConnectionStore } = await import("@/stores/connection-store");
|
||||
useConnectionStore.setState({
|
||||
status: "connected",
|
||||
failedSince: null,
|
||||
lastHealthCheck: null,
|
||||
});
|
||||
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ error: "Not found" }), { status: 404 }),
|
||||
);
|
||||
await expect(apiGet("/v1/test")).rejects.toThrow();
|
||||
|
||||
expect(useConnectionStore.getState().status).toBe("connected");
|
||||
});
|
||||
});
|
||||
|
||||
// -- Cross-cutting: token is read fresh on every call --------------------
|
||||
|
||||
describe("token freshness", () => {
|
||||
@@ -765,3 +799,30 @@ describe("API lib", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// useAuth security
|
||||
// ==========================================================================
|
||||
|
||||
describe("useAuth security — network error", () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
it("does NOT grant admin when API is unreachable", async () => {
|
||||
fetchMock.mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
|
||||
const { renderHook, act } = await import("@testing-library/react");
|
||||
const { useAuth } = await import("@/hooks/use-auth");
|
||||
|
||||
const { result } = renderHook(() => useAuth());
|
||||
|
||||
// Flush the async checkAuth() call
|
||||
await act(async () => {});
|
||||
|
||||
// loading stays true — setState was never called
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.isAuthenticated).toBe(false);
|
||||
expect(result.current.role).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,9 @@ import { defineConfig } from "vitest/config";
|
||||
// Resolve api-workspace packages that pnpm only exposes under apps/api/node_modules.
|
||||
const apiNodeModules = path.resolve(__dirname, "apps/api/node_modules");
|
||||
|
||||
// Resolve web-workspace packages that pnpm only exposes under apps/web/node_modules.
|
||||
const webNodeModules = path.resolve(__dirname, "apps/web/node_modules");
|
||||
|
||||
// Temp dir for integration test DB + workspace (set BEFORE any app code loads)
|
||||
const testDir = path.join(os.tmpdir(), `ashim-test-${crypto.randomUUID().slice(0, 8)}`);
|
||||
|
||||
@@ -88,6 +91,10 @@ export default defineConfig({
|
||||
jsqr: path.join(apiNodeModules, "jsqr"),
|
||||
pdfkit: path.join(apiNodeModules, "pdfkit"),
|
||||
sharp: path.join(apiNodeModules, "sharp"),
|
||||
// Map web-only dependencies so component tests can resolve them
|
||||
// from the root vitest runner.
|
||||
react: path.join(webNodeModules, "react"),
|
||||
"react-dom": path.join(webNodeModules, "react-dom"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user