feat: implement persistent personalized certificates

- Modified encodeCertificateId to include userId parameter
- Updated decodeCertificateId to extract and return userId
- Added backward compatibility for old 2-part certificate IDs
- Certificate page now fetches and displays certificate owner's name
- Submission page passes current user's ID when generating certificate
- Fixed certificate template to use certificate owner's name instead of team leader
- Certificates now show the encoded user's name regardless of who views them

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Maulana Sodiqin
2025-12-10 21:40:34 +07:00
co-authored by Claude
parent 59c1bb0366
commit 1dbe704230
3 changed files with 45 additions and 15 deletions
@@ -5,6 +5,8 @@ import { decodeCertificateId } from '../../../utils/certificate';
import { import {
useTeamById, useTeamById,
useTeamSubmission, useTeamSubmission,
useAuthStore,
useUserById,
} from '@imphnen-frontend-service/service'; } from '@imphnen-frontend-service/service';
import QRCode from 'qrcode'; import QRCode from 'qrcode';
import html2canvas from 'html2canvas'; import html2canvas from 'html2canvas';
@@ -12,11 +14,13 @@ import html2canvas from 'html2canvas';
interface DecodedCert { interface DecodedCert {
teamId: string; teamId: string;
submissionId: string; submissionId: string;
userId: string;
} }
const CertificatePage: FC = (): ReactElement => { const CertificatePage: FC = (): ReactElement => {
const { certId } = useParams<{ certId: string }>(); const { certId } = useParams<{ certId: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { session } = useAuthStore();
const [decodedInfo, setDecodedInfo] = useState<DecodedCert | null>(null); const [decodedInfo, setDecodedInfo] = useState<DecodedCert | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const teamNameRef = useRef<HTMLHeadingElement>(null); const teamNameRef = useRef<HTMLHeadingElement>(null);
@@ -65,11 +69,21 @@ const CertificatePage: FC = (): ReactElement => {
const { data: submissionData, isLoading: isLoadingSubmission } = const { data: submissionData, isLoading: isLoadingSubmission } =
useTeamSubmission(decodedInfo?.teamId || '', !!decodedInfo?.teamId); useTeamSubmission(decodedInfo?.teamId || '', !!decodedInfo?.teamId);
// Fetch the certificate owner's user data
const { data: certificateUserData, isLoading: isLoadingCertUser } = useUserById(
decodedInfo?.userId || '',
!!decodedInfo?.userId
);
const team = teamData?.data; const team = teamData?.data;
const submission = submissionData?.data; const submission = submissionData?.data;
const certificateUser = certificateUserData?.data;
const isLoading = const isLoading =
(!decodedInfo && !error) || isLoadingTeam || isLoadingSubmission; (!decodedInfo && !error) || isLoadingTeam || isLoadingSubmission || isLoadingCertUser;
// Certificate name: Use the user from the certificate ID, fallback to team leader
const certificateName = certificateUser?.fullname || team?.leader?.fullname;
// Dynamic font sizing: shrink by 2px if height exceeds 80px // Dynamic font sizing: shrink by 2px if height exceeds 80px
useEffect(() => { useEffect(() => {
@@ -98,7 +112,7 @@ const CertificatePage: FC = (): ReactElement => {
}, 0); }, 0);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [team?.name, team?.leader?.fullname]); }, [team?.name, certificateName]);
// Generate certificate canvas screenshot // Generate certificate canvas screenshot
useEffect(() => { useEffect(() => {
@@ -335,7 +349,7 @@ const CertificatePage: FC = (): ReactElement => {
margin: 0, margin: 0,
}} }}
> >
{team?.leader?.fullname || 'N/A'} {certificateName || 'N/A'}
</h3> </h3>
</div> </div>
@@ -1,12 +1,13 @@
import { FC, ReactElement } from 'react'; import { FC, ReactElement } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms'; import { Button } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate, useParams } from 'react-router'; import { useNavigate, useParams } from 'react-router';
import { useTeamById, useTeamSubmission } from '@imphnen-frontend-service/service'; import { useTeamById, useTeamSubmission, useAuthStore } from '@imphnen-frontend-service/service';
import { encodeCertificateId } from '../../../../utils/certificate'; import { encodeCertificateId } from '../../../../utils/certificate';
const SubmissionViewPage: FC = (): ReactElement => { const SubmissionViewPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>(); const { teamId } = useParams<{ teamId: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { session } = useAuthStore();
const { data: teamData } = useTeamById(teamId || ''); const { data: teamData } = useTeamById(teamId || '');
const { data: submissionData, isLoading } = useTeamSubmission(teamId || '', !!teamId); const { data: submissionData, isLoading } = useTeamSubmission(teamId || '', !!teamId);
@@ -113,13 +114,17 @@ const SubmissionViewPage: FC = (): ReactElement => {
View Your Certificate View Your Certificate
</h3> </h3>
<p className="text-amber-700 dark:text-amber-300 text-sm"> <p className="text-amber-700 dark:text-amber-300 text-sm">
Congratulations! Your certificate is ready to download and share. Congratulations! Your personalized certificate is ready to download and share.
</p> </p>
</div> </div>
</div> </div>
<button <button
onClick={async () => { onClick={async () => {
const certId = await encodeCertificateId(teamId || '', submission.id); const certId = await encodeCertificateId(
teamId || '',
submission.id,
session?.user?.id || ''
);
navigate(`/certificate/${encodeURIComponent(certId)}`); navigate(`/certificate/${encodeURIComponent(certId)}`);
}} }}
className="shrink-0 px-6 py-2 bg-amber-600 hover:bg-amber-700 dark:bg-amber-600 dark:hover:bg-amber-700 text-white font-medium rounded-lg transition-colors" className="shrink-0 px-6 py-2 bg-amber-600 hover:bg-amber-700 dark:bg-amber-600 dark:hover:bg-amber-700 text-white font-medium rounded-lg transition-colors"
+20 -9
View File
@@ -3,27 +3,38 @@ import { decryptText, encryptText } from "./aesclient";
const SECRET_KEY = 'imphnen-hackathon-2025'; const SECRET_KEY = 'imphnen-hackathon-2025';
/** /**
* Encode teamId and submissionId into a certificate ID * Encode teamId, submissionId, and userId into a certificate ID
* Uses base64 encoding for simple obfuscation * Uses AES encryption for secure encoding
* @param teamId - The team ID * @param teamId - The team ID
* @param submissionId - The submission ID * @param submissionId - The submission ID
* @param userId - The user ID (team member)
* @returns Encoded certificate ID * @returns Encoded certificate ID
*/ */
export const encodeCertificateId = async (teamId: string, submissionId: string): Promise<string> => { export const encodeCertificateId = async (teamId: string, submissionId: string, userId: string): Promise<string> => {
const combined = `${teamId}::${submissionId}`; const combined = `${teamId}::${submissionId}::${userId}`;
return encryptText(combined, SECRET_KEY); return encryptText(combined, SECRET_KEY);
}; };
/** /**
* Decode certificate ID back to teamId and submissionId * Decode certificate ID back to teamId, submissionId, and userId
* @param certId - The encoded certificate ID * @param certId - The encoded certificate ID
* @returns Object containing teamId and submissionId * @returns Object containing teamId, submissionId, and userId
*/ */
export const decodeCertificateId = async (certId: string): Promise<{ teamId: string; submissionId: string }> => { export const decodeCertificateId = async (certId: string): Promise<{ teamId: string; submissionId: string; userId: string }> => {
try { try {
const decoded = await decryptText(certId, SECRET_KEY); const decoded = await decryptText(certId, SECRET_KEY);
const [teamId, submissionId] = decoded.split('::'); const parts = decoded.split('::');
return { teamId, submissionId };
// Handle both old format (teamId::submissionId) and new format (teamId::submissionId::userId)
if (parts.length === 2) {
const [teamId, submissionId] = parts;
return { teamId, submissionId, userId: '' };
} else if (parts.length === 3) {
const [teamId, submissionId, userId] = parts;
return { teamId, submissionId, userId };
}
throw new Error('Invalid certificate format');
} catch { } catch {
throw new Error('Invalid certificate ID'); throw new Error('Invalid certificate ID');
} }