Skip to content
Closed
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
47 changes: 37 additions & 10 deletions src/app/baseball/(auth)/demo/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

import { Suspense, useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { ArrowRight, Brain, ClipboardList, Users } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { ArrowRight, Brain, ClipboardList, Clock, Users } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { createClient } from '@/lib/supabase/client';
Expand All @@ -14,6 +14,7 @@ import {
isBaseballDemoAvailable,
isBaseballDemoSession,
} from '@/app/baseball/actions/demo-access';
import { probeSignedIn, raceWithTimeout } from '@/lib/demo/gate-probe';
import {
AuthBezel,
AuthCard,
Expand Down Expand Up @@ -62,6 +63,8 @@ function validateProgram(v: string): string | undefined {

function DemoGateContent() {
const router = useRouter();
const searchParams = useSearchParams();
const sessionExpired = searchParams.get('message') === 'demo_session_expired';

// Form state
const [name, setName] = useState('');
Expand Down Expand Up @@ -92,27 +95,44 @@ function DemoGateContent() {
useEffect(() => {
let active = true;
async function checkAuth() {
// probeSignedIn races supabase.auth.getUser() against a hard 4s
// deadline and treats ANY failure — timeout, thrown error, or a
// refresh-token error surfaced through getUser()'s own `error` field —
// as signed out. Without this, a stale/expired httpOnly Supabase
// cookie can leave getUser()'s internal refresh hanging forever, and
// "Checking sign-in status" never resolves (#918).
const user = await probeSignedIn(supabase);
if (!active || !user) return;
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) return;
// The client can't detect the shared demo account itself (its email
// is a server-only secret) — verify it server-side.
const { isDemo } = await isBaseballDemoSession();
// is a server-only secret) — verify it server-side. Same hard
// timeout applied here: a stuck server action must not hang this
// check either.
const { isDemo } = await raceWithTimeout(isBaseballDemoSession());
if (active && isDemo) setIsDemoSession(true);
} finally {
if (active) setCheckingAuth(false);
} catch {
// Timeout or thrown error — fall through to the form below.
}
}
void checkAuth();
void checkAuth().finally(() => {
if (active) setCheckingAuth(false);
});
return () => { active = false; };
}, [supabase]);

useEffect(() => {
let active = true;
async function checkAvailability() {
try {
const { enabled } = await isBaseballDemoAvailable();
// Same hard-timeout guard as the auth check above — a stuck
// kill-switch read must not leave the spinner up forever. Fails
// open (demo treated as enabled) so a slow read shows the form
// instead of a permanent spinner; the real kill-switch is still
// enforced server-side on submit.
const { enabled } = await raceWithTimeout(isBaseballDemoAvailable());
if (active) setDemoEnabled(enabled);
} catch {
if (active) setDemoEnabled(true);
} finally {
if (active) setCheckingAvailability(false);
}
Expand Down Expand Up @@ -225,6 +245,13 @@ function DemoGateContent() {
</p>
</div>

{/* Friendly notice when bounced here after a demo session timed out (#918) */}
{sessionExpired && (
<InkNotice ink="team" icon={Clock} role="status" className="mb-4">
Your demo session timed out. Enter your info again to jump right back in.
</InkNotice>
)}

{checkingAuth || checkingAvailability ? (
<div className="flex justify-center py-6">
<AuthPendingDots label="Checking sign-in status" ink="pursuit" />
Expand Down
22 changes: 22 additions & 0 deletions src/app/baseball/actions/__tests__/demo-access.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const mocks = vi.hoisted(() => ({
},
error: null,
})),
updateUser: vi.fn(async () => ({ data: { user: null }, error: null })),
adminFrom: vi.fn(),
insert: vi.fn(async () => ({ data: null, error: null })),
logLogin: vi.fn(async () => ({})),
Expand Down Expand Up @@ -56,6 +57,7 @@ vi.mock('@/lib/supabase/server', () => ({
auth: {
getUser: mocks.getUser,
signInWithPassword: mocks.signInWithPassword,
updateUser: mocks.updateUser,
},
})),
}));
Expand Down Expand Up @@ -116,6 +118,26 @@ beforeEach(() => {
},
error: null,
});
mocks.updateUser.mockResolvedValue({ data: { user: null }, error: null });
});

describe('enterBaseballDemo — is_demo metadata stamp (#918)', () => {
it('stamps user_metadata.is_demo = true right after sign-in, before redirecting', async () => {
await expect(enterBaseballDemo(VALID_INPUT)).rejects.toThrow('REDIRECT:/baseball/dashboard?demo=1');

expect(mocks.updateUser).toHaveBeenCalledWith({ data: { is_demo: true } });
const signInOrder = mocks.signInWithPassword.mock.invocationCallOrder[0]!;
const updateOrder = mocks.updateUser.mock.invocationCallOrder[0]!;
expect(signInOrder).toBeLessThan(updateOrder);
});

it('never blocks demo entry when the metadata stamp fails', async () => {
mocks.updateUser.mockRejectedValue(new Error('gotrue hiccup'));

await expect(enterBaseballDemo(VALID_INPUT)).rejects.toThrow('REDIRECT:/baseball/dashboard?demo=1');

expect(mocks.signInWithPassword).toHaveBeenCalledTimes(1);
});
});

describe('enterBaseballDemo — rate limit', () => {
Expand Down
12 changes: 12 additions & 0 deletions src/app/baseball/actions/demo-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,18 @@ async function enterBaseballDemoImpl(
};
}

// --- 6b. Stamp the session as a demo session ------------------------------
// `user_metadata.is_demo` is readable by BOTH client and server code
// (unlike the demo email, which stays a server-only secret) — the shared
// idle-timeout flow (middleware + useSessionActivity) reads it to route
// an expired demo session back to /baseball/demo instead of the password
// login (#918). Best-effort: a failure here must never block entry.
try {
await supabase.auth.updateUser({ data: { is_demo: true } });
} catch {
// Worst case the session_expired redirect falls back to /baseball/login.
}

// --- 7. Mirror into the admin_events feed (best-effort) ------------------
// userId is the shared demo account's real uuid; userEmail is the
// visitor's email so admins can identify who entered. The
Expand Down
41 changes: 29 additions & 12 deletions src/app/golf/(auth)/demo/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { Suspense, useState, useEffect, useMemo } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import { LazyMotion, m, useReducedMotion } from 'framer-motion';
import { loadFeatures } from '@/lib/motion/load-features';
import { AlertCircle, Loader2, ArrowRight, BarChart2, Users, Brain } from 'lucide-react';
Expand All @@ -15,6 +15,7 @@ import { useMediaQuery } from '@/hooks/use-media-query';
import { createClient } from '@/lib/supabase/client';
import { DEMO_LANDING_PATH } from '@/lib/demo/config';
import { enterDemo } from '@/app/golf/actions/demo-access';
import { probeSignedIn } from '@/lib/demo/gate-probe';

// ---------------------------------------------------------------------------
// Value-prop pill items shown below the headline
Expand Down Expand Up @@ -56,6 +57,8 @@ function validateSchool(v: string): string | undefined {
function DemoGateContent() {
const prefersReducedMotion = useReducedMotion();
const router = useRouter();
const searchParams = useSearchParams();
const sessionExpired = searchParams.get('message') === 'demo_session_expired';
const isDesktop = useMediaQuery('(min-width: 768px)');

// Form state
Expand All @@ -77,20 +80,24 @@ function DemoGateContent() {
const supabase = useMemo(() => createClient(), []);

useEffect(() => {
let active = true;
async function checkAuth() {
try {
const { data: { user } } = await supabase.auth.getUser();
// Any already-authenticated visitor gets a "continue" shortcut. We can't
// reliably detect the shared demo account client-side (its email is a
// server-only secret), so we don't special-case it here.
if (user) {
setIsDemoUser(true);
}
} finally {
setCheckingAuth(false);
}
// probeSignedIn races supabase.auth.getUser() against a hard 4s
// deadline and treats ANY failure — timeout, thrown error, or a
// refresh-token error surfaced through getUser()'s own `error` field —
// as signed out. Without this, a stale/expired httpOnly Supabase
// cookie can leave getUser()'s internal refresh hanging forever, and
// this "Checking sign-in status" spinner never resolves (#918).
const user = await probeSignedIn(supabase);
if (!active) return;
// Any already-authenticated visitor gets a "continue" shortcut. We can't
// reliably detect the shared demo account client-side (its email is a
// server-only secret), so we don't special-case it here.
if (user) setIsDemoUser(true);
setCheckingAuth(false);
}
void checkAuth();
return () => { active = false; };
}, [supabase]);

// Derived inline errors (only shown after touch)
Expand Down Expand Up @@ -273,6 +280,16 @@ function DemoGateContent() {
</p>
</div>

{/* Friendly notice when bounced here after a demo session timed out (#918) */}
{sessionExpired && (
<div
role="status"
className="bg-primary-400/10 border border-primary-400/30 text-primary-800 px-4 py-3 rounded-xl text-sm text-center mb-4"
>
Your demo session timed out. Enter your info again to jump right back in.
</div>
)}

{/* Already-signed-in shortcut */}
{checkingAuth ? (
<div className="flex justify-center py-4">
Expand Down
Loading
Loading