Skip to content

Commit 67ef00c

Browse files
committed
improvement(academy): polish, security hardening, and certificate claim UI
- Replace raw localStorage with BrowserStorage utility in local-progress - Pre-compute slug/id Maps in content/index for O(1) course lookups - Move blockMap construction into edge_exists branch only in validation - Extract navBtnClass constant and MetaRow/formatDate helpers in UI - Add rate limiting, server-side completion verification, audit logging, and nanoid cert numbers to certificate issuance endpoint - Add useIssueCertificate mutation hook with completedLessonIds - Wire certificate claim UI into CourseProgress: sign-in prompt, claim button with loading state, and post-issuance view with link to certificate page - Fix lesson page scroll container and quiz scroll-on-focus bug
1 parent fb3b4e6 commit 67ef00c

File tree

8 files changed

+162
-83
lines changed

8 files changed

+162
-83
lines changed

apps/sim/app/academy/(catalog)/[courseSlug]/components/course-progress.tsx

Lines changed: 62 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
'use client'
22

33
import { useEffect, useState } from 'react'
4-
import { CheckCircle2, Circle, GraduationCap } from 'lucide-react'
4+
import { CheckCircle2, Circle, ExternalLink, GraduationCap, Loader2 } from 'lucide-react'
55
import Link from 'next/link'
66
import { getCompletedLessons } from '@/lib/academy/local-progress'
77
import type { Course } from '@/lib/academy/types'
8+
import { useSession } from '@/lib/auth/auth-client'
9+
import { useIssueCertificate } from '@/hooks/queries/academy'
810

