App.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  1. import { Component, Suspense, type ReactNode, type ErrorInfo } from 'react';
  2. import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom';
  3. import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
  4. import { Layout } from './components/Layout';
  5. import { useWebSocket } from './hooks/useWebSocket';
  6. import { usePrintProgressTitle } from './hooks/usePrintProgressTitle';
  7. import { useStreamTokenSync } from './hooks/useCameraStreamToken';
  8. import { ThemeProvider } from './contexts/ThemeContext';
  9. import { ToastProvider } from './contexts/ToastContext';
  10. import { SliceJobTrackerProvider } from './contexts/SliceJobTrackerContext';
  11. import { AuthProvider, useAuth } from './contexts/AuthContext';
  12. import { ColorCatalogProvider } from './contexts/ColorCatalogContext';
  13. import { EmbeddedCameraProvider } from './contexts/EmbeddedCameraContext';
  14. import { SpoolBuddyLayout } from './components/spoolbuddy/SpoolBuddyLayout';
  15. import { PageLoading } from './components/PageLoading';
  16. import { AuthRetrying } from './components/AuthRetrying';
  17. import { lazyPage } from './utils/lazyPage';
  18. // Every page loads as its own file when first opened (#3175).
  19. const PrintersPage = lazyPage(() => import('./pages/PrintersPage'), 'PrintersPage');
  20. const ArchivesPage = lazyPage(() => import('./pages/ArchivesPage'), 'ArchivesPage');
  21. const ArchiveDetailPage = lazyPage(() => import('./pages/ArchiveDetailPage'), 'ArchiveDetailPage');
  22. const QueuePage = lazyPage(() => import('./pages/QueuePage'), 'QueuePage');
  23. const StatsPage = lazyPage(() => import('./pages/StatsPage'), 'StatsPage');
  24. const SettingsPage = lazyPage(() => import('./pages/SettingsPage'), 'SettingsPage');
  25. const FinancePage = lazyPage(() => import('./pages/FinancePage'), 'FinancePage');
  26. const ProfilesPage = lazyPage(() => import('./pages/ProfilesPage'), 'ProfilesPage');
  27. const MaintenancePage = lazyPage(() => import('./pages/MaintenancePage'), 'MaintenancePage');
  28. const ProjectsPage = lazyPage(() => import('./pages/ProjectsPage'), 'ProjectsPage');
  29. const ProjectDetailPage = lazyPage(() => import('./pages/ProjectDetailPage'), 'ProjectDetailPage');
  30. const FileManagerPage = lazyPage(() => import('./pages/FileManagerPage'), 'FileManagerPage');
  31. const LibraryTrashPage = lazyPage(() => import('./pages/LibraryTrashPage'), 'LibraryTrashPage');
  32. const CameraPage = lazyPage(() => import('./pages/CameraPage'), 'CameraPage');
  33. const CamWallPage = lazyPage(() => import('./pages/CamWallPage'), 'CamWallPage');
  34. const StreamOverlayPage = lazyPage(() => import('./pages/StreamOverlayPage'), 'StreamOverlayPage');
  35. const ExternalLinkPage = lazyPage(() => import('./pages/ExternalLinkPage'), 'ExternalLinkPage');
  36. const GroupEditPage = lazyPage(() => import('./pages/GroupEditPage'), 'GroupEditPage');
  37. const PrinterLocationsPage = lazyPage(() => import('./pages/PrinterLocationsPage'), 'PrinterLocationsPage');
  38. const InventoryPage = lazyPage(() => import('./pages/InventoryPage'), 'default');
  39. const ModelSourcesPage = lazyPage(() => import('./pages/ModelSourcesPage'), 'ModelSourcesPage');
  40. const SystemInfoPage = lazyPage(() => import('./pages/SystemInfoPage'), 'SystemInfoPage');
  41. const LoginPage = lazyPage(() => import('./pages/LoginPage'), 'LoginPage');
  42. const ConnectAuthorizePage = lazyPage(() => import('./pages/ConnectAuthorizePage'), 'ConnectAuthorizePage');
  43. const SetupPage = lazyPage(() => import('./pages/SetupPage'), 'SetupPage');
  44. const NotificationsPage = lazyPage(() => import('./pages/NotificationsPage'), 'NotificationsPage');
  45. const GCodeViewerPage = lazyPage(() => import('./pages/GCodeViewerPage'), 'GCodeViewerPage');
  46. const SpoolBuddyDashboard = lazyPage(() => import('./pages/spoolbuddy/SpoolBuddyDashboard'), 'SpoolBuddyDashboard');
  47. const SpoolBuddyAmsPage = lazyPage(() => import('./pages/spoolbuddy/SpoolBuddyAmsPage'), 'SpoolBuddyAmsPage');
  48. const SpoolBuddySettingsPage = lazyPage(() => import('./pages/spoolbuddy/SpoolBuddySettingsPage'), 'SpoolBuddySettingsPage');
  49. const SpoolBuddyWriteTagPage = lazyPage(() => import('./pages/spoolbuddy/SpoolBuddyWriteTagPage'), 'SpoolBuddyWriteTagPage');
  50. const SpoolBuddyInventoryPage = lazyPage(() => import('./pages/spoolbuddy/SpoolBuddyInventoryPage'), 'SpoolBuddyInventoryPage');
  51. class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null; errorInfo: ErrorInfo | null }> {
  52. state = { error: null as Error | null, errorInfo: null as ErrorInfo | null };
  53. static getDerivedStateFromError(error: Error) {
  54. return { error };
  55. }
  56. componentDidCatch(error: Error, errorInfo: ErrorInfo) {
  57. this.setState({ errorInfo });
  58. console.error('React crash:', error, errorInfo);
  59. }
  60. render() {
  61. if (this.state.error) {
  62. return (
  63. <div style={{ padding: 24, color: '#ef4444', backgroundColor: '#18181b', minHeight: '100vh', fontFamily: 'monospace' }}>
  64. <h1 style={{ fontSize: 20, marginBottom: 12 }}>UI Crash</h1>
  65. <pre style={{ whiteSpace: 'pre-wrap', fontSize: 14 }}>{this.state.error.message}</pre>
  66. <pre style={{ whiteSpace: 'pre-wrap', fontSize: 12, color: '#a1a1aa', marginTop: 12 }}>
  67. {this.state.error.stack}
  68. </pre>
  69. <button
  70. onClick={() => { this.setState({ error: null, errorInfo: null }); }}
  71. style={{ marginTop: 16, padding: '8px 16px', backgroundColor: '#3b82f6', color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer' }}
  72. >
  73. Retry
  74. </button>
  75. </div>
  76. );
  77. }
  78. return this.props.children;
  79. }
  80. }
  81. const queryClient = new QueryClient({
  82. defaultOptions: {
  83. queries: {
  84. staleTime: 1000 * 60,
  85. retry: 1,
  86. },
  87. },
  88. });
  89. function StreamTokenSync() {
  90. useStreamTokenSync();
  91. return null;
  92. }
  93. function WebSocketProvider({ children }: { children: React.ReactNode }) {
  94. useWebSocket();
  95. usePrintProgressTitle();
  96. return <>{children}</>;
  97. }
  98. export function ProtectedRoute({ children }: { children: React.ReactNode }) {
  99. const { authEnabled, loading, user, authUnconfirmed } = useAuth();
  100. const location = useLocation();
  101. if (loading) {
  102. return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
  103. }
  104. // A stored sign-in the server couldn't check yet: wait here, so a kiosk
  105. // isn't left on a login page it can't use (#3360).
  106. if (authUnconfirmed) {
  107. return <AuthRetrying />;
  108. }
  109. if (authEnabled && !user) {
  110. return <Navigate to="/login" replace state={{ from: location }} />;
  111. }
  112. return <>{children}</>;
  113. }
  114. export function PermissionRoute({ permission, children }: { permission: string | string[]; children: React.ReactNode }) {
  115. // Permission-gated route: any user with the given permission can enter, not
  116. // just admins. Individual components below this guard apply their own
  117. // per-action permission checks. Used for pages where delegation is supported
  118. // (e.g. settings:read grants read-only access to Settings; specific tabs
  119. // require their own permissions like users:read, groups:update, etc.).
  120. const { authEnabled, loading, user, hasPermission, authUnconfirmed } = useAuth();
  121. const location = useLocation();
  122. if (loading) {
  123. return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
  124. }
  125. if (authUnconfirmed) {
  126. return <AuthRetrying />;
  127. }
  128. // Auth disabled → open access (backward compatibility)
  129. if (!authEnabled) {
  130. return <>{children}</>;
  131. }
  132. if (!user) {
  133. return <Navigate to="/login" replace state={{ from: location }} />;
  134. }
  135. // A list lets in anyone holding at least one of the permissions.
  136. const required = (Array.isArray(permission) ? permission : [permission]) as Parameters<typeof hasPermission>[0][];
  137. if (!required.some((p) => hasPermission(p))) {
  138. return <Navigate to="/" replace />;
  139. }
  140. return <>{children}</>;
  141. }
  142. function SetupRoute({ children }: { children: React.ReactNode }) {
  143. const { authEnabled, loading } = useAuth();
  144. if (loading) {
  145. return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
  146. }
  147. // If auth is already enabled, redirect to login
  148. // Otherwise, allow access to setup page (even if setup was completed before)
  149. // This allows users to enable auth later if they skipped it during initial setup
  150. if (authEnabled) {
  151. return <Navigate to="/login" replace />;
  152. }
  153. return <>{children}</>;
  154. }
  155. function App() {
  156. return (
  157. <ErrorBoundary>
  158. <ToastProvider>
  159. <QueryClientProvider client={queryClient}>
  160. <AuthProvider>
  161. {/* ThemeProvider sits inside AuthProvider so its initial
  162. ``api.getSettings()`` fetch can wait for AuthContext to
  163. resolve — otherwise it fires unconditionally on every
  164. login page load and returns 401. ErrorBoundary uses
  165. inline styles, so a missing theme on a crash screen is
  166. not a regression. */}
  167. <ThemeProvider>
  168. <ColorCatalogProvider>
  169. <SliceJobTrackerProvider>
  170. <StreamTokenSync />
  171. <BrowserRouter>
  172. {/* Standalone pages (login, camera, overlay, ...) wait here; pages
  173. inside a layout wait in that layout, so its frame stays up. */}
  174. <Suspense fallback={<PageLoading fullScreen />}>
  175. <Routes>
  176. {/* Setup page - only accessible if auth not enabled */}
  177. <Route path="/setup" element={<SetupRoute><SetupPage /></SetupRoute>} />
  178. {/* Login page */}
  179. <Route path="/login" element={<LoginPage />} />
  180. {/* "Sign in with Bambuddy" for connected apps: standalone, no layout,
  181. so it also fits inside the sidebar iframe of the app asking. */}
  182. <Route path="/connect/authorize" element={<ProtectedRoute><ConnectAuthorizePage /></ProtectedRoute>} />
  183. {/* Camera page - standalone, no layout, no WebSocket (doesn't need real-time updates) */}
  184. <Route path="/camera/:printerId" element={<CameraPage />} />
  185. {/* Stream overlay page - standalone for OBS/streaming embeds, no auth required */}
  186. <Route path="/overlay/:printerId" element={<StreamOverlayPage />} />
  187. {/* Cam Wall on its own URL (#2531). Outside ProtectedRoute because a
  188. ?token= kiosk has no session to protect; the page itself sends a
  189. tokenless visitor to /login, and the backend gates the feed. */}
  190. <Route path="/camwall" element={<CamWallPage />} />
  191. {/* SpoolBuddy kiosk UI */}
  192. <Route element={<ProtectedRoute><WebSocketProvider><SpoolBuddyLayout /></WebSocketProvider></ProtectedRoute>}>
  193. <Route path="spoolbuddy" element={<SpoolBuddyDashboard />} />
  194. <Route path="spoolbuddy/ams" element={<SpoolBuddyAmsPage />} />
  195. <Route path="spoolbuddy/write-tag" element={<SpoolBuddyWriteTagPage />} />
  196. <Route path="spoolbuddy/inventory" element={<SpoolBuddyInventoryPage />} />
  197. <Route path="spoolbuddy/settings" element={<SpoolBuddySettingsPage />} />
  198. {/* Calibration moved into the settings page; old bookmarks land there. */}
  199. <Route path="spoolbuddy/calibration" element={<Navigate to="/spoolbuddy/settings" replace />} />
  200. </Route>
  201. {/* Main app with WebSocket for real-time updates */}
  202. <Route element={<ProtectedRoute><WebSocketProvider><EmbeddedCameraProvider><Layout /></EmbeddedCameraProvider></WebSocketProvider></ProtectedRoute>}>
  203. <Route index element={<PrintersPage />} />
  204. <Route path="archives" element={<ArchivesPage />} />
  205. <Route path="archives/:id" element={<ArchiveDetailPage />} />
  206. <Route path="queue" element={<QueuePage />} />
  207. {/* Slicer Pipelines (#1425) — Pipelines tab lives on the
  208. Print Queue page (Queue + History + Timeline +
  209. Pipelines). Old standalone URL redirects. */}
  210. <Route path="pipelines/runs" element={<Navigate to="/queue?tab=pipelines" replace />} />
  211. <Route path="stats" element={<StatsPage />} />
  212. <Route path="profiles" element={<ProfilesPage />} />
  213. <Route path="finance" element={<PermissionRoute permission="cost_centers:read_own"><FinancePage /></PermissionRoute>} />
  214. <Route path="maintenance" element={<MaintenancePage />} />
  215. <Route path="projects" element={<ProjectsPage />} />
  216. <Route path="projects/:id" element={<ProjectDetailPage />} />
  217. <Route path="inventory" element={<InventoryPage />} />
  218. <Route path="files" element={<FileManagerPage />} />
  219. <Route path="files/trash" element={<LibraryTrashPage />} />
  220. <Route path="model-sources" element={<PermissionRoute permission={['makerworld:view', 'manyfold:view']}><ModelSourcesPage /></PermissionRoute>} />
  221. {/* The page was MakerWorld-only until Manyfold joined it (#1471); old links and bookmarks still land on that tab. */}
  222. <Route path="makerworld" element={<Navigate to="/model-sources?tab=makerworld" replace />} />
  223. <Route path="settings" element={<PermissionRoute permission="settings:read"><SettingsPage /></PermissionRoute>} />
  224. <Route path="groups/new" element={<PermissionRoute permission="groups:create"><GroupEditPage /></PermissionRoute>} />
  225. <Route path="groups/:id/edit" element={<PermissionRoute permission="groups:update"><GroupEditPage /></PermissionRoute>} />
  226. <Route path="printer-locations" element={<PermissionRoute permission="printers:read"><PrinterLocationsPage /></PermissionRoute>} />
  227. <Route path="users" element={<Navigate to="/settings?tab=users" replace />} />
  228. <Route path="groups" element={<Navigate to="/settings?tab=users" replace />} />
  229. <Route path="system" element={<SystemInfoPage />} />
  230. <Route path="notifications" element={<NotificationsPage />} />
  231. <Route path="gcode-viewer" element={<GCodeViewerPage />} />
  232. <Route path="external/:id" element={<ExternalLinkPage />} />
  233. <Route path="camera-tokens" element={<Navigate to="/settings?tab=camera#card-camera-tokens" replace />} />
  234. </Route>
  235. </Routes>
  236. </Suspense>
  237. </BrowserRouter>
  238. </SliceJobTrackerProvider>
  239. </ColorCatalogProvider>
  240. </ThemeProvider>
  241. </AuthProvider>
  242. </QueryClientProvider>
  243. </ToastProvider>
  244. </ErrorBoundary>
  245. );
  246. }
  247. export default App;