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
10 changes: 9 additions & 1 deletion apps/api/src/auth/google/google-oidc.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,14 @@ describe('GET /auth/google', () => {
expect(secondFlow.nonce).not.toBe(firstFlow.nonce);
expect(secondFlow.codeVerifier).not.toBe(firstFlow.codeVerifier);
});

it('returns successful logins to the dashboard by default', async () => {
const { app, store } = createTestContext();

await request(app).get('/auth/google');

expect(readStoredFlow(store).returnTo).toBe('/dashboard');
});
});

describe('validateReturnTo', () => {
Expand All @@ -152,7 +160,7 @@ describe('validateReturnTo', () => {
'javascript:alert(1)',
'/path%00suffix',
])('rejects unsafe destination %s', (destination) => {
expect(validateReturnTo(destination)).toBe('/');
expect(validateReturnTo(destination)).toBe('/dashboard');
});

it('accepts a root-relative application destination', () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/auth/google/google-oidc.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import type { Env } from '../../config/env-schema.js';
import type { GoogleUserProfile } from './google-user.service.js';

const GOOGLE_ISSUER = new URL('https://accounts.google.com');
const DEFAULT_RETURN_PATH = '/';
const DEFAULT_RETURN_PATH = '/dashboard';
const OIDC_FLOW_LIFETIME_MS = 10 * 60 * 1000;

const googleIdentitySchema = z.object({
Expand Down
19 changes: 19 additions & 0 deletions apps/web/src/features/auth/guest-session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Verifies the temporary guest marker can be started and cleared by the welcome flow.
import { afterEach, describe, expect, it } from 'vitest';

import { clearGuestSession, startGuestSession } from './guest-session';

afterEach(() => {
sessionStorage.clear();
});

describe('guest session', () => {
it('can clear guest mode when the welcome page loads', () => {
startGuestSession();
expect(sessionStorage.getItem('goforlift.guest')).toBe('true');

clearGuestSession();

expect(sessionStorage.getItem('goforlift.guest')).toBeNull();
});
});
10 changes: 10 additions & 0 deletions apps/web/src/features/auth/guest-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Stores the temporary browser-tab marker used to identify guest mode.
const GUEST_SESSION_KEY = 'goforlift.guest';

export function startGuestSession() {
sessionStorage.setItem(GUEST_SESSION_KEY, 'true');
}

export function clearGuestSession() {
sessionStorage.removeItem(GUEST_SESSION_KEY);
}
10 changes: 10 additions & 0 deletions apps/web/src/features/auth/welcome-page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Presents the responsive welcome page and entry points for Google sign-in and guest access.
import { useNavigate } from '@tanstack/react-router';
import { UserRound } from 'lucide-react';

import shipUrl from '../../assets/goforlift-ship.png';
Expand All @@ -7,8 +8,16 @@ import desktopStarfieldUrl from '../../assets/welcome-starfield-desktop.webp';
import starfieldUrl from '../../assets/welcome-starfield.webp';
import wordmarkUrl from '../../assets/goforlift-wordmark.png';
import { Button } from '../../components/ui/button';
import { startGuestSession } from './guest-session';

export function WelcomePage() {
const navigate = useNavigate();

function continueAsGuest() {
startGuestSession();
void navigate({ to: '/dashboard' });
}

return (
<main className="welcome-starfield flex min-h-screen items-center justify-center overflow-hidden px-6 py-8 text-foreground sm:px-8">
<picture aria-hidden="true" className="absolute inset-0">
Expand Down Expand Up @@ -50,6 +59,7 @@ export function WelcomePage() {
</Button>
<Button
className="h-14 rounded-md border-white/45 bg-white/[0.025] text-[16px] font-medium hover:bg-white/10"
onClick={continueAsGuest}
type="button"
variant="outline"
>
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/features/dashboard/dashboard-landing.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Presents the temporary shared landing state before the full dashboard is designed.
export function DashboardLanding() {
return (
<main className="flex min-h-screen items-center justify-center bg-background px-6 text-foreground">
<section className="max-w-md text-center">
<p className="text-sm font-medium uppercase tracking-[0.2em] text-primary">
Dashboard
</p>
<h1 className="mt-3 text-3xl font-semibold">Welcome to GoForLift</h1>
<p className="mt-4 text-muted-foreground">
Your session is ready. The full dashboard is the next step.
</p>
</section>
</main>
);
}
24 changes: 21 additions & 3 deletions apps/web/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,33 +10,43 @@

import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as DashboardRouteImport } from './routes/dashboard'

const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const DashboardRoute = DashboardRouteImport.update({
id: '/dashboard',
path: '/dashboard',
getParentRoute: () => rootRouteImport,
} as any)

export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/dashboard': typeof DashboardRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/dashboard': typeof DashboardRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/dashboard': typeof DashboardRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/'
fullPaths: '/' | '/dashboard'
fileRoutesByTo: FileRoutesByTo
to: '/'
id: '__root__' | '/'
to: '/' | '/dashboard'
id: '__root__' | '/' | '/dashboard'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
DashboardRoute: typeof DashboardRoute
}

declare module '@tanstack/react-router' {
Expand All @@ -48,11 +58,19 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/dashboard': {
id: '/dashboard'
path: '/dashboard'
fullPath: '/dashboard'
preLoaderRoute: typeof DashboardRouteImport
parentRoute: typeof rootRouteImport
}
}
}

const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
DashboardRoute: DashboardRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/routes/dashboard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Maps the dashboard route to the shared post-entry landing state.
import { createFileRoute } from '@tanstack/react-router';

import { DashboardLanding } from '../features/dashboard/dashboard-landing';

export const Route = createFileRoute('/dashboard')({
component: DashboardLanding,
});
29 changes: 26 additions & 3 deletions apps/web/src/routes/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
// Verifies the welcome page's primary entry points and guest-storage notice.
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RouterProvider } from '@tanstack/react-router';
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';

import { router } from '../router';

afterEach(() => {
cleanup();
sessionStorage.clear();
});

describe('welcome page', () => {
it('renders the Google and guest entry points', async () => {
it('renders both entry points and starts a temporary guest session', async () => {
const queryClient = new QueryClient();
sessionStorage.clear();
await router.navigate({ to: '/' });

render(
Expand All @@ -27,5 +39,16 @@ describe('welcome page', () => {
screen.getByRole('button', { name: /continue as guest/i }),
).toBeEnabled();
expect(screen.getByText(/guest progress is temporary/i)).toBeVisible();

fireEvent.click(screen.getByRole('button', { name: /continue as guest/i }));

expect(await screen.findByText(/your session is ready/i)).toBeVisible();
expect(sessionStorage.getItem('goforlift.guest')).toBe('true');
expect(router.state.location.pathname).toBe('/dashboard');

await router.navigate({ to: '/' });
await waitFor(() => {
expect(sessionStorage.getItem('goforlift.guest')).toBeNull();
});
});
});
6 changes: 5 additions & 1 deletion apps/web/src/routes/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
// Maps the root route to the public welcome page.
import { createFileRoute } from '@tanstack/react-router';

import { clearGuestSession } from '../features/auth/guest-session';
import { WelcomePage } from '../features/auth/welcome-page';

export const Route = createFileRoute('/')({ component: WelcomePage });
export const Route = createFileRoute('/')({
beforeLoad: clearGuestSession,
component: WelcomePage,
});