diff --git a/src/app/credentials/[credentialId]/page.tsx b/src/app/credentials/[credentialId]/page.tsx index f86c676..04754c5 100644 --- a/src/app/credentials/[credentialId]/page.tsx +++ b/src/app/credentials/[credentialId]/page.tsx @@ -67,8 +67,17 @@ export default function CredentialDetailPage({ const handleDownload = useCallback(() => { if (!credential) return; + // #489 — escape all user-supplied values before interpolating into HTML + // to prevent XSS via malicious course titles or IDs. + const esc = (s: string | number | null | undefined): string => + String(s ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); const html = ` -Certificate - ${credential.courseTitle} +Certificate - ${esc(credential.courseTitle)}

Certificate of Completion

-

${credential.courseTitle}

+

${esc(credential.courseTitle)}

-

Issued: ${formatDate(credential.issuedAt)}

-

Credential ID: ${credential.id}

-

Token: ${truncateAddress(credential.tokenId, 8)}

- ${credential.metadata.score ? `

Score: ${credential.metadata.score}%

` : ""} +

Issued: ${esc(formatDate(credential.issuedAt))}

+

Credential ID: ${esc(credential.id)}

+

Token: ${esc(truncateAddress(credential.tokenId, 8))}

+ ${credential.metadata.score ? `

Score: ${esc(credential.metadata.score)}%

` : ""}

Verified on Stellar blockchain

@@ -91,7 +100,7 @@ export default function CredentialDetailPage({ const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; - a.download = `certificate-${credential.courseTitle.toLowerCase().replace(/\s+/g, "-")}.html`; + a.download = `certificate-${credential.courseTitle.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.html`; a.click(); URL.revokeObjectURL(url); }, [credential]); diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 5e70728..1654d59 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { Component, type ReactNode } from "react"; import { useRequireAuth } from "@/lib/hooks/use-require-auth"; import { useCourses, useRecommendedCourses } from "@/lib/hooks/use-courses"; import { useRewards } from "@/lib/hooks/use-rewards"; @@ -20,17 +21,58 @@ import { Award, TrendingUp, ArrowRight, + AlertTriangle, } from "lucide-react"; import Link from "next/link"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +// #488 — lightweight per-section error boundary so one failing section +// (rewards, credentials, etc.) never takes down the whole dashboard. +class SectionErrorBoundary extends Component< + { label: string; children: ReactNode }, + { hasError: boolean } +> { + state = { hasError: false }; + + static getDerivedStateFromError() { + return { hasError: true }; + } + + render() { + if (this.state.hasError) { + return ( + + + +

+ {this.props.label} failed to load. Other sections are unaffected. +

+
+
+ ); + } + return this.props.children; + } +} + +function SectionError({ label, message }: { label: string; message: string }) { + return ( + + + +

{label}: {message}

+
+
+ ); +} + export default function DashboardPage() { const { ready } = useRequireAuth(); - const { courses, enrollments, loading: coursesLoading } = useCourses(); + const { courses, enrollments, loading: coursesLoading, error: coursesError } = useCourses(); const { recommended, loading: recommendedLoading, error: recommendedError } = useRecommendedCourses(); - const { balances, history, loading: rewardsLoading } = useRewards(); - const { credentials, loading: credentialsLoading } = useCredentials(); + const { balances, history, loading: rewardsLoading, error: rewardsError } = useRewards(); + const { credentials, loading: credentialsLoading, error: credentialsError } = useCredentials(); const progress = useCourseStore((s) => s.progress); if (!ready) return null; @@ -104,116 +146,130 @@ export default function DashboardPage() { /> -
-

- Recommended for You -

- {recommendedError ? ( - - -

{recommendedError}

-
-
- ) : recommended.length === 0 ? ( - - - Complete your profile to get personalized recommendations. - - - ) : ( -
- {recommended.slice(0, 3).map((course) => ( - - ))} -
- )} -
+ +
+

+ Recommended for You +

+ {recommendedError ? ( + + ) : recommended.length === 0 ? ( + + + Complete your profile to get personalized recommendations. + + + ) : ( +
+ {recommended.slice(0, 3).map((course) => ( + + ))} +
+ )} +
+
-
-
-

My Courses

- - - -
- {enrolledCourses.length === 0 ? ( - - - - } - /> - ) : ( -
- {enrolledCourses.map(({ enrollment, course }) => ( - - ))} + +
+
+

My Courses

+ + +
- )} -
+ {coursesError ? ( + + ) : enrolledCourses.length === 0 ? ( + + + + } + /> + ) : ( +
+ {enrolledCourses.map(({ enrollment, course }) => ( + + ))} +
+ )} +
+
-
-

- My Credentials -

- {credentials.length === 0 ? ( - - ) : ( -
- {credentials.map((cred) => ( - - ))} -
- )} -
+ +
+

+ My Credentials +

+ {credentialsError ? ( + + ) : credentials.length === 0 ? ( + + ) : ( +
+ {credentials.map((cred) => ( + + ))} +
+ )} +
+
-
-

- Rewards Balance -

- - - - - -
- -
-

- Reward History -

- - - - - -
+ + {rewardsError ? ( + + ) : ( + <> +
+

+ Rewards Balance +

+ + + + + +
+ +
+

+ Reward History +

+ + + + + +
+ + )} +
diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 4cd030d..172864b 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -130,16 +130,19 @@ export default function SettingsPage() { Profile Information - {/* Avatar Upload */} -
+ {/* Avatar Upload — upload is not yet wired to a backend (#486). + Show a clear "coming soon" placeholder instead of a fake success. */} +
{ - await new Promise((resolve) => setTimeout(resolve, 1000)); - addToast("Avatar uploaded successfully", "success"); + addToast("Avatar upload is not yet available", "info"); }} /> +

+ Avatar upload coming soon +

{/* Display Name */} diff --git a/src/lib/hooks/use-quiz.ts b/src/lib/hooks/use-quiz.ts index 888a788..41b203e 100644 --- a/src/lib/hooks/use-quiz.ts +++ b/src/lib/hooks/use-quiz.ts @@ -1,5 +1,6 @@ "use client"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useState } from "react"; import { useAuthStore } from "@/store/auth-store"; import { getQuiz } from "@/lib/api/quizzes"; @@ -11,6 +12,14 @@ export function useQuiz(courseId: string) { const [quiz, setQuiz] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + // #487 — ref-based trigger so refetch() can force a re-read without the + // useEffect depending on the callback identity (which would loop). + const [fetchTick, setFetchTick] = useState(0); + const abortRef = useRef(null); + + const refetch = useCallback(() => { + setFetchTick((t) => t + 1); + }, []); const fetchQuiz = useCallback(() => { if (!courseId) return; @@ -22,17 +31,22 @@ export function useQuiz(courseId: string) { setLoading(false); return; } + abortRef.current?.abort(); const controller = new AbortController(); + abortRef.current = controller; setLoading(true); + setError(null); getQuiz(courseId, jwt ?? undefined, controller.signal) .then(setQuiz) .catch((err) => { if (isAbortError(err)) return; setError(err instanceof Error ? err.message : "Failed to load quiz"); }) - .finally(() => setLoading(false)); + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); return () => controller.abort(); - }, [courseId, jwt]); + }, [courseId, jwt, fetchTick]); useEffect(() => { fetchQuiz(); @@ -42,6 +56,7 @@ export function useQuiz(courseId: string) { quiz, loading, error, + refetch, refetch: fetchQuiz, }; }