diff --git a/.env.template b/.env.template index 7d91723cb..f2adce506 100644 --- a/.env.template +++ b/.env.template @@ -1,16 +1,16 @@ -HOST='0.0.0.0' -PORT=3000 -REACT_APP_FIREBASE_VAPIDKEY= -REACT_APP_WS_URL=ws://wallet-backend-server:8002 -REACT_APP_WALLET_BACKEND_URL=http://wallet-backend-server:8002 -REACT_APP_LOGIN_WITH_PASSWORD=false -REACT_APP_FIREBASE_API_KEY= -REACT_APP_FIREBASE_AUTH_DOMAIN= -REACT_APP_FIREBASE_PROJECT_ID= -REACT_APP_FIREBASE_STORAGE_BUCKET= -REACT_APP_FIREBASE_MESSAGING_SENDER_ID= -REACT_APP_FIREBASE_APP_ID= -REACT_APP_FIREBASE_MEASUREMENT_ID= -REACT_APP_DID_KEY_VERSION=jwk_jcs-pub +HOST=VAR_HOST +PORT=VAR_PORT +REACT_APP_FIREBASE_VAPIDKEY=VAR_REACT_APP_FIREBASE_VAPIDKEY +REACT_APP_WS_URL=VAR_REACT_APP_WS_URL +REACT_APP_WALLET_BACKEND_URL=VAR_REACT_APP_WALLET_BACKEND_URL +REACT_APP_LOGIN_WITH_PASSWORD=VAR_REACT_APP_LOGIN_WITH_PASSWORD +REACT_APP_FIREBASE_API_KEY=VAR_REACT_APP_FIREBASE_API_KEY +REACT_APP_FIREBASE_AUTH_DOMAIN=VAR_REACT_APP_FIREBASE_AUTH_DOMAIN +REACT_APP_FIREBASE_PROJECT_ID=VAR_REACT_APP_FIREBASE_PROJECT_ID +REACT_APP_FIREBASE_STORAGE_BUCKET=VAR_REACT_APP_FIREBASE_STORAGE_BUCKET +REACT_APP_FIREBASE_MESSAGING_SENDER_ID=VAR_REACT_APP_FIREBASE_MESSAGING_SENDER_ID +REACT_APP_FIREBASE_APP_ID=VAR_REACT_APP_FIREBASE_APP_ID +REACT_APP_FIREBASE_MEASUREMENT_ID=VAR_REACT_APP_FIREBASE_MEASUREMENT_ID +REACT_APP_DID_KEY_VERSION=VAR_REACT_APP_DID_KEY_VERSION REACT_APP_VERSION=$npm_package_version -REACT_APP_DISPLAY_CONSOLE=true +REACT_APP_DISPLAY_CONSOLE=VAR_REACT_APP_DISPLAY_CONSOLE diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 000000000..443324db7 --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,20 @@ +name: Build Docker image +on: + push: + branches-ignore: + - master + - dc4eu-rome + pull_request: + +jobs: + build-front: + permissions: + contents: read + packages: read + + uses: gunet/wallet-ecosystem/.github/workflows/docker-build-push.yml@dc4eu-rome + secrets: inherit + with: + image-tag: ghcr.io/gunet/wallet-frontend:latest + docker-push: false + dockerfile-path: ./Dockerfile diff --git a/.github/workflows/docker-push.yml b/.github/workflows/docker-push.yml new file mode 100644 index 000000000..e26139008 --- /dev/null +++ b/.github/workflows/docker-push.yml @@ -0,0 +1,20 @@ +name: Push Docker image +on: + push: + tags: + - v0.* + - v1.* + - v2.* + +jobs: + push-front: + permissions: + contents: read + packages: write + + uses: gunet/wallet-ecosystem/.github/workflows/docker-build-push.yml@dc4eu-rome + secrets: inherit + with: + image-tag: ghcr.io/gunet/wallet-frontend:${{ github.ref_name }} + docker-push: true + dockerfile-path: ./Dockerfile diff --git a/.gitignore b/.gitignore index 3ef7e4ad6..48f9e0c19 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,5 @@ src/config/config.prod.js ssl_keys/* *.tar.gz .npmrc + +variables.vars diff --git a/Dockerfile b/Dockerfile index 0b6fbf9f5..eebb0aa4b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,7 @@ WORKDIR /home/node/app # Install dependencies first so rebuild of these layers is only needed when dependencies change COPY package.json yarn.lock . +COPY .env.template .env RUN --mount=type=secret,id=npmrc,required=true,target=./.npmrc,uid=1000 \ yarn cache clean -f && yarn install @@ -28,9 +29,12 @@ FROM nginx:alpine as deploy WORKDIR /usr/share/nginx/html COPY ./nginx/nginx.conf /etc/nginx/conf.d/default.conf - COPY --from=builder /home/node/app/build/ . +COPY ./var_replacement.sh / + EXPOSE 80 -CMD ["nginx", "-g", "daemon off;"] +RUN chmod +x /var_replacement.sh && cat /var_replacement.sh + +CMD /bin/sh /var_replacement.sh /variables.vars && nginx -g "daemon off;" diff --git a/src/App.js b/src/App.js index b85e1c1fc..567dc38b9 100644 --- a/src/App.js +++ b/src/App.js @@ -6,6 +6,7 @@ import Spinner from './components/Spinner'; // Make sure this Spinner component import { I18nextProvider } from 'react-i18next'; import i18n from './i18n'; +import { CredentialsProvider } from './context/CredentialsContext'; import useCheckURL from './components/useCheckURL'; // Import the custom hook import handleServerMessagesGuard from './hoc/handleServerMessagesGuard'; import HandlerNotification from './components/HandlerNotification'; @@ -81,35 +82,35 @@ function App() { }; return ( - - - - }> - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - {showSelectCredentialsPopup && - - } - {showPinInputPopup && - - } - {showMessagePopup && - setMessagePopup(false)} /> - } - - - + + + + }> + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + {showSelectCredentialsPopup && + + } + {showPinInputPopup && + + } + {showMessagePopup && + setMessagePopup(false)} /> + } + + + ); } diff --git a/src/components/HandlerNotification.js b/src/components/HandlerNotification.js index 62ec5596b..9d984bac5 100644 --- a/src/components/HandlerNotification.js +++ b/src/components/HandlerNotification.js @@ -1,8 +1,9 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useContext } from 'react'; import toast, { Toaster } from 'react-hot-toast'; import { onMessageListener } from '../firebase'; import { AiOutlineClose } from 'react-icons/ai'; import logo from '../assets/images/logo.png'; +import CredentialsContext from '../context/CredentialsContext'; const ToastDisplay = ({ id, notification }) => { return ( @@ -29,16 +30,12 @@ const ToastDisplay = ({ id, notification }) => { ); }; -const HandlerNotification = ({ children }) => { +const HandlerNotification = () => { const [notification, setNotification] = useState({ title: '', body: '' }); - const [isMessageReceived, setMessageReceived] = useState(null); + const { getData } = useContext(CredentialsContext); const showToast = () => - toast((t) => , { - onClick: () => { - window.location.href = '/'; - }, - }); + toast((t) => ); useEffect(() => { if (notification?.title) { @@ -47,47 +44,28 @@ const HandlerNotification = ({ children }) => { }, [notification]); useEffect(() => { - let messageReceived = false; - const unregisterMessageListener = onMessageListener() + const messageListener = onMessageListener() .then((payload) => { - // Process the received message setNotification({ title: payload?.notification?.title, body: payload?.notification?.body, }); - setMessageReceived(true); // Message has been received + getData(); }) .catch((err) => { console.log('Failed to receive message:', err); - setMessageReceived(false); // Set isMessageReceived to false if there's an error }); - return () => { - if (!messageReceived) { - setMessageReceived(false); // Set isMessageReceived to false if no message was received before unmount + if (messageListener && typeof messageListener === 'function') { + messageListener(); } }; - }, []); - - // Render just children when waiting for message reception - if (isMessageReceived === null || isMessageReceived === false) { - // Render children when waiting for a message - return ( -
- {children} -
- ); - } else { - // Render Toaster and children when a message is received - return ( -
- - {children} -
- ); - } + }, [getData]); + return ( + + ); }; export default HandlerNotification; diff --git a/src/components/Popups/PinInput.js b/src/components/Popups/PinInput.js index fa36c6ab2..acc85d6c8 100644 --- a/src/components/Popups/PinInput.js +++ b/src/components/Popups/PinInput.js @@ -144,7 +144,7 @@ function PinInput({ showPopup, setShowPopup }) { onClick={() => handleInputClick(index)} onPaste={(e) => handleInputPaste(e.clipboardData.getData('Text'))} onKeyPress={(e) => handleInputKeyPress(e)} - className="w-10 px-3 mx-1 my-2 py-2 dark:bg-gray-700 dark:text-white border border-gray-300 dark:border-gray-500 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500" + className="w-10 px-3 mx-1 my-2 py-2 dark:bg-gray-700 dark:text-white border border-gray-300 dark:border-gray-500 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:inputDarkModeOverride" ref={inputRefs[index]} /> ))} diff --git a/src/context/CredentialsContext.js b/src/context/CredentialsContext.js new file mode 100644 index 000000000..6d64aa0e1 --- /dev/null +++ b/src/context/CredentialsContext.js @@ -0,0 +1,45 @@ +import React, { createContext, useState, useEffect, useCallback } from 'react'; +import { useApi } from '../api'; +import { extractCredentialFriendlyName } from '../functions/extractCredentialFriendlyName'; + +const CredentialsContext = createContext(); + +export const CredentialsProvider = ({ children }) => { + const api = useApi(); + const [vcEntityList, setVcEntityList] = useState([]); + const [latestCredentials, setLatestCredentials] = useState(new Set()); + + const getData = useCallback(async () => { + try { + const response = await api.get('/storage/vc'); + const fetchedVcList = response.data.vc_list; + const vcEntityList = await Promise.all(fetchedVcList.map(async vcEntity => { + const name = await extractCredentialFriendlyName(vcEntity.credential); + return { ...vcEntity, friendlyName: name }; + })); + vcEntityList.sort((vcA, vcB) => new Date(vcB.issuanceDate) - new Date(vcA.issuanceDate)); + + const latestIssuanceDate = vcEntityList[0]?.issuanceDate; + const latestCreds = new Set(vcEntityList.filter(vc => vc.issuanceDate === latestIssuanceDate).map(vc => vc.id)); + + if (window.location.pathname.includes('/cb')) { + setLatestCredentials(latestCreds); + setTimeout(() => { + setLatestCredentials(new Set()); + }, 4000); + } + + setVcEntityList(vcEntityList); + } catch (error) { + console.error('Failed to fetch data', error); + } + }, [api]); + + return ( + + {children} + + ); +}; + +export default CredentialsContext; diff --git a/src/index.css b/src/index.css index 114fc50dd..6f080c6d7 100644 --- a/src/index.css +++ b/src/index.css @@ -96,3 +96,44 @@ button.reactour__close { top: 12px; right: 12px; } + +/* Animations for new credentials */ +@keyframes highlight-filter { + 0%, 100% { + filter: brightness(1); + } + 50% { + filter: brightness(1.17); + } +} + +@keyframes fade-in { + 0% { + opacity: 0; + transform: translateY(-300px); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} + +.highlight-filter { + animation: highlight-filter 3s ease-in-out; +} + +.fade-in { + animation: fade-in 1s ease-in-out; +} + +/* Light and Dark mode input autofill */ +input:-webkit-autofill { + -webkit-box-shadow: 0 0 0 30px rgb(245, 245, 245) inset !important; +} + +@layer components { + .inputDarkModeOverride:-webkit-autofill { + -webkit-box-shadow: 0 0 0 30px rgb(70, 70, 70) inset !important; + -webkit-text-fill-color: white; + } +} diff --git a/src/pages/AddCredentials/AddCredentials.js b/src/pages/AddCredentials/AddCredentials.js index a91bf4c85..66ddc17e0 100644 --- a/src/pages/AddCredentials/AddCredentials.js +++ b/src/pages/AddCredentials/AddCredentials.js @@ -137,7 +137,7 @@ const Issuers = () => { diff --git a/src/pages/Home/Home.js b/src/pages/Home/Home.js index 35c049921..8aab446e8 100644 --- a/src/pages/Home/Home.js +++ b/src/pages/Home/Home.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useContext } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; @@ -20,11 +20,11 @@ import FullscreenPopup from '../../components/Popups/FullscreenImg'; import DeletePopup from '../../components/Popups/DeletePopup'; import { CredentialImage } from '../../components/Credentials/CredentialImage'; import QRButton from '../../components/Buttons/QRButton'; -import { extractCredentialFriendlyName } from "../../functions/extractCredentialFriendlyName"; +import CredentialsContext from '../../context/CredentialsContext'; const Home = () => { const api = useApi(); - const [vcEntityList, setVcEntityList] = useState([]); + const { vcEntityList, latestCredentials, getData } = useContext(CredentialsContext); const [isSmallScreen, setIsSmallScreen] = useState(window.innerWidth < 768); const [currentSlide, setCurrentSlide] = useState(1); const [showFullscreenImgPopup, setShowFullscreenImgPopup] = useState(false); @@ -51,6 +51,10 @@ const Home = () => { style: { margin: '0 10px' }, }; + useEffect(() => { + getData(); + }, [getData]); + useEffect(() => { const handleResize = () => { setIsSmallScreen(window.innerWidth < 768); @@ -63,19 +67,6 @@ const Home = () => { }; }, []); - useEffect(() => { - const getData = async () => { - const response = await api.get('/storage/vc'); - const vcEntityList = await Promise.all(response.data.vc_list.map(async vcEntity => { - const name = await extractCredentialFriendlyName(vcEntity.credential); - return { ...vcEntity, friendlyName: name }; - })); - vcEntityList.sort((vcA, vcB) => vcB.issuanceDate - vcA.issuanceDate); - setVcEntityList(vcEntityList); - }; - getData(); - }, [api]); - const handleAddCredential = () => { navigate('/add'); }; @@ -99,12 +90,12 @@ const Home = () => { setLoading(true); try { await api.del(`/storage/vc/${selectedVcEntity.credentialIdentifier}`); + await getData(); } catch (error) { console.error('Failed to delete data', error); } setLoading(false); setShowDeletePopup(false); - window.location.href = '/'; }; return ( @@ -137,53 +128,43 @@ const Home = () => { ) : ( <> - {vcEntityList && vcEntityList.map((vcEntity, index) => ( - <> - - {(currentSlide === index + 1 ? 'button' : 'div') - .split() - .map(Tag => ( - <> - { setShowFullscreenImgPopup(true); setSelectedVcEntity(vcEntity); }} - aria-label={`${vcEntity.friendlyName}`} - title={t('pageCredentials.credentialFullScreenTitle', { friendlyName: vcEntity.friendlyName })} - > - - -
- {currentSlide} of {vcEntityList.length} - sliderRef.current.slickPrev()} - aria-label={currentSlide === 1 ? t('pageCredentials.slideButtonAriaLabelDisable', { direction: t('pageCredentials.slidePrevious') }) : t('pageCredentials.slideButtonAriaLabelEnable', { direction: t('pageCredentials.slidePrevious') })} - title={currentSlide === 1 ? t('pageCredentials.slideButtonTitleDisable', { direction: t('pageCredentials.slidePrevious') }) : t('pageCredentials.slideButtonTitleEnable', { direction: t('pageCredentials.slidePrevious') })} - disabled={currentSlide === 1} - className={`${currentSlide === 1 ? 'opacity-50 cursor-not-allowed dark:text-gray-400' : 'text-primary dark:text-white hover:text-primary-hover dark:hover:text-gray-300'}`} - > - - - sliderRef.current.slickNext()} - aria-label={currentSlide === vcEntityList.length ? t('pageCredentials.slideButtonAriaLabelDisable', { direction: t('pageCredentials.slideNext') }) : t('pageCredentials.slideButtonAriaLabelEnable', { direction: t('pageCredentials.slideNext') })} - title={currentSlide === vcEntityList.length ? t('pageCredentials.slideButtonTitleDisable', { direction: t('pageCredentials.slideNext') }) : t('pageCredentials.slideButtonTitleEnable', { direction: t('pageCredentials.slideNext') })} - disabled={currentSlide === vcEntityList.length} - className={`${currentSlide === vcEntityList.length ? 'opacity-50 cursor-not-allowed dark:text-gray-400' : 'text-primary dark:text-white hover:text-primary-hover dark:hover:text-gray-300'}`} - > - - -
- - ))} - -
- -
+ {vcEntityList.map((vcEntity, index) => ( +
+ +
+
+ {currentSlide} of {vcEntityList.length} + + +
{ setShowDeletePopup(true); setSelectedVcEntity(vcEntity); }} />
- - +
))} @@ -194,15 +175,14 @@ const Home = () => { {vcEntityList.map((vcEntity) => ( ))} -