From c53bf35228e86f2633fcc5b31b52d4fab57f44bd Mon Sep 17 00:00:00 2001 From: PrimeFactor-Dev Date: Fri, 23 Oct 2026 21:39:48 +0100 Subject: [PATCH 1/4] fix(wallet): point balance display link at /rewards instead of missing /transactions The View Transactions link targeted a route that does not exist, sending users to a 404. Point it at /rewards, which already shows reward history. --- src/components/wallet/balance-display.tsx | 4 ++-- src/tests/wallet/balance-display.test.tsx | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) 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/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 () => { From e144a8570aeadc913a9490fdebc99d9d4ca093af Mon Sep 17 00:00:00 2001 From: PrimeFactor-Dev Date: Fri, 23 Oct 2026 21:40:35 +0100 Subject: [PATCH 2/4] fix(courses): stop double-fetching courses on the listing page useCourses() and useInfiniteCourses() each requested the catalog on mount. Add useEnrollments() for enrollment badges so the listing only uses useInfiniteCourses for course data. --- src/app/courses/page.tsx | 4 +-- src/lib/hooks/use-courses.ts | 54 ++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) 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/lib/hooks/use-courses.ts b/src/lib/hooks/use-courses.ts index 2d2665e..a581a91 100644 --- a/src/lib/hooks/use-courses.ts +++ b/src/lib/hooks/use-courses.ts @@ -121,6 +121,60 @@ function invalidateEnrollmentsCache(jwt?: string | null) { enrollmentsCache.clear(); } +/** + * 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() { const jwt = useAuthStore((s) => s.jwt); const { enrollments, setEnrollments, enroll: addEnrollment } = useCourseStore(); From f0c1376958ef73e148284b0e2a8e9f730b128b0b Mon Sep 17 00:00:00 2001 From: PrimeFactor-Dev Date: Fri, 23 Oct 2026 21:41:48 +0100 Subject: [PATCH 3/4] fix(middleware): reject protocol schemes and control chars in redirect param Extend isSafeRedirectPath to reject javascript:/data:/vbscript: style schemes and control characters used in open-redirect bypasses, and cover the acceptance criteria with tests. --- src/middleware.test.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ src/middleware.ts | 9 ++++++--- 2 files changed, 48 insertions(+), 3 deletions(-) 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; } From 6d39201351384fc0e3abfcbe51b3013d3475bd81 Mon Sep 17 00:00:00 2001 From: PrimeFactor-Dev Date: Fri, 23 Oct 2026 21:53:10 +0100 Subject: [PATCH 4/4] fix(dashboard): fetch tab data lazily instead of all hooks on mount Add an enabled option to useCourses, useRecommendedCourses, useRewards and useCredentials. The dashboard now gates each section by the active tab, replaces the global loading gate with per-section skeletons, and memoizes fetches so switching tabs does not re-request data. --- src/app/dashboard/page.tsx | 74 ++++++++++++++++++++++---------- src/lib/hooks/use-courses.ts | 35 +++++++++------ src/lib/hooks/use-credentials.ts | 15 ++++--- src/lib/hooks/use-rewards.ts | 15 ++++--- 4 files changed, 90 insertions(+), 49 deletions(-) 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/lib/hooks/use-courses.ts b/src/lib/hooks/use-courses.ts index a581a91..764c6bf 100644 --- a/src/lib/hooks/use-courses.ts +++ b/src/lib/hooks/use-courses.ts @@ -175,15 +175,17 @@ export function useEnrollments() { return { enrollments, enroll, fetchEnrollments, error }; } -export function useCourses() { +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); @@ -194,7 +196,7 @@ export function useCourses() { const cached = getCachedCourses(key); if (cached) { setCourses(cached); - setLoading(false); + setFetched(true); setError(null); return; } @@ -202,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); } }, [] @@ -254,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, @@ -444,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(); @@ -464,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,