diff --git a/src/App.tsx b/src/App.tsx index 5c462d6d..8c67093a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ -import React, { useEffect, useState, useCallback, useRef, lazy, Suspense } from 'react'; +import React, { useEffect, useState, useCallback, useRef, Suspense } from 'react'; import { showToast } from './utils/toast'; -import { Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom'; +import { useNavigate, useLocation } from 'react-router-dom'; import { listen } from '@tauri-apps/api/event'; import { invoke } from '@tauri-apps/api/core'; import { getCurrentWindow } from '@tauri-apps/api/window'; @@ -9,46 +9,11 @@ import { useTranslation } from 'react-i18next'; import Sidebar from './components/Sidebar'; import PlayerBar from './components/PlayerBar'; import BottomNav from './components/BottomNav'; -import MobilePlayerView from './components/MobilePlayerView'; import { useIsMobile } from './hooks/useIsMobile'; import LiveSearch from './components/LiveSearch'; import NowPlayingDropdown from './components/NowPlayingDropdown'; import QueuePanel from './components/QueuePanel'; - -// Route-level lazy loading: keeps the non-page graph (shell, player, stores) in -// the entry chunk; each page is fetched when its route is first visited. -const Home = lazy(() => import('./pages/Home')); -const Albums = lazy(() => import('./pages/Albums')); -const Artists = lazy(() => import('./pages/Artists')); -const ArtistDetail = lazy(() => import('./pages/ArtistDetail')); -const Composers = lazy(() => import('./pages/Composers')); -const ComposerDetail = lazy(() => import('./pages/ComposerDetail')); -const NewReleases = lazy(() => import('./pages/NewReleases')); -const Favorites = lazy(() => import('./pages/Favorites')); -const RandomMix = lazy(() => import('./pages/RandomMix')); -const RandomLanding = lazy(() => import('./pages/RandomLanding')); -const AlbumDetail = lazy(() => import('./pages/AlbumDetail')); -const MostPlayed = lazy(() => import('./pages/MostPlayed')); -const LosslessAlbums = lazy(() => import('./pages/LosslessAlbums')); -const RandomAlbums = lazy(() => import('./pages/RandomAlbums')); -const LuckyMixPage = lazy(() => import('./pages/LuckyMix')); -const SearchResults = lazy(() => import('./pages/SearchResults')); -const Playlists = lazy(() => import('./pages/Playlists')); -const PlaylistDetail = lazy(() => import('./pages/PlaylistDetail')); -const NowPlayingPage = lazy(() => import('./pages/NowPlaying')); -const Tracks = lazy(() => import('./pages/Tracks')); -const Settings = lazy(() => import('./pages/Settings')); -const Statistics = lazy(() => import('./pages/Statistics')); -const Help = lazy(() => import('./pages/Help')); -const WhatsNew = lazy(() => import('./pages/WhatsNew')); -const DeviceSync = lazy(() => import('./pages/DeviceSync')); -const OfflineLibrary = lazy(() => import('./pages/OfflineLibrary')); -const LabelAlbums = lazy(() => import('./pages/LabelAlbums')); -const AdvancedSearch = lazy(() => import('./pages/AdvancedSearch')); -const FolderBrowser = lazy(() => import('./pages/FolderBrowser')); -const InternetRadio = lazy(() => import('./pages/InternetRadio')); -const Genres = lazy(() => import('./pages/Genres')); -const GenreDetail = lazy(() => import('./pages/GenreDetail')); +import AppRoutes from './app/AppRoutes'; import FullscreenPlayer from './components/FullscreenPlayer'; import ContextMenu from './components/ContextMenu'; import SongInfoModal from './components/SongInfoModal'; @@ -71,7 +36,6 @@ import { useOrbitGuest } from './hooks/useOrbitGuest'; import { cleanupOrphanedOrbitPlaylists, endOrbitSession, leaveOrbitSession } from './utils/orbit'; import { useOrbitStore } from './store/orbitStore'; import { IS_LINUX, IS_MACOS, IS_WINDOWS } from './utils/platform'; -import { version } from '../package.json'; import { useConnectionStatus } from './hooks/useConnectionStatus'; import { useAuthStore } from './store/authStore'; import { @@ -86,7 +50,6 @@ import { switchActiveServer } from './utils/switchActiveServer'; import { usePlayerStore, getPlaybackProgressSnapshot, - initAudioListeners, songToTrack, shuffleArray, flushPlayQueuePosition, @@ -126,12 +89,6 @@ function persistSidebarCollapsed(collapsed: boolean): void { } } -export function RequireAuth({ children }: { children: React.ReactNode }) { - const { isLoggedIn, servers, activeServerId } = useAuthStore(); - if (!isLoggedIn || !activeServerId || servers.length === 0) return ; - return <>{children}; -} - /** * Avoid grabbing the queue resizer when aiming at the main overlay scrollbar. * Uses the real main viewport edge (not innerWidth − queueWidth — sidebar/zoom skew that). @@ -679,40 +636,7 @@ export function AppShell() { {perfFlags.disableMainRouteContentMount ? (
) : ( - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - : } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + )} diff --git a/src/app/AppRoutes.tsx b/src/app/AppRoutes.tsx new file mode 100644 index 00000000..074267de --- /dev/null +++ b/src/app/AppRoutes.tsx @@ -0,0 +1,85 @@ +import { lazy } from 'react'; +import { Route, Routes } from 'react-router-dom'; +import MobilePlayerView from '../components/MobilePlayerView'; +import { useIsMobile } from '../hooks/useIsMobile'; + +// Route-level lazy loading: keeps the non-page graph (shell, player, stores) in +// the entry chunk; each page is fetched when its route is first visited. +const Home = lazy(() => import('../pages/Home')); +const Albums = lazy(() => import('../pages/Albums')); +const Artists = lazy(() => import('../pages/Artists')); +const ArtistDetail = lazy(() => import('../pages/ArtistDetail')); +const Composers = lazy(() => import('../pages/Composers')); +const ComposerDetail = lazy(() => import('../pages/ComposerDetail')); +const NewReleases = lazy(() => import('../pages/NewReleases')); +const Favorites = lazy(() => import('../pages/Favorites')); +const RandomMix = lazy(() => import('../pages/RandomMix')); +const RandomLanding = lazy(() => import('../pages/RandomLanding')); +const AlbumDetail = lazy(() => import('../pages/AlbumDetail')); +const MostPlayed = lazy(() => import('../pages/MostPlayed')); +const LosslessAlbums = lazy(() => import('../pages/LosslessAlbums')); +const RandomAlbums = lazy(() => import('../pages/RandomAlbums')); +const LuckyMixPage = lazy(() => import('../pages/LuckyMix')); +const SearchResults = lazy(() => import('../pages/SearchResults')); +const Playlists = lazy(() => import('../pages/Playlists')); +const PlaylistDetail = lazy(() => import('../pages/PlaylistDetail')); +const NowPlayingPage = lazy(() => import('../pages/NowPlaying')); +const Tracks = lazy(() => import('../pages/Tracks')); +const Settings = lazy(() => import('../pages/Settings')); +const Statistics = lazy(() => import('../pages/Statistics')); +const Help = lazy(() => import('../pages/Help')); +const WhatsNew = lazy(() => import('../pages/WhatsNew')); +const DeviceSync = lazy(() => import('../pages/DeviceSync')); +const OfflineLibrary = lazy(() => import('../pages/OfflineLibrary')); +const LabelAlbums = lazy(() => import('../pages/LabelAlbums')); +const AdvancedSearch = lazy(() => import('../pages/AdvancedSearch')); +const FolderBrowser = lazy(() => import('../pages/FolderBrowser')); +const InternetRadio = lazy(() => import('../pages/InternetRadio')); +const Genres = lazy(() => import('../pages/Genres')); +const GenreDetail = lazy(() => import('../pages/GenreDetail')); + +/** + * The main application route table. Rendered inside `AppShell`'s scroll + * viewport. `/now-playing` swaps to the mobile player view on narrow widths; + * `MobilePlayerView` is intentionally not lazy because the mobile breakpoint + * is detected synchronously and the layout swap should be flicker-free. + */ +export default function AppRoutes() { + const isMobile = useIsMobile(); + return ( + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + : } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + ); +} diff --git a/src/app/MainApp.tsx b/src/app/MainApp.tsx index 7c785d24..cb534a45 100644 --- a/src/app/MainApp.tsx +++ b/src/app/MainApp.tsx @@ -14,7 +14,8 @@ import { initAudioListeners } from '../store/playerStore'; import { initHotCachePrefetch } from '../hotCachePrefetch'; import { initMiniPlayerBridgeOnMain } from '../utils/miniPlayerBridge'; import { IS_WINDOWS } from '../utils/platform'; -import { AppShell, RequireAuth, TauriEventBridge } from '../App'; +import { AppShell, TauriEventBridge } from '../App'; +import RequireAuth from './RequireAuth'; const Login = lazy(() => import('../pages/Login')); diff --git a/src/app/RequireAuth.test.tsx b/src/app/RequireAuth.test.tsx new file mode 100644 index 00000000..5bee93fe --- /dev/null +++ b/src/app/RequireAuth.test.tsx @@ -0,0 +1,77 @@ +/** + * Route guard for the main webview. Verifies that each auth-store precondition + * (logged-in flag, an active server id, at least one stored server) is enforced + * independently before the children render. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; + +const { authState } = vi.hoisted(() => ({ + authState: { + isLoggedIn: true, + activeServerId: 'srv-1', + servers: [{ id: 'srv-1', name: 'home' }], + }, +})); + +vi.mock('../store/authStore', () => ({ + useAuthStore: () => authState, +})); + +vi.mock('react-router-dom', () => ({ + Navigate: ({ to }: { to: string }) =>
, +})); + +import { RequireAuth } from './RequireAuth'; + +afterEach(() => { + cleanup(); + authState.isLoggedIn = true; + authState.activeServerId = 'srv-1'; + authState.servers = [{ id: 'srv-1', name: 'home' }]; +}); + +describe('RequireAuth', () => { + it('renders children when fully authenticated', () => { + const { getByTestId, queryByTestId } = render( + +
+ , + ); + expect(getByTestId('protected')).toBeTruthy(); + expect(queryByTestId('redirect')).toBeNull(); + }); + + it('redirects to /login when not logged in', () => { + authState.isLoggedIn = false; + const { getByTestId, queryByTestId } = render( + +
+ , + ); + expect(queryByTestId('protected')).toBeNull(); + expect(getByTestId('redirect').getAttribute('data-to')).toBe('/login'); + }); + + it('redirects to /login when no active server id is set', () => { + authState.activeServerId = null as unknown as string; + const { queryByTestId, getByTestId } = render( + +
+ , + ); + expect(queryByTestId('protected')).toBeNull(); + expect(getByTestId('redirect').getAttribute('data-to')).toBe('/login'); + }); + + it('redirects to /login when the server list is empty', () => { + authState.servers = []; + const { queryByTestId, getByTestId } = render( + +
+ , + ); + expect(queryByTestId('protected')).toBeNull(); + expect(getByTestId('redirect').getAttribute('data-to')).toBe('/login'); + }); +}); diff --git a/src/app/RequireAuth.tsx b/src/app/RequireAuth.tsx new file mode 100644 index 00000000..986ffe55 --- /dev/null +++ b/src/app/RequireAuth.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { Navigate } from 'react-router-dom'; +import { useAuthStore } from '../store/authStore'; + +/** + * Route guard for everything under `/*`. Redirects to `/login` until the user + * has at least one configured server and is marked logged-in. Kept as its own + * file so MainApp can wrap `` without pulling the rest of App.tsx + * through a re-export. + */ +export function RequireAuth({ children }: { children: React.ReactNode }) { + const { isLoggedIn, servers, activeServerId } = useAuthStore(); + if (!isLoggedIn || !activeServerId || servers.length === 0) return ; + return <>{children}; +} + +export default RequireAuth;