diff --git a/.changeset/five-poets-yell.md b/.changeset/five-poets-yell.md new file mode 100644 index 000000000..2a50ed7ca --- /dev/null +++ b/.changeset/five-poets-yell.md @@ -0,0 +1,5 @@ +--- +"@blockchain-lab-um/dapp": patch +--- + +Adds reclaim option for campaigns. diff --git a/.changeset/rich-pandas-add.md b/.changeset/rich-pandas-add.md new file mode 100644 index 000000000..7c7dcc532 --- /dev/null +++ b/.changeset/rich-pandas-add.md @@ -0,0 +1,5 @@ +--- +"@blockchain-lab-um/dapp": patch +--- + +Adds campaign issue checks. diff --git a/biome.json b/biome.json index 3eabe74ab..ba81b458c 100644 --- a/biome.json +++ b/biome.json @@ -25,8 +25,7 @@ "patches/**", "assets/**", ".changeset/**", - ".nx/**", - "post-process.js" + ".nx/**" ] }, "organizeImports": { diff --git a/package.json b/package.json index 6a144161b..6fc09caef 100644 --- a/package.json +++ b/package.json @@ -77,8 +77,16 @@ "cross-fetch@4.0.0": "patches/cross-fetch@4.0.0.patch", "@ceramicnetwork/common@2.30.0": "patches/@ceramicnetwork__common@2.30.0.patch", "@metamask/snaps-sdk@1.2.0": "patches/@metamask__snaps-sdk@1.2.0.patch", - "@changesets/assemble-release-plan@6.0.0": "patches/@changesets__assemble-release-plan@6.0.0.patch" + "@changesets/assemble-release-plan@6.0.0": "patches/@changesets__assemble-release-plan@6.0.0.patch", + "@iden3/js-iden3-core@1.2.1": "patches/@iden3__js-iden3-core@1.2.1.patch", + "@iden3/js-crypto@1.0.3": "patches/@iden3__js-crypto@1.0.3.patch", + "@iden3/js-jwz@1.3.0": "patches/@iden3__js-jwz@1.3.0.patch", + "@iden3/js-merkletree@1.1.2": "patches/@iden3__js-merkletree@1.1.2.patch", + "ffjavascript@0.2.63": "patches/ffjavascript@0.2.63.patch", + "did-jwt@6.11.6": "patches/did-jwt@6.11.6.patch", + "web-worker@1.3.0": "patches/web-worker@1.3.0.patch", + "@iden3/js-jsonld-merklization@1.1.2": "patches/@iden3__js-jsonld-merklization@1.1.2.patch" }, - "allowNonAppliedPatches": true + "allowNonAppliedPatches": false } } diff --git a/packages/dapp/src/app/[locale]/(public)/ecosystem/page.tsx b/packages/dapp/src/app/[locale]/(public)/ecosystem/page.tsx index bd389d73a..6fbbb5087 100644 --- a/packages/dapp/src/app/[locale]/(public)/ecosystem/page.tsx +++ b/packages/dapp/src/app/[locale]/(public)/ecosystem/page.tsx @@ -80,7 +80,7 @@ const projectsDark: ProjectIconProps[] = [ export default function Page() { return ( -
+

Applications diff --git a/packages/dapp/src/app/[locale]/(public)/layout.tsx b/packages/dapp/src/app/[locale]/(public)/layout.tsx index a2bcc5996..f50522c79 100644 --- a/packages/dapp/src/app/[locale]/(public)/layout.tsx +++ b/packages/dapp/src/app/[locale]/(public)/layout.tsx @@ -7,8 +7,10 @@ export default async function PublicLayout({ children: React.ReactNode; }) { return ( -
- +
+
+ +
{children} diff --git a/packages/dapp/src/app/[locale]/app/(protected)/settings/page.tsx b/packages/dapp/src/app/[locale]/app/(protected)/settings/page.tsx index d399f8beb..ba2d3ea18 100644 --- a/packages/dapp/src/app/[locale]/app/(protected)/settings/page.tsx +++ b/packages/dapp/src/app/[locale]/app/(protected)/settings/page.tsx @@ -9,7 +9,7 @@ export const metadata: Metadata = { export default function Page() { return ( -
+
diff --git a/packages/dapp/src/app/[locale]/app/(public)/campaigns/page.tsx b/packages/dapp/src/app/[locale]/app/(public)/campaigns/page.tsx index a1e0f24b3..600bee2b4 100644 --- a/packages/dapp/src/app/[locale]/app/(public)/campaigns/page.tsx +++ b/packages/dapp/src/app/[locale]/app/(public)/campaigns/page.tsx @@ -8,7 +8,7 @@ export const metadata: Metadata = { export default function Page() { return ( -
+
); diff --git a/packages/dapp/src/app/[locale]/app/layout.tsx b/packages/dapp/src/app/[locale]/app/layout.tsx index 8cb014258..94aa900d3 100644 --- a/packages/dapp/src/app/[locale]/app/layout.tsx +++ b/packages/dapp/src/app/[locale]/app/layout.tsx @@ -5,6 +5,7 @@ import AppNavbar from '@/components/AppNavbar'; import { SignInModal } from '@/components/SignInModal'; import ToastWrapper from '@/components/ToastWrapper'; import { Providers } from '@/components/Providers'; +import { ScrollShadow } from '@nextui-org/react'; export default async function AppLayout({ children, @@ -13,16 +14,17 @@ export default async function AppLayout({ }) { return ( - -
-
- {children} +
+
+
+ + {children} +
diff --git a/packages/dapp/src/app/api/campaigns/claims/route.ts b/packages/dapp/src/app/api/campaigns/claims/route.ts new file mode 100644 index 000000000..a393bc8d3 --- /dev/null +++ b/packages/dapp/src/app/api/campaigns/claims/route.ts @@ -0,0 +1,58 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import jwt from 'jsonwebtoken'; +import { supabaseServiceRoleClient } from '@/utils/supabase/supabaseServiceRoleClient'; +import { supabaseClient } from '@/utils/supabase/supabaseClient'; + +const CORS_HEADERS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', +}; + +export const dynamic = 'force-dynamic'; + +export async function GET(request: NextRequest) { + const token = request.headers.get('Authorization')?.replace('Bearer ', ''); + if (!token) { + return new NextResponse('Unauthorized', { + status: 401, + headers: { + ...CORS_HEADERS, + }, + }); + } + const user = jwt.verify(token, process.env.SUPABASE_JWT_SECRET!) as { + sub: string; + address: string; + aud: string; + role: string; + iat: number; + exp: number; + }; + + const supabase = supabaseServiceRoleClient(); + const { data: userId } = await supabase + .from('users') + .select('id') + .eq('address', user.address.toLowerCase()) + .single(); + if (!userId) { + return NextResponse.json({ claims: [] }, { headers: { ...CORS_HEADERS } }); + } + const { data: claims, error } = await supabase + .from('claims') + .select('*') + .eq('user_id', userId.id); + + if (error) { + console.error('Error getting claims', error); + return new NextResponse('Error getting claims', { + status: 500, + headers: { + ...CORS_HEADERS, + }, + }); + } + + return NextResponse.json({ claims }, { headers: { ...CORS_HEADERS } }); +} diff --git a/packages/dapp/src/app/api/campaigns/issue/route.ts b/packages/dapp/src/app/api/campaigns/issue/route.ts index a2c9cd04e..e07cd7f60 100644 --- a/packages/dapp/src/app/api/campaigns/issue/route.ts +++ b/packages/dapp/src/app/api/campaigns/issue/route.ts @@ -59,40 +59,13 @@ export async function POST(request: NextRequest) { }); } - const agent = await getAgent(); - const didResolution = await agent.resolveDid({ didUrl: did }); - - if ( - !didResolution.didDocument || - !didResolution.didDocument.verificationMethod - ) { - return new NextResponse('Error resolving did', { - status: 400, - headers: { - ...CORS_HEADERS, - }, - }); - } - - if ( - didResolution.didDocument.verificationMethod[0].blockchainAccountId - ?.split(':')[2] - .toLowerCase() !== user.address.toLowerCase() - ) { - return new NextResponse('Unauthorized', { - status: 401, - headers: { - ...CORS_HEADERS, - }, - }); - } - const supabase = supabaseServiceRoleClient(); const { data: campaign, error: campaignError } = await supabase .from('campaigns') - .select('*') + .select('*, requirements(id, *)') .eq('id', campaignId) + .order('created_at', { ascending: false }) .single() .throwOnError(); @@ -105,15 +78,6 @@ export async function POST(request: NextRequest) { }); } - if (campaign.total && campaign.claimed >= campaign.total) { - return new NextResponse('Campaign is already fully claimed', { - status: 400, - headers: { - ...CORS_HEADERS, - }, - }); - } - const { data: claim, error: claimError } = await supabase .from('claims') .select('*') @@ -129,6 +93,89 @@ export async function POST(request: NextRequest) { }); } + let claimDate = new Date().toISOString(); + let alreadyClaimed = false; + if (claim.length > 0) { + claimDate = claim[0].claimed_at; + alreadyClaimed = true; + } + + if (!alreadyClaimed) { + const now = new Date(); + const notYetStarted = + campaign.start_date && new Date(campaign.start_date) > now; + const alreadyFinished = + campaign.end_date && new Date(campaign.end_date) < now; + const isCampaignInactive = notYetStarted || alreadyFinished; + const isCampaignFullyClaimed = + campaign.total && campaign.claimed >= campaign.total; + + if (isCampaignInactive) { + return new NextResponse('Campaign is not active', { + status: 400, + headers: { ...CORS_HEADERS }, + }); + } + + if (isCampaignFullyClaimed) { + return new NextResponse('Campaign is already fully claimed', { + status: 400, + headers: { ...CORS_HEADERS }, + }); + } + + const { data: completedRequirements, error: completedRequirementsError } = + await supabase.rpc('get_num_of_users_requirements_by_campaign', { + input_campaign_id: campaignId, + input_user_id: user.sub, + }); + + if (completedRequirementsError) { + return new NextResponse('Internal Server Error', { + status: 500, + headers: { + ...CORS_HEADERS, + }, + }); + } + if (completedRequirements !== campaign.requirements.length) { + return new NextResponse('User has not completed all requirements', { + status: 400, + headers: { + ...CORS_HEADERS, + }, + }); + } + } + + const agent = await getAgent(); + const didResolution = await agent.resolveDid({ didUrl: did }); + + if ( + !didResolution.didDocument || + !didResolution.didDocument.verificationMethod + ) { + return new NextResponse('Error resolving did', { + status: 400, + headers: { + ...CORS_HEADERS, + }, + }); + } + + if ( + didResolution.didDocument.verificationMethod[0].blockchainAccountId + ?.split(':')[2] + .toLowerCase() !== user.address.toLowerCase() + ) { + return new NextResponse('Unauthorized', { + status: 401, + headers: { + ...CORS_HEADERS, + }, + }); + } + const controllerKeyId = 'key-1'; const issuerDid = await agent.didManagerImport({ @@ -145,12 +192,8 @@ export async function POST(request: NextRequest) { ], }); - let claimDate = new Date().toISOString(); - if (claim.length > 0) { - claimDate = claim[0].claimed_at; - } let credentialId = claim[0]?.credential_id; - if (claim.length === 0) { + if (!alreadyClaimed) { const { data: insertedClaimData, error: updatedClaimError } = await supabase .from('claims') @@ -173,7 +216,6 @@ export async function POST(request: NextRequest) { }); } - console.error('Error updating claim', updatedClaimError); return new NextResponse('Internal Server Error', { status: 500, headers: { diff --git a/packages/dapp/src/app/api/campaigns/requirements/verify/[id]/route.ts b/packages/dapp/src/app/api/campaigns/requirements/verify/[id]/route.ts index 966cbf973..5bd633f5f 100644 --- a/packages/dapp/src/app/api/campaigns/requirements/verify/[id]/route.ts +++ b/packages/dapp/src/app/api/campaigns/requirements/verify/[id]/route.ts @@ -180,8 +180,15 @@ export async function POST( let credentials: VerifiableCredential[]; try { - credentials = presentation.verifiableCredential.map((credential) => - normalizeCredential(credential) + credentials = presentation.verifiableCredential.reduce( + (result, credential) => { + const normalized = normalizeCredential(credential); + if (normalized?.credentialSubject?.id === did) { + result.push(normalized); + } + return result; + }, + [] as VerifiableCredential[] ); } catch (error) { console.error('Error decoding credentials', error); diff --git a/packages/dapp/src/components/AppNavbar/index.tsx b/packages/dapp/src/components/AppNavbar/index.tsx index 349de074d..96d945548 100644 --- a/packages/dapp/src/components/AppNavbar/index.tsx +++ b/packages/dapp/src/components/AppNavbar/index.tsx @@ -35,8 +35,8 @@ export default function AppNavbar() { const { isConnected } = useAccount(); return ( -
-
+
+
@@ -45,7 +45,7 @@ export default function AppNavbar() {

-
+
{MAIN_LINKS.map(({ name, href, requiresConnection }) => { if ((requiresConnection && isConnected) || !requiresConnection) { return ( diff --git a/packages/dapp/src/components/AuthorizationRequest/index.tsx b/packages/dapp/src/components/AuthorizationRequest/index.tsx index 400489cf0..703c97a1f 100644 --- a/packages/dapp/src/components/AuthorizationRequest/index.tsx +++ b/packages/dapp/src/components/AuthorizationRequest/index.tsx @@ -112,14 +112,6 @@ const AuthorizationRequestFlow = () => { } }; - const sendAuthorizationResponse = async () => { - if (!api || !authorizationRequestURI || !parsedAuthorizationRequestURI) { - return; - } - - console.log('here'); - }; - useEffect(() => { if (!credentials.length) return; setIsSelectModalOpen(true); @@ -127,8 +119,6 @@ const AuthorizationRequestFlow = () => { useEffect(() => { if (!selectedCredentials.length) return; - - console.log(selectedCredentials); // TODO: // sendAuthorizationResponse().catch((e) => console.log(e)); }, [selectedCredentials]); diff --git a/packages/dapp/src/components/CampaignsDisplay/CampaignDisplay.tsx b/packages/dapp/src/components/CampaignsDisplay/CampaignDisplay.tsx index 1ba73ffef..5182f05b7 100644 --- a/packages/dapp/src/components/CampaignsDisplay/CampaignDisplay.tsx +++ b/packages/dapp/src/components/CampaignsDisplay/CampaignDisplay.tsx @@ -14,9 +14,11 @@ import { useSwitchChain, } from '@/hooks'; import { useAccount } from 'wagmi'; +import { RewardDisplay } from './RewardDisplay'; type CampaignProps = { campaign: Campaigns[number]; + alreadyClaimed: boolean; }; export const CampaignDisplay = ({ @@ -28,7 +30,9 @@ export const CampaignDisplay = ({ total, image_url: imageUrl, requirements, + rewards, }, + alreadyClaimed, }: CampaignProps) => { const t = useTranslations('CampaignDisplay'); @@ -122,6 +126,12 @@ export const CampaignDisplay = ({

{description}

+
+ {t('rewards')} +
+
+ +
{requirements.length > 0 && (
{t('requirements')} @@ -142,13 +152,27 @@ export const CampaignDisplay = ({
diff --git a/packages/dapp/src/components/CampaignsDisplay/RequirementDisplay.tsx b/packages/dapp/src/components/CampaignsDisplay/RequirementDisplay.tsx index dd76c6562..ca68e307b 100644 --- a/packages/dapp/src/components/CampaignsDisplay/RequirementDisplay.tsx +++ b/packages/dapp/src/components/CampaignsDisplay/RequirementDisplay.tsx @@ -12,6 +12,7 @@ import { useSwitchChain, useVerifyRequirement } from '@/hooks'; import { useAuthStore, useMascaStore, useToastStore } from '@/stores'; import { isError } from '@blockchain-lab-um/masca-connector'; import { shallow } from 'zustand/shallow'; +import type { W3CVerifiableCredential } from '@veramo/core'; type RequirementProps = { requirement: Tables<'requirements'>; @@ -24,7 +25,14 @@ export const RequirementDisplay = ({ }: RequirementProps) => { const t = useTranslations('RequirementDisplay'); - const token = useAuthStore((state) => state.token); + const { token, isSignedIn, changeIsSignInModalOpen } = useAuthStore( + (state) => ({ + token: state.token, + isSignedIn: state.isSignedIn, + changeIsSignInModalOpen: state.changeIsSignInModalOpen, + }), + shallow + ); const { api, did, didMethod, changeDID, changeCurrDIDMethod } = useMascaStore( (state) => ({ @@ -80,9 +88,31 @@ export const RequirementDisplay = ({ return; } - // Create a presentation from all the user's credentials + // Create a presentation from all the user's credentials except the polygonid ones const createPresentationResult = await api.createPresentation({ - vcs: queryCredentialsResult.data.map((queryResult) => queryResult.data), + vcs: queryCredentialsResult.data.reduce((acc, queryResult) => { + const credential = queryResult.data; + + let issuer = null; + + if (!credential.issuer) return acc; + + if (typeof credential.issuer === 'string') { + issuer = credential.issuer; + } else if (credential.issuer.id) { + issuer = credential.issuer.id; + } + + if (!issuer) return acc; + + if ( + !issuer.includes('did:poylgonid') && + !issuer.includes('did:iden3') + ) { + acc.push(credential); + } + return acc; + }, [] as W3CVerifiableCredential[]), proofFormat: 'EthereumEip712Signature2021', }); @@ -91,6 +121,7 @@ export const RequirementDisplay = ({ return; } setStartedVerifying(false); + await verifyRequirement({ did: currentDid, presentation: createPresentationResult.data, @@ -120,7 +151,9 @@ export const RequirementDisplay = ({