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
7 changes: 2 additions & 5 deletions src/app/dashboard/events/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<EventRecord | null>(null);
const [loadingEvent, setLoadingEvent] = useState(true);
Expand All @@ -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<EventRecord>(`/events/${id}`);
Expand Down
7 changes: 2 additions & 5 deletions src/app/dashboard/organizations/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -31,7 +31,6 @@ export default function OrganizationPage({
}) {
const { id } = use(params);
const { user, loading } = useAuth();
const router = useRouter();

const [org, setOrg] = useState<Organization | null>(null);
const [events, setEvents] = useState<EventRecord[]>([]);
Expand All @@ -48,9 +47,7 @@ export default function OrganizationPage({
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);

useEffect(() => {
if (!loading && !user) router.push("/login");
}, [loading, user, router]);
useRequireAuth();

useEffect(() => {
if (!user) return;
Expand Down
7 changes: 2 additions & 5 deletions src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -12,7 +12,6 @@ import { Button } from '@/components/button';

export default function DashboardPage() {
const { user, loading } = useAuth();
const router = useRouter();
const [orgs, setOrgs] = useState<Organization[]>([]);
const [loadingOrgs, setLoadingOrgs] = useState(true);

Expand Down Expand Up @@ -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;
Expand Down
7 changes: 2 additions & 5 deletions src/app/marketplace/page.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -13,16 +13,13 @@ import { Button } from '@/components/button';

export default function MarketplacePage() {
const { user, loading } = useAuth();
const router = useRouter();
const [listings, setListings] = useState<ResaleListing[]>([]);
const [loadingListings, setLoadingListings] = useState(true);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [buyingTicketId, setBuyingTicketId] = useState<string | null>(null);

useEffect(() => {
if (!loading && !user) router.push('/login');
}, [loading, user, router]);
useRequireAuth();

useEffect(() => {
if (!user) return;
Expand Down
7 changes: 2 additions & 5 deletions src/app/my-tickets/page.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -53,7 +53,6 @@ function GateCode({ secret }: { secret: string }) {

export default function MyTicketsPage() {
const { user, loading } = useAuth();
const router = useRouter();
const [tickets, setTickets] = useState<Ticket[]>([]);
const [loadingTickets, setLoadingTickets] = useState(true);
const [loadFailed, setLoadFailed] = useState(false);
Expand All @@ -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 {
Expand Down
9 changes: 3 additions & 6 deletions src/app/verify/page.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -23,17 +23,14 @@ interface VerifyResult {

export default function VerifyPage() {
const { user, loading } = useAuth();
const router = useRouter();
const [code, setCode] = useState('');
const [result, setResult] = useState<VerifyResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [checking, setChecking] = useState(false);
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);
Expand Down
88 changes: 88 additions & 0 deletions src/lib/use-require-auth.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
25 changes: 25 additions & 0 deletions src/lib/use-require-auth.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
Loading