diff --git a/AGENTS.md b/AGENTS.md index c8ebca3..d0e1082 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -251,6 +251,9 @@ Do not bypass type errors using assertions simply to make the compiler pass. Prefer inference when the resulting type remains clear. +In object type and interface declarations, list required properties before +optional properties. + Avoid duplicating types that can safely be inferred from Zod schemas or other authoritative definitions. diff --git a/apps/web/src/features/auth/current-user.query.ts b/apps/web/src/features/auth/current-user.query.ts index 62aab22..1516bed 100644 --- a/apps/web/src/features/auth/current-user.query.ts +++ b/apps/web/src/features/auth/current-user.query.ts @@ -11,24 +11,36 @@ export const currentUserQueryOptions = queryOptions({ retry: false, }); -export type AuthenticationStatus = - 'loading' | 'anonymous' | 'authenticated' | 'error'; - export function useCurrentUser() { const query = useQuery(currentUserQueryOptions); - let authenticationStatus: AuthenticationStatus = 'anonymous'; if (query.isPending) { - authenticationStatus = 'loading'; - } else if (query.isError) { - authenticationStatus = 'error'; - } else if (query.data.user) { - authenticationStatus = 'authenticated'; + return { + ...query, + authenticationStatus: 'loading' as const, + user: null, + }; + } + + if (query.isError) { + return { + ...query, + authenticationStatus: 'error' as const, + user: null, + }; + } + + if (!query.data.user) { + return { + ...query, + authenticationStatus: 'anonymous' as const, + user: null, + }; } return { ...query, - authenticationStatus, - user: query.data?.user ?? null, + authenticationStatus: 'authenticated' as const, + user: query.data.user, }; } diff --git a/apps/web/src/features/dashboard/application-shell.test.tsx b/apps/web/src/features/dashboard/application-shell.test.tsx index 3395336..6247f80 100644 --- a/apps/web/src/features/dashboard/application-shell.test.tsx +++ b/apps/web/src/features/dashboard/application-shell.test.tsx @@ -24,6 +24,8 @@ describe('application shell', () => { ); expect(screen.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); + expect(screen.getAllByText('Guest')).toHaveLength(2); + expect(screen.getAllByText('Progress is temporary')).toHaveLength(2); const navigationRegions = screen.getAllByRole('navigation', { name: /primary navigation/i, diff --git a/apps/web/src/features/dashboard/application-shell.tsx b/apps/web/src/features/dashboard/application-shell.tsx index bcb5d02..b3f476a 100644 --- a/apps/web/src/features/dashboard/application-shell.tsx +++ b/apps/web/src/features/dashboard/application-shell.tsx @@ -4,6 +4,7 @@ import { History, LayoutDashboard, Settings, + UserRound, type LucideIcon, } from 'lucide-react'; import { Link } from '@tanstack/react-router'; @@ -11,6 +12,7 @@ import type { ReactNode } from 'react'; import wordmarkUrl from '../../assets/goforlift-wordmark.png'; import { cn } from '../../lib/utils'; +import type { ApplicationIdentity } from './dashboard-access-boundary'; const navigationItems = [ { href: '/dashboard', icon: LayoutDashboard, label: 'Dashboard' }, @@ -21,9 +23,13 @@ const navigationItems = [ type ApplicationShellProps = { children: ReactNode; + identity: ApplicationIdentity; }; -export function ApplicationShell({ children }: ApplicationShellProps) { +export function ApplicationShell({ + children, + identity, +}: ApplicationShellProps) { return (
+
@@ -45,6 +52,11 @@ export function ApplicationShell({ children }: ApplicationShellProps) { )} > +
+ {avatarUrl ? ( + + ) : ( + + + )} +
+

{label}

+

+ {detail} +

+
+ + ); +} + function Brand({ className }: { className?: string }) { return ( { cleanup(); @@ -34,6 +37,17 @@ describe('dashboard access boundary', () => { expect( await screen.findByRole('heading', { name: 'Dashboard' }), ).toBeVisible(); + expect(screen.getAllByText('Go For Lifter')).toHaveLength(2); + expect(screen.getAllByText('lifter@example.com')).toHaveLength(2); + + for (const identity of screen.getAllByRole('region', { + name: 'Current identity', + })) { + expect(identity.querySelector('img')).toHaveAttribute( + 'src', + publicUser.avatarUrl, + ); + } }); it('allows an active guest without waiting for the API', async () => { @@ -47,6 +61,8 @@ describe('dashboard access boundary', () => { expect( await screen.findByRole('heading', { name: 'Dashboard' }), ).toBeVisible(); + expect(screen.getAllByText('Guest')).toHaveLength(2); + expect(screen.getAllByText('Progress is temporary')).toHaveLength(2); }); it('redirects an anonymous visitor to the welcome page', async () => { @@ -97,7 +113,11 @@ function renderDashboard() { path: '/dashboard', component: () => ( -

Dashboard

+ {(identity) => ( + +

Dashboard

+
+ )}
), }); diff --git a/apps/web/src/features/dashboard/dashboard-access-boundary.tsx b/apps/web/src/features/dashboard/dashboard-access-boundary.tsx index 68e25c2..d694f7e 100644 --- a/apps/web/src/features/dashboard/dashboard-access-boundary.tsx +++ b/apps/web/src/features/dashboard/dashboard-access-boundary.tsx @@ -1,4 +1,5 @@ // Allows authenticated and guest visitors into application routes while handling unresolved access. +import type { PublicUser } from '@goforlift/contracts'; import { Navigate } from '@tanstack/react-router'; import type { ReactNode } from 'react'; @@ -6,8 +7,11 @@ import { Button } from '../../components/ui/button'; import { useCurrentUser } from '../auth/current-user.query'; import { isGuestSession } from '../auth/guest-session'; +export type ApplicationIdentity = + { kind: 'guest' } | { kind: 'user'; user: PublicUser }; + type DashboardAccessBoundaryProps = { - children: ReactNode; + children: (identity: ApplicationIdentity) => ReactNode; }; export function DashboardAccessBoundary({ @@ -16,7 +20,7 @@ export function DashboardAccessBoundary({ const currentUser = useCurrentUser(); if (isGuestSession()) { - return children; + return children({ kind: 'guest' }); } if (currentUser.authenticationStatus === 'loading') { @@ -37,7 +41,7 @@ export function DashboardAccessBoundary({ return ; } - return children; + return children({ kind: 'user', user: currentUser.user }); } function AccessStatus({ diff --git a/apps/web/src/routes/_app.tsx b/apps/web/src/routes/_app.tsx index 2e173b6..1905f27 100644 --- a/apps/web/src/routes/_app.tsx +++ b/apps/web/src/routes/_app.tsx @@ -11,9 +11,11 @@ export const Route = createFileRoute('/_app')({ function ProtectedApplicationLayout() { return ( - - - + {(identity) => ( + + + + )} ); }