From fdeb367d529ff58303470a6a5c8a6aaf0e541040 Mon Sep 17 00:00:00 2001 From: richardtoms100 <315124855+richardtoms100@users.noreply.github.com> Date: Sat, 26 Sep 2026 18:45:10 +0100 Subject: [PATCH 1/2] feat(auth): add useRequireAuth hook that redirects with router.replace Protected-page counterpart to useRedirectIfAuthenticated (#50): once auth has loaded and there is no user, router.replace('/login') so the protected URL isn't left in history. Refs #46 --- src/lib/use-require-auth.test.ts | 88 ++++++++++++++++++++++++++++++++ src/lib/use-require-auth.ts | 25 +++++++++ 2 files changed, 113 insertions(+) create mode 100644 src/lib/use-require-auth.test.ts create mode 100644 src/lib/use-require-auth.ts diff --git a/src/lib/use-require-auth.test.ts b/src/lib/use-require-auth.test.ts new file mode 100644 index 0000000..856ab38 --- /dev/null +++ b/src/lib/use-require-auth.test.ts @@ -0,0 +1,88 @@ +import { createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { act } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const push = vi.fn(); +const replace = vi.fn(); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push, replace }), +})); + +const authState: { user: unknown; loading: boolean } = { user: null, loading: false }; +vi.mock('./auth-context', () => ({ + useAuth: () => authState, +})); + +import { LOGIN_PATH, useRequireAuth } from './use-require-auth'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +function Probe({ to }: { to?: string }) { + useRequireAuth(to); + return null; +} + +let container: HTMLDivElement; +let root: Root; + +function renderProbe(to?: string) { + act(() => { + root.render(createElement(Probe, { to })); + }); +} + +beforeEach(() => { + push.mockReset(); + replace.mockReset(); + authState.user = null; + authState.loading = false; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); +}); + +describe('useRequireAuth (#46)', () => { + it('replaces (not pushes) to /login for a signed-out user', () => { + renderProbe(); + expect(replace).toHaveBeenCalledWith(LOGIN_PATH); + expect(LOGIN_PATH).toBe('/login'); + // push would leave the protected page in history and trap the Back button. + expect(push).not.toHaveBeenCalled(); + }); + + it('does not redirect while auth is still loading', () => { + authState.loading = true; + renderProbe(); + expect(replace).not.toHaveBeenCalled(); + expect(push).not.toHaveBeenCalled(); + }); + + it('does not redirect a signed-in user', () => { + authState.user = { id: 'u1' }; + renderProbe(); + expect(replace).not.toHaveBeenCalled(); + expect(push).not.toHaveBeenCalled(); + }); + + it('redirects once loading finishes without a user', () => { + authState.loading = true; + renderProbe(); + expect(replace).not.toHaveBeenCalled(); + + authState.loading = false; + renderProbe(); + expect(replace).toHaveBeenCalledTimes(1); + expect(replace).toHaveBeenCalledWith('/login'); + }); + + it('supports a custom destination', () => { + renderProbe('/login?next=%2Fverify'); + expect(replace).toHaveBeenCalledWith('/login?next=%2Fverify'); + }); +}); diff --git a/src/lib/use-require-auth.ts b/src/lib/use-require-auth.ts new file mode 100644 index 0000000..6d69f3c --- /dev/null +++ b/src/lib/use-require-auth.ts @@ -0,0 +1,25 @@ +'use client'; + +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { useAuth } from './auth-context'; + +/** Where a signed-out user is sent from a protected page. */ +export const LOGIN_PATH = '/login'; + +/** + * Protected pages: once auth has finished loading, send a signed-out user to + * /login with `router.replace` (#46). Using `push` left the protected URL in + * history, so pressing Back from /login re-entered it and was immediately + * pushed to /login again, trapping the Back button. + * + * Mirror of `useRedirectIfAuthenticated` for guest-only pages (#50). + */ +export function useRequireAuth(to: string = LOGIN_PATH): void { + const { user, loading } = useAuth(); + const router = useRouter(); + + useEffect(() => { + if (!loading && !user) router.replace(to); + }, [loading, user, router, to]); +} From a781cc80ff5d73d0005c6f042a68057bdd45e6fd Mon Sep 17 00:00:00 2001 From: richardtoms100 <315124855+richardtoms100@users.noreply.github.com> Date: Sat, 26 Sep 2026 18:46:27 +0100 Subject: [PATCH 2/2] fix(auth): stop protected pages trapping the Back button Replace the per-page router.push('/login') effects in dashboard, dashboard/organizations/[id], dashboard/events/[id], my-tickets, verify and marketplace with useRequireAuth(), which uses router.replace, so the protected URL is not left in history. Drops now-unused useRouter / useEffect imports. Closes #46 --- src/app/dashboard/events/[id]/page.tsx | 7 ++----- src/app/dashboard/organizations/[id]/page.tsx | 7 ++----- src/app/dashboard/page.tsx | 7 ++----- src/app/marketplace/page.tsx | 7 ++----- src/app/my-tickets/page.tsx | 7 ++----- src/app/verify/page.tsx | 9 +++------ 6 files changed, 13 insertions(+), 31 deletions(-) diff --git a/src/app/dashboard/events/[id]/page.tsx b/src/app/dashboard/events/[id]/page.tsx index 7a396fe..91463f8 100644 --- a/src/app/dashboard/events/[id]/page.tsx +++ b/src/app/dashboard/events/[id]/page.tsx @@ -1,8 +1,8 @@ 'use client'; import { use, useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; import { useAuth } from '@/lib/auth-context'; +import { useRequireAuth } from '@/lib/use-require-auth'; import { apiFetch, ApiError } from '@/lib/api'; import { signAndSubmit } from '@/lib/onchain'; import type { EventRecord, TicketType } from '@/lib/types'; @@ -17,7 +17,6 @@ import { buildPublishSummary } from '@/lib/event-details'; export default function EventPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params); const { user, loading } = useAuth(); - const router = useRouter(); const [event, setEvent] = useState(null); const [loadingEvent, setLoadingEvent] = useState(true); @@ -37,9 +36,7 @@ export default function EventPage({ params }: { params: Promise<{ id: string }> const [issueSeat, setIssueSeat] = useState(''); const [issuing, setIssuing] = useState(false); - useEffect(() => { - if (!loading && !user) router.push('/login'); - }, [loading, user, router]); + useRequireAuth(); async function loadEvent() { const res = await apiFetch(`/events/${id}`); diff --git a/src/app/dashboard/organizations/[id]/page.tsx b/src/app/dashboard/organizations/[id]/page.tsx index 53b8d8f..9a60ef6 100644 --- a/src/app/dashboard/organizations/[id]/page.tsx +++ b/src/app/dashboard/organizations/[id]/page.tsx @@ -2,8 +2,8 @@ import { use, useEffect, useState } from "react"; import Link from "next/link"; -import { useRouter } from "next/navigation"; import { useAuth } from "@/lib/auth-context"; +import { useRequireAuth } from "@/lib/use-require-auth"; import { apiFetch, ApiError } from "@/lib/api"; import { INDUSTRIES, @@ -31,7 +31,6 @@ export default function OrganizationPage({ }) { const { id } = use(params); const { user, loading } = useAuth(); - const router = useRouter(); const [org, setOrg] = useState(null); const [events, setEvents] = useState([]); @@ -48,9 +47,7 @@ export default function OrganizationPage({ const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); - useEffect(() => { - if (!loading && !user) router.push("/login"); - }, [loading, user, router]); + useRequireAuth(); useEffect(() => { if (!user) return; diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 8f4e3f6..2a29905 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -2,8 +2,8 @@ import { useEffect, useRef, useState } from 'react'; import Link from 'next/link'; -import { useRouter } from 'next/navigation'; import { useAuth } from '@/lib/auth-context'; +import { useRequireAuth } from '@/lib/use-require-auth'; import { apiFetch, ApiError } from '@/lib/api'; import { INDUSTRIES, INDUSTRY_LABELS, type Organization } from '@/lib/types'; import { FormError } from '@/components/form-error'; @@ -12,7 +12,6 @@ import { Button } from '@/components/button'; export default function DashboardPage() { const { user, loading } = useAuth(); - const router = useRouter(); const [orgs, setOrgs] = useState([]); const [loadingOrgs, setLoadingOrgs] = useState(true); @@ -53,9 +52,7 @@ export default function DashboardPage() { const SLUG_VALIDATION_MESSAGE = 'Use lowercase letters, numbers, and single hyphens between words (e.g. my-organization).'; - useEffect(() => { - if (!loading && !user) router.push('/login'); - }, [loading, user, router]); + useRequireAuth(); useEffect(() => { if (stellarAccountTouched.current) return; diff --git a/src/app/marketplace/page.tsx b/src/app/marketplace/page.tsx index a08d7e8..5f819f8 100644 --- a/src/app/marketplace/page.tsx +++ b/src/app/marketplace/page.tsx @@ -1,8 +1,8 @@ 'use client'; import { useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; import { useAuth } from '@/lib/auth-context'; +import { useRequireAuth } from '@/lib/use-require-auth'; import { apiFetch } from '@/lib/api'; import { actionErrorMessage } from '@/lib/action-error'; import { signAndSubmit } from '@/lib/onchain'; @@ -13,16 +13,13 @@ import { Button } from '@/components/button'; export default function MarketplacePage() { const { user, loading } = useAuth(); - const router = useRouter(); const [listings, setListings] = useState([]); const [loadingListings, setLoadingListings] = useState(true); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [buyingTicketId, setBuyingTicketId] = useState(null); - useEffect(() => { - if (!loading && !user) router.push('/login'); - }, [loading, user, router]); + useRequireAuth(); useEffect(() => { if (!user) return; diff --git a/src/app/my-tickets/page.tsx b/src/app/my-tickets/page.tsx index 9f76bbd..401c1b8 100644 --- a/src/app/my-tickets/page.tsx +++ b/src/app/my-tickets/page.tsx @@ -1,9 +1,9 @@ 'use client'; import { useEffect, useState, type FormEvent } from 'react'; -import { useRouter } from 'next/navigation'; import { Eye, EyeOff, ExternalLink } from 'lucide-react'; import { useAuth } from '@/lib/auth-context'; +import { useRequireAuth } from '@/lib/use-require-auth'; import { apiFetch, ApiError } from '@/lib/api'; import { signAndSubmit } from '@/lib/onchain'; import { ticketingContractUrl } from '@/lib/event-details'; @@ -53,7 +53,6 @@ function GateCode({ secret }: { secret: string }) { export default function MyTicketsPage() { const { user, loading } = useAuth(); - const router = useRouter(); const [tickets, setTickets] = useState([]); const [loadingTickets, setLoadingTickets] = useState(true); const [loadFailed, setLoadFailed] = useState(false); @@ -69,9 +68,7 @@ export default function MyTicketsPage() { recipient: TransferRecipient; } | null>(null); - useEffect(() => { - if (!loading && !user) router.push('/login'); - }, [loading, user, router]); + useRequireAuth(); async function loadTickets() { try { diff --git a/src/app/verify/page.tsx b/src/app/verify/page.tsx index 1693f73..a82e6c3 100644 --- a/src/app/verify/page.tsx +++ b/src/app/verify/page.tsx @@ -1,8 +1,8 @@ 'use client'; -import { useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; +import { useState } from 'react'; import { useAuth } from '@/lib/auth-context'; +import { useRequireAuth } from '@/lib/use-require-auth'; import { apiFetch, ApiError } from '@/lib/api'; import { signAndSubmit } from '@/lib/onchain'; import { FormError } from '@/components/form-error'; @@ -23,7 +23,6 @@ interface VerifyResult { export default function VerifyPage() { const { user, loading } = useAuth(); - const router = useRouter(); const [code, setCode] = useState(''); const [result, setResult] = useState(null); const [error, setError] = useState(null); @@ -31,9 +30,7 @@ export default function VerifyPage() { const [actionBusy, setActionBusy] = useState(false); const [scanning, setScanning] = useState(false); - useEffect(() => { - if (!loading && !user) router.push('/login'); - }, [loading, user, router]); + useRequireAuth(); async function lookup(ticketCode: string) { setError(null);