diff --git a/apps/web/src/app/application-access-boundary.tsx b/apps/web/src/app/application-access-boundary.tsx index 40d6b2e..5b802a1 100644 --- a/apps/web/src/app/application-access-boundary.tsx +++ b/apps/web/src/app/application-access-boundary.tsx @@ -14,12 +14,16 @@ type ApplicationAccessBoundaryProps = { export function ApplicationAccessBoundary({ children, }: ApplicationAccessBoundaryProps) { - const currentUser = useCurrentUser(); - if (isGuestSession()) { return children({ kind: 'guest' }); } + return {children}; +} + +function RegisteredUserAccess({ children }: ApplicationAccessBoundaryProps) { + const currentUser = useCurrentUser(); + if (currentUser.authenticationStatus === 'loading') { return ; } diff --git a/apps/web/src/app/application-shell.test.tsx b/apps/web/src/app/application-shell.test.tsx index 8778db2..dd64b05 100644 --- a/apps/web/src/app/application-shell.test.tsx +++ b/apps/web/src/app/application-shell.test.tsx @@ -1,19 +1,29 @@ // Verifies the application shell exposes accessible responsive navigation and active state. import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { RouterProvider } from '@tanstack/react-router'; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from '@tanstack/react-router'; import { cleanup, render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { isGuestSession, startGuestSession, } from '../features/auth/guest-session'; import { router } from '../router'; +import { createPublicUser } from '../test/fixtures/public-user.fixture'; +import { ApplicationShell } from './application-shell'; afterEach(() => { cleanup(); sessionStorage.clear(); + vi.unstubAllGlobals(); }); describe('application shell', () => { @@ -21,7 +31,9 @@ describe('application shell', () => { const user = userEvent.setup(); await router.navigate({ to: '/dashboard' }); startGuestSession(); - const queryClient = new QueryClient(); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); render( @@ -74,4 +86,41 @@ describe('application shell', () => { await screen.findByRole('heading', { name: 'Train with purpose.' }), ).toBeVisible(); }); + + it('exposes registered sign out from the profile menu', async () => { + const user = userEvent.setup(); + const publicUser = createPublicUser(); + const onLogout = vi.fn(); + const rootRoute = createRootRoute({ component: Outlet }); + const dashboardRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/dashboard', + component: () => ( + +

Dashboard

+
+ ), + }); + const testRouter = createRouter({ + history: createMemoryHistory({ initialEntries: ['/dashboard'] }), + routeTree: rootRoute.addChildren([dashboardRoute]), + }); + + render(); + + expect( + await screen.findByRole('heading', { name: 'Dashboard' }), + ).toBeVisible(); + + const profileMenus = screen.getAllByRole('button', { + name: 'Open profile menu', + }); + await user.click(profileMenus[0]!); + await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); + + expect(onLogout).toHaveBeenCalledOnce(); + }); }); diff --git a/apps/web/src/app/application-shell.tsx b/apps/web/src/app/application-shell.tsx index 045d524..1b3f1fc 100644 --- a/apps/web/src/app/application-shell.tsx +++ b/apps/web/src/app/application-shell.tsx @@ -26,12 +26,14 @@ type ApplicationShellProps = { children: ReactNode; identity: ApplicationIdentity; onExitGuest?: () => void; + onLogout?: () => void; }; export function ApplicationShell({ children, identity, onExitGuest, + onLogout, }: ApplicationShellProps) { return (
@@ -64,6 +67,7 @@ export function ApplicationShell({ compact identity={identity} onExitGuest={onExitGuest} + onLogout={onLogout} />
void; + onLogout?: () => void; }; function IdentitySummary({ @@ -101,6 +106,7 @@ function IdentitySummary({ compact = false, identity, onExitGuest, + onLogout, }: IdentitySummaryProps) { const [isMenuOpen, setIsMenuOpen] = useState(false); @@ -111,8 +117,9 @@ function IdentitySummary({ const detail = isGuest ? 'Progress is temporary' : identity.user.email; const avatarUrl = isGuest ? null : identity.user.avatarUrl; - const shouldShowGuestMenu = - isMenuOpen && isGuest && onExitGuest !== undefined; + const menuAction = isGuest ? onExitGuest : onLogout; + const menuLabel = isGuest ? 'Exit guest' : 'Sign out'; + const shouldShowIdentityMenu = isMenuOpen && menuAction !== undefined; return (
- {shouldShowGuestMenu && ( + {shouldShowIdentityMenu && (
)} diff --git a/apps/web/src/routes/_app.tsx b/apps/web/src/routes/_app.tsx index 7fa9bc4..036050d 100644 --- a/apps/web/src/routes/_app.tsx +++ b/apps/web/src/routes/_app.tsx @@ -5,6 +5,7 @@ import { ApplicationAccessBoundary } from '../app/application-access-boundary'; import { ApplicationIdentityContext } from '../app/application-identity'; import { ApplicationShell } from '../app/application-shell'; import { clearGuestSession } from '../features/auth/guest-session'; +import { useLogoutMutation } from '../features/auth/logout.mutation'; export const Route = createFileRoute('/_app')({ component: ProtectedApplicationLayout, @@ -12,12 +13,17 @@ export const Route = createFileRoute('/_app')({ function ProtectedApplicationLayout() { const navigate = useNavigate(); + const logoutMutation = useLogoutMutation(); function exitGuest() { clearGuestSession(); void navigate({ to: '/', replace: true }); } + function logoutUser() { + logoutMutation.mutate(); + } + return ( {(identity) => ( @@ -25,6 +31,7 @@ function ProtectedApplicationLayout() {