diff --git a/frontend/package.json b/frontend/package.json index 6c2c003..24a288c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,8 +17,6 @@ }, "dependencies": { "@tanstack/react-query": "^5.62.16", - "ajv": "^8.18.0", - "axios": "^1.13.6", "clsx": "^2.1.1", "epubjs": "^0.3.93", "nosleep.js": "^0.12.0", diff --git a/frontend/src/Routes.tsx b/frontend/src/Routes.tsx index f7ec262..2d9bcae 100644 --- a/frontend/src/Routes.tsx +++ b/frontend/src/Routes.tsx @@ -1,4 +1,4 @@ -import { Route, Routes as ReactRoutes } from 'react-router-dom'; +import { Outlet, Route, Routes as ReactRoutes } from 'react-router-dom'; import Layout from './components/Layout'; import HomePage from './pages/HomePage'; import DocumentsPage from './pages/DocumentsPage'; @@ -16,108 +16,33 @@ import AdminUsersPage from './pages/AdminUsersPage'; import AdminLogsPage from './pages/AdminLogsPage'; import ReaderPage from './pages/ReaderPage'; import { ProtectedRoute } from './auth/ProtectedRoute'; +import { AdminRoute } from './auth/AdminRoute'; export function Routes() { return ( - }> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - {/* Admin routes */} - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + }> + } /> + } /> + } /> + } /> + } /> + ({ + useAuth: vi.fn(), +})); + +const mockedUseAuth = vi.mocked(useAuth); + +describe('AdminRoute', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders children for admin users', () => { + mockedUseAuth.mockReturnValue({ + isAuthenticated: true, + isCheckingAuth: false, + user: { username: 'evan', is_admin: true }, + login: vi.fn(), + register: vi.fn(), + logout: vi.fn(), + }); + + render( + + +
Admin Panel
+
+
+ ); + + expect(screen.getByText('Admin Panel')).toBeInTheDocument(); + }); + + it('redirects non-admin users to the home page', () => { + mockedUseAuth.mockReturnValue({ + isAuthenticated: true, + isCheckingAuth: false, + user: { username: 'evan', is_admin: false }, + login: vi.fn(), + register: vi.fn(), + logout: vi.fn(), + }); + + render( + + + +
Admin Panel
+ + } + /> + Home Page} /> +
+
+ ); + + expect(screen.getByText('Home Page')).toBeInTheDocument(); + expect(screen.queryByText('Admin Panel')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/auth/AdminRoute.tsx b/frontend/src/auth/AdminRoute.tsx new file mode 100644 index 0000000..cca0194 --- /dev/null +++ b/frontend/src/auth/AdminRoute.tsx @@ -0,0 +1,17 @@ +import { Navigate } from 'react-router-dom'; +import { useAuth } from './AuthContext'; + +interface AdminRouteProps { + children: React.ReactNode; +} + +// Role Guard - Runs behind ProtectedRoute, so auth is already resolved and `user` is populated by the time this renders. +export function AdminRoute({ children }: AdminRouteProps) { + const { user } = useAuth(); + + if (!user?.is_admin) { + return ; + } + + return children; +} diff --git a/frontend/src/auth/authInterceptor.test.ts b/frontend/src/auth/authInterceptor.test.ts deleted file mode 100644 index de23385..0000000 --- a/frontend/src/auth/authInterceptor.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { setupAuthInterceptors } from './authInterceptor'; - -describe('setupAuthInterceptors', () => { - it('is a no-op when auth is handled by HttpOnly cookies', () => { - const cleanup = setupAuthInterceptors(); - - expect(typeof cleanup).toBe('function'); - expect(() => cleanup()).not.toThrow(); - }); -}); diff --git a/frontend/src/auth/authInterceptor.ts b/frontend/src/auth/authInterceptor.ts deleted file mode 100644 index 6a764c0..0000000 --- a/frontend/src/auth/authInterceptor.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function setupAuthInterceptors() { - return () => {}; -} diff --git a/frontend/src/components/Button.tsx b/frontend/src/components/Button.tsx index 0646a6f..21e4090 100644 --- a/frontend/src/components/Button.tsx +++ b/frontend/src/components/Button.tsx @@ -10,7 +10,7 @@ type ButtonProps = BaseButtonProps & ButtonHTMLAttributes; const getVariantClasses = (variant: 'default' | 'secondary' = 'default'): string => { const baseClass = - 'h-full w-full px-2 py-1 font-medium transition duration-100 ease-in disabled:cursor-not-allowed disabled:opacity-50'; + 'inline-flex items-center justify-center px-4 py-2 font-medium transition duration-100 ease-in disabled:cursor-not-allowed disabled:opacity-50'; if (variant === 'secondary') { return `${baseClass} bg-content text-content-inverse shadow-md hover:bg-content-muted disabled:hover:bg-content`; diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 8af3ee0..cbcd4cf 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,6 +1,5 @@ import { useState, useEffect, useRef } from 'react'; -import { Link, useLocation, Outlet, Navigate } from 'react-router-dom'; -import { useGetMe } from '../generated/anthoLumeAPIV1'; +import { Link, useLocation, Outlet } from 'react-router-dom'; import { useAuth } from '../auth/AuthContext'; import { UserIcon, DropdownIcon } from '../icons'; import { useTheme } from '../theme/ThemeProvider'; @@ -12,12 +11,8 @@ const themeModes: ThemeMode[] = ['light', 'dark', 'system']; export default function Layout() { const location = useLocation(); - const { isAuthenticated, user, logout, isCheckingAuth } = useAuth(); + const { user, logout } = useAuth(); const { themeMode, setThemeMode } = useTheme(); - const { data } = useGetMe(isAuthenticated ? {} : undefined); - const fetchedUser = - data?.status === 200 && data.data && 'username' in data.data ? data.data : null; - const userData = user ?? fetchedUser; const [isUserDropdownOpen, setIsUserDropdownOpen] = useState(false); const dropdownRef = useRef(null); @@ -45,14 +40,6 @@ export default function Layout() { document.title = `AnthoLume - ${currentPageTitle}`; }, [currentPageTitle]); - if (isCheckingAuth) { - return
Loading...
; - } - - if (!isAuthenticated) { - return ; - } - return (
@@ -135,7 +122,7 @@ export default function Layout() { onClick={() => setIsUserDropdownOpen(!isUserDropdownOpen)} className="flex cursor-pointer items-center gap-2 py-4 text-content-muted" > - {userData ? ('username' in userData ? userData.username : 'User') : 'User'} + {user?.username ?? 'User'} Promise; prevPage: () => Promise; goToHref: (href: string) => Promise; - setTheme: (theme: { - colorScheme?: ReaderColorScheme; - fontFamily?: ReaderFontFamily; - fontSize?: number; - }) => Promise; } export function useEpubReader({ @@ -210,17 +205,6 @@ export function useEpubReader({ await readerRef.current?.displayHref(href); }, []); - const setTheme = useCallback( - async (theme: { - colorScheme?: ReaderColorScheme; - fontFamily?: ReaderFontFamily; - fontSize?: number; - }) => { - await readerRef.current?.applyThemeChange(theme); - }, - [] - ); - return useMemo( () => ({ viewerRef: setViewerNode, @@ -232,8 +216,7 @@ export function useEpubReader({ nextPage, prevPage, goToHref, - setTheme, }), - [error, goToHref, isLoading, isReady, nextPage, prevPage, setTheme, stats, toc] + [error, goToHref, isLoading, isReady, nextPage, prevPage, stats, toc] ); } diff --git a/frontend/src/pages/AdminLogsPage.tsx b/frontend/src/pages/AdminLogsPage.tsx index fa59a49..238584d 100644 --- a/frontend/src/pages/AdminLogsPage.tsx +++ b/frontend/src/pages/AdminLogsPage.tsx @@ -35,11 +35,9 @@ export default function AdminLogsPage() { />
-
- -
+ diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index a2ccf48..a974218 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -3,6 +3,7 @@ import { LoadingState } from '../components'; import { useGetAdmin, usePostAdminAction } from '../generated/anthoLumeAPIV1'; import { Button } from '../components/Button'; import { useToasts } from '../components/ToastContext'; +import { useMutationWithToast } from '../hooks/useMutationWithToast'; import { getErrorMessage } from '../utils/errors'; import { streamResponseToFile, backupFilename } from '../utils/download'; @@ -15,6 +16,7 @@ export default function AdminPage() { const { isLoading } = useGetAdmin(); const postAdminAction = usePostAdminAction(); const { showInfo, showError, removeToast } = useToasts(); + const toastMutationOptions = useMutationWithToast(); const [backupTypes, setBackupTypes] = useState({ covers: false, @@ -61,6 +63,7 @@ export default function AdminPage() { e.preventDefault(); if (!restoreFile) return; + // Persistent progress toast - Restore is long-running, so we surface a 'started' toast that is dismissed on completion; the standard toast helper has no notion of a progress indicator. const startedToastId = showInfo('Restore started', 0); try { @@ -88,20 +91,20 @@ export default function AdminPage() { const handleMetadataMatch = () => { postAdminAction.mutate( { data: { action: 'METADATA_MATCH' } }, - { - onSuccess: () => showInfo('Metadata matching started'), - onError: error => showError('Metadata matching failed: ' + getErrorMessage(error)), - } + toastMutationOptions({ + success: 'Metadata matching started', + error: 'Failed to start metadata matching', + }) ); }; const handleCacheTables = () => { postAdminAction.mutate( { data: { action: 'CACHE_TABLES' } }, - { - onSuccess: () => showInfo('Cache tables started'), - onError: error => showError('Cache tables failed: ' + getErrorMessage(error)), - } + toastMutationOptions({ + success: 'Cache tables started', + error: 'Failed to start cache tables', + }) ); }; @@ -135,8 +138,8 @@ export default function AdminPage() { -
-
@@ -151,8 +154,8 @@ export default function AdminPage() { className="w-full" /> -
-
@@ -161,35 +164,21 @@ export default function AdminPage() {
-

Tasks

- - - - - - - - - - - -
-

Metadata Matching

-
-
- -
-
-

Cache Tables

-
-
- -
-
+

Tasks

+
    +
  • +

    Metadata Matching

    + +
  • +
  • +

    Cache Tables

    + +
  • +
); diff --git a/frontend/src/pages/DocumentPage.tsx b/frontend/src/pages/DocumentPage.tsx index e37c479..c8eac42 100644 --- a/frontend/src/pages/DocumentPage.tsx +++ b/frontend/src/pages/DocumentPage.tsx @@ -17,7 +17,7 @@ const iconButtonClassName = 'cursor-pointer text-content-muted hover:text-conten const popupClassName = 'rounded bg-surface-strong p-3 text-content shadow-lg transition-all duration-200'; const editInputClassName = - 'w-full rounded border border-secondary-200 bg-secondary-50 p-2 text-lg font-medium text-content focus:outline-hidden focus:ring-2 focus:ring-secondary-400 dark:border-secondary-700 dark:bg-secondary-900/20 dark:focus:ring-secondary-500'; + 'w-full rounded border border-border bg-surface-muted p-2 text-lg font-medium text-content focus:outline-hidden focus:ring-2 focus:ring-primary-600'; interface EditableFieldProps { label: string; @@ -92,7 +92,7 @@ function EditableField({