diff --git a/src/app/courses/page.tsx b/src/app/courses/page.tsx index ffd025c..9895af6 100644 --- a/src/app/courses/page.tsx +++ b/src/app/courses/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useMemo, useRef, useState } from "react"; -import { useCourses, useInfiniteCourses } from "@/lib/hooks/use-courses"; +import { useEnrollments, useInfiniteCourses } from "@/lib/hooks/use-courses"; import { useDebounce } from "@/lib/hooks/use-debounce"; import { useCourseStore } from "@/store/course-store"; import { CourseCard } from "@/components/course/course-card"; @@ -29,7 +29,7 @@ const categories = [ const difficulties = ["All", "Beginner", "Intermediate", "Advanced"]; export default function CoursesPage() { - const { enrollments } = useCourses(); + const { enrollments } = useEnrollments(); const progress = useCourseStore((s) => s.progress); const [search, setSearch] = useState(""); const debouncedSearch = useDebounce(search, 300); diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 47a7364..9dc56d3 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,8 +1,12 @@ "use client"; -import { Component, type ReactNode } from "react"; +import { Component, useState, type ReactNode } from "react"; import { useRequireAuth } from "@/lib/hooks/use-require-auth"; -import { useCourses, useRecommendedCourses } from "@/lib/hooks/use-courses"; +import { + useCourses, + useEnrollments, + useRecommendedCourses, +} from "@/lib/hooks/use-courses"; import { useRewards } from "@/lib/hooks/use-rewards"; import { useCredentials } from "@/lib/hooks/use-credentials"; import { Card, CardContent } from "@/components/ui/card"; @@ -11,7 +15,12 @@ import { CourseProgressCard } from "@/components/course/course-progress-card"; import { BalanceDisplay } from "@/components/wallet/balance-display"; import { RewardHistory } from "@/components/rewards/reward-history"; import { CredentialCard } from "@/components/credentials/credential-card"; -import { DashboardSkeleton } from "@/components/shared/loading-skeleton"; +import { + CourseGridSkeleton, + CredentialCardSkeleton, + DashboardSkeleton, + RewardsSkeleton, +} from "@/components/shared/loading-skeleton"; import { EmptyState } from "@/components/shared/empty-state"; import { StatsCard } from "@/components/shared/stats-card"; import { useCourseStore } from "@/store/course-store"; @@ -69,24 +78,31 @@ function SectionError({ label, message }: { label: string; message: string }) { export default function DashboardPage() { const { ready } = useRequireAuth(); - const { courses, enrollments, loading: coursesLoading, error: coursesError } = useCourses(); - const { recommended, loading: recommendedLoading, error: recommendedError } = useRecommendedCourses(); - const { balances, history, loading: rewardsLoading, error: rewardsError } = useRewards(); - const { credentials, loading: credentialsLoading, error: credentialsError } = useCredentials(); - const progress = useCourseStore((s) => s.progress); + const [tab, setTab] = useState("overview"); - if (!ready) { - return ( -
- -
- ); - } + const onOverview = tab === "overview"; + const onCourses = tab === "courses"; + const onCredentials = tab === "credentials"; + const onRewards = tab === "rewards"; - const isLoading = - coursesLoading || rewardsLoading || credentialsLoading || recommendedLoading; + // Each section fetches only when its tab (or the overview summary) needs it. + // State is kept across tab switches and fetches are memoized (#519). + const { enrollments } = useEnrollments(); + const { courses, loading: coursesLoading, error: coursesError } = useCourses({ + enabled: onCourses, + }); + const { recommended, loading: recommendedLoading, error: recommendedError } = useRecommendedCourses({ + enabled: onOverview, + }); + const { balances, history, loading: rewardsLoading, error: rewardsError } = useRewards({ + enabled: onOverview || onRewards, + }); + const { credentials, loading: credentialsLoading, error: credentialsError } = useCredentials({ + enabled: onOverview || onCredentials, + }); + const progress = useCourseStore((s) => s.progress); - if (isLoading) { + if (!ready) { return (
@@ -115,7 +131,7 @@ export default function DashboardPage() {

- + Overview Courses @@ -157,7 +173,9 @@ export default function DashboardPage() {

Recommended for You

- {recommendedError ? ( + {recommendedLoading ? ( + + ) : recommendedError ? ( ) : recommended.length === 0 ? ( @@ -187,7 +205,9 @@ export default function DashboardPage() { - {coursesError ? ( + {coursesLoading ? ( + + ) : coursesError ? ( ) : enrolledCourses.length === 0 ? ( My Credentials - {credentialsError ? ( + {credentialsLoading ? ( +
+ + + +
+ ) : credentialsError ? ( ) : credentials.length === 0 ? ( - {rewardsError ? ( + {rewardsLoading ? ( + + ) : rewardsError ? ( ) : ( <> diff --git a/src/components/wallet/balance-display.tsx b/src/components/wallet/balance-display.tsx index 89bf9b2..0e485f9 100644 --- a/src/components/wallet/balance-display.tsx +++ b/src/components/wallet/balance-display.tsx @@ -99,10 +99,10 @@ export function BalanceDisplay({ className, compact = false }: BalanceDisplayPro Token Balance - View Transactions + View Rewards diff --git a/src/lib/hooks/use-courses.ts b/src/lib/hooks/use-courses.ts index 2d2665e..764c6bf 100644 --- a/src/lib/hooks/use-courses.ts +++ b/src/lib/hooks/use-courses.ts @@ -121,15 +121,71 @@ function invalidateEnrollmentsCache(jwt?: string | null) { enrollmentsCache.clear(); } -export function useCourses() { +/** + * Enrollment state only — no courses request. Use this when a screen already + * gets courses from another source (e.g. useInfiniteCourses) and only needs + * the enrolled badges (#517). + */ +export function useEnrollments() { + const jwt = useAuthStore((s) => s.jwt); + const { enrollments, setEnrollments, enroll: addEnrollment } = useCourseStore(); + const [error, setError] = useState(null); + const abortRef = useRef(null); + + const fetchEnrollments = useCallback(async () => { + if (!jwt) return; + + const cached = getCachedEnrollments(jwt); + if (cached) { + setEnrollments(cached); + return; + } + + const controller = new AbortController(); + abortRef.current = controller; + + try { + const data = await loadEnrollments(jwt, controller.signal); + setEnrollments(data); + setError(null); + } catch (err) { + if (isAbortError(err)) return; + setError( + err instanceof Error ? err.message : "Failed to fetch enrollments" + ); + } + }, [jwt, setEnrollments]); + + const enroll = useCallback( + async (courseId: string) => { + if (!jwt) throw new Error("Not authenticated"); + const enrollment = await enrollInCourse(courseId, jwt); + addEnrollment(enrollment); + invalidateEnrollmentsCache(jwt); + return enrollment; + }, + [jwt, addEnrollment] + ); + + useEffect(() => { + fetchEnrollments(); + return () => abortRef.current?.abort(); + }, [fetchEnrollments]); + + return { enrollments, enroll, fetchEnrollments, error }; +} + +export function useCourses(options?: { enabled?: boolean }) { + const enabled = options?.enabled ?? true; const jwt = useAuthStore((s) => s.jwt); const { enrollments, setEnrollments, enroll: addEnrollment } = useCourseStore(); const [courses, setCourses] = useState(() => { return getCachedCourses(coursesCacheKey()) ?? []; }); - const [loading, setLoading] = useState(() => { - return getCachedCourses(coursesCacheKey()) === null; + const [fetched, setFetched] = useState(() => { + return getCachedCourses(coursesCacheKey()) !== null; }); + const loading = enabled && !fetched; const [error, setError] = useState(null); const coursesAbortRef = useRef(null); const enrollmentsAbortRef = useRef(null); @@ -140,7 +196,7 @@ export function useCourses() { const cached = getCachedCourses(key); if (cached) { setCourses(cached); - setLoading(false); + setFetched(true); setError(null); return; } @@ -148,16 +204,15 @@ export function useCourses() { const controller = new AbortController(); coursesAbortRef.current = controller; - setLoading(true); setError(null); try { const data = await loadCourses(params, controller.signal); setCourses(data); + setFetched(true); } catch (err) { if (isAbortError(err)) return; + setFetched(true); setError(err instanceof Error ? err.message : "Failed to fetch courses"); - } finally { - setLoading(false); } }, [] @@ -200,14 +255,16 @@ export function useCourses() { ); useEffect(() => { + if (!enabled) return; fetchCourses(); return () => coursesAbortRef.current?.abort(); - }, [fetchCourses]); + }, [fetchCourses, enabled]); useEffect(() => { + if (!enabled) return; fetchEnrollments(); return () => enrollmentsAbortRef.current?.abort(); - }, [fetchEnrollments]); + }, [fetchEnrollments, enabled]); return { courses, @@ -390,15 +447,19 @@ export function useModule(courseId: string, moduleId: string) { return { module, loading, error, complete }; } -export function useRecommendedCourses() { +export function useRecommendedCourses(options?: { enabled?: boolean }) { + const enabled = options?.enabled ?? true; const jwt = useAuthStore((s) => s.jwt); const [recommended, setRecommended] = useState([]); - const [loading, setLoading] = useState(true); + const [fetched, setFetched] = useState(false); + const loading = enabled && !fetched; const [error, setError] = useState(null); useEffect(() => { + if (!enabled) return; + if (fetched) return; if (!jwt) { - setLoading(false); + setFetched(true); return; } const controller = new AbortController(); @@ -410,9 +471,9 @@ export function useRecommendedCourses() { const message = err instanceof Error ? err.message : "Failed to load recommendations"; setError(message); }) - .finally(() => setLoading(false)); + .finally(() => setFetched(true)); return () => controller.abort(); - }, [jwt]); + }, [jwt, enabled, fetched]); return { recommended, loading, error }; } diff --git a/src/lib/hooks/use-credentials.ts b/src/lib/hooks/use-credentials.ts index 46f81fa..878e577 100644 --- a/src/lib/hooks/use-credentials.ts +++ b/src/lib/hooks/use-credentials.ts @@ -12,21 +12,22 @@ import { import { isAbortError } from "@/lib/api/client"; import type { CredentialNFT } from "@/types/stellar"; -export function useCredentials() { +export function useCredentials(options?: { enabled?: boolean }) { + const enabled = options?.enabled ?? true; const jwt = useAuthStore((s) => s.jwt); const [credentials, setCredentials] = useState([]); - const [loading, setLoading] = useState(true); + const [fetched, setFetched] = useState(false); + const loading = enabled && !fetched; const [error, setError] = useState(null); const abortRef = useRef(null); const fetchCredentials = useCallback(async () => { if (!jwt) { - setLoading(false); + setFetched(true); return; } const controller = new AbortController(); abortRef.current = controller; - setLoading(true); setError(null); try { const data = await getCredentials(jwt, controller.signal); @@ -36,7 +37,7 @@ export function useCredentials() { const message = err instanceof Error ? err.message : "Failed to fetch credentials"; setError(message); } finally { - setLoading(false); + setFetched(true); } }, [jwt]); @@ -58,9 +59,11 @@ export function useCredentials() { ); useEffect(() => { + if (!enabled) return; + if (fetched) return; fetchCredentials(); return () => abortRef.current?.abort(); - }, [fetchCredentials]); + }, [fetchCredentials, enabled, fetched]); return { credentials, loading, error, mint, refetch: fetchCredentials }; } diff --git a/src/lib/hooks/use-rewards.ts b/src/lib/hooks/use-rewards.ts index 9386eca..2b8285e 100644 --- a/src/lib/hooks/use-rewards.ts +++ b/src/lib/hooks/use-rewards.ts @@ -79,7 +79,8 @@ function invalidateCache(jwt: string) { rewardsCache.delete(jwt); } -export function useRewards() { +export function useRewards(options?: { enabled?: boolean }) { + const enabled = options?.enabled ?? true; const jwt = useAuthStore((s) => s.jwt); const jwtRef = useRef(jwt); jwtRef.current = jwt; @@ -94,7 +95,8 @@ export function useRewards() { const [claimables, setClaimables] = useState< { id: string; amount: string; source: string; sourceTitle: string }[] >([]); - const [loading, setLoading] = useState(true); + const [fetched, setFetched] = useState(false); + const loading = enabled && !fetched; const [claiming, setClaiming] = useState(false); const [error, setError] = useState(null); const abortRef = useRef(null); @@ -102,12 +104,11 @@ export function useRewards() { const fetchAll = useCallback(async (historyParams?: RewardHistoryParams) => { const token = jwtRef.current; if (!token) { - setLoading(false); + setFetched(true); return; } const controller = new AbortController(); abortRef.current = controller; - setLoading(true); try { await loadRewards(token, controller.signal, historyParams); const cached = rewardsCache.get(token); @@ -121,7 +122,7 @@ export function useRewards() { if (isAbortError(e)) return; setError(e instanceof Error ? e.message : "Failed to fetch rewards"); } finally { - setLoading(false); + setFetched(true); } }, []); @@ -159,9 +160,11 @@ export function useRewards() { ); useEffect(() => { + if (!enabled) return; + if (fetched) return; fetchAll(); return () => abortRef.current?.abort(); - }, [fetchAll]); + }, [fetchAll, enabled, fetched]); return { balances, diff --git a/src/middleware.test.ts b/src/middleware.test.ts index 48bfe63..e0acb28 100644 --- a/src/middleware.test.ts +++ b/src/middleware.test.ts @@ -221,6 +221,48 @@ describe("open redirect protection (#541)", () => { }); }); +describe("open redirect protection (#518)", () => { + it.each([ + "javascript:alert(1)", + "JavaScript:alert(1)", + "data:text/html,", + "vbscript:msgbox(1)", + "http:evil.com", + ])("rejects protocol scheme %s", (redirect) => { + const target = redirectTarget( + middleware(makeRequest("/connect", "jwt-abc", `?redirect=${encodeURIComponent(redirect)}`)) + ); + + expect(target?.pathname).toBe("/dashboard"); + }); + + it("rejects a scheme embedded after a leading slash", () => { + const target = redirectTarget( + middleware(makeRequest("/connect", "jwt-abc", "?redirect=/javascript:alert(1)")) + ); + + expect(target?.pathname).toBe("/dashboard"); + }); + + it("rejects control characters used in parser-differential bypasses", () => { + const target = redirectTarget( + middleware( + makeRequest("/connect", "jwt-abc", "?redirect=/%09/evil.com") + ) + ); + + expect(target?.pathname).toBe("/dashboard"); + }); + + it("still allows a normal internal path", () => { + const target = redirectTarget( + middleware(makeRequest("/connect", "jwt-abc", "?redirect=/dashboard/settings")) + ); + + expect(target?.pathname).toBe("/dashboard/settings"); + }); +}); + describe("matcher", () => { it("covers every protected area and both auth routes", () => { expect(config.matcher).toEqual( diff --git a/src/middleware.ts b/src/middleware.ts index 3206b1e..e50254d 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -48,16 +48,19 @@ function isAuthRoute(pathname: string): boolean { } /** - * Reject open-redirect targets (#541). Only same-origin relative paths are - * allowed: must start with a single "/", must not be protocol-relative - * ("//evil.com"), and must not embed a scheme ("https://evil.com"). + * Reject open-redirect targets (#518, #541). Only same-origin relative paths + * are allowed: must start with a single "/", must not be protocol-relative + * ("//evil.com"), must not embed a scheme ("https:", "javascript:", "data:"), + * and must not contain control characters used in parser-differential bypasses. */ function isSafeRedirectPath(redirect: string | null): redirect is string { if (!redirect) return false; if (!redirect.startsWith("/")) return false; if (redirect.startsWith("//")) return false; if (redirect.includes("://")) return false; + if (/[a-zA-Z][a-zA-Z0-9+.-]*:/.test(redirect)) return false; if (redirect.includes("\\")) return false; + if (/[\u0000-\u001F\u007F]/.test(redirect)) return false; return true; } diff --git a/src/tests/wallet/balance-display.test.tsx b/src/tests/wallet/balance-display.test.tsx index b49fe4a..d7dee76 100644 --- a/src/tests/wallet/balance-display.test.tsx +++ b/src/tests/wallet/balance-display.test.tsx @@ -59,10 +59,10 @@ describe("BalanceDisplay", () => { expect(screen.getByText(/LEARN decimals: 7/)).toBeInTheDocument(); }); - it("links to transaction history", () => { + it("links to the rewards page", () => { render(); - const link = screen.getByRole("link", { name: /view transactions/i }); - expect(link).toHaveAttribute("href", "/transactions"); + const link = screen.getByRole("link", { name: /view rewards/i }); + expect(link).toHaveAttribute("href", "/rewards"); }); it("copies the issuer address on click", async () => {