From f18ef4178062a38295854acdd410cc53ddc58219 Mon Sep 17 00:00:00 2001 From: tadejpodrekar Date: Fri, 1 Sep 2023 20:00:26 +0200 Subject: [PATCH] feat: google drive support (#387) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add store export/import * chore: update types for export * chore: lint & build * chore: add basic backup tests * chore: lint * chore: add unit tests * feat: add state backup encryption * chore: remove duplicate call * chore: add test google login to dapp * feat: add google drive service * chore: add basic gdrive rpc methods * chore: build * fix: rename method in test * chore: update google service * chore: update snap service methods * chore: add setGoogleToken connector method * chore: lint * chore: add connector methods * feat: improve PR (added UI, refactor code) * chore: rename Crypto.service to Encryption.service * chore: lint * chore: add validate google token method * chore: update google button to store token * fix: dapp & connector bugs * feat: add google api route * feat: refactor google drive buttons * chore: lint and build * chore: remove old drive implementation * fix: remove old google test suite * fix: typo in QR scanner view * fix: narrow google scopes * fix: add backup success toast * fix: google button spacing * fix: menu popover errors * chore: update .env.example Signed-off-by: Urban Vidovič * fix: use translations for google drive import/export buttons Signed-off-by: Urban Vidovič * chore: add changeset Signed-off-by: Urban Vidovič * chore: remove console.log and use switch statement Signed-off-by: Urban Vidovič * chore: format index.tsx Signed-off-by: Urban Vidovič * fix: error [object object] * fix: google error handling refactor * fix: google delete button style * chore: lint --------- Signed-off-by: Urban Vidovič Co-authored-by: martines3000 Co-authored-by: Andraz <69682837+andyv09@users.noreply.github.com> Co-authored-by: andyv09 Co-authored-by: Urban Vidovič --- .changeset/fifty-dodos-knock.md | 5 + packages/connector/src/index.ts | 2 +- packages/connector/src/snap.ts | 26 +- packages/dapp/.env.example | 3 + packages/dapp/package.json | 2 + packages/dapp/src/app/[locale]/layout.tsx | 2 +- packages/dapp/src/app/api/google/route.tsx | 191 ++++++ .../components/GoogleDriveButton/index.tsx | 293 ++++++++ .../dapp/src/components/MascaLogo/index.tsx | 1 + .../dapp/src/components/MenuPopover/index.tsx | 22 +- .../QRSessionDisplay/ScanQRCodeView.tsx | 2 +- .../SettingsCard/GoogleBackupForm.tsx | 30 + .../src/components/SettingsCard/index.tsx | 18 + packages/dapp/src/messages/en.json | 13 +- packages/dapp/src/stores/snapStore.ts | 5 + packages/snap/src/Snap.service.ts | 4 + pnpm-lock.yaml | 624 ++++++++++++++++-- 17 files changed, 1148 insertions(+), 95 deletions(-) create mode 100644 .changeset/fifty-dodos-knock.md create mode 100644 packages/dapp/src/app/api/google/route.tsx create mode 100644 packages/dapp/src/components/GoogleDriveButton/index.tsx create mode 100644 packages/dapp/src/components/SettingsCard/GoogleBackupForm.tsx diff --git a/.changeset/fifty-dodos-knock.md b/.changeset/fifty-dodos-knock.md new file mode 100644 index 000000000..10c4f8ddd --- /dev/null +++ b/.changeset/fifty-dodos-knock.md @@ -0,0 +1,5 @@ +--- +'@blockchain-lab-um/dapp': patch +--- + +Adds support for backing up data to Google Drive. diff --git a/packages/connector/src/index.ts b/packages/connector/src/index.ts index 29e05ec1b..3547837ab 100644 --- a/packages/connector/src/index.ts +++ b/packages/connector/src/index.ts @@ -102,6 +102,6 @@ export async function enableMasca( return ResultObject.success(snap); } catch (err: unknown) { - return ResultObject.error((err as Error).toString()); + return ResultObject.error((err as Error).message); } } diff --git a/packages/connector/src/snap.ts b/packages/connector/src/snap.ts index ceab716d1..65c346763 100644 --- a/packages/connector/src/snap.ts +++ b/packages/connector/src/snap.ts @@ -454,33 +454,33 @@ export async function validateStoredCeramicSession( } /** - * Import encrypted Masca state + * Export Masca state * @param this - Masca instance - * @param params - Encrypted Masca state - * @returns Result - true if successful + * @returns Result - Encrypted Masca state */ -export async function importStateBackup( - this: Masca, - params: ImportStateBackupRequestParams -): Promise> { +export async function exportStateBackup(this: Masca): Promise> { return sendSnapMethod( { - method: 'importStateBackup', - params, + method: 'exportStateBackup', }, this.snapId ); } /** - * Export Masca state + * Import encrypted Masca state * @param this - Masca instance - * @returns Result - Encrypted Masca state + * @param params - Encrypted Masca state + * @returns Result - true if successful */ -export async function exportStateBackup(this: Masca): Promise> { +export async function importStateBackup( + this: Masca, + params: ImportStateBackupRequestParams +): Promise> { return sendSnapMethod( { - method: 'exportStateBackup', + method: 'importStateBackup', + params, }, this.snapId ); diff --git a/packages/dapp/.env.example b/packages/dapp/.env.example index 8e8fc7e1a..b24095995 100644 --- a/packages/dapp/.env.example +++ b/packages/dapp/.env.example @@ -1,5 +1,8 @@ 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= # Prisma DATABASE_URL= diff --git a/packages/dapp/package.json b/packages/dapp/package.json index 9caeee9ac..2e6a9fc21 100644 --- a/packages/dapp/package.json +++ b/packages/dapp/package.json @@ -28,6 +28,7 @@ "@metamask/providers": "^10.2.0", "@prisma/client": "^5.1.1", "@radix-ui/react-toast": "^1.1.4", + "@react-oauth/google": "^0.11.1", "@tanstack/react-table": "^8.9.3", "@veramo/core": "5.4.1", "@veramo/utils": "5.4.1", @@ -36,6 +37,7 @@ "clsx": "^2.0.0", "did-jwt-vc": "^3.2.5", "file-saver": "^2.0.5", + "googleapis": "^126.0.1", "headless-stepper": "^1.8.3", "html5-qrcode": "^2.3.8", "luxon": "^3.3.0", diff --git a/packages/dapp/src/app/[locale]/layout.tsx b/packages/dapp/src/app/[locale]/layout.tsx index 654025c11..1d9393fa0 100644 --- a/packages/dapp/src/app/[locale]/layout.tsx +++ b/packages/dapp/src/app/[locale]/layout.tsx @@ -102,7 +102,7 @@ export default async function LocaleLayout({ } return ( - + diff --git a/packages/dapp/src/app/api/google/route.tsx b/packages/dapp/src/app/api/google/route.tsx new file mode 100644 index 000000000..ee2c6c133 --- /dev/null +++ b/packages/dapp/src/app/api/google/route.tsx @@ -0,0 +1,191 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { drive_v3, google } from 'googleapis'; + +const CORS_HEADERS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', +}; + +const actions = ['import', 'backup', 'delete']; + +async function createDriveInstance(accessToken: string) { + if (!accessToken) throw new Error('Missing accessToken'); + const oauth2Client = new google.auth.OAuth2(); + oauth2Client.setCredentials({ access_token: accessToken }); + google.options({ auth: oauth2Client }); + const drive = google.drive({ + version: 'v3', + auth: oauth2Client, + }); + return drive; +} + +async function verifyAccessToken(accessToken: string) { + const oauth2Client = new google.auth.OAuth2(); + const tokenInfo = await oauth2Client.getTokenInfo(accessToken); + return tokenInfo; +} + +async function createDriveFile(drive: drive_v3.Drive, content: string) { + const mimeType = 'text/plain'; + const fileMetadata = { + parents: ['appDataFolder'], + name: process.env.GOOGLE_DRIVE_FILE_NAME, + mimeType, + }; + const media = { + mimeType, + body: content, + }; + const res = await drive.files.create({ + requestBody: fileMetadata, + media, + }); + + if (!res.data || res.status !== 200) throw new Error('Error creating file'); + return res.data; +} + +async function getBackupFileId(drive: drive_v3.Drive) { + let id; + const list = await drive.files.list({ + q: `name='${process.env.GOOGLE_DRIVE_FILE_NAME}'`, + spaces: 'appDataFolder', + }); + + if (list.data.files?.length) { + id = list.data.files[0].id!; + } + + return id; +} + +async function getBackupFileContent(drive: drive_v3.Drive) { + const id = await getBackupFileId(drive); + if (!id) throw new Error('Backup file not found'); + + const res = await drive.files.get({ + fileId: `${id}`, + alt: 'media', + }); + + if (!res.data || res.status !== 200) + throw new Error('Error getting file content'); + return res.data as string; +} + +async function updateDriveFile(drive: drive_v3.Drive, content: string) { + const fileId = await getBackupFileId(drive); + if (!fileId) { + const res = createDriveFile(drive, content); + return res; + } + const media = { + mimeType: 'text/plains', + body: content, + }; + const res = await drive.files.update({ + fileId, + media, + }); + + if (!res.data || res.status !== 200) throw new Error('Error updating file'); + return res.data; +} + +async function deleteDriveFile(drive: drive_v3.Drive) { + const fileId = await getBackupFileId(drive); + if (!fileId) throw new Error('Backup file not found'); + + const res = await drive.files.delete({ + fileId, + }); + + return res.data; +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + + if (!body.data) { + return NextResponse.json( + { success: false, error: 'Missing data parameter' }, + { status: 400, headers: { ...CORS_HEADERS } } + ); + } + + const { accessToken, action, content } = body.data; + + if (!actions.includes(action)) { + return NextResponse.json( + { success: false, error: 'Invalid action' }, + { status: 400, headers: { ...CORS_HEADERS } } + ); + } + + if (action === 'backup' && !content) { + return NextResponse.json( + { success: false, error: 'Missing content parameter' }, + { status: 400, headers: { ...CORS_HEADERS } } + ); + } + + // Verify access token + const tokenInfo = await verifyAccessToken(accessToken); + + const scopes = process.env.NEXT_PUBLIC_GOOGLE_SCOPES?.split(' '); + if ( + !tokenInfo.scopes || + !scopes?.every((scope) => tokenInfo.scopes.includes(scope)) + ) { + return NextResponse.json( + { success: false, error: 'Invalid access token' }, + { status: 400, headers: { ...CORS_HEADERS } } + ); + } + + const drive = await createDriveInstance(accessToken); + + if (!drive) { + return NextResponse.json( + { success: false, error: 'Error creating drive instance' }, + { status: 500, headers: { ...CORS_HEADERS } } + ); + } + switch (action) { + case 'import': { + const fileContent = await getBackupFileContent(drive); + return NextResponse.json( + { success: true, data: fileContent }, + { status: 200, headers: { ...CORS_HEADERS } } + ); + } + case 'backup': { + await updateDriveFile(drive, content); + return NextResponse.json( + { success: true }, + { status: 200, headers: { ...CORS_HEADERS } } + ); + } + case 'delete': { + await deleteDriveFile(drive); + return NextResponse.json( + { success: true }, + { status: 200, headers: { ...CORS_HEADERS } } + ); + } + default: + return NextResponse.json( + { success: false, error: 'Invalid action' }, + { status: 400, headers: { ...CORS_HEADERS } } + ); + } + } catch (e) { + return NextResponse.json( + { success: false, error: (e as Error).message }, + { status: 500, headers: { ...CORS_HEADERS } } + ); + } +} diff --git a/packages/dapp/src/components/GoogleDriveButton/index.tsx b/packages/dapp/src/components/GoogleDriveButton/index.tsx new file mode 100644 index 000000000..290da93ec --- /dev/null +++ b/packages/dapp/src/components/GoogleDriveButton/index.tsx @@ -0,0 +1,293 @@ +'use client'; + +import { useState } from 'react'; +import { isError } from '@blockchain-lab-um/masca-connector'; +import { useGoogleLogin } from '@react-oauth/google'; +import { useTranslations } from 'next-intl'; + +import { useMascaStore, useToastStore } from '@/stores'; +import Button from '../Button'; + +interface GoogleDriveButtonProps { + buttonText: string; + action: 'import' | 'backup' | 'delete'; + variant?: 'primary' | 'cancel-red'; +} + +const GoogleDriveButton = ({ + buttonText, + action, + variant = 'primary', +}: GoogleDriveButtonProps) => { + const t = useTranslations('SettingsCard'); + const { + api, + changeAvailableCredentialStores, + changeAvailableMethods, + changeCurrMethod, + changeDID, + changePopups, + } = useMascaStore((state) => ({ + api: state.mascaApi, + changeAvailableCredentialStores: state.changeAvailableCredentialStores, + changeAvailableMethods: state.changeAvailableMethods, + changeCurrMethod: state.changeCurrDIDMethod, + changeDID: state.changeCurrDID, + changePopups: state.changePopups, + })); + const [loading, setLoading] = useState(false); + + const handleExport = async (accessToken: string) => { + if (!api) return; + setLoading(true); + const exportResult = await api.exportStateBackup(); + + if (isError(exportResult)) { + console.log(exportResult); + setTimeout(() => { + useToastStore.setState({ + open: true, + title: t('export-error'), + type: 'error', + loading: false, + }); + }, 200); + return; + } + + const response = await fetch(`/api/google`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + data: { + accessToken, + action: 'backup', + content: exportResult.data, + }, + }), + }); + + const res = await response.json(); + if (res.error) { + setTimeout(() => { + useToastStore.setState({ + open: true, + title: res.error, + type: 'error', + loading: false, + }); + }, 200); + setLoading(false); + return; + } + + setTimeout(() => { + useToastStore.setState({ + open: true, + title: t('export-success'), + type: 'success', + loading: false, + }); + }, 200); + + setLoading(false); + }; + + const handleImport = async (accessToken: string) => { + setLoading(true); + const response = await fetch(`/api/google`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + data: { + accessToken, + action: 'import', + }, + }), + }); + + const res = await response.json(); + if (res.error) { + setTimeout(() => { + useToastStore.setState({ + open: true, + title: res.error, + type: 'error', + loading: false, + }); + }, 200); + setLoading(false); + return; + } + + if (!api) return; + const importResult = await api.importStateBackup({ + serializedState: res.data, + }); + + if (isError(importResult)) { + setTimeout(() => { + useToastStore.setState({ + open: true, + title: t('import-error'), + type: 'error', + loading: false, + }); + }, 200); + setLoading(false); + return; + } + + setTimeout(() => { + useToastStore.setState({ + open: true, + title: t('import-success'), + type: 'success', + loading: false, + }); + }, 200); + + const did = await api.getDID(); + if (isError(did)) { + console.log("Couldn't get DID"); + throw new Error(did.error); + } + + const availableMethods = await api.getAvailableMethods(); + if (isError(availableMethods)) { + console.log("Couldn't get available methods"); + throw new Error(availableMethods.error); + } + + const method = await api.getSelectedMethod(); + if (isError(method)) { + console.log("Couldn't get selected method"); + throw new Error(method.error); + } + + const accountSettings = await api.getAccountSettings(); + if (isError(accountSettings)) { + console.log("Couldn't get account settings"); + throw new Error(accountSettings.error); + } + + const snapSettings = await api.getSnapSettings(); + if (isError(snapSettings)) { + console.log("Couldn't get snap settings"); + throw new Error(snapSettings.error); + } + + changeDID(did.data); + changeAvailableMethods(availableMethods.data); + changeCurrMethod(method.data); + changeAvailableCredentialStores(accountSettings.data.ssi.storesEnabled); + changePopups(snapSettings.data.dApp.disablePopups); + setLoading(false); + }; + + const handleDelete = async (accessToken: string) => { + setLoading(true); + const response = await fetch(`/api/google`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + data: { + accessToken, + action: 'delete', + }, + }), + }); + + const res = await response.json(); + if (res.error) { + setTimeout(() => { + useToastStore.setState({ + open: true, + title: res.error, + type: 'error', + loading: false, + }); + }, 200); + setLoading(false); + } + + setTimeout(() => { + useToastStore.setState({ + open: true, + title: t('delete-success'), + type: 'success', + loading: false, + }); + }, 200); + + setLoading(false); + }; + + const login = useGoogleLogin({ + onSuccess: async (tokenResponse) => { + if (!api) return; + switch (action) { + case 'backup': + await handleExport(tokenResponse.access_token); + break; + case 'import': + await handleImport(tokenResponse.access_token); + break; + case 'delete': + await handleDelete(tokenResponse.access_token); + break; + default: + break; + } + }, + onError: (error) => { + console.log(error); + let errorMessage = error.error?.replace(/_/g, ' '); + if (!errorMessage) errorMessage = 'Unknown error'; + errorMessage = + errorMessage.charAt(0).toUpperCase() + errorMessage.slice(1); + setTimeout(() => { + useToastStore.setState({ + open: true, + title: errorMessage, + type: 'error', + loading: false, + }); + }, 200); + setLoading(false); + }, + onNonOAuthError: (error) => { + console.log(error); + let errorMessage = error.type.replace(/_/g, ' '); + errorMessage = + errorMessage.charAt(0).toUpperCase() + errorMessage.slice(1); + setTimeout(() => { + useToastStore.setState({ + open: true, + title: errorMessage, + type: 'error', + loading: false, + }); + }, 200); + setLoading(false); + }, + scope: + process.env.NEXT_PUBLIC_GOOGLE_SCOPES ?? + 'https://www.googleapis.com/auth/drive.appdata', + }); + return ( +
+ +
+ ); +}; + +export default GoogleDriveButton; diff --git a/packages/dapp/src/components/MascaLogo/index.tsx b/packages/dapp/src/components/MascaLogo/index.tsx index 4cb3ff13b..c83be835a 100644 --- a/packages/dapp/src/components/MascaLogo/index.tsx +++ b/packages/dapp/src/components/MascaLogo/index.tsx @@ -13,6 +13,7 @@ const MascaLogo = () => ( src={'/images/masca_white.png'} alt="Masca Logo" fill={true} + sizes="(max-width: 640px) 28px, (max-width: 768px) 36px, (max-width: 1024px) 46px, (max-width: 1280px) 48px, 54px" /> ); diff --git a/packages/dapp/src/components/MenuPopover/index.tsx b/packages/dapp/src/components/MenuPopover/index.tsx index b9d8fd63c..6d9f212a9 100644 --- a/packages/dapp/src/components/MenuPopover/index.tsx +++ b/packages/dapp/src/components/MenuPopover/index.tsx @@ -12,13 +12,13 @@ const IconCreateCredential = () => ( xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" - stroke-width="1.5" + strokeWidth="1.5" stroke="currentColor" className="dark:text-navy-blue-900 h-6 w-6 text-pink-500" > @@ -29,13 +29,13 @@ const IconVerifyData = () => ( xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" - stroke-width="1.5" + strokeWidth="1.5" stroke="currentColor" className="dark:text-navy-blue-900 h-6 w-6 text-pink-500" > @@ -46,18 +46,18 @@ const IconCamera = () => ( xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" - stroke-width="1.5" + strokeWidth="1.5" stroke="currentColor" className="dark:text-navy-blue-900 h-6 w-6 text-pink-500" > diff --git a/packages/dapp/src/components/QRSessionDisplay/ScanQRCodeView.tsx b/packages/dapp/src/components/QRSessionDisplay/ScanQRCodeView.tsx index 7d5dc6ee1..e219271ee 100644 --- a/packages/dapp/src/components/QRSessionDisplay/ScanQRCodeView.tsx +++ b/packages/dapp/src/components/QRSessionDisplay/ScanQRCodeView.tsx @@ -100,7 +100,7 @@ export const ScanQRCodeView = ({ onQRCodeScanned }: ScanQRCodeViewProps) => { setTimeout(() => { useToastStore.setState({ open: true, - title: t('succeess'), + title: t('success'), type: 'success', loading: false, link: null, diff --git a/packages/dapp/src/components/SettingsCard/GoogleBackupForm.tsx b/packages/dapp/src/components/SettingsCard/GoogleBackupForm.tsx new file mode 100644 index 000000000..50e3f2d3c --- /dev/null +++ b/packages/dapp/src/components/SettingsCard/GoogleBackupForm.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { GoogleOAuthProvider } from '@react-oauth/google'; +import { useTranslations } from 'next-intl'; + +import GoogleDriveButton from '@/components/GoogleDriveButton'; + +const GoogleBackupForm = () => { + const t = useTranslations('SettingsCard'); + return ( +
+ +
+ + +
+
+ +
+
+
+ ); +}; + +export default GoogleBackupForm; diff --git a/packages/dapp/src/components/SettingsCard/index.tsx b/packages/dapp/src/components/SettingsCard/index.tsx index 23c899cc8..40bb90155 100644 --- a/packages/dapp/src/components/SettingsCard/index.tsx +++ b/packages/dapp/src/components/SettingsCard/index.tsx @@ -12,6 +12,7 @@ import Button from '../Button'; import InfoIcon from '../InfoIcon'; import UploadButton from '../UploadButton'; import { FriendlydAppTable } from './FriendlydAppTable'; +import GoogleBackupForm from './GoogleBackupForm'; const SettingsCard = () => { const t = useTranslations('SettingsCard'); @@ -24,6 +25,8 @@ const SettingsCard = () => { changeCurrMethod, changeDID, changePopups, + isSignedInGoogle, + changeIsSignedInGooge, } = useMascaStore((state) => ({ api: state.mascaApi, availableCredentialStores: state.availableCredentialStores, @@ -33,6 +36,8 @@ const SettingsCard = () => { changeCurrMethod: state.changeCurrDIDMethod, changeDID: state.changeCurrDID, changePopups: state.changePopups, + isSignedInGoogle: state.isSignedInGoogle, + changeIsSignedInGooge: state.changeIsSignedInGoogle, })); const snapGetAvailableCredentialStores = async () => { @@ -248,6 +253,19 @@ const SettingsCard = () => {
{t('backup')}
+
+

+ {t('backup-google-desc')}{' '} +

+
+ +
+
+
+

+ {t('backup-manual-desc')}{' '} +

+