diff --git a/fileglancer/app.py b/fileglancer/app.py index b4e92bb4..cbacdb7d 100644 --- a/fileglancer/app.py +++ b/fileglancer/app.py @@ -212,11 +212,11 @@ def mask_password(url: str) -> str: # Initialize database (run migrations once at startup) db.initialize_database(settings.db_url) - # Mount static assets (CSS, JS, images) at /fg/assets + # Mount static assets (CSS, JS, images) at /assets assets_dir = ui_dir / "assets" if assets_dir.exists(): - app.mount("/fg/assets", StaticFiles(directory=str(assets_dir)), name="assets") - logger.debug(f"Mounted static assets at /fg/assets from {assets_dir}") + app.mount("/assets", StaticFiles(directory=str(assets_dir)), name="assets") + logger.debug(f"Mounted static assets at /assets from {assets_dir}") else: logger.warning(f"Assets directory not found at {assets_dir}") @@ -294,7 +294,7 @@ async def login(request: Request, next: Optional[str] = Query(None)): raise HTTPException(status_code=404, detail="OKTA authentication not enabled") # Store the next URL in the session for use after OAuth callback - if next and next.startswith("/fg/"): + if next and next.startswith("/"): request.session['next_url'] = next redirect_uri = str(settings.okta_redirect_uri) @@ -346,11 +346,11 @@ async def auth_callback(request: Request, response: Response): session_id = user_session.session_id # Get the next URL from session (stored during initial login redirect) - next_url = request.session.pop('next_url', '/fg/browse') + next_url = request.session.pop('next_url', '/browse') # Validate next_url to prevent open redirect vulnerabilities - if not next_url.startswith('/fg/'): - next_url = '/fg/browse' + if not next_url.startswith('/'): + next_url = '/browse' # Create redirect response redirect_response = RedirectResponse(url=next_url) @@ -408,7 +408,7 @@ async def cli_login(request: Request, session_id: str): username = user_session.username # Create redirect response to browse page - redirect_response = RedirectResponse(url="/fg/browse") + redirect_response = RedirectResponse(url="/browse") # Set session cookie auth.create_session_cookie(redirect_response, session_id, settings) @@ -1144,7 +1144,7 @@ async def simple_login_handler(request: Request, body: dict = Body(...)): # Parse JSON body username = body.get("username") - next_url = body.get("next", "/fg/browse") + next_url = body.get("next", "/browse") if not username or not username.strip(): raise HTTPException(status_code=400, detail="Username is required") @@ -1152,9 +1152,9 @@ async def simple_login_handler(request: Request, body: dict = Body(...)): username = username.strip() # Validate next_url to prevent open redirect vulnerabilities - # Only allow relative URLs that start with /fg/ - if not next_url.startswith("/fg/"): - next_url = "/fg/browse" + # Only allow relative URLs that start with / + if not next_url.startswith("/"): + next_url = "/browse" # Create session in database expires_at = datetime.now(UTC) + timedelta(hours=settings.session_expiry_hours) @@ -1182,19 +1182,15 @@ async def simple_login_handler(request: Request, body: dict = Body(...)): return response - # Home page - redirect to /fg - @app.get("/", include_in_schema=False) - async def home_page(): - """Redirect root to /fg""" - return RedirectResponse(url="/fg/") - - - # Serve SPA at /fg/* for client-side routing + # Serve SPA at /* for client-side routing # This must be the LAST route registered - @app.get("/fg/{full_path:path}", include_in_schema=False) - @app.get("/fg", include_in_schema=False) + @app.get("/{full_path:path}", include_in_schema=False) async def serve_spa(full_path: str = ""): - """Serve index.html for all SPA routes under /fg/ (client-side routing)""" + """Serve index.html for all SPA routes (client-side routing)""" + # Don't serve SPA for API or files paths - those should 404 if not found + if full_path and (full_path.startswith("api/") or full_path.startswith("files/")): + raise HTTPException(status_code=404, detail="Not found") + # append the full_path to the ui_dir and ensure it is within the ui_dir after resolving resolved_dir = os.path.normpath(ui_dir / full_path) # if the resolved_dir is outside of ui_dir, reject the request diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c703a119..e62dafa7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,12 +1,13 @@ import type { ReactNode } from 'react'; -import { BrowserRouter, Route, Routes } from 'react-router'; +import { useEffect } from 'react'; +import { BrowserRouter, Route, Routes, useNavigate } from 'react-router'; import { ErrorBoundary } from 'react-error-boundary'; import { AuthContextProvider, useAuthContext } from '@/contexts/AuthContext'; import { MainLayout } from './layouts/MainLayout'; import { BrowsePageLayout } from './layouts/BrowseLayout'; import { OtherPagesLayout } from './layouts/OtherPagesLayout'; -import Home from '@/components/Home'; +import Login from '@/components/Login'; import Browse from '@/components/Browse'; import Help from '@/components/Help'; import Jobs from '@/components/Jobs'; @@ -26,12 +27,12 @@ function RequireAuth({ children }: { readonly children: ReactNode }) { ); } - // If not authenticated, redirect to home page with the current URL as 'next' parameter + // If not authenticated, redirect to login page with the current URL as 'next' parameter if (!authStatus?.authenticated) { const currentPath = window.location.pathname + window.location.search + window.location.hash; const encodedNext = encodeURIComponent(currentPath); - window.location.href = `/fg/?next=${encodedNext}`; + window.location.href = `/login?next=${encodedNext}`; return (
Redirecting to login...
@@ -42,37 +43,53 @@ function RequireAuth({ children }: { readonly children: ReactNode }) { return children; } -function getBasename() { - const { pathname } = window.location; - // Try to match /user/:username/lab - const userLabMatch = pathname.match(/^\/user\/[^/]+\/fg/); - if (userLabMatch) { - // Return the matched part, e.g. "/user//lab" - return userLabMatch[0]; - } - // Otherwise, check if it starts with /lab - if (pathname.startsWith('/fg')) { - return '/fg'; - } - // Fallback to root if no match is found - return '/fg'; -} - /** - * React component for a counter. - * - * @returns The React component + * Root redirect component that handles smart routing based on auth status + * This component serves as a safe landing page after login to allow + * auth queries to update before navigating to protected routes */ +function RootRedirect() { + const { loading, authStatus } = useAuthContext(); + const navigate = useNavigate(); + + useEffect(() => { + if (loading) { + return; + } + + const urlParams = new URLSearchParams(window.location.search); + const nextUrl = urlParams.get('next'); + + if (authStatus?.authenticated) { + // User is authenticated - navigate to next URL or default to /browse + const destination = + nextUrl && nextUrl.startsWith('/') ? nextUrl : '/browse'; + navigate(destination, { replace: true }); + } else { + // User is not authenticated - redirect to login + const encodedNext = nextUrl ? `?next=${encodeURIComponent(nextUrl)}` : ''; + navigate(`/login${encodedNext}`, { replace: true }); + } + }, [loading, authStatus, navigate]); + + // Show loading state while determining where to route + return ( +
+
Loading...
+
+ ); +} + const AppComponent = () => { - const basename = getBasename(); const tasksEnabled = import.meta.env.VITE_ENABLE_TASKS === 'true'; return ( - + } path="/*"> }> - } index /> + } index /> + } path="login" /> diff --git a/frontend/src/components/Home.tsx b/frontend/src/components/Home.tsx deleted file mode 100644 index 23f50c41..00000000 --- a/frontend/src/components/Home.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import type { FormEvent } from 'react'; -import { Link } from 'react-router'; -import { - HiFolderOpen, - HiLink, - HiBriefcase, - HiCog, - HiQuestionMarkCircle, - HiLogin -} from 'react-icons/hi'; -import { useAuthContext } from '@/contexts/AuthContext'; -import { useSimpleLoginMutation } from '@/queries/authQueries'; - -export default function Home() { - const { authStatus, loading } = useAuthContext(); - const isAuthenticated = authStatus?.authenticated; - const isSimpleAuth = authStatus?.auth_method === 'simple'; - const simpleLoginMutation = useSimpleLoginMutation(); - - // Get the 'next' parameter from URL to redirect after login - const urlParams = new URLSearchParams(window.location.search); - const nextUrl = urlParams.get('next') || '/fg/browse'; - - const handleLogin = async (e: FormEvent) => { - e.preventDefault(); - const formData = new FormData(e.currentTarget); - const username = formData.get('username') as string; - - simpleLoginMutation.mutate( - { username, next: nextUrl }, - { - onSuccess: data => { - window.location.href = data.redirect || '/fg/browse'; - } - } - ); - }; - - if (loading) { - return
Loading...
; - } - - return ( -
-

- Welcome to Fileglancer -

-

- {isAuthenticated - ? 'Browse and manage your files with ease' - : 'A powerful file browser and management tool'} -

- - {isAuthenticated ? ( -
- - -
-

- Browse Files -

-

- Navigate through your file shares and directories -

-
- - - - -
-

- Shared Links -

-

- Manage your shared file links and proxied paths -

-
- - - - -
-

- Jobs & Tickets -

-

- View and manage your support tickets -

-
- - - - -
-

- Preferences -

-

- Customize your Fileglancer settings -

-
- -
- ) : ( -
- - -
-

- Help & Documentation -

-

- Learn more about Fileglancer and how to use it -

-
- - - {isSimpleAuth ? ( -
-

- Log In -

-

- Enter your username to access your files -

-
-
- - -
- {simpleLoginMutation.error ? ( -
- {simpleLoginMutation.error.message} -
- ) : null} - -
-
- ) : ( -
-
- -
-

- Log In with OKTA -

-

- Sign in to access your files and manage settings -

-
-
- - Log In - -
- )} -
- )} -
- ); -} diff --git a/frontend/src/components/Login.tsx b/frontend/src/components/Login.tsx new file mode 100644 index 00000000..17446407 --- /dev/null +++ b/frontend/src/components/Login.tsx @@ -0,0 +1,136 @@ +import type { FormEvent } from 'react'; +import { Link, useNavigate } from 'react-router'; +import { HiQuestionMarkCircle, HiLogin } from 'react-icons/hi'; +import { useAuthContext } from '@/contexts/AuthContext'; +import { useSimpleLoginMutation } from '@/queries/authQueries'; +import { useEffect } from 'react'; + +export default function Login() { + const { authStatus, loading } = useAuthContext(); + const navigate = useNavigate(); + const isAuthenticated = authStatus?.authenticated; + const isSimpleAuth = authStatus?.auth_method === 'simple'; + const simpleLoginMutation = useSimpleLoginMutation(); + + // Get the 'next' parameter from URL to redirect after login + const urlParams = new URLSearchParams(window.location.search); + const nextUrl = urlParams.get('next') || '/browse'; + + // If already authenticated, redirect to browse or next URL + useEffect(() => { + if (!loading && isAuthenticated) { + navigate(nextUrl, { replace: true }); + } + }, [loading, isAuthenticated, nextUrl, navigate]); + + const handleLogin = async (e: FormEvent) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + const username = formData.get('username') as string; + + simpleLoginMutation.mutate( + { username, next: nextUrl }, + { + onSuccess: data => { + // Redirect to root with next parameter + // Root component will handle final navigation after auth updates + const destination = + data.redirect || `/?next=${encodeURIComponent(nextUrl)}`; + window.location.href = destination; + } + } + ); + }; + + if (loading) { + return
Loading...
; + } + + return ( +
+

+ Welcome to Fileglancer +

+

+ A powerful file browser and management tool +

+ +
+ + +
+

+ Help & Documentation +

+

+ Learn more about Fileglancer and how to use it +

+
+ + + {isSimpleAuth ? ( +
+

Log In

+

+ Enter your username to access your files +

+
+
+ + +
+ {simpleLoginMutation.error ? ( +
+ {simpleLoginMutation.error.message} +
+ ) : null} + +
+
+ ) : ( +
+
+ +
+

+ Log In with OKTA +

+

+ Sign in to access your files and manage settings +

+
+
+ + Log In + +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/ui/Navbar/ProfileMenu.tsx b/frontend/src/components/ui/Navbar/ProfileMenu.tsx index 31802388..2bff1555 100644 --- a/frontend/src/components/ui/Navbar/ProfileMenu.tsx +++ b/frontend/src/components/ui/Navbar/ProfileMenu.tsx @@ -21,7 +21,7 @@ export default function ProfileMenu() { const isAuthenticated = authStatus?.authenticated; const loginUrl = - authStatus?.auth_method === 'okta' ? '/api/auth/login' : '/fg/'; + authStatus?.auth_method === 'okta' ? '/api/auth/login' : '/login'; return ( diff --git a/frontend/ui-tests/fixtures/fileglancer-fixture.ts b/frontend/ui-tests/fixtures/fileglancer-fixture.ts index e251c85c..d2e783d9 100644 --- a/frontend/ui-tests/fixtures/fileglancer-fixture.ts +++ b/frontend/ui-tests/fixtures/fileglancer-fixture.ts @@ -16,7 +16,7 @@ export type FileglancerFixtures = { const openFileglancer = async (page: Page) => { // Navigate directly to Fileglancer standalone app - await page.goto('/fg/', { + await page.goto('/', { waitUntil: 'domcontentloaded' }); // Wait for the app to be ready diff --git a/frontend/ui-tests/playwright.config.js b/frontend/ui-tests/playwright.config.js index f83ed0a9..41278871 100644 --- a/frontend/ui-tests/playwright.config.js +++ b/frontend/ui-tests/playwright.config.js @@ -39,12 +39,14 @@ export default defineConfig({ workers: 1, webServer: { command: 'pixi run test-launch', - url: 'http://localhost:7879/fg/', + url: 'http://localhost:7879/', timeout: 120_000, env: { FGC_DB_URL: `sqlite:///${testDbPath}`, FGC_FILE_SHARE_MOUNTS: JSON.stringify([scratchDir]), - FGC_EXTERNAL_PROXY_URL: 'http://testURL/files' + FGC_EXTERNAL_PROXY_URL: 'http://testURL/files', + FGC_USE_ACCESS_FLAGS: false, + FGC_ENABLE_OKTA_AUTH: false } } }); diff --git a/frontend/ui-tests/tests/data-link-operations.spec.ts b/frontend/ui-tests/tests/data-link-operations.spec.ts index 80516568..e81bd560 100644 --- a/frontend/ui-tests/tests/data-link-operations.spec.ts +++ b/frontend/ui-tests/tests/data-link-operations.spec.ts @@ -7,7 +7,7 @@ const navigateToZarrDir = async ( testDir: string, zarrDirName: string ) => { - await page.goto('/fg/browse', { + await page.goto('/browse', { waitUntil: 'domcontentloaded' }); await navigateToScratchFsp(page); diff --git a/frontend/ui-tests/tests/data-link-table-filtering.spec.ts b/frontend/ui-tests/tests/data-link-table-filtering.spec.ts index 4e3f8009..bd755cb9 100644 --- a/frontend/ui-tests/tests/data-link-table-filtering.spec.ts +++ b/frontend/ui-tests/tests/data-link-table-filtering.spec.ts @@ -88,7 +88,7 @@ test.describe('Data Link Table Filtering', () => { await test.step('Create a real data link', async () => { // Navigate to a zarr directory - await page.goto('/fg/browse', { + await page.goto('/browse', { waitUntil: 'domcontentloaded' }); await navigateToScratchFsp(page); diff --git a/frontend/ui-tests/tests/fgzones.spec.ts b/frontend/ui-tests/tests/fgzones.spec.ts index 8162f767..8fe5532f 100644 --- a/frontend/ui-tests/tests/fgzones.spec.ts +++ b/frontend/ui-tests/tests/fgzones.spec.ts @@ -18,7 +18,7 @@ test('favorite list persists after reloading page', async ({ await expect(localFavorite).toBeVisible(); // Reload page to verify favorites persist - await page.goto('/fg/browse', { + await page.goto('/browse', { waitUntil: 'domcontentloaded' }); await expect(localFavorite).toBeVisible(); diff --git a/frontend/ui-tests/tests/navigation-input.spec.ts b/frontend/ui-tests/tests/navigation-input.spec.ts index db40c9f5..7beb51f3 100644 --- a/frontend/ui-tests/tests/navigation-input.spec.ts +++ b/frontend/ui-tests/tests/navigation-input.spec.ts @@ -4,7 +4,7 @@ import { ZARR_TEST_FILE_INFO } from '../mocks/zarrDirs'; test.describe('Navigation Input', () => { test.beforeEach('Navigate to browse', async ({ fileglancerPage: page }) => { - await page.goto('/fg/browse', { + await page.goto('/browse', { waitUntil: 'domcontentloaded' }); }); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 182aa953..3673f7d4 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -5,7 +5,7 @@ import { nodePolyfills } from 'vite-plugin-node-polyfills'; // https://vite.dev/config/ export default defineConfig({ - base: '/fg/', + base: '/', plugins: [react(), nodePolyfills({ include: ['path'] })], resolve: { alias: { diff --git a/tests/conftest.py b/tests/conftest.py index 868b3206..f7dd4dd8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,4 +6,5 @@ def pytest_sessionstart(session): Called after the Session object has been created and before performing collection and entering the run test loop. """ - os.environ['FGC_EXTERNAL_PROXY_URL'] = 'http://localhost/files' \ No newline at end of file + os.environ['FGC_EXTERNAL_PROXY_URL'] = 'http://localhost/files' + os.environ['FGC_USE_ACCESS_FLAGS'] = 'false' \ No newline at end of file diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index 1ad84376..4be6ab55 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -124,24 +124,22 @@ def test_version_endpoint(test_client): def test_root_endpoint(test_client): - """Test root endpoint - should redirect to /fg/""" + """Test root endpoint - should serve SPA index.html""" response = test_client.get("/", follow_redirects=False) - assert response.status_code == 307 # Temporary redirect - assert response.headers.get('location') == '/fg/' + assert response.status_code == 200 + assert 'text/html' in response.headers.get('content-type', '') -def test_fg_endpoint(test_client): - """Test /fg/ endpoint - should serve SPA index.html""" - response = test_client.get("/fg/", follow_redirects=False) +def test_spa_routing(test_client): + """Test /browse and other SPA routes - should serve SPA index.html""" + response = test_client.get("/browse", follow_redirects=False) assert response.status_code == 200 assert 'text/html' in response.headers.get('content-type', '') -def test_fg_spa_routing(test_client): - """Test /fg/browse and other SPA routes - should serve SPA index.html""" - response = test_client.get("/fg/browse", follow_redirects=False) + response = test_client.get("/browse/some/path", follow_redirects=False) assert response.status_code == 200 assert 'text/html' in response.headers.get('content-type', '') - response = test_client.get("/fg/browse/some/path", follow_redirects=False) + response = test_client.get("/login", follow_redirects=False) assert response.status_code == 200 assert 'text/html' in response.headers.get('content-type', '')