diff --git a/.changeset/breezy-wombats-sin.md b/.changeset/breezy-wombats-sin.md new file mode 100644 index 000000000..cc63234b1 --- /dev/null +++ b/.changeset/breezy-wombats-sin.md @@ -0,0 +1,5 @@ +--- +'@blockchain-lab-um/dapp': patch +--- + +Updated the table using NextUI, drastically improved filtering & fixed some bugs diff --git a/.changeset/young-camels-give.md b/.changeset/young-camels-give.md new file mode 100644 index 000000000..c18363a1d --- /dev/null +++ b/.changeset/young-camels-give.md @@ -0,0 +1,5 @@ +--- +'@blockchain-lab-um/dapp': minor +--- + +Add support for credential sharing. diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 7b28c1d08..daf11b937 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -105,5 +105,6 @@ module.exports = { 'templates', 'external', '.nx', + '**/database.types.ts', ], }; diff --git a/.npmrc b/.npmrc index 6c59086d8..f84ca57e8 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,3 @@ enable-pre-post-scripts=true + +public-hoist-pattern[]=*@nextui-org/* diff --git a/packages/dapp/.env.example b/packages/dapp/.env.example index fe377ee95..629bb043a 100644 --- a/packages/dapp/.env.example +++ b/packages/dapp/.env.example @@ -1,11 +1,19 @@ NEXT_PUBLIC_DEMO_ISSUER=http://localhost:3003 NEXT_PUBLIC_DEMO_VERIFIER=http://localhost:3004 NEXT_PUBLIC_GOOGLE_CLIENT_ID= -NEXT_PUBLIC_GOOGLE_SCOPES= -GOOGLE_DRIVE_FILE_NAME= +NEXT_PUBLIC_GOOGLE_SCOPES=https://www.googleapis.com/auth/drive.appdata +GOOGLE_DRIVE_FILE_NAME=masca-backup- # Prisma DATABASE_URL= # Masca version -NEXT_PUBLIC_MASCA_VERSION=v1.0.0 +NEXT_PUBLIC_MASCA_VERSION=v1.1.0 + +# SupaBase Public +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_ANON_KEY= + +# SupaBase Private +SUPABASE_SECRET_KEY= +SUPABASE_JWT_SECRET= diff --git a/packages/dapp/next.config.js b/packages/dapp/next.config.js index 6c31912f0..8ebf78fd9 100644 --- a/packages/dapp/next.config.js +++ b/packages/dapp/next.config.js @@ -1,5 +1,6 @@ const StylelintPlugin = require('stylelint-webpack-plugin'); const path = require('path'); +const withNextIntl = require('next-intl/plugin')(); // Content-Security-Policy // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy @@ -109,4 +110,4 @@ const nextConfig = { }, }; -module.exports = nextConfig; +module.exports = withNextIntl(nextConfig); diff --git a/packages/dapp/package.json b/packages/dapp/package.json index 93709b74d..2284295da 100644 --- a/packages/dapp/package.json +++ b/packages/dapp/package.json @@ -8,7 +8,8 @@ "build": "rimraf .next && next build", "postbuild": "next-sitemap --config=next-sitemap.config.js", "build:docker": "pnpm build", - "dev": "cross-env USE_LOCAL='true' next dev", + "dev": "cross-env next dev", + "dev:local": "cross-env USE_LOCAL='true' next dev", "docker:build": "docker build . -t blockchain-lab-um/dapp:latest", "postinstall": "pnpm prisma generate --schema=./prisma/schema.prisma", "lint": "pnpm lint:next && pnpm lint:tsc && pnpm lint:prettier && pnpm lint:stylelint", @@ -20,30 +21,46 @@ "start": "next start" }, "dependencies": { + "@blockchain-lab-um/did-provider-key": "1.0.7", "@blockchain-lab-um/masca-connector": "1.2.1", "@blockchain-lab-um/oidc-types": "0.0.8", + "@ethersproject/providers": "^5.7.2", "@headlessui/react": "^1.7.17", "@heroicons/react": "^2.0.18", "@metamask/detect-provider": "^2.0.0", "@metamask/providers": "13.1.0", + "@nextui-org/react": "^2.2.9", "@prisma/client": "^5.7.0", "@radix-ui/react-toast": "^1.1.5", "@react-oauth/google": "^0.11.1", + "@supabase/supabase-js": "^2.38.5", "@tanstack/react-table": "^8.10.7", + "@types/js-cookie": "^3.0.6", "@veramo/core": "5.5.3", + "@veramo/credential-eip712": "5.5.3", + "@veramo/credential-w3c": "5.5.3", + "@veramo/did-provider-ethr": "5.5.3", + "@veramo/did-provider-pkh": "5.5.3", + "@veramo/did-resolver": "5.5.3", "@veramo/utils": "5.5.3", "@vercel/analytics": "^1.1.1", "@vercel/og": "^0.5.20", "clsx": "^2.0.0", + "date-fns": "^2.30.0", "did-jwt-vc": "^3.2.13", + "did-resolver": "4.1.0", "ethers": "^6.9.0", + "ethr-did-resolver": "8.1.2", "file-saver": "^2.0.5", + "framer-motion": "^10.16.5", "googleapis": "^128.0.0", "headless-stepper": "^1.9.1", "html5-qrcode": "^2.3.8", + "js-cookie": "^3.0.5", + "jsonwebtoken": "^9.0.2", "luxon": "^3.4.3", "next": "13.5.6", - "next-intl": "3.0.0-beta.9", + "next-intl": "3.4.0", "next-sitemap": "^4.2.3", "next-themes": "^0.2.1", "qrcode.react": "^3.1.0", @@ -51,6 +68,7 @@ "react": "18.2.0", "react-dom": "18.2.0", "sharp": "^0.32.6", + "siwe": "^2.1.4", "swr": "^2.2.4", "tailwind-scrollbar": "^3.0.5", "zustand": "^4.4.4" @@ -58,6 +76,7 @@ "devDependencies": { "@svgr/webpack": "^8.1.0", "@types/file-saver": "^2.0.6", + "@types/jsonwebtoken": "^9.0.5", "@types/luxon": "^3.3.3", "@types/qs": "^6.9.9", "@types/react": "18.2.33", @@ -73,6 +92,7 @@ "stylelint-config-standard-scss": "^11.0.0", "stylelint-prettier": "^4.0.2", "stylelint-webpack-plugin": "^4.1.1", + "supabase": "^1.113.3", "tailwindcss": "^3.3.5" }, "nx": { diff --git a/packages/dapp/src/app/[locale]/app/(protected)/create-credential/page.tsx b/packages/dapp/src/app/[locale]/app/(protected)/create-credential/page.tsx index 0800e826f..1411c4a44 100644 --- a/packages/dapp/src/app/[locale]/app/(protected)/create-credential/page.tsx +++ b/packages/dapp/src/app/[locale]/app/(protected)/create-credential/page.tsx @@ -9,7 +9,7 @@ export const metadata: Metadata = { export default function Page() { return ( -
+
diff --git a/packages/dapp/src/app/[locale]/app/(protected)/page.tsx b/packages/dapp/src/app/[locale]/app/(protected)/page.tsx index a4f3ac5ac..06cfddf2d 100644 --- a/packages/dapp/src/app/[locale]/app/(protected)/page.tsx +++ b/packages/dapp/src/app/[locale]/app/(protected)/page.tsx @@ -1,7 +1,7 @@ import { Metadata } from 'next'; import Controlbar from '@/components/Controlbar/Controlbar'; -import Table from '@/components/VCTable'; +import DashboardDisplay from '@/components/DashboardDisplay'; export const metadata: Metadata = { title: 'Dashboard', @@ -12,8 +12,8 @@ export default function Page() { return (
-
- +
+
); diff --git a/packages/dapp/src/app/[locale]/app/(protected)/shared-presentations/page.tsx b/packages/dapp/src/app/[locale]/app/(protected)/shared-presentations/page.tsx new file mode 100644 index 000000000..04836079a --- /dev/null +++ b/packages/dapp/src/app/[locale]/app/(protected)/shared-presentations/page.tsx @@ -0,0 +1,21 @@ +import { Metadata } from 'next'; + +import { SupabaseProvider } from '@/components/SupabaseProvider'; +import { SharedPresentations } from './sharedPresentations'; + +export const metadata: Metadata = { + title: 'Shared Presentations', + description: 'Dashboard for managing shared presentations', +}; + +export default async function Page() { + return ( +
+
+ + + +
+
+ ); +} diff --git a/packages/dapp/src/app/[locale]/app/(protected)/shared-presentations/sharedPresentations.tsx b/packages/dapp/src/app/[locale]/app/(protected)/shared-presentations/sharedPresentations.tsx new file mode 100644 index 000000000..519283609 --- /dev/null +++ b/packages/dapp/src/app/[locale]/app/(protected)/shared-presentations/sharedPresentations.tsx @@ -0,0 +1,266 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { EyeIcon, TrashIcon } from '@heroicons/react/24/solid'; +import { + Pagination, + Spinner, + Table, + TableBody, + TableCell, + TableColumn, + TableHeader, + TableRow, + Tooltip, +} from '@nextui-org/react'; +import clsx from 'clsx'; +import { useTranslations } from 'next-intl'; + +import { DeleteSharedPresentationModal } from '@/components/DeleteSharedPresentationModal'; +import { createClient } from '@/utils/supabase/client'; +import { Tables } from '@/utils/supabase/helper.types'; +import { useAuthStore } from '@/stores/authStore'; + +const ITEMS_PER_PAGE = 10; + +const getFromAndTo = (page: number) => { + const from = page === 0 ? 0 : (page - 1) * ITEMS_PER_PAGE; + const to = from + ITEMS_PER_PAGE - 1; + + return { from, to }; +}; + +const queryPresentations = async (token: string, page: number) => { + const supabase = createClient(token); + const { from, to } = getFromAndTo(page); + + const { data, error } = await supabase + .from('presentations') + .select('*') + .range(from, to); + + if (error) throw new Error('Failed to fetch presentations'); + + return data; +}; + +const totalPresentations = async (token: string) => { + const supabase = createClient(token); + + const { count, error } = await supabase.from('presentations').select('id', { + count: 'exact', + }); + + if (error) throw new Error('Failed to fetch presentations'); + + return count; +}; + +export const SharedPresentations = () => { + const t = useTranslations('SharedPresentations'); + + const router = useRouter(); + + // Local state + const [presentations, setPresentations] = useState[]>( + [] + ); + const [total, setTotal] = useState(null); + const [loading, setLoading] = useState(false); + const [isDeleteModalOpen, setDeleteModalOpen] = useState(false); + const [selectedPresentationId, setSelectedPresentationId] = useState< + string | null + >(null); + const [page, setPage] = useState(1); + + const pages = useMemo(() => { + if (!total) return 1; + return Math.ceil(total / ITEMS_PER_PAGE); + }, [total]); + + // Global state + const token = useAuthStore((state) => state.token); + + const columns = [ + { + key: 'title', + label: t('table-header.title'), + }, + { + key: 'created_at', + label: t('table-header.created_at'), + }, + { + key: 'expires_at', + label: t('table-header.expires_at'), + }, + { + key: 'views', + label: t('table-header.views'), + }, + { + key: 'actions', + label: t('table-header.actions'), + }, + ]; + + // Functions + const renderCell = useCallback( + (presentation: Tables<'presentations'>, columnKey: React.Key) => { + switch (columnKey) { + case 'expires_at': + return presentation.expires_at + ? new Date(presentation.expires_at).toLocaleDateString() + : 'Never'; + case 'created_at': + return new Date(presentation.created_at).toLocaleDateString(); + case 'actions': + return ( +
+ +
+ router.push(`/app/share-presentation/${presentation.id}`) + } + > + +
+
+ +
{ + setSelectedPresentationId(presentation.id); + setDeleteModalOpen(true); + }} + > + +
+
+
+ ); + + default: + return presentation[ + columnKey as keyof Omit, 'presentation'> + ]; + } + }, + [] + ); + + useEffect(() => { + if (!token) return; + totalPresentations(token) + .then((data) => setTotal(data)) + .catch((error) => { + console.error(error); + }); + }, [token]); + + useEffect(() => { + if (!token) return; + + setLoading(true); + queryPresentations(token, page) + .then((data) => { + setPresentations(data); + setLoading(false); + }) + .catch((error) => { + console.error(error); + setLoading(false); + }); + }, [token, page]); + + if (!token) return null; + + return ( + <> +
+
+

+ {t('title')} +

+
+

{t('total')}

+

+ {total} +

+
+
+
+ + {(column) => ( + + {column.label} + + )} + + } + > + {(item) => ( + + {(columnKey) => ( + + {renderCell(item, columnKey)} + + )} + + )} + +
+
+ {pages > 1 && ( + setPage(newPage)} + /> + )} +
+
+ + + ); +}; diff --git a/packages/dapp/src/app/[locale]/app/(public)/share-presentation/[id]/credentialPanel.tsx b/packages/dapp/src/app/[locale]/app/(public)/share-presentation/[id]/credentialPanel.tsx new file mode 100644 index 000000000..b7055dbd4 --- /dev/null +++ b/packages/dapp/src/app/[locale]/app/(public)/share-presentation/[id]/credentialPanel.tsx @@ -0,0 +1,258 @@ +'use client'; + +import { Fragment, useMemo, useState } from 'react'; +import { usePathname, useRouter } from 'next/navigation'; +import { + CheckCircleIcon, + DocumentDuplicateIcon, + ExclamationCircleIcon, +} from '@heroicons/react/24/outline'; +import { Tooltip } from '@nextui-org/react'; +import { VerifiableCredential } from '@veramo/core'; +import clsx from 'clsx'; +import { useTranslations } from 'next-intl'; + +import { DIDDisplay } from '@/components/DIDDisplay'; +import JsonModal from '@/components/JsonModal'; +import { convertTypes, copyToClipboard } from '@/utils/string'; + +interface FormatedPanelProps { + credential: VerifiableCredential; +} + +const AddressDisplay = ({ address }: { address: string }) => { + const t = useTranslations('AddressDisplay'); + return ( +
+

+ {t('title')}: +

+
+ + + {`${address.slice(0, 8)}...${address.slice(-8)}`} + + + +
+
+ ); +}; + +const DisplayDate = ({ text, date }: { text: string; date: string }) => ( +
+

+ {text}: +

+

+ {new Date(Date.parse(date)).toDateString()} +

+
+); + +const CredentialSubject = ({ + data, + viewJsonText, + selectJsonData, +}: { + data: Record; + viewJsonText: string; + selectJsonData: React.Dispatch>; +}) => ( + <> + {Object.entries(data).map(([key, value]: [string, any]) => ( + + {(() => { + if (key === 'id') { + return ( + <> +
+

+ DID: +

+
+ +
+
+ + ); + } + + if (key === 'address') return ; + + const isObject = !( + typeof value === 'string' || typeof value === 'number' + ); + + return ( +
+

+ {key}: +

+
+ {isObject ? ( + + ) : ( + value + )} +
+
+ ); + })()} +
+ ))} + +); + +const CredentialPanel = ({ credential }: FormatedPanelProps) => { + const t = useTranslations('CredentialPanel'); + + const pathname = usePathname(); + const router = useRouter(); + + // Local state + const types = useMemo(() => convertTypes(credential.type), [credential.type]); + const [jsonModalOpen, setJsonModalOpen] = useState(false); + const [selectedJsonData, setSelectedJsonData] = useState({}); + + const isValid = useMemo(() => { + if (!credential.expirationDate) return true; + return Date.parse(credential.expirationDate) > Date.now(); + }, [credential]); + + const selectJsonData = (data: any) => { + setSelectedJsonData(data); + setJsonModalOpen(true); + }; + + return ( + <> +
+
+
+

+ {t('title')} +

+ +

+ {types} +

+
+
+
+ + {isValid ? ( + + ) : ( + + )} + +
+

+ {t('status')} +

+

+ {isValid ? 'Valid' : 'Invalid'} +

+
+
+
+
+
+

+ {t('subject')} +

+ +
+
+
+
+

+ {t('issuer')} +

+
+

+ DID: +

+
+ +
+
+
+
+

+ {t('dates')} +

+ + {credential.expirationDate && ( + + )} +
+
+
+
+
{ + const params = new URLSearchParams(window.location.search); + params.set('view', 'Json'); + router.replace(`${pathname}?${params.toString()}`); + }} + > + {t('view-json')} +
+
+ + + ); +}; + +export default CredentialPanel; diff --git a/packages/dapp/src/app/[locale]/app/(public)/share-presentation/[id]/formatedView.tsx b/packages/dapp/src/app/[locale]/app/(public)/share-presentation/[id]/formatedView.tsx new file mode 100644 index 000000000..cc2077352 --- /dev/null +++ b/packages/dapp/src/app/[locale]/app/(public)/share-presentation/[id]/formatedView.tsx @@ -0,0 +1,120 @@ +'use client'; + +import { useMemo } from 'react'; +import { usePathname, useRouter } from 'next/navigation'; +import { + CheckCircleIcon, + ExclamationCircleIcon, +} from '@heroicons/react/24/outline'; +import { Pagination, Tooltip } from '@nextui-org/react'; +import { VerifiableCredential } from '@veramo/core'; +import { useTranslations } from 'next-intl'; + +import CredentialPanel from './credentialPanel'; + +export const FormatedView = ({ + credential, + holder, + expirationDate, + issuanceDate, + page, + total, +}: { + credential: VerifiableCredential; + holder: string; + expirationDate: string | undefined; + issuanceDate: string | undefined; + page: string; + total: number; +}) => { + const t = useTranslations('FormatedView'); + + const router = useRouter(); + const pathname = usePathname(); + + const isValid = useMemo(() => { + if (!expirationDate) return true; + return Date.parse(expirationDate) > Date.now(); + }, [expirationDate]); + + return ( + <> +
+
+
+
+
+

+ {t('holder')} +

+

+ {holder.substring(0, 20)}... + {holder.substring(holder.length, holder.length - 10)} +

+
+ {issuanceDate && ( +
+

+ {t('presented')} +

+ {new Date(Date.parse(issuanceDate)).toDateString()} +
+ )} +
+

+ {t('credentials')} +

+
+
+
+
+ + {isValid ? ( + + ) : ( + + )} + +
+

+ {t('credential-status')} +

+

+ {isValid ? t('valid') : t('invalid')} +

+
+
+
+
+
+ { + const params = new URLSearchParams(window.location.search); + params.set('page', val.toString()); + params.set('view', 'Normal'); + router.replace(`${pathname}?${params.toString()}`); + }} + classNames={{ + wrapper: + 'space-x-2 pl-4 pr-6 pb-2 pt-1 rounded-none rounded-b-2xl bg-gradient-to-tr from-pink-100 to-orange-100 dark:from-navy-blue-700 dark:to-navy-blue-700', + item: 'flex-nowrap w-5 h-5 text-black dark:text-navy-blue-200 bg-inherit shadow-none active:bg-inherit active:text-black dark:active:text-navy-blue-200 [&[data-hover=true]:not([data-active=true])]:bg-inherit', + cursor: + 'w-5 h-5 rounded-full dark:bg-orange-accent-dark bg-pink-500 text-white dark:text-navy-blue-800', + }} + /> +
+ +
+
+ + ); +}; diff --git a/packages/dapp/src/app/[locale]/app/(public)/share-presentation/[id]/page.tsx b/packages/dapp/src/app/[locale]/app/(public)/share-presentation/[id]/page.tsx new file mode 100644 index 000000000..bbc86c983 --- /dev/null +++ b/packages/dapp/src/app/[locale]/app/(public)/share-presentation/[id]/page.tsx @@ -0,0 +1,87 @@ +import { Metadata } from 'next'; +import { notFound } from 'next/navigation'; +import { createClient } from '@supabase/supabase-js'; +import { VerifiablePresentation } from '@veramo/core'; +import { decodeCredentialToObject } from '@veramo/utils'; + +import JsonPanel from '@/components/CredentialDisplay/JsonPanel'; +import { Database } from '@/utils/supabase/database.types'; +import { FormatedView } from './formatedView'; + +export const metadata: Metadata = { + title: 'Share presentation', + description: 'Page for displaying shared presentations', +}; + +export const revalidate = 0; + +const getPresentation = async (id: string): Promise => { + const supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SECRET_KEY! + ); + + // Query the presentation + const { data, error } = await supabase + .from('presentations') + .select() + .eq('id', id) + .limit(1); + + if (error) { + throw new Error('Failed to fetch presentation'); + } + + if (!data || data.length === 0) { + return notFound(); + } + + // Update views + await supabase + .from('presentations') + .update({ views: data[0].views + 1 }) + .eq('id', id); + + const presentation = data[0].presentation as VerifiablePresentation; + return presentation; +}; + +export default async function Page({ + params: { id }, + searchParams, +}: { + params: { id: string }; + searchParams: { + view: 'Normal' | 'Json'; + page: string | undefined; + }; +}) { + const presentation = await getPresentation(id); + const credentials = presentation.verifiableCredential + ? presentation.verifiableCredential.map(decodeCredentialToObject) + : []; + const page = searchParams.page ?? '1'; + const view = searchParams.view ?? 'Normal'; + + return ( +
+
+ {view === 'Normal' && ( + + )} + {view === 'Json' && ( +
+ +
+ )} +
+
+ ); +} diff --git a/packages/dapp/src/app/[locale]/app/(public)/share-presentation/not-found.tsx b/packages/dapp/src/app/[locale]/app/(public)/share-presentation/not-found.tsx new file mode 100644 index 000000000..1c8556f75 --- /dev/null +++ b/packages/dapp/src/app/[locale]/app/(public)/share-presentation/not-found.tsx @@ -0,0 +1,8 @@ +import { useTranslations } from 'next-intl'; + +import BasicNotFound from '@/components/BasicNotFound'; + +export default function NotFound() { + const t = useTranslations('NotFoundPresentation'); + return ; +} diff --git a/packages/dapp/src/app/[locale]/app/error.tsx b/packages/dapp/src/app/[locale]/app/error.tsx new file mode 100644 index 000000000..28803a76c --- /dev/null +++ b/packages/dapp/src/app/[locale]/app/error.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { useEffect } from 'react'; +import { useTranslations } from 'next-intl'; + +import Button from '@/components/Button'; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + const t = useTranslations('Error'); + + useEffect(() => { + // Optionally log the error to an error reporting service + console.error(error); + }, [error]); + + return ( +
+

{t('title')}

+ +
+ ); +} diff --git a/packages/dapp/src/app/[locale]/app/layout.tsx b/packages/dapp/src/app/[locale]/app/layout.tsx index ecd34bc8e..759b5ab58 100644 --- a/packages/dapp/src/app/[locale]/app/layout.tsx +++ b/packages/dapp/src/app/[locale]/app/layout.tsx @@ -1,7 +1,11 @@ +import clsx from 'clsx'; + import AppBottomBar from '@/components/AppBottomBar'; import AppNavbar from '@/components/AppNavbar'; import CheckMetaMaskCompatibility from '@/components/CheckMetaMaskCompatibility'; +import { CookiesProvider } from '@/components/CookiesProvider'; import QRCodeSessionProvider from '@/components/QRCodeSessionProvider'; +import { SignInModal } from '@/components/SignInModal'; import ToastWrapper from '@/components/ToastWrapper'; export default async function AppLayout({ @@ -10,10 +14,15 @@ export default async function AppLayout({ children: React.ReactNode; }) { return ( -
+ <> -
-
+
+
{children}
@@ -21,6 +30,8 @@ export default async function AppLayout({ -
+ + + ); } diff --git a/packages/dapp/src/app/[locale]/layout.tsx b/packages/dapp/src/app/[locale]/layout.tsx index 1d9393fa0..e9a0a047c 100644 --- a/packages/dapp/src/app/[locale]/layout.tsx +++ b/packages/dapp/src/app/[locale]/layout.tsx @@ -2,9 +2,8 @@ import '@/styles/globals.css'; import { Metadata } from 'next'; import { Cabin, JetBrains_Mono, Ubuntu } from 'next/font/google'; -import { notFound } from 'next/navigation'; import clsx from 'clsx'; -import { NextIntlClientProvider } from 'next-intl'; +import { NextIntlClientProvider, useMessages } from 'next-intl'; import AnalyticsWrapper from '@/components/AnalyticsWrapper'; import ThemeProvider from '@/components/ThemeProvider'; @@ -82,24 +81,14 @@ export const metadata: Metadata = { manifest: null, }; -export function generateStaticParams() { - return [{ locale: 'en' }]; -} - -export default async function LocaleLayout({ +export default function LocaleLayout({ children, params, }: { children: React.ReactNode; params: { locale: string }; }) { - let messages; - - try { - messages = (await import(`../../messages/${params.locale}.json`)).default; - } catch (error) { - notFound(); - } + const messages = useMessages(); return ( @@ -109,7 +98,7 @@ export default async function LocaleLayout({ diff --git a/packages/dapp/src/app/api/google/route.tsx b/packages/dapp/src/app/api/google/route.ts similarity index 100% rename from packages/dapp/src/app/api/google/route.tsx rename to packages/dapp/src/app/api/google/route.ts diff --git a/packages/dapp/src/app/api/qr-code-session/[id]/route.tsx b/packages/dapp/src/app/api/qr-code-session/[id]/route.ts similarity index 100% rename from packages/dapp/src/app/api/qr-code-session/[id]/route.tsx rename to packages/dapp/src/app/api/qr-code-session/[id]/route.ts diff --git a/packages/dapp/src/app/api/share/presentation/[id]/route.ts b/packages/dapp/src/app/api/share/presentation/[id]/route.ts new file mode 100644 index 000000000..f637fcd84 --- /dev/null +++ b/packages/dapp/src/app/api/share/presentation/[id]/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@supabase/supabase-js'; + +import { Database } from '@/utils/supabase/database.types'; + +const CORS_HEADERS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', +}; + +export async function GET( + _: NextRequest, + { params: { id } }: { params: { id: string } } +) { + try { + const supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SECRET_KEY! + ); + + const { data, error } = await supabase + .from('presentations') + .select() + .eq('id', id) + .limit(1) + .single(); + + if (error || !data) { + return new NextResponse('Presentation not found', { + status: 404, + headers: { + ...CORS_HEADERS, + }, + }); + } + + return NextResponse.json( + { + presentation: data.presentation, + }, + { + status: 200, + headers: { + ...CORS_HEADERS, + }, + } + ); + } catch (error) { + return new NextResponse('Internal Server Error', { + status: 500, + headers: { + ...CORS_HEADERS, + }, + }); + } +} + +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: { + ...CORS_HEADERS, + }, + }); +} diff --git a/packages/dapp/src/app/api/share/presentation/route.ts b/packages/dapp/src/app/api/share/presentation/route.ts new file mode 100644 index 000000000..855b1be55 --- /dev/null +++ b/packages/dapp/src/app/api/share/presentation/route.ts @@ -0,0 +1,125 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@supabase/supabase-js'; +import jwt from 'jsonwebtoken'; + +import { Database } from '@/utils/supabase/database.types'; +import { getAgent } from '../../veramoSetup'; + +const CORS_HEADERS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', +}; + +export async function POST(request: NextRequest) { + try { + 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 { presentation, title } = await request.json(); + + if (!presentation) { + return new NextResponse('Missing presentation', { + status: 400, + headers: { + ...CORS_HEADERS, + }, + }); + } + + if (!title) { + return new NextResponse('Missing title', { + status: 400, + headers: { + ...CORS_HEADERS, + }, + }); + } + + const agent = await getAgent(); + + const { verified } = await agent.verifyPresentation({ + presentation, + }); + + if (!verified) { + return new NextResponse('Presentation not valid', { + status: 400, + headers: { + ...CORS_HEADERS, + }, + }); + } + + const supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SECRET_KEY! + ); + + const { data, error } = await supabase + .from('presentations') + .insert({ + user_id: user.sub, + presentation, + created_at: new Date().toISOString(), + title, + }) + .select() + .limit(1) + .single(); + + if (error || !data) { + return new NextResponse('Internal Server Error', { + status: 500, + headers: { + ...CORS_HEADERS, + }, + }); + } + + return NextResponse.json( + { + presentationId: data.id, + }, + { + status: 201, + headers: { + ...CORS_HEADERS, + }, + } + ); + } catch (error) { + return new NextResponse('Internal Server Error', { + status: 500, + headers: { + ...CORS_HEADERS, + }, + }); + } +} + +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: { + ...CORS_HEADERS, + }, + }); +} diff --git a/packages/dapp/src/app/api/siwe/nonce/route.ts b/packages/dapp/src/app/api/siwe/nonce/route.ts new file mode 100644 index 000000000..f3ba16e67 --- /dev/null +++ b/packages/dapp/src/app/api/siwe/nonce/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from 'next/server'; +import { createClient } from '@supabase/supabase-js'; +import { add, format } from 'date-fns'; + +import { Database } from '@/utils/supabase/database.types'; + +const CORS_HEADERS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', +}; + +export async function GET() { + const supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SECRET_KEY! + ); + + // Insert a new nonce and select 1 row + const { data, error } = await supabase + .from('authorization') + .insert({ + // Expires in 5 minutes (ISO String) + expires_at: format( + add(new Date(), { minutes: 5 }), + "yyyy-MM-dd'T'HH:mm:ss.SSSxxx" + ), + }) + .select() + .limit(1) + .single(); + + if (error || !data) { + return new NextResponse('Internal server error', { + status: 500, + headers: { + ...CORS_HEADERS, + }, + }); + } + + return NextResponse.json( + { + nonce: data.nonce, + expiresAt: data.expires_at, + createdAt: data.created_at, + }, + { + headers: { + ...CORS_HEADERS, + 'Set-Cookie': `verify.session=${data.id}; Path=/; HttpOnly; Secure; SameSite=Strict;`, + }, + status: 200, + } + ); +} + +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: { + ...CORS_HEADERS, + }, + }); +} diff --git a/packages/dapp/src/app/api/siwe/verify/route.ts b/packages/dapp/src/app/api/siwe/verify/route.ts new file mode 100644 index 000000000..ada920ab8 --- /dev/null +++ b/packages/dapp/src/app/api/siwe/verify/route.ts @@ -0,0 +1,167 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@supabase/supabase-js'; +import jwt from 'jsonwebtoken'; +import { SiweMessage } from 'siwe'; + +import { Database } from '@/utils/supabase/database.types'; + +const CORS_HEADERS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', +}; + +export async function POST(request: NextRequest) { + try { + // Get session id from cookie + const sessionId = request.cookies.get('verify.session')?.value; + + if (!sessionId) { + return new NextResponse('Unauthorized', { + status: 401, + headers: { + ...CORS_HEADERS, + }, + }); + } + + const supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SECRET_KEY! + ); + + const { data: authorizationQueryData } = await supabase + .from('authorization') + .select() + .eq('id', sessionId) + .limit(1); + + if (!authorizationQueryData || authorizationQueryData.length === 0) { + return new NextResponse('Unauthorized', { + status: 401, + headers: { + ...CORS_HEADERS, + }, + }); + } + + const [authorizationData] = authorizationQueryData; + + // Check if expired + if (new Date(authorizationData.expires_at) < new Date()) { + return new NextResponse('Unauthorized', { + status: 401, + headers: { + ...CORS_HEADERS, + }, + }); + } + + const { message, signature } = await request.json(); + + if (!message || !signature) { + return new NextResponse('Unauthorized', { + status: 401, + headers: { + ...CORS_HEADERS, + }, + }); + } + + const siweObject = new SiweMessage(message); + + const { data } = await siweObject.verify({ + signature, + nonce: authorizationData.nonce.replaceAll('-', ''), + }); + + const { address } = data; + + let userData; + + // Find user by address + const { data: userQueryData, error: userQueryError } = await supabase + .from('users') + .select() + .eq('address', address.toLowerCase()) + .limit(1); + + if (userQueryError) { + return new NextResponse('Failed to query user by address', { + status: 500, + headers: { + ...CORS_HEADERS, + }, + }); + } + + // If user does not exist, create a new one + if (!userQueryData || userQueryData.length === 0) { + const { data: userCreateData, error: userCreateError } = await supabase + .from('users') + .insert({ + address: address.toLowerCase(), + }) + .select() + .limit(1) + .single(); + + if (userCreateError || !userCreateData) { + return new NextResponse('Failed to create user', { + status: 500, + headers: { + ...CORS_HEADERS, + }, + }); + } + + userData = userCreateData; + } else { + [userData] = userQueryData; + } + + // Create a access token + const token = jwt.sign( + { + sub: userData.id, + address, + // Neded for Supabase + aud: 'authenticated', + role: 'authenticated', + }, + process.env.SUPABASE_JWT_SECRET!, + { + expiresIn: '12h', + } + ); + + // Return response and set cookie + return NextResponse.json( + { + jwt: token, + }, + { + status: 200, + headers: { + ...CORS_HEADERS, + }, + } + ); + } catch (error) { + return new NextResponse('An unexpected error occurred.', { + status: 500, + headers: { + ...CORS_HEADERS, + }, + }); + } +} + +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: { + ...CORS_HEADERS, + }, + }); +} diff --git a/packages/dapp/src/app/api/supabase/verify/route.ts b/packages/dapp/src/app/api/supabase/verify/route.ts new file mode 100644 index 000000000..ee38aa5ed --- /dev/null +++ b/packages/dapp/src/app/api/supabase/verify/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from 'next/server'; +import jwt from 'jsonwebtoken'; + +const CORS_HEADERS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type Authorization', +}; + +export async function GET(request: NextRequest) { + try { + // Get token from header + const authorization = request.headers.get('Authorization'); + + if (!authorization) { + return new NextResponse('Unauthorized', { + status: 401, + headers: { + ...CORS_HEADERS, + }, + }); + } + + const token = authorization.replace('Bearer ', ''); + + // Verify token + jwt.verify(token, process.env.SUPABASE_JWT_SECRET!); + + // Return without content + return new NextResponse(null, { + status: 204, + headers: { + ...CORS_HEADERS, + }, + }); + } catch (error) { + console.error(error); + return new NextResponse('An unexpected error occurred.', { + status: 500, + headers: { + ...CORS_HEADERS, + }, + }); + } +} + +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: { + ...CORS_HEADERS, + }, + }); +} diff --git a/packages/dapp/src/app/api/veramoSetup.ts b/packages/dapp/src/app/api/veramoSetup.ts new file mode 100644 index 000000000..fbb0255ff --- /dev/null +++ b/packages/dapp/src/app/api/veramoSetup.ts @@ -0,0 +1,64 @@ +import { getDidKeyResolver as didKeyResolver } from '@blockchain-lab-um/did-provider-key'; +import { JsonRpcProvider } from '@ethersproject/providers'; +import { + createAgent, + type ICredentialVerifier, + type IDIDManager, + type IKeyManager, + type IResolver, + type TAgent, +} from '@veramo/core'; +import { CredentialIssuerEIP712 } from '@veramo/credential-eip712'; +import { + CredentialPlugin, + type ICredentialIssuer, +} from '@veramo/credential-w3c'; +import { getDidPkhResolver as didPkhResolver } from '@veramo/did-provider-pkh'; +import { DIDResolverPlugin } from '@veramo/did-resolver'; +import { Resolver } from 'did-resolver'; +import { getResolver as didEthrResolver } from 'ethr-did-resolver'; + +export type Agent = TAgent< + IDIDManager & IKeyManager & IResolver & ICredentialVerifier +>; + +const networks = [ + { + name: 'mainnet', + provider: new JsonRpcProvider( + 'https://mainnet.infura.io/v3/bf246ad3028f42318f2e996a7aa85bfc' + ), + }, + { + name: 'sepolia', + provider: new JsonRpcProvider( + 'https://sepolia.infura.io/v3/bf246ad3028f42318f2e996a7aa85bfc' + ), + chainId: '0xaa36a7', + }, +]; + +export const getAgent = async (): Promise => { + const agent = createAgent< + IDIDManager & + IKeyManager & + IResolver & + ICredentialIssuer & + ICredentialVerifier + >({ + plugins: [ + new CredentialPlugin(), + new CredentialIssuerEIP712(), + + new DIDResolverPlugin({ + resolver: new Resolver({ + ...didEthrResolver({ networks }), + ...didPkhResolver(), + ...didKeyResolver(), + }), + }), + ], + }); + + return agent; +}; diff --git a/packages/dapp/src/components/AppNavbar/index.tsx b/packages/dapp/src/components/AppNavbar/index.tsx index 9eebee719..099e4fd0f 100644 --- a/packages/dapp/src/components/AppNavbar/index.tsx +++ b/packages/dapp/src/components/AppNavbar/index.tsx @@ -30,7 +30,7 @@ export default function AppNavbar() { const isConnected = useGeneralStore((state) => state.isConnected); return ( -
+
diff --git a/packages/dapp/src/components/BasicNotFound/index.tsx b/packages/dapp/src/components/BasicNotFound/index.tsx index db5154c81..8ad8c82b0 100644 --- a/packages/dapp/src/components/BasicNotFound/index.tsx +++ b/packages/dapp/src/components/BasicNotFound/index.tsx @@ -3,7 +3,7 @@ interface BasicNotFoundProps { } const BasicNotFound = ({ text }: BasicNotFoundProps) => ( -
+

{text}

); diff --git a/packages/dapp/src/components/CheckMetaMaskCompatibility/index.tsx b/packages/dapp/src/components/CheckMetaMaskCompatibility/index.tsx index 7088f8a7c..99fbff13d 100644 --- a/packages/dapp/src/components/CheckMetaMaskCompatibility/index.tsx +++ b/packages/dapp/src/components/CheckMetaMaskCompatibility/index.tsx @@ -9,6 +9,7 @@ import { import detectEthereumProvider from '@metamask/detect-provider'; import { useGeneralStore, useMascaStore } from '@/stores'; +import { useAuthStore } from '@/stores/authStore'; const snapId = process.env.USE_LOCAL === 'true' @@ -63,6 +64,11 @@ const CheckMetaMaskCompatibility = () => { changePopups: state.changePopups, })); + const { isSignedIn, changeIsSignInModalOpen } = useAuthStore((state) => ({ + isSignedIn: state.isSignedIn, + changeIsSignInModalOpen: state.changeIsSignInModalOpen, + })); + const connectHandler = async () => { if (window.ethereum) { const result: unknown = await window.ethereum.request({ @@ -226,6 +232,13 @@ const CheckMetaMaskCompatibility = () => { }); }, [isConnected, isConnecting]); + useEffect(() => { + if (isSignedIn) return; + if (!isConnected) return; + + changeIsSignInModalOpen(true); + }, [isSignedIn, isConnected]); + return null; }; diff --git a/packages/dapp/src/components/ConnectionModal/CreateConnectionModal.tsx b/packages/dapp/src/components/ConnectionModal/CreateConnectionModal.tsx index 8abe07300..61c16e9c9 100644 --- a/packages/dapp/src/components/ConnectionModal/CreateConnectionModal.tsx +++ b/packages/dapp/src/components/ConnectionModal/CreateConnectionModal.tsx @@ -1,11 +1,10 @@ 'use client'; import { useEffect, useState } from 'react'; -import { Dialog } from '@headlessui/react'; +import { Modal, ModalBody, ModalContent, ModalHeader } from '@nextui-org/react'; import { useTranslations } from 'next-intl'; import { QRCodeSVG } from 'qrcode.react'; -import Modal from '@/components/Modal'; import { useSessionStore } from '@/stores'; interface CreateConnectionModalProps { @@ -68,27 +67,51 @@ const CreateConnectionModal = ({ useEffect(() => { if (isOpen) { createSession() - .then((data) => setConnectionData(data)) + .then((data) => { + console.log(data); + setConnectionData(data); + }) .catch(console.error); } }, [isOpen]); return ( - - - {t('title')} - -

{t('desc')}

-
-
- {connectionData && ( - - )} -
-
+ setOpen(false)} + hideCloseButton={true} + placement="center" + className="main-bg mx-4 py-2" + > + + {() => ( + <> + +
+ {t('title')} +
+
+ +

+ {t('desc')} +

+
+
+ {connectionData && ( + + )} +
+
+
+ + )} +
); }; diff --git a/packages/dapp/src/components/Controlbar/Controlbar.tsx b/packages/dapp/src/components/Controlbar/Controlbar.tsx index 88beef232..88351d629 100644 --- a/packages/dapp/src/components/Controlbar/Controlbar.tsx +++ b/packages/dapp/src/components/Controlbar/Controlbar.tsx @@ -7,16 +7,28 @@ import { type QueryCredentialsRequestResult, } from '@blockchain-lab-um/masca-connector'; import { ArrowPathIcon, PlusIcon } from '@heroicons/react/24/outline'; +import { ShareIcon } from '@heroicons/react/24/solid'; +import { Tooltip } from '@nextui-org/react'; import { W3CVerifiableCredential } from '@veramo/core'; import clsx from 'clsx'; import { useTranslations } from 'next-intl'; +import GlobalFilter from '@/components/Controlbar/GlobalFilter'; +import ViewTabs from '@/components/Controlbar/ViewTabs'; import ImportModal from '@/components/ImportModal'; -import GlobalFilter from '@/components/VCTable/GlobalFilter'; -import ViewTabs from '@/components/VCTable/ViewTabs'; -import { stringifyCredentialSubject } from '@/utils/format'; -import { useGeneralStore, useMascaStore, useToastStore } from '@/stores'; -import FilterPopover from '../VCTable/FilterPopover'; +import { + removeCredentialSubjectFilterString, + stringifyCredentialSubject, +} from '@/utils/format'; +import { + useGeneralStore, + useMascaStore, + useTableStore, + useToastStore, +} from '@/stores'; +import { useAuthStore } from '@/stores/authStore'; +import { useShareModalStore } from '@/stores/shareModalStore'; +import FilterPopover from './FilterPopover'; // import PlaygroundModal from '../PlaygroundModal'; @@ -27,17 +39,28 @@ const Controlbar = () => { // const [playgroundModalOpen, setPlaygroundModalOpen] = useState(false); const [spinner, setSpinner] = useState(false); - // Stores + // Global state + const { isSignedIn, changeIsSignInModalOpen } = useAuthStore((state) => ({ + isSignedIn: state.isSignedIn, + changeIsSignInModalOpen: state.changeIsSignInModalOpen, + })); const isConnected = useGeneralStore((state) => state.isConnected); - const { vcs, changeLastFetch } = useMascaStore((state) => ({ + const { api, vcs, changeLastFetch, changeVcs } = useMascaStore((state) => ({ + api: state.mascaApi, + vcs: state.vcs, changeLastFetch: state.changeLastFetch, - })); - - const { api, changeVcs } = useMascaStore((state) => ({ - api: state.mascaApi, changeVcs: state.changeVcs, })); + const selectedCredentials = useTableStore( + (state) => state.selectedCredentials + ); + const { setShareCredentials, setShareModalMode, setIsShareModalOpen } = + useShareModalStore((state) => ({ + setShareCredentials: state.setCredentials, + setShareModalMode: state.setMode, + setIsShareModalOpen: state.setIsOpen, + })); const refreshVCs = async () => { if (!api) return; @@ -190,31 +213,72 @@ const Controlbar = () => { > */} + {selectedCredentials.length > 0 && ( + + + + )} + + + + + )} + {vcs.length > 0 && ( + - - )} - {vcs.length > 0 && ( - + )}
diff --git a/packages/dapp/src/components/VCTable/FilterPopover/CheckBox.tsx b/packages/dapp/src/components/Controlbar/FilterPopover/CheckBox.tsx similarity index 100% rename from packages/dapp/src/components/VCTable/FilterPopover/CheckBox.tsx rename to packages/dapp/src/components/Controlbar/FilterPopover/CheckBox.tsx diff --git a/packages/dapp/src/components/VCTable/FilterPopover/CredentialTypes.tsx b/packages/dapp/src/components/Controlbar/FilterPopover/CredentialTypes.tsx similarity index 87% rename from packages/dapp/src/components/VCTable/FilterPopover/CredentialTypes.tsx rename to packages/dapp/src/components/Controlbar/FilterPopover/CredentialTypes.tsx index 20afcb19d..62987c8da 100644 --- a/packages/dapp/src/components/VCTable/FilterPopover/CredentialTypes.tsx +++ b/packages/dapp/src/components/Controlbar/FilterPopover/CredentialTypes.tsx @@ -1,10 +1,10 @@ import React, { useState } from 'react'; import { ChevronRightIcon } from '@heroicons/react/24/solid'; +import { Checkbox } from '@nextui-org/react'; import clsx from 'clsx'; import { useTranslations } from 'next-intl'; import { useTableStore } from '@/stores'; -import { CheckBox } from './CheckBox'; export const CredentialTypes = () => { const t = useTranslations('FilterPopover'); @@ -68,7 +68,7 @@ export const CredentialTypes = () => { {open && (
{ setFilter(e.target.value); @@ -83,9 +83,9 @@ export const CredentialTypes = () => { return (
- { + { const newCredentialTypes = credentialTypes.map((tp) => { if (tp.type === type.type) { return { @@ -98,8 +98,8 @@ export const CredentialTypes = () => { setCredentialTypes(newCredentialTypes); }} > - {type.type} - +
{type.type}
+
); })} diff --git a/packages/dapp/src/components/VCTable/FilterPopover/DataStores.tsx b/packages/dapp/src/components/Controlbar/FilterPopover/DataStores.tsx similarity index 57% rename from packages/dapp/src/components/VCTable/FilterPopover/DataStores.tsx rename to packages/dapp/src/components/Controlbar/FilterPopover/DataStores.tsx index 975535442..5de7f784b 100644 --- a/packages/dapp/src/components/VCTable/FilterPopover/DataStores.tsx +++ b/packages/dapp/src/components/Controlbar/FilterPopover/DataStores.tsx @@ -1,10 +1,10 @@ import React, { useState } from 'react'; import { ChevronRightIcon } from '@heroicons/react/24/solid'; +import { Checkbox } from '@nextui-org/react'; import clsx from 'clsx'; import { useTranslations } from 'next-intl'; import { useTableStore } from '@/stores'; -import { CheckBox } from './CheckBox'; const DataStoreNames = { snap: 'Snap', @@ -39,24 +39,29 @@ export const DataStores = () => { {open && (
{dataStores.map((dataStore) => ( - { - const newDataStores = dataStores.map((ds) => { - if (ds.dataStore === dataStore.dataStore) { - return { - ...dataStore, - selected, - }; - } - return ds; - }); - setDataStores(newDataStores); - }} - > - {DataStoreNames[dataStore.dataStore]} - +
+ { + const newDataStores = dataStores.map((ds) => { + if (ds.dataStore === dataStore.dataStore) { + return { + ...dataStore, + selected, + }; + } + return ds; + }); + setDataStores(newDataStores); + }} + > +
+ {DataStoreNames[dataStore.dataStore]} +
+
+
))}
)} diff --git a/packages/dapp/src/components/VCTable/FilterPopover/Ecosystems.tsx b/packages/dapp/src/components/Controlbar/FilterPopover/Ecosystems.tsx similarity index 54% rename from packages/dapp/src/components/VCTable/FilterPopover/Ecosystems.tsx rename to packages/dapp/src/components/Controlbar/FilterPopover/Ecosystems.tsx index b990c76b1..126c52b9d 100644 --- a/packages/dapp/src/components/VCTable/FilterPopover/Ecosystems.tsx +++ b/packages/dapp/src/components/Controlbar/FilterPopover/Ecosystems.tsx @@ -1,14 +1,14 @@ import React, { useState } from 'react'; import { ChevronRightIcon } from '@heroicons/react/24/solid'; +import { Checkbox } from '@nextui-org/react'; import clsx from 'clsx'; import { useTranslations } from 'next-intl'; import { useTableStore } from '@/stores'; -import { CheckBox } from './CheckBox'; -const EcosystemSNames = { +const EcosystemNames = { ebsi: 'EBSI', - polygonid: 'Polygon', + polygonid: 'PolygonID', other: 'Other', }; @@ -40,24 +40,30 @@ export const Ecosystems = () => { {open && (
{ecosystems.map((ecosystem) => ( - { - const newDataStores = ecosystems.map((ds) => { - if (ds.ecosystem === ecosystem.ecosystem) { - return { - ...ecosystem, - selected, - }; - } - return ds; - }); - setEcosystems(newDataStores); - }} - > - {EcosystemSNames[ecosystem.ecosystem]} - +
+ { + const newDataStores = ecosystems.map((ds) => { + if (ds.ecosystem === ecosystem.ecosystem) { + return { + ...ecosystem, + selected, + }; + } + return ds; + }); + setEcosystems(newDataStores); + }} + > +
+ {EcosystemNames[ecosystem.ecosystem]} +
+
+
))}
)} diff --git a/packages/dapp/src/components/VCTable/FilterPopover/index.tsx b/packages/dapp/src/components/Controlbar/FilterPopover/index.tsx similarity index 59% rename from packages/dapp/src/components/VCTable/FilterPopover/index.tsx rename to packages/dapp/src/components/Controlbar/FilterPopover/index.tsx index 4db9cecb5..e90dc4458 100644 --- a/packages/dapp/src/components/VCTable/FilterPopover/index.tsx +++ b/packages/dapp/src/components/Controlbar/FilterPopover/index.tsx @@ -1,10 +1,7 @@ 'use client'; import { Fragment, useEffect } from 'react'; -import { - AvailableCredentialStores, - QueryCredentialsRequestResult, -} from '@blockchain-lab-um/masca-connector'; +import { QueryCredentialsRequestResult } from '@blockchain-lab-um/masca-connector'; import { Popover, Transition } from '@headlessui/react'; import { ChevronDownIcon } from '@heroicons/react/20/solid'; import clsx from 'clsx'; @@ -22,53 +19,7 @@ interface FilterPopoverProps { function FilterPopover({ vcs }: FilterPopoverProps) { const t = useTranslations('FilterPopover'); - const { - dataStores, - ecosystems, - credentialTypes, - columnFilters, - setColumnFilters, - setCredentialTypes, - } = useTableStore((state) => ({ - dataStores: state.dataStores, - credentialTypes: state.credentialTypes, - columnFilters: state.columnFilters, - setDataStores: state.setDataStores, - ecosystems: state.ecosystems, - setEcosystems: state.setEcosystems, - setCredentialTypes: state.setCredentialTypes, - setColumnFilters: state.setColumnFilters, - })); - - const updateColumnFiltersDataStore = () => { - const dsFilter = { - id: 'data_store', - value: [] as AvailableCredentialStores[], - }; - dsFilter.value = dataStores - .filter((ds) => ds.selected) - .map((ds) => ds.dataStore); - - const newColumnFilters = columnFilters.filter( - (cf) => cf.id !== 'data_store' - ); - newColumnFilters.push(dsFilter); - setColumnFilters(newColumnFilters); - }; - - const updateColumnFiltersCredentialTypes = () => { - const typeFilter = { - id: 'type', - value: [] as string[], - }; - typeFilter.value = credentialTypes - .filter((type) => type.selected) - .map((type) => type.type); - - const newColumnFilters = columnFilters.filter((cf) => cf.id !== 'type'); - newColumnFilters.push(typeFilter); - setColumnFilters(newColumnFilters); - }; + const setCredentialTypes = useTableStore((state) => state.setCredentialTypes); const getAvailableCredentialTypes = () => { const allCredentialTypes: string[] = []; @@ -92,36 +43,10 @@ function FilterPopover({ vcs }: FilterPopoverProps) { ); }; - const updateColumnFiltersEcosystems = () => { - const esFilter = { - id: 'issuer', - value: [] as string[], - }; - esFilter.value = ecosystems - .filter((es) => es.selected) - .map((es) => es.ecosystem); - - const newColumnFilters = columnFilters.filter((cf) => cf.id !== 'issuer'); - newColumnFilters.push(esFilter); - setColumnFilters(newColumnFilters); - }; - - useEffect(() => { - updateColumnFiltersDataStore(); - }, [dataStores]); - useEffect(() => { getAvailableCredentialTypes(); }, [vcs]); - useEffect(() => { - updateColumnFiltersEcosystems(); - }, [ecosystems]); - - useEffect(() => { - updateColumnFiltersCredentialTypes(); - }, [credentialTypes]); - return ( {({ open, close }) => ( diff --git a/packages/dapp/src/components/VCTable/GlobalFilter/index.tsx b/packages/dapp/src/components/Controlbar/GlobalFilter/index.tsx similarity index 97% rename from packages/dapp/src/components/VCTable/GlobalFilter/index.tsx rename to packages/dapp/src/components/Controlbar/GlobalFilter/index.tsx index f8b1a461d..5f63c42e0 100644 --- a/packages/dapp/src/components/VCTable/GlobalFilter/index.tsx +++ b/packages/dapp/src/components/Controlbar/GlobalFilter/index.tsx @@ -10,7 +10,7 @@ interface GlobalFilterProps { } const GlobalFilter = ({ isConnected, vcs }: GlobalFilterProps) => { - const t = useTranslations('Dashboard'); + const t = useTranslations('GlobalFilter'); const { globalFilter, setGlobalFilter } = useTableStore((state) => ({ globalFilter: state.globalFilter, setGlobalFilter: state.setGlobalFilter, diff --git a/packages/dapp/src/components/VCTable/ViewTabs/index.tsx b/packages/dapp/src/components/Controlbar/ViewTabs/index.tsx similarity index 100% rename from packages/dapp/src/components/VCTable/ViewTabs/index.tsx rename to packages/dapp/src/components/Controlbar/ViewTabs/index.tsx diff --git a/packages/dapp/src/components/CookiesProvider/index.tsx b/packages/dapp/src/components/CookiesProvider/index.tsx new file mode 100644 index 000000000..1f7174d96 --- /dev/null +++ b/packages/dapp/src/components/CookiesProvider/index.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { useEffect } from 'react'; +import Cookies from 'js-cookie'; + +import { useAuthStore } from '@/stores/authStore'; + +export const CookiesProvider = () => { + const { isSignedIn, changeToken, changeIsSignedIn } = useAuthStore( + (state) => ({ + isSignedIn: state.isSignedIn, + changeToken: state.changeToken, + changeIsSignedIn: state.changeIsSignedIn, + }) + ); + + const verifyToken = async (token: string) => { + const response = await fetch('/api/supabase/verify', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + }); + + if (response.status !== 204) { + Cookies.remove('token'); + changeToken(''); + changeIsSignedIn(false); + } + + changeToken(token); + changeIsSignedIn(true); + }; + + useEffect(() => { + if (isSignedIn) return; + + const token = Cookies.get('token'); + if (!token) return; + + verifyToken(token).catch((error) => { + Cookies.remove('token'); + console.error(error); + }); + }, []); + + return null; +}; diff --git a/packages/dapp/src/components/CreateCredentialDisplay/index.tsx b/packages/dapp/src/components/CreateCredentialDisplay/index.tsx index 38692e852..ce5b7f1f2 100644 --- a/packages/dapp/src/components/CreateCredentialDisplay/index.tsx +++ b/packages/dapp/src/components/CreateCredentialDisplay/index.tsx @@ -43,9 +43,8 @@ const CreateCredentialDisplay = () => { const availableStores = Object.entries(credentialStores) .filter(([, value]) => value) .map(([key]) => key as AvailableCredentialStores); - const [selectedItems, setSelectedItems] = useState< - AvailableCredentialStores[] - >([availableStores[0], availableStores[1]]); + const [selectedItems, setSelectedItems] = + useState(availableStores); const { didMethod, api, did, setVCs } = useMascaStore( (state) => ({ didMethod: state.currDIDMethod, @@ -136,7 +135,7 @@ const CreateCredentialDisplay = () => { proofFormat: proofFormats[format], options: { save, - store: selectedItems, + store: selectedItems, // TODO: fix this doesn't create new credential }, }); @@ -184,7 +183,7 @@ const CreateCredentialDisplay = () => { setLoading(false); }; return ( -
+
-
-
+
+