Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement ICP verfiable Credential with Dacade as relying party #1284

Merged
merged 23 commits into from
Nov 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3eb9e96
feat: implement ICP verfiable Credential with Dacade as relying party
Jonath-z Aug 7, 2024
d827bff
chore: upgrade node version for verifiable credential SDK to work
Jonath-z Aug 7, 2024
e097bda
chore: change moduleResolution to bundler for the verifiable credenti…
Jonath-z Aug 7, 2024
d0f58e7
feat: add check to ensure the feature only for ICP achievement
Jonath-z Aug 7, 2024
7edf22b
feat: add local testing canisters
Jonath-z Aug 27, 2024
79842a9
fix: make sure the user authenticate first
Jonath-z Aug 27, 2024
afec598
chore: update the verifiable credential SDK to fix the build error
Jonath-z Sep 9, 2024
0da6c3f
feat: remove unused variable
Jonath-z Sep 10, 2024
3f9230d
feat: add production issuer canister and icp identity provider
Jonath-z Oct 24, 2024
caae4c5
feat: add course completion logic to the issuer
Jonath-z Nov 11, 2024
41812f4
chore: upgrade the node verson in the CI
Jonath-z Nov 15, 2024
2e1ccad
chore: upgrade to node 22
Jonath-z Nov 15, 2024
5617aa6
chore: trigger deployment
Jonath-z Nov 18, 2024
3dda8c0
fix: correct the issuer canister existance check
Jonath-z Nov 18, 2024
aa11662
feat: add loading state for completion pending
Jonath-z Nov 18, 2024
c8b19b6
fix: disable the record ICP certificate button when add completion co…
Jonath-z Nov 18, 2024
356aed3
feat: open the login window in the popup
Jonath-z Nov 18, 2024
0228735
fix: correct the typo in the endpoint
Jonath-z Nov 19, 2024
5827fc3
chore: enhence error message when the record failed
Jonath-z Nov 19, 2024
8b8550a
chore: use api instead of axios instance for complete call
Jonath-z Nov 19, 2024
9d19e81
refactor: move the complete call to the certificate service
Jonath-z Nov 19, 2024
5b424b2
refactor: remove .well-known folder
Jonath-z Nov 19, 2024
797ae20
refactor: use primary-outline variant and change button text
serapieTuyishime Nov 25, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20.0.0"
node-version: "22.6.0"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

node 22 is not fully supported by many node modules yet. Especially next js

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately, it's needed for some ICPs peer dependencies to work

cache: "yarn"

