Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions apps/web/src/app/application-access-boundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,16 @@ type ApplicationAccessBoundaryProps = {
export function ApplicationAccessBoundary({
children,
}: ApplicationAccessBoundaryProps) {
const currentUser = useCurrentUser();

if (isGuestSession()) {
return children({ kind: 'guest' });
}

return <RegisteredUserAccess>{children}</RegisteredUserAccess>;
}

function RegisteredUserAccess({ children }: ApplicationAccessBoundaryProps) {
const currentUser = useCurrentUser();

if (currentUser.authenticationStatus === 'loading') {
return <AccessStatus message="Checking your session…" />;
}
Expand Down
55 changes: 52 additions & 3 deletions apps/web/src/app/application-shell.test.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,39 @@
// 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', () => {
it('renders responsive navigation and exits guest mode', async () => {
const user = userEvent.setup();
await router.navigate({ to: '/dashboard' });
startGuestSession();
const queryClient = new QueryClient();
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
Expand Down Expand Up @@ -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: () => (
<ApplicationShell
identity={{ kind: 'user', user: publicUser }}
onLogout={onLogout}
>
<h1>Dashboard</h1>
</ApplicationShell>
),
});
const testRouter = createRouter({
history: createMemoryHistory({ initialEntries: ['/dashboard'] }),
routeTree: rootRoute.addChildren([dashboardRoute]),
});

render(<RouterProvider router={testRouter} />);

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();
});
});
17 changes: 12 additions & 5 deletions apps/web/src/app/application-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,14 @@ type ApplicationShellProps = {
children: ReactNode;
identity: ApplicationIdentity;
onExitGuest?: () => void;
onLogout?: () => void;
};

export function ApplicationShell({
children,
identity,
onExitGuest,
onLogout,
}: ApplicationShellProps) {
return (
<div
Expand All @@ -47,6 +49,7 @@ export function ApplicationShell({
className="border-t px-5 py-5"
identity={identity}
onExitGuest={onExitGuest}
onLogout={onLogout}
/>
</aside>

Expand All @@ -64,6 +67,7 @@ export function ApplicationShell({
compact
identity={identity}
onExitGuest={onExitGuest}
onLogout={onLogout}
/>
</header>
<main
Expand Down Expand Up @@ -94,13 +98,15 @@ type IdentitySummaryProps = {
className?: string;
compact?: boolean;
onExitGuest?: () => void;
onLogout?: () => void;
};

function IdentitySummary({
className,
compact = false,
identity,
onExitGuest,
onLogout,
}: IdentitySummaryProps) {
const [isMenuOpen, setIsMenuOpen] = useState(false);

Expand All @@ -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 (
<section
Expand Down Expand Up @@ -151,7 +158,7 @@ function IdentitySummary({
</div>
</button>

{shouldShowGuestMenu && (
{shouldShowIdentityMenu && (
<div
className={cn(
'absolute z-40 min-w-44 rounded-md border bg-surface p-1 shadow-lg',
Expand All @@ -167,12 +174,12 @@ function IdentitySummary({
'transition-colors hover:bg-accent',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
)}
onClick={onExitGuest}
onClick={menuAction}
role="menuitem"
type="button"
>
<LogOut aria-hidden="true" className="size-4" />
<span>Exit guest</span>
<span>{menuLabel}</span>
</button>
</div>
)}
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/routes/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,33 @@ 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,
});

function ProtectedApplicationLayout() {
const navigate = useNavigate();
const logoutMutation = useLogoutMutation();

function exitGuest() {
clearGuestSession();
void navigate({ to: '/', replace: true });
}

function logoutUser() {
logoutMutation.mutate();
}

return (
<ApplicationAccessBoundary>
{(identity) => (
<ApplicationIdentityContext.Provider value={identity}>
<ApplicationShell
identity={identity}
onExitGuest={identity.kind === 'guest' ? exitGuest : undefined}
onLogout={identity.kind === 'user' ? logoutUser : undefined}
>
<Outlet />
</ApplicationShell>
Expand Down