911
interface CourseProgressProps {
1012
course: Course
@@ -13,6 +15,8 @@ interface CourseProgressProps {
1315

1416
export function CourseProgress({ course, courseSlug }: CourseProgressProps) {
1517
const [completedIds, setCompletedIds] = useState<Set<string> | null>(null)
18+
const { data: session } = useSession()
19+
const { mutate: issueCertificate, isPending, data: certificate, error } = useIssueCertificate()
1620

1721
useEffect(() => {
1822
setCompletedIds(getCompletedLessons())
@@ -83,21 +87,65 @@ export function CourseProgress({ course, courseSlug }: CourseProgressProps) {
8387
{completedIds && totalLessons > 0 && completedCount === totalLessons && (
8488
<section className='px-4 pb-16 sm:px-8 md:px-[80px]'>
8589
<div className='mx-auto max-w-3xl rounded-[8px] border border-[#3A4A3A] bg-[#1F2A1F] p-6'>
86-
<div className='flex items-center justify-between'>
87-
<div className='flex items-center gap-3'>
88-
<GraduationCap className='h-6 w-6 text-[#4CAF50]' />
89-
<div>
90-
<p className='font-[430] text-[#ECECEC] text-[15px]'>Course Complete!</p>
91-
<p className='text-[#666] text-[13px]'>Sign in to claim your certificate.</p>
90+
{certificate ? (
91+
<div className='flex items-center justify-between'>
92+
<div className='flex items-center gap-3'>
93+
<GraduationCap className='h-6 w-6 text-[#4CAF50]' />
94+
<div>
95+
<p className='font-[430] text-[#ECECEC] text-[15px]'>Certificate issued!</p>
96+
<p className='font-mono text-[#666] text-[13px]'>
97+
{certificate.certificateNumber}
98+
</p>
99+
</div>
92100
</div>
101+
<Link
102+
href={`/academy/certificate/${certificate.certificateNumber}`}
103+
className='flex items-center gap-1.5 rounded-[5px] bg-[#4CAF50] px-4 py-2 font-[430] text-[#1C1C1C] text-[13px] transition-colors hover:bg-[#5DBF61]'
104+
>
105+
View certificate
106+
<ExternalLink className='h-3.5 w-3.5' />
107+
</Link>
93108
</div>
94-
<Link
95-
href='/sign-in'
96-
className='rounded-[5px] bg-[#ECECEC] px-4 py-2 font-[430] text-[#1C1C1C] text-[13px] transition-colors hover:bg-white'
97-
>
98-
Get certificate
99-
</Link>
100-
</div>
109+
) : (
110+
<div className='flex items-center justify-between'>
111+
<div className='flex items-center gap-3'>
112+
<GraduationCap className='h-6 w-6 text-[#4CAF50]' />
113+
<div>
114+
<p className='font-[430] text-[#ECECEC] text-[15px]'>Course Complete!</p>
115+
<p className='text-[#666] text-[13px]'>
116+
{session
117+
? error
118+
? 'Something went wrong. Try again.'
119+
: 'Claim your certificate of completion.'
120+
: 'Sign in to claim your certificate.'}
121+
</p>
122+
</div>
123+
</div>
124+
{session ? (
125+
<button
126+
type='button'
127+
disabled={isPending}
128+
onClick={() =>
129+
issueCertificate({
130+
courseId: course.id,
131+
completedLessonIds: [...completedIds],
132+
})
133+
}
134+
className='flex items-center gap-2 rounded-[5px] bg-[#ECECEC] px-4 py-2 font-[430] text-[#1C1C1C] text-[13px] transition-colors hover:bg-white disabled:opacity-50'
135+
>
136+
{isPending && <Loader2 className='h-3.5 w-3.5 animate-spin' />}
137+
{isPending ? 'Issuing…' : 'Get certificate'}
138+
</button>
139+
) : (
140+
<Link
141+
href='/sign-in'
142+
className='rounded-[5px] bg-[#ECECEC] px-4 py-2 font-[430] text-[#1C1C1C] text-[13px] transition-colors hover:bg-white'
143+
>
144+
Sign in
145+
</Link>
146+
)}
147+
</div>
148+
)}
101149
</div>
102150
</section>
103151
)}

apps/sim/app/academy/(catalog)/certificate/[certificateNumber]/page.tsx

Lines changed: 25 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,24 @@
11
'use client'
22

3+
import type React from 'react'
34
import { use } from 'react'
45
import { CheckCircle2, GraduationCap } from 'lucide-react'
56
import { useAcademyCertificate } from '@/hooks/queries/academy'
67

8+
const DATE_FORMAT: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', day: 'numeric' }
9+
function formatDate(date: string | Date) {
10+
return new Date(date).toLocaleDateString('en-US', DATE_FORMAT)
11+
}
12+
13+
function MetaRow({ label, children }: { label: string; children: React.ReactNode }) {
14+
return (
15+
<div className='flex items-center justify-between px-5 py-3.5'>
16+
<span className='text-[#666] text-[13px]'>{label}</span>
17+
{children}
18+
</div>
19+
)
20+
}
21+
722
interface CertificatePageProps {
823
params: Promise<{ certificateNumber: string }>
924
}
@@ -56,43 +71,29 @@ export default function CertificatePage({ params }: CertificatePageProps) {
5671
</div>
5772

5873
<div className='mt-6 divide-y divide-[#2A2A2A] rounded-[8px] border border-[#2A2A2A] bg-[#222]'>
59-
<div className='flex items-center justify-between px-5 py-3.5'>
60-
<span className='text-[#666] text-[13px]'>Certificate number</span>
74+
<MetaRow label='Certificate number'>
6175
<span className='font-mono text-[#ECECEC] text-[13px]'>
6276
{certificate.certificateNumber}
6377
</span>
64-
</div>
65-
<div className='flex items-center justify-between px-5 py-3.5'>
66-
<span className='text-[#666] text-[13px]'>Issued</span>
67-
<span className='text-[#ECECEC] text-[13px]'>
68-
{new Date(certificate.issuedAt).toLocaleDateString('en-US', {
69-
year: 'numeric',
70-
month: 'long',
71-
day: 'numeric',
72-
})}
73-
</span>
74-
</div>
75-
<div className='flex items-center justify-between px-5 py-3.5'>
76-
<span className='text-[#666] text-[13px]'>Status</span>
78+
</MetaRow>
79+
<MetaRow label='Issued'>
80+
<span className='text-[#ECECEC] text-[13px]'>{formatDate(certificate.issuedAt)}</span>
81+
</MetaRow>
82+
<MetaRow label='Status'>
7783
<span
7884
className={`text-[13px] capitalize ${
7985
certificate.status === 'active' ? 'text-[#4CAF50]' : 'text-[#f44336]'
8086
}`}
8187
>
8288
{certificate.status}
8389
</span>
84-
</div>
90+
</MetaRow>
8591
{certificate.expiresAt && (
86-
<div className='flex items-center justify-between px-5 py-3.5'>
87-
<span className='text-[#666] text-[13px]'>Expires</span>
92+
<MetaRow label='Expires'>
8893
<span className='text-[#ECECEC] text-[13px]'>
89-
{new Date(certificate.expiresAt).toLocaleDateString('en-US', {
90-
year: 'numeric',
91-
month: 'long',
92-
day: 'numeric',
93-
})}
94+
{formatDate(certificate.expiresAt)}
9495
</span>
95-
</div>
96+
</MetaRow>
9697
)}
9798
</div>
9899

apps/sim/app/academy/[courseSlug]/[lessonSlug]/page.tsx

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import { LessonVideo } from '@/app/academy/components/lesson-video'
1010
import { ExerciseView } from './components/exercise-view'
1111
import { LessonQuiz } from './components/lesson-quiz'
1212

13+
const navBtnClass =
14+
'flex items-center gap-1 rounded-[5px] border border-[#2A2A2A] px-3 py-1.5 text-[#999] text-[12px] transition-colors hover:border-[#3A3A3A] hover:text-[#ECECEC]'
15+
1316
interface LessonPageProps {
1417
params: Promise<{ courseSlug: string; lessonSlug: string }>
1518
}
@@ -74,18 +77,12 @@ export default function LessonPage({ params }: LessonPageProps) {
7477

7578
<div className='flex items-center gap-2'>
7679
{prevLesson ? (
77-
<Link
78-
href={`/academy/${courseSlug}/${prevLesson.slug}`}
79-
className='flex items-center gap-1 rounded-[5px] border border-[#2A2A2A] px-3 py-1.5 text-[#999] text-[12px] transition-colors hover:border-[#3A3A3A] hover:text-[#ECECEC]'
80-
>
80+
<Link href={`/academy/${courseSlug}/${prevLesson.slug}`} className={navBtnClass}>
8181
<ChevronLeft className='h-3.5 w-3.5' />
8282
Previous
8383
</Link>
8484
) : (
85-
<Link
86-
href={`/academy/${courseSlug}`}
87-
className='flex items-center gap-1 rounded-[5px] border border-[#2A2A2A] px-3 py-1.5 text-[#999] text-[12px] transition-colors hover:border-[#3A3A3A] hover:text-[#ECECEC]'
88-
>
85+
<Link href={`/academy/${courseSlug}`} className={navBtnClass}>
8986
<ChevronLeft className='h-3.5 w-3.5' />
9087
Course
9188
</Link>

apps/sim/app/api/academy/certificates/route.ts

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,28 @@ import { z } from 'zod'
88
import { getCourseById } from '@/lib/academy/content'
99
import type { CertificateMetadata } from '@/lib/academy/types'
1010
import { getSession } from '@/lib/auth'
11+
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
12+
import { RateLimiter } from '@/lib/core/rate-limiter'
1113

1214
const logger = createLogger('AcademyCertificatesAPI')
1315

16+
const rateLimiter = new RateLimiter()
17+
const CERT_RATE_LIMIT: TokenBucketConfig = {
18+
maxTokens: 5,
19+
refillRate: 1,
20+
refillIntervalMs: 60 * 60_000, // 1 per hour refill
21+
}
22+
1423
const IssueCertificateSchema = z.object({
1524
courseId: z.string(),
25+
completedLessonIds: z.array(z.string()),
1626
})
1727

1828
/**
1929
* POST /api/academy/certificates
20-
* Issues a certificate for the given course.
21-
* The client is responsible for verifying completion locally before calling this.
30+
* Issues a certificate for the given course after verifying all lessons are completed.
31+
* Completion is client-attested: the client sends completed lesson IDs and the server
32+
* validates them against the full lesson list for the course.
2233
*/
2334
export async function POST(req: NextRequest) {
2435
try {
@@ -27,15 +38,34 @@ export async function POST(req: NextRequest) {
2738
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
2839
}
2940

41+
const { allowed } = await rateLimiter.checkRateLimitDirect(
42+
`academy:cert:${session.user.id}`,
43+
CERT_RATE_LIMIT
44+
)
45+
if (!allowed) {
46+
return NextResponse.json({ error: 'Too many requests' }, { status: 429 })
47+
}
48+
3049
const body = await req.json()
3150
const parsed = IssueCertificateSchema.safeParse(body)
3251
if (!parsed.success) {
3352
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
3453
}
3554

36-
const { courseId } = parsed.data
55+
const { courseId, completedLessonIds } = parsed.data
3756

3857
const course = getCourseById(courseId)
58+
if (!course) {
59+
return NextResponse.json({ error: 'Course not found' }, { status: 404 })
60+
}
61+
62+
// Verify all lessons in the course are reported as completed
63+
const allLessonIds = course.modules.flatMap((m) => m.lessons.map((l) => l.id))
64+
const completedSet = new Set(completedLessonIds)
65+
const incomplete = allLessonIds.filter((id) => !completedSet.has(id))
66+
if (incomplete.length > 0) {
67+
return NextResponse.json({ error: 'Course not fully completed', incomplete }, { status: 422 })
68+
}
3969

4070
const [existing, learner] = await Promise.all([
4171
db
@@ -57,10 +87,6 @@ export async function POST(req: NextRequest) {
5787
.then((rows) => rows[0] ?? null),
5888
])
5989

60-
if (!course) {
61-
return NextResponse.json({ error: 'Course not found' }, { status: 404 })
62-
}
63-
6490
if (existing?.status === 'active') {
6591
return NextResponse.json({ certificate: existing })
6692
}
@@ -83,6 +109,12 @@ export async function POST(req: NextRequest) {
83109
})
84110
.returning()
85111

112+
logger.info('Certificate issued', {
113+
userId: session.user.id,
114+
courseId,
115+
certificateNumber,
116+
})
117+
86118
return NextResponse.json({ certificate }, { status: 201 })
87119
} catch (error) {
88120
logger.error('Failed to issue certificate', { error })
@@ -120,11 +152,8 @@ export async function GET(req: NextRequest) {
120152
}
121153
}
122154

123-
/** Generates a human-readable certificate number, e.g. SIM-2026-00042 */
155+
/** Generates a human-readable certificate number, e.g. SIM-2026-A3K9XZ2P */
124156
function generateCertificateNumber(): string {
125157
const year = new Date().getFullYear()
126-
const suffix = Math.floor(Math.random() * 99999)
127-
.toString()
128-
.padStart(5, '0')
129-
return `SIM-${year}-${suffix}`
158+
return `SIM-${year}-${nanoid(8).toUpperCase()}`
130159
}

apps/sim/hooks/queries/academy.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useQuery } from '@tanstack/react-query'
1+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
22
import type { AcademyCertificate } from '@/lib/academy/types'
33
import { fetchJson } from '@/hooks/selectors/helpers'
44

@@ -21,3 +21,18 @@ export function useAcademyCertificate(certificateNumber?: string, options?: { en
2121
staleTime: 10 * 60 * 1000,
2222
})
2323
}
24+
25+
export function useIssueCertificate() {
26+
const queryClient = useQueryClient()
27+
return useMutation({
28+
mutationFn: (variables: { courseId: string; completedLessonIds: string[] }) =>
29+
fetchJson<{ certificate: AcademyCertificate }>('/api/academy/certificates', {
30+
method: 'POST',
31+
headers: { 'Content-Type': 'application/json' },
32+
body: JSON.stringify(variables),
33+
}).then((d) => d.certificate),
34+
onSettled: () => {
35+
queryClient.invalidateQueries({ queryKey: academyKeys.certificates() })
36+
},
37+
})
38+
}

apps/sim/lib/academy/content/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@ import { simFoundations } from './courses/sim-foundations'
44
/** All published courses in display order. */
55
export const COURSES: Course[] = [simFoundations]
66

7+
const bySlug = new Map(COURSES.map((c) => [c.slug, c]))
8+
const byId = new Map(COURSES.map((c) => [c.id, c]))
9+
710
export function getCourse(slug: string): Course | undefined {
8-
return COURSES.find((c) => c.slug === slug)
11+
return bySlug.get(slug)
912
}
1013

1114
export function getCourseById(id: string): Course | undefined {
12-
return COURSES.find((c) => c.id === id)
15+
return byId.get(id)
1316
}
Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,13 @@
1-
import { createLogger } from '@sim/logger'
1+
import { BrowserStorage } from '@/lib/core/utils/browser-storage'
22

3-
const logger = createLogger('AcademyProgress')
43
const STORAGE_KEY = 'academy:completed'
54

65
export function getCompletedLessons(): Set<string> {
7-
try {
8-
const raw = localStorage.getItem(STORAGE_KEY)
9-
const ids: string[] = raw ? JSON.parse(raw) : []
10-
return new Set(ids)
11-
} catch (error) {
12-
logger.warn('Failed to read lesson progress from localStorage', { error })
13-
return new Set()
14-
}
6+
return new Set(BrowserStorage.getItem<string[]>(STORAGE_KEY, []))
157
}
168

179
export function markLessonComplete(lessonId: string): void {
18-
try {
19-
const ids = getCompletedLessons()
20-
ids.add(lessonId)
21-
localStorage.setItem(STORAGE_KEY, JSON.stringify([...ids]))
22-
} catch (error) {
23-
logger.warn('Failed to persist lesson completion', { lessonId, error })
24-
}
10+
const ids = getCompletedLessons()
11+
ids.add(lessonId)
12+
BrowserStorage.setItem(STORAGE_KEY, [...ids])
2513
}

0 commit comments

Comments
 (0)