diff --git a/client/src/common/ui/fileuploadbutton/index.js b/client/src/common/ui/fileuploadbutton/index.js index 5d7796a..c73907e 100644 --- a/client/src/common/ui/fileuploadbutton/index.js +++ b/client/src/common/ui/fileuploadbutton/index.js @@ -1,5 +1,5 @@ -import { useEffect, useState, useMemo } from 'react' -import { updateSyncV } from 'use-sync-v' +import { useEffect, useState, useMemo, useRef } from 'react' +import { updateSyncV, useSyncV } from 'use-sync-v' import PropTypes from 'prop-types' import IconButton from '@mui/material/IconButton' @@ -45,6 +45,9 @@ function FileUploadButton ({ styles = {} }) { const fileId = useMemo(() => fileDomID, [fileDomID]) + const objectData = useSyncV(fileId) + const fileRef = useRef() + const [icon, setIcon] = useState((hasFile) ? ICON_STATES.CANCEL : ICON_STATES.SEARCH) @@ -64,6 +67,16 @@ function FileUploadButton ({ } }, [fileId]) + useEffect(() => { + if (objectData?.imgSrc === null) { + return + } + + return () => { + URL.revokeObjectURL(objectData?.imgSrc) + } + }, [objectData]) + const IconPicture = useMemo(() => { return (icon === ICON_STATES.SEARCH) ? PhotoCameraIcon @@ -96,6 +109,9 @@ function FileUploadButton ({ const clearFile = (e) => { e.preventDefault() + fileRef.current.value = null + URL.revokeObjectURL(objectData?.imgSrc) + setIcon(ICON_STATES.SEARCH) updateSyncV(fileDomID, { @@ -124,6 +140,7 @@ function FileUploadButton ({ hidden accept="image/*" type="file" + ref={fileRef} onChange={setSelectedFile} /> diff --git a/client/src/components/account/messages.js b/client/src/components/account/messages.js new file mode 100644 index 0000000..c55da89 --- /dev/null +++ b/client/src/components/account/messages.js @@ -0,0 +1,26 @@ +const messages = [ + { + mode: 'verifyEmail', + message: 'Please wait while we verify your email.', + success: `Success! Email verified. You can now sign in with your new account.`, + error: `Try verifying your email again.
Your request to verify your email has expired or the link has already been used.

Resend email verification? Resend` + }, + { + mode: 'resetPassword', + message: 'Reset your password.', + success: `Success! Password changed. You can now sign in using your new password.`, + error: `Try resetting your password again.
Your request to change your password has expired or the link has already been used.` + }, + { + mode: 'resend_email_verification', + message: 'Enter your registration email', + success: 'Success! Email verification sent.' + }, + { + mode: 'recoverEmail', + message: '', + success: '' + } +] + +export default messages diff --git a/client/src/components/recoverPassword/index.js b/client/src/components/recoverPassword/index.js index 5b5c330..93644c0 100644 --- a/client/src/components/recoverPassword/index.js +++ b/client/src/components/recoverPassword/index.js @@ -1,4 +1,5 @@ import PropTypes from 'prop-types' +import Link from 'next/link' import Page from '@/common/layout/page' import { Paper, Typography } from '@mui/material' import LoadingButton from '@/common/ui/loadingbutton' @@ -38,7 +39,8 @@ const RecoverPasswordComponent = ({state, eventsHandler}) => { gridTemplateColumns: '1fr 20px', gridTemplateAreas: `"username icon1" - "recoverPassword ."`, + "recoverPassword ." + "resend ."`, alignItems:'stretch', gap: '10px', minWidth: '300px', @@ -94,6 +96,23 @@ const RecoverPasswordComponent = ({state, eventsHandler}) => { /> } + {(state?.message?.includes('User is not yet email-verified')) && + + Did not receive the account verification email? Resend it   + here. + + } diff --git a/client/src/components/register/index.js b/client/src/components/register/index.js index eeb7f3a..9076bfc 100644 --- a/client/src/components/register/index.js +++ b/client/src/components/register/index.js @@ -12,7 +12,7 @@ import { useTheme } from '@emotion/react' const RegisterComponent = ({ state, eventsHandler }) => { const theme = useTheme() - const {joke, username, password, passwordConfirmation, errorMessage, successMessage, loading } = state + const {joke, username, password, passwordConfirmation, errorMessage, successMessage, loading, initialized } = state const {usernameHandler, passwordHandler, passwordConfirmationHandler, registerHandler, resetError } = eventsHandler return ( @@ -147,7 +147,7 @@ const RegisterComponent = ({ state, eventsHandler }) => { - {(errorMessage?.includes('auth/email-already-in-use')) && + {(!loading && initialized) && Submit diff --git a/client/src/lib/hooks/useFetchContactPhoto.js b/client/src/lib/hooks/useFetchContactPhoto.js index 9ae8106..ca1290e 100644 --- a/client/src/lib/hooks/useFetchContactPhoto.js +++ b/client/src/lib/hooks/useFetchContactPhoto.js @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useAsyncV, updateAsyncV } from 'use-sync-v' import { downloadBlobFromStorage } from '@/utils/firebase/storageutils' @@ -8,6 +8,7 @@ const PHOTO_STORE_KEY = 'savedPhotoBlob' * Downloads a Contact photo directly as Blob from Firebase Storage, taking the defined Firebase Storage Rules into account. * Returns the downloaded photo's local URL converted from the downloaded Blob data, or blank String ''. * Returns the photo download error as String, or blank ''. + * Revokes the local object URL from storage on compoment unmount. * * @param {String} storageFilePath - Contact photo's full Firebase Storage file path * @returns {Object} @@ -18,6 +19,7 @@ const PHOTO_STORE_KEY = 'savedPhotoBlob' */ export default function useFetchContactPhoto (storageFilePath) { const storagePhotoFile = useAsyncV(PHOTO_STORE_KEY) + const [objectURL, setObjectURL] = useState('') useEffect(() => { // Reset the Storage photo Blob @@ -29,17 +31,29 @@ export default function useFetchContactPhoto (storageFilePath) { return () => resetPhotoStore() }, []) - const photo = useMemo(() => { - return (storagePhotoFile.data !== null && !storagePhotoFile.loading) - ? URL.createObjectURL(storagePhotoFile.data) - : '' + useEffect(() => { + if (!objectURL) { + return + } + + // Revoke/clear the local URL object URL on component unmount + return () => { + URL.revokeObjectURL(objectURL) + } + }, [objectURL]) + + useEffect(() => { + // Set the local URL object URL + if (storagePhotoFile.data !== null && !storagePhotoFile.loading) { + const url = URL.createObjectURL(storagePhotoFile.data) + setObjectURL(url) + } }, [storagePhotoFile]) useEffect(() => { + // Download photo if (storageFilePath) { updateAsyncV(PHOTO_STORE_KEY, async () => downloadBlobFromStorage(storageFilePath)) - } else { - updateAsyncV(PHOTO_STORE_KEY, async () => null, { deleteExistingData: true }) } }, [storageFilePath]) @@ -53,6 +67,6 @@ export default function useFetchContactPhoto (storageFilePath) { data: storagePhotoFile.data, loading: storagePhotoFile.loading, error: errorString, - photo + photo: objectURL } } diff --git a/client/src/pages/account/index.js b/client/src/pages/account/index.js index 129f0b8..035f7cf 100644 --- a/client/src/pages/account/index.js +++ b/client/src/pages/account/index.js @@ -12,7 +12,7 @@ import { import { usePromise, RequestStatus } from '@/lib/hooks/usePromise' import AccountComponent from '@/components/account' -import messages from './messages' +import messages from '@/components/account/messages' const defaultState = { loading: true, diff --git a/client/src/pages/account/messages.json b/client/src/pages/account/messages.json deleted file mode 100644 index 3bb94ba..0000000 --- a/client/src/pages/account/messages.json +++ /dev/null @@ -1,24 +0,0 @@ -[ - { - "mode": "verifyEmail", - "message": "Please wait while we verify your email.", - "success": "Success! Email verified. You can now sign in with your new account.", - "error": "Try verifying your email again.
Your request to verify your email has expired or the link has already been used.

Resend email verification? Resend" - }, - { - "mode": "resetPassword", - "message": "Reset your password.", - "success": "Success! Password changed. You can now sign in using your new password.", - "error": "Try resetting your password again.
Your request to change your password has expired or the link has already been used." - }, - { - "mode": "resend_email_verification", - "message": "Enter your registration email", - "success": "Success! Email verification sent." - }, - { - "mode": "recoverEmail", - "message": "", - "success": "" - } -] diff --git a/client/src/pages/recoverPassword/index.js b/client/src/pages/recoverPassword/index.js index e7e2984..b8236f5 100644 --- a/client/src/pages/recoverPassword/index.js +++ b/client/src/pages/recoverPassword/index.js @@ -60,7 +60,7 @@ const RecoverPassword = () => { ...state, loading, message: (status === RequestStatus.SUCCESS) - ? 'Email sent. Please check your inbox.' + ? 'Email sent. Please check your inbox or your Spam folder. Wait for at most 5 minutes if you do not see the email right away.' : error }} eventsHandler={eventsHandler} diff --git a/client/src/pages/register/index.js b/client/src/pages/register/index.js index 6c9e9fd..aac5fad 100644 --- a/client/src/pages/register/index.js +++ b/client/src/pages/register/index.js @@ -28,7 +28,8 @@ const defaultState = { }, errorMessage: undefined, successMessage: '', - loading: false + loading: false, + initialized: false } const Register = () => { @@ -108,9 +109,10 @@ const Register = () => { setState(prev=>({ ...prev, loading: false, + initialized: true, errorMessage, successMessage: (sendVerificationStatus === PromiseWrapper.STATUS.SUCCESS) - ? 'Email sent. Please check your email.' + ? 'Email sent. Please check your email. Check your Spam folder or wait for at most 5 minutes if you do not see the email right away.' : '' })) })() diff --git a/server/src/controllers/index.js b/server/src/controllers/index.js index 8274ce0..13c67ef 100644 --- a/server/src/controllers/index.js +++ b/server/src/controllers/index.js @@ -2,7 +2,10 @@ const { Router } = require('express') const router = new Router() // Middleware -const { validToken } = require('../middleware/validtoken') +const { + validToken, + attachAccessControllAllowOrigin +} = require('../middleware') // Controllers const Email = require('./email') @@ -263,6 +266,6 @@ router.post('/account/action', Account.manageAccount) * document.body.removeChild(link) */ -router.post('/contacts/export', validToken, Contact.exportContact) +router.post('/contacts/export', validToken, attachAccessControllAllowOrigin, Contact.exportContact) module.exports = router diff --git a/server/src/middleware/attachaccesscontrolheader.js b/server/src/middleware/attachaccesscontrolheader.js new file mode 100644 index 0000000..5093e8e --- /dev/null +++ b/server/src/middleware/attachaccesscontrolheader.js @@ -0,0 +1,14 @@ +const { whitelist } = require('../utils/cors_options') + +// Attach 'Access-Control-Allow-Origin' to the response header for whitelisted "BASE" origins. +const attachAccessControllAllowOrigin = (req, res, next) => { + const origin = req.headers.origin + + if (whitelist.includes(origin)) { + res.setHeader('Access-Control-Allow-Origin', origin) + } + + next() +} + +module.exports = attachAccessControllAllowOrigin diff --git a/server/src/middleware/index.js b/server/src/middleware/index.js new file mode 100644 index 0000000..a6bb9d9 --- /dev/null +++ b/server/src/middleware/index.js @@ -0,0 +1,7 @@ +const validToken = require('./validtoken') +const attachAccessControllAllowOrigin = require('./attachaccesscontrolheader') + +module.exports = { + validToken, + attachAccessControllAllowOrigin +} diff --git a/server/src/middleware/validtoken.js b/server/src/middleware/validtoken.js index b671a0b..11d2473 100644 --- a/server/src/middleware/validtoken.js +++ b/server/src/middleware/validtoken.js @@ -2,7 +2,7 @@ const { getAuth } = require('../utils/db') // Inspects if the Authorization Bearer token from client belongs to a valid signed-in user. // Injects the decoded Firebase Auth user's information to req.user if token is valid. -module.exports.validToken = async (req, res, next) => { +const validToken = async (req, res, next) => { if ( (!req.headers.authorization || !req.headers.authorization.startsWith('Bearer ')) && @@ -47,3 +47,5 @@ module.exports.validToken = async (req, res, next) => { return res.status(403).send('Unauthorized') } } + +module.exports = validToken diff --git a/server/src/modules/contact/exportcsv.js b/server/src/modules/contact/exportcsv.js index 6a9676c..0d60e6e 100644 --- a/server/src/modules/contact/exportcsv.js +++ b/server/src/modules/contact/exportcsv.js @@ -5,6 +5,7 @@ const { CONTACT_FIELDS } = require('../../utils/constants') /** * Exports Contacts Firestore document/s from a user's /contacts subcollection to a CSV file. + * Requires using the attachAccessControllAllowOrigin middleware for enhanced cross-origin security. * @param {Object[]} contacts - Firestore Contact documents. * @param {Object} res - Express response object. * @returns @@ -18,7 +19,6 @@ const exportCSV = (contacts = [], res) => { res.setHeader('Content-Type', 'text/csv') res.setHeader('Content-Disposition', `'attachment; filename="${filename}"'`) - res.setHeader('Access-Control-Allow-Origin', process.env.CLIENT_WEBSITE_URL) contacts.forEach((contact) => { const obj = Object.values(CONTACT_FIELDS).reduce((list, key) => ({ diff --git a/server/src/modules/contact/exportpdf.js b/server/src/modules/contact/exportpdf.js index 6588882..3eab762 100644 --- a/server/src/modules/contact/exportpdf.js +++ b/server/src/modules/contact/exportpdf.js @@ -19,6 +19,7 @@ const printer = new PDFPrinter(fonts) /** * Exports Contacts Firestore document/s from a user's /contacts subcollection to a PDF file. + * Requires using the attachAccessControllAllowOrigin middleware for enhanced cross-origin security. * @param {Object[]} contacts - Firestore Contact documents. * @param {Object} res - Express response object. * @returns @@ -30,7 +31,6 @@ const exportPDF = (contacts = [], res) => { res.setHeader('Content-Type', 'application/pdf') res.setHeader('Content-Disposition', `'attachment; filename="${filename}"'`) - res.setHeader('Access-Control-Allow-Origin', process.env.CLIENT_WEBSITE_URL) const docDefinition = { defaultStyle: {