From f01f7efb06177a69f1a047bfd82fff5cb5cbd906 Mon Sep 17 00:00:00 2001 From: swve Date: Sun, 20 Jul 2025 10:37:48 +0200 Subject: [PATCH] feat: user certificate verification backend and UI --- .../api/src/routers/courses/certifications.py | 16 + .../src/services/courses/certifications.py | 58 ++++ .../Pages/Activity/CourseEndView.tsx | 11 +- .../CertificateVerificationPage.tsx | 289 ++++++++++++++++++ apps/web/services/courses/certifications.ts | 16 + 5 files changed, 389 insertions(+), 1 deletion(-) create mode 100644 apps/web/components/Pages/Certificate/CertificateVerificationPage.tsx diff --git a/apps/api/src/routers/courses/certifications.py b/apps/api/src/routers/courses/certifications.py index 90ce5328..24c9b659 100644 --- a/apps/api/src/routers/courses/certifications.py +++ b/apps/api/src/routers/courses/certifications.py @@ -17,6 +17,7 @@ from src.services.courses.certifications import ( update_certification, delete_certification, get_user_certificates_for_course, + get_certificate_by_user_certification_uuid, ) router = APIRouter() @@ -110,4 +111,19 @@ async def api_get_user_certificates_for_course( """ return await get_user_certificates_for_course( request, course_uuid, current_user, db_session + ) + + +@router.get("/certificate/{user_certification_uuid}") +async def api_get_certificate_by_user_certification_uuid( + request: Request, + user_certification_uuid: str, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +) -> dict: + """ + Get a certificate by user_certification_uuid with certification and course details + """ + return await get_certificate_by_user_certification_uuid( + request, user_certification_uuid, current_user, db_session ) \ No newline at end of file diff --git a/apps/api/src/services/courses/certifications.py b/apps/api/src/services/courses/certifications.py index 33a4de6b..d664e19b 100644 --- a/apps/api/src/services/courses/certifications.py +++ b/apps/api/src/services/courses/certifications.py @@ -396,6 +396,64 @@ async def check_course_completion_and_create_certificate( return False +async def get_certificate_by_user_certification_uuid( + request: Request, + user_certification_uuid: str, + current_user: PublicUser | AnonymousUser, + db_session: Session, +) -> dict: + """Get a certificate by user_certification_uuid with certification details""" + + # Get certificate user by user_certification_uuid + statement = select(CertificateUser).where( + CertificateUser.user_certification_uuid == user_certification_uuid + ) + certificate_user = db_session.exec(statement).first() + + if not certificate_user: + raise HTTPException( + status_code=404, + detail="Certificate not found", + ) + + # Get the associated certification + statement = select(Certifications).where(Certifications.id == certificate_user.certification_id) + certification = db_session.exec(statement).first() + + if not certification: + raise HTTPException( + status_code=404, + detail="Certification not found", + ) + + # Get course for RBAC check + statement = select(Course).where(Course.id == certification.course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException( + status_code=404, + detail="Course not found", + ) + + # RBAC check - allow read access to the certificate owner or course owners/admins + if current_user.id != certificate_user.user_id: + # If not the certificate owner, check course access + await rbac_check(request, course.course_uuid, current_user, "read", db_session) + + return { + "certificate_user": CertificateUserRead(**certificate_user.model_dump()), + "certification": CertificationRead(**certification.model_dump()), + "course": { + "id": course.id, + "course_uuid": course.course_uuid, + "name": course.name, + "description": course.description, + "thumbnail_image": course.thumbnail_image, + } + } + + #################################################### # RBAC Utils #################################################### diff --git a/apps/web/components/Pages/Activity/CourseEndView.tsx b/apps/web/components/Pages/Activity/CourseEndView.tsx index 741331b9..8bfedc59 100644 --- a/apps/web/components/Pages/Activity/CourseEndView.tsx +++ b/apps/web/components/Pages/Activity/CourseEndView.tsx @@ -1,6 +1,6 @@ import React, { useMemo, useEffect, useState } from 'react'; import ReactConfetti from 'react-confetti'; -import { Trophy, ArrowLeft, BookOpen, Target, Download } from 'lucide-react'; +import { Trophy, ArrowLeft, BookOpen, Target, Download, Shield } from 'lucide-react'; import Link from 'next/link'; import { getUriWithOrg } from '@services/config/config'; import { getCourseThumbnailMediaDirectory } from '@services/media/media'; @@ -491,6 +491,15 @@ const CourseEndView: React.FC = ({ Download Certificate PDF + + + Verify Certificate + ) : ( diff --git a/apps/web/components/Pages/Certificate/CertificateVerificationPage.tsx b/apps/web/components/Pages/Certificate/CertificateVerificationPage.tsx new file mode 100644 index 00000000..f7cf3f01 --- /dev/null +++ b/apps/web/components/Pages/Certificate/CertificateVerificationPage.tsx @@ -0,0 +1,289 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import { getCertificateByUuid } from '@services/courses/certifications'; +import CertificatePreview from '@components/Dashboard/Pages/Course/EditCourseCertification/CertificatePreview'; +import { Shield, CheckCircle, XCircle, AlertTriangle, ArrowLeft } from 'lucide-react'; +import Link from 'next/link'; +import { getUriWithOrg } from '@services/config/config'; +import { useOrg } from '@components/Contexts/OrgContext'; + +interface CertificateVerificationPageProps { + certificateUuid: string; +} + +const CertificateVerificationPage: React.FC = ({ certificateUuid }) => { + const [certificateData, setCertificateData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [verificationStatus, setVerificationStatus] = useState<'valid' | 'invalid' | 'loading'>('loading'); + const org = useOrg() as any; + + // Fetch certificate data + useEffect(() => { + const fetchCertificate = async () => { + try { + const result = await getCertificateByUuid(certificateUuid); + + if (result.success && result.data) { + setCertificateData(result.data); + setVerificationStatus('valid'); + } else { + setError('Certificate not found'); + setVerificationStatus('invalid'); + } + } catch (error) { + console.error('Error fetching certificate:', error); + setError('Failed to verify certificate. Please try again later.'); + setVerificationStatus('invalid'); + } finally { + setIsLoading(false); + } + }; + + fetchCertificate(); + }, [certificateUuid]); + + const getVerificationStatusIcon = () => { + switch (verificationStatus) { + case 'valid': + return ; + case 'invalid': + return ; + case 'loading': + return ; + default: + return ; + } + }; + + const getVerificationStatusText = () => { + switch (verificationStatus) { + case 'valid': + return 'Certificate Verified'; + case 'invalid': + return 'Certificate Not Found'; + case 'loading': + return 'Verifying Certificate...'; + default: + return 'Verification Status Unknown'; + } + }; + + const getVerificationStatusColor = () => { + switch (verificationStatus) { + case 'valid': + return 'text-green-600 bg-green-50 border-green-200'; + case 'invalid': + return 'text-red-600 bg-red-50 border-red-200'; + case 'loading': + return 'text-yellow-600 bg-yellow-50 border-yellow-200'; + default: + return 'text-yellow-600 bg-yellow-50 border-yellow-200'; + } + }; + + if (isLoading) { + return ( +
+
+
+
+
+
+

Verifying Certificate

+

Please wait while we verify the certificate...

+
+
+
+ ); + } + + if (error || verificationStatus === 'invalid') { + return ( +
+
+
+
+ +
+ +

+ Certificate Not Found +

+ +

+ The certificate with ID {certificateUuid} could not be found in our system. +

+ +
+

+ This could mean: +

+
    +
  • The certificate ID is incorrect
  • +
  • The certificate has been revoked
  • +
  • The certificate has expired
  • +
  • The certificate was issued by a different organization
  • +
+
+ +
+ + + Go Home + +
+
+
+
+ ); + } + + if (!certificateData) { + return null; + } + + const qrCodeLink = getUriWithOrg(org?.org_slug || '', `/certificates/${certificateData.certificate_user.user_certification_uuid}/verify`); + + return ( +
+
+ {/* Header */} +
+
+
+
+ +
+
+

Certificate Verification

+

Verify the authenticity of this certificate

+
+
+ +
+ {getVerificationStatusIcon()} + {getVerificationStatusText()} +
+
+
+ + {/* Certificate Details */} +
+ {/* Certificate Preview */} +
+
+

Certificate Preview

+
+ +
+
+
+ + {/* Certificate Details */} +
+
+

Certificate Information

+ +
+
+ +
+ + {certificateData.certificate_user.user_certification_uuid} + +
+
+ +
+ +
+ {certificateData.course.name} +
+
+ +
+ +
+ + {certificateData.certification.config.certification_type.replace('_', ' ')} + +
+
+ +
+ +
+ + {new Date(certificateData.certificate_user.created_at).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + })} + +
+
+ + {certificateData.certification.config.certificate_instructor && ( +
+ +
+ {certificateData.certification.config.certificate_instructor} +
+
+ )} +
+
+ + + +
+
+ +

Security Information

+
+
    +
  • • Certificate verified against our secure database
  • +
  • • QR code contains verification link
  • +
  • • Certificate ID is cryptographically secure
  • +
  • • Timestamp verified and authenticated
  • +
+
+
+
+ + {/* Footer */} +
+ + + Go Home + +
+
+
+ ); +}; + +export default CertificateVerificationPage; \ No newline at end of file diff --git a/apps/web/services/courses/certifications.ts b/apps/web/services/courses/certifications.ts index ff308a7d..5df4497e 100644 --- a/apps/web/services/courses/certifications.ts +++ b/apps/web/services/courses/certifications.ts @@ -71,4 +71,20 @@ export async function getUserCertificates( ) const res = await getResponseMetadata(result) return res +} + +export async function getCertificateByUuid( + user_certification_uuid: string +) { + const result = await fetch( + `${getAPIUrl()}certifications/certificate/${user_certification_uuid}`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + } + ) + const res = await getResponseMetadata(result) + return res } \ No newline at end of file