- name: Install dependencies
Expand Down
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v20.14.0
v22.6.0
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@
},
"dependencies": {
"@coingecko/cryptoformat": "^0.5.4",
"@dfinity/agent": "^2.0.0",
"@dfinity/auth-client": "^2.0.0",
"@dfinity/candid": "^2.0.0",
"@dfinity/identity": "^2.0.0",
"@dfinity/principal": "^2.0.0",
"@dfinity/verifiable-credentials": "0.0.4",
"@reduxjs/toolkit": "^1.9.3",
"@stefanprobst/remark-extract-toc": "^2.2.0",
"@sumsub/websdk": "^1.4.0",
Expand Down
1 change: 1 addition & 0 deletions public/locales/bg/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,7 @@
"profile.achievement.award": "Awarded to",
"profile.achievement.issued": "Issued by",
"profile.achievement.comment": "Comment",
"profile.achievement.complete-certificate": "Получете сертификат ICP",
"notifications.emails.unsubscribe.confirm.title": "Confirm unsubscribing to email notifications",
"notifications.emails.unsubscribe.confirm.text": "You will no longer receive email notifications from <strong>{{appName}}</strong>.",
"notifications.emails.unsubscribe.button.confirm": "Confirm",
Expand Down
1 change: 1 addition & 0 deletions public/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,7 @@
"profile.achievement.award": "Awarded to",
"profile.achievement.issued": "Issued by",
"profile.achievement.comment": "Comment",
"profile.achievement.complete-certificate": "Get ICP Certificate",
"profile.edit.wallet.current.address": "Current address",
"profile.edit.wallet.new.address": "New address",
"profile.edit.wallet.error.matches-existing": "New address matches the existing one",
Expand Down
1 change: 1 addition & 0 deletions public/locales/es/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,7 @@
"profile.achievement.award": "Awarded to",
"profile.achievement.issued": "Issued by",
"profile.achievement.comment": "Comment",
"profile.achievement.complete-certificate": "Obtener Certificado ICP",
"email-verification.title": "Verify your email address",
"email-verification.subtitle": "You are almost there! We sent an email to",
"email-verification.message": "Just click on the link in that email to complete your signup.<br/>If you don't see the email, check your spam folder.",
Expand Down
1 change: 1 addition & 0 deletions public/locales/hr/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,7 @@
"profile.achievement.award": "Awarded to",
"profile.achievement.issued": "Issued by",
"profile.achievement.comment": "Comment",
"profile.achievement.complete-certificate": "Dobijte ICP certifikat",
"notifications.emails.unsubscribe.confirm.title": "Confirm unsubscribing to email notifications",
"notifications.emails.unsubscribe.confirm.text": "You will no longer receive email notifications from <strong>{{appName}}</strong>.",
"notifications.emails.unsubscribe.button.confirm": "Confirm",
Expand Down
2 changes: 1 addition & 1 deletion src/components/sections/profile/achievements/LinkField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export default function AchievementLinkField({ link }: AchievementLinkFieldProps

return (
<div className="border relative p-2 rounded">
<p className="text-gray-500 line-clamp-1 break-all flex-1 text-sm md:text-base overflow-hidden" onClick={copy}>
<p className="text-gray-500 line-clamp-1 break-all flex-1 text-sm md:text-base cursor-pointer overflow-hidden" onClick={copy}>
{link}
</p>
<div className="bg-gradient-to-l input-background absolute h-full w-40 top-0 flex justify-end items-center pr-2 right-0">
Expand Down
2 changes: 1 addition & 1 deletion src/components/sections/profile/communities/List.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export default function SubmissionList(): ReactElement {
<SubmissionCard
key={submission.id}
stats
link={navigation.community.submissionPath(submission.id, submission.challenge.id, community?.slug)}
link={navigation.community.submissionPath(submission.id, submission.challenge?.id, community?.slug)}
submission={submission}
last={i === submissions.length - 1}
/>
Expand Down
26 changes: 14 additions & 12 deletions src/components/sections/profile/overview/Achievements.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,20 @@ export default function ProfileOverviewAchievements(): ReactElement {
<AchievementCard key={`profile-achievement-${index}`} data={achievement} />
))}
</div>
{showAll ? (
<button onClick={() => setShowAll(false)} className="text-brand bg-transparent pt-4 flex items-center gap-1.5 cursor-pointer">
{" "}
<Less />
<span>See Less</span>
</button>
) : (
<button onClick={() => setShowAll(true)} className="text-brand bg-transparent pt-4 flex items-center gap-1.5 cursor-pointer">
{" "}
<Plus />
<span>See All</span>
</button>
{achievements.length > 4 && (
<>
{showAll ? (
<button onClick={() => setShowAll(false)} className="text-brand bg-transparent pt-4 flex items-center gap-1.5 cursor-pointer">
<Less />
<span>See Less</span>
</button>
) : (
<button onClick={() => setShowAll(true)} className="text-brand bg-transparent pt-4 flex items-center gap-1.5 cursor-pointer">
<Plus />
<span>See All</span>
</button>
)}
</>
)}
</ProfileOverviewSection>
);
Expand Down
100 changes: 100 additions & 0 deletions src/hooks/useIcpAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { canisterId, createActor } from "@/utilities/icp/issuerFactory";
import { Identity } from "@dfinity/agent";
import { AuthClient } from "@dfinity/auth-client";
import { Principal } from "@dfinity/principal";
import { useCallback, useEffect } from "react";

type Auth = {
client: AuthClient;
isAuthenticated: boolean;
identity: Identity;
principal: Principal;
principalText: string;
};

declare global {
interface Window {
auth: Auth;
issuerCanister: any;
}
}

export const MAX_TTL = BigInt(7 * 24 * 60 * 60 * 1000 * 1000 * 1000);

/**
* For production ready we shall use https://identity.ic0.app/ as identity provider
*/
export const IDENTITY_PROVIDER = "https://identity.ic0.app/";
const useIcpAuth = () => {
useEffect(() => {
if (!window) return;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Use typeof window check for universal rendering environments.

To prevent potential ReferenceError when window is undefined in server-side rendering, use if (typeof window === 'undefined') return;.

Apply this diff to fix the issue:

- if (!window) return;
+ if (typeof window === 'undefined') return;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!window) return;
if (typeof window === 'undefined') return;

async function initializeContract() {
const authClient = await AuthClient.create();
window.auth = {} as any;
window.auth.client = authClient;
window.auth.isAuthenticated = await authClient.isAuthenticated();
window.auth.identity = authClient.getIdentity();
window.auth.principal = authClient.getIdentity()?.getPrincipal();
window.auth.principalText = authClient.getIdentity()?.getPrincipal().toText();
window.issuerCanister = createActor(canisterId ?? "", {
agentOptions: {
host: "https://icp0.io",
identity: authClient.getIdentity(),
},
});
}

initializeContract();
}, []);

const popupCenter = useCallback((): string | undefined => {
const AUTH_POPUP_WIDTH = 576;
const AUTH_POPUP_HEIGHT = 625;

if (typeof window === "undefined" || !window.top) {
return undefined;
}

const { innerWidth, innerHeight, screenX, screenY } = window;

const y = innerHeight / 2 + screenY - AUTH_POPUP_HEIGHT / 2;
const x = innerWidth / 2 + screenX - AUTH_POPUP_WIDTH / 2;

return `toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=yes, resizable=no, copyhistory=no, width=${AUTH_POPUP_WIDTH}, height=${AUTH_POPUP_HEIGHT}, top=${y}, left=${x}`;
}, []);

async function login(callback: (principal: string) => void) {
if (!window) return;
const authClient = window.auth.client;

const isAuthenticated = await authClient.isAuthenticated();
if (!isAuthenticated) {
await authClient?.login({
maxTimeToLive: MAX_TTL,
identityProvider: IDENTITY_PROVIDER,
windowOpenerFeatures: popupCenter(),
onSuccess: async () => {
window.auth.isAuthenticated = await authClient.isAuthenticated();
const principal = await authClient.getIdentity()?.getPrincipal().toText();
callback(principal);
},
});
} else {
const principal = await authClient.getIdentity()?.getPrincipal().toText();
callback(principal);
}
}

async function logout() {
if (!window) return;
const authClient = window.auth.client;
authClient.logout();
}
Comment on lines +88 to +92
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Ensure authClient is initialized before using it in logout function.

As with the login function, ensure that authClient is initialized before calling logout to prevent runtime errors.

Consider adding a check to confirm authClient is available or delaying the logout call until initialization is complete.


⚠️ Potential issue

Await the authClient.logout() call in the logout function.

The logout method is asynchronous and should be awaited to ensure it completes properly.

Apply this diff to fix the issue:

 async function logout() {
   if (typeof window === 'undefined') return;
   const authClient = window.auth.client;
-  authClient.logout();
+  await authClient.logout();
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function logout() {
if (!window) return;
const authClient = window.auth.client;
authClient.logout();
}
async function logout() {
if (typeof window === 'undefined') return;
const authClient = window.auth.client;
await authClient.logout();
}


return {
login,
logout,
};
};

export default useIcpAuth;
49 changes: 44 additions & 5 deletions src/pages/achievements/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,14 @@ import Head from "next/head";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import { useRouter } from "next/router";
import { useDispatch } from "@/hooks/useTypedDispatch";
import { findCertificate } from "@/store/services/profile/certificate.service";
import { completeIcpCertificate, findCertificate } from "@/store/services/profile/certificate.service";
import { useTranslation } from "next-i18next";
import Logo from "@/icons/logo.svg";
import MintCertificate from "@/components/sections/profile/modals/MintCertificate";
import { Certificate } from "@/types/certificate";
import { User } from "@/types/bounty";
import { IRootState } from "@/store";
import useIcpAuth from "@/hooks/useIcpAuth";

/**
* interface for Achievement multiSelector
Expand All @@ -45,6 +46,8 @@ const Achievement = () => {
const [showMintCertificate, setShowMintCertificate] = useState(false);
const dispatch = useDispatch();
const { locale, query } = useRouter();
const { login } = useIcpAuth();
const [addCourseToCompletionPending, setAddCourseToCompletionPending] = useState(false);

const findCertificateById = useCallback(async () => {
await dispatch(findCertificate({ id: query.id as string }));
Expand Down Expand Up @@ -96,14 +99,38 @@ const Achievement = () => {
return user.id === achievement?.user_id;
}, [user, achievement]);

const mintable = useMemo(() => {
return achievement?.community?.can_mint_certificates;
const isICPSubmission = useMemo(() => {
return achievement?.community.name === "Internet Computer";
Comment on lines +102 to +103
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Avoid direct string comparison for community names.

Comparing community names using direct strings can lead to errors if the name changes or has variations. Consider using a constant or an enumeration for the community name.

Apply this diff to improve robustness:

- return achievement?.community.name === "Internet Computer";
+ const INTERNET_COMPUTER_COMMUNITY = "Internet Computer";
+ return achievement?.community.name === INTERNET_COMPUTER_COMMUNITY;

Committable suggestion was skipped due to low confidence.

}, [achievement]);

const mintable = useMemo(() => {
return achievement?.community?.can_mint_certificates || isICPSubmission;
}, [achievement, isICPSubmission]);

const isNotCertificateIcon = useMemo(() => {
return !achievement?.metadata?.image?.includes("/img/certificates/");
}, [achievement]);

const addCourseCompletionToTheIssuerCanister = useCallback(async () => {
if (!achievement?.metadata) return;
const { name } = achievement?.metadata;
if (!window.issuerCanister) return;
try {
setAddCourseToCompletionPending(true);
await window.issuerCanister.add_course_completion(name.toLowerCase().replaceAll(" ", "-"));
await dispatch(completeIcpCertificate({ id: achievement.id }));
} catch (err) {
console.error("Failed to complete course: ", err);
} finally {
setAddCourseToCompletionPending(false);
}
}, [achievement?.id, achievement?.metadata, dispatch, findCertificateById]);

const onMint = () => {
if (isICPSubmission) return login(() => addCourseCompletionToTheIssuerCanister());
setShowMintCertificate(true);
};

return (
<>
<Head>
Expand Down Expand Up @@ -160,11 +187,23 @@ const Achievement = () => {
<div className="w-full flex">
{!achievementMinted && belongsToCurrentUser && <MintCertificate show={showMintCertificate} close={() => setShowMintCertificate(false)} />}

{belongsToCurrentUser && !minted && (
<ArrowButton target="__blank" variant="primary" className="flex ml-auto mt-5" onClick={() => setShowMintCertificate(true)}>
{belongsToCurrentUser && !minted && !isICPSubmission && (
<ArrowButton target="__blank" variant="outline-primary" className="flex ml-auto mt-5" onClick={onMint}>
Mint certificate
</ArrowButton>
)}
{belongsToCurrentUser && !achievement.completed && isICPSubmission && (
<ArrowButton
target="__blank"
variant="outline-primary"
className="flex ml-auto mt-5"
disabled={addCourseToCompletionPending}
loading={addCourseToCompletionPending}
onClick={onMint}
>
Record Course Completion
</ArrowButton>
)}
</div>
</div>
)}
Expand Down
18 changes: 17 additions & 1 deletion src/store/services/profile/certificate.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,21 @@ const certificateService = createApi({
},
}),

complete: builder.mutation({
query: ({ id }: { id: string }) => ({
url: "certificates/complete",
method: "POST",
body: {
certificateId: id,
},
}),

onQueryStarted: async (_, { dispatch, queryFulfilled }) => {
const { data } = await queryFulfilled;
dispatch(setCurrentCertificate(data));
},
}),

mint: builder.mutation({
query: ({ id, address, signature }) => ({
url: "certificates/mint",
Expand All @@ -55,7 +70,6 @@ const certificateService = createApi({

onQueryStarted: async (_, { dispatch, queryFulfilled, getState }) => {
const { data } = await queryFulfilled;
console.log("This is the returned thing", data);
if (data.certificate) {
const state: any = getState();
const currentCertificate = state.profileCertificate.current;
Expand Down Expand Up @@ -97,5 +111,7 @@ export const mintCertificate = ({ id, address, signature }: MintCertificateArgs)
signature,
});

export const completeIcpCertificate = ({ id }: { id: string }) => certificateService.endpoints.complete.initiate({ id });

export const { useFetchAllCertificatesQuery, useFindCertificateQuery } = certificateService;
export default certificateService;
1 change: 1 addition & 0 deletions src/types/certificate.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface Certificate {
submission: Submission;
minting: Minting;
user: User;
completed: boolean;
}

/**
Expand Down
Loading