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 () => {