diff --git a/.firebaserc b/.firebaserc index e115307a..7536d832 100644 --- a/.firebaserc +++ b/.firebaserc @@ -1,5 +1,8 @@ { "projects": { - "default": "cioos-metadata-form" - } -} + "default": "cioos-metadata-form", + "dev": "cioos-metadata-form-dev" + }, + "targets": {}, + "etags": {} +} \ No newline at end of file diff --git a/.github/workflows/firebase-hosting-pull-request.yml b/.github/workflows/firebase-hosting-pull-request.yml index 0b2ca8c7..0b1d48b5 100644 --- a/.github/workflows/firebase-hosting-pull-request.yml +++ b/.github/workflows/firebase-hosting-pull-request.yml @@ -26,7 +26,7 @@ jobs: firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_CIOOS_METADATA_FORM }}' expires: 30d entryPoint: '.' - projectId: cioos-metadata-form + projectId: cioos-metadata-form-dev env: FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }} GMAIL_USER: ${{ secrets.GMAIL_USER }} diff --git a/README.md b/README.md index 6d5e9400..ee2b462b 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,26 @@ We use a GitHub Actions workflow named `firebase-deploy` for deploying Firebase 2. Select the `firebase-deploy` workflow. 3. Click "Run workflow", select the branch to deploy, and initiate the workflow. +### Deploying to Development Project + +To deploy updated Firebase functions to the "cioos-metadata-form-dev" development project, follow these steps: + +1. **Ensure your local setup is linked to the correct Firebase project** by using the Firebase CLI to login and select the "cioos-metadata-form-dev" project. + + ```bash + firebase use cioos-metadata-form-dev + ``` + +2. **Make necessary changes to your Firebase functions.** + +3. **Deploy the changes by running the command:** + + ```bash + firebase deploy --only functions + ``` + +This will deploy the updated functions to the development project. The GitHub Action for deploying to the preview URL is already configured to use this development project, ensuring that any previews generated from pull requests will interact with the updated dev functions instead of the production version. + #### GitHub Secrets and .env File Creation The workflow utilizes the following secrets to create the virtual `.env` file for the deployment process: diff --git a/firebase-functions/.firebaserc b/firebase-functions/.firebaserc index c26a95bf..71dcf944 100644 --- a/firebase-functions/.firebaserc +++ b/firebase-functions/.firebaserc @@ -1,6 +1,7 @@ { "projects": { - "default": "cioos-metadata-form" + "default": "cioos-metadata-form", + "dev": "cioos-metadata-form-dev" }, "targets": { "cioos-metadata-form": { @@ -12,6 +13,16 @@ "cioos-metadata-form-dev" ] } + }, + "development": { + "database": { + "prod": [ + "cioos-metadata-form" + ], + "dev": [ + "cioos-metadata-form-dev" + ] + } } }, "etags": {} diff --git a/firebase-functions/database.rules.json b/firebase-functions/database.rules.json index 062716fa..01841b02 100644 --- a/firebase-functions/database.rules.json +++ b/firebase-functions/database.rules.json @@ -5,7 +5,7 @@ // This design requires careful structuring of rules (and data) to ensure appropriate access control // throughout the database hierarchy. "$region": { - // Allow read access to any authenticated user. + // Allow read access to any authenticated user. ".read": "auth.uid != null", "shares": { "$userid": { @@ -17,48 +17,52 @@ "users": { "$userid": { // Allow write access if the authenticated user is the user specified by $userid or if the authenticated user's email is listed as a reviewer in the admin permissions for the region. - ".write": "(auth.uid == $userid) || root.child('admin').child($region).child('permissions').child('reviewers').val().contains(auth.email)", + ".write": "(auth.uid == $userid) || root.child('admin').child($region).child('permissions').child('reviewers').val().contains(auth.email)", "records": { "$recordid": { - // Allow read access if the authenticated users's uid is listed in the 'shared with' list for the record - ".read": "root.child($region).child('users').child($userid).child('records').child($recordid).child('sharedWith').child(auth.uid).exists()", - // Allow write access if the authenticated users's uid is listed in the 'shared with' list for the record - ".write": "root.child($region).child('users').child($userid).child('records').child($recordid).child('sharedWith').child(auth.uid).exists()", + // Allow read access if the authenticated users's uid exists in the 'shared with' object for the record + ".read": "root.child($region).child('users').child($userid).child('records').child($recordid).child('sharedWith').child(auth.uid).exists()", + // Allow write access if the authenticated users's uid exists in the 'shared with' object for the record + ".write": "root.child($region).child('users').child($userid).child('records').child($recordid).child('sharedWith').child(auth.uid).exists()", } - }, + } } } }, "admin": { // Section of the database dedicated to admin configurations. "$regionAdmin": { // Allow read access if the authenticated user's email is listed as a reviewer in the permissions for the region. - ".read": "root.child('admin').child($regionAdmin).child('permissions').child('reviewers').val().contains(auth.email) || root.child('admin').child($regionAdmin).child('permissions').child('admins').val().contains(auth.email)", - "projects": { - // Allow write access to projects if the authenticated user's email is listed as a reviewer in the permissions for the region. - ".write": "root.child('admin').child($regionAdmin).child('permissions').child('reviewers').val().contains(auth.email)", - // Allow read access to any authenticated user. - ".read": "auth.uid != null" - }, - "permissions": { - // Allow read access to any authenticated user. - ".read": "auth.uid != null", - "admins": { - // Allow write access for admins if the authenticated user's email is listed as an admin in the permissions for the region. - ".write": "root.child('admin').child($regionAdmin).child('permissions').child('admins').val().contains(auth.email)", + ".read": "root.child('admin').child($regionAdmin).child('permissions').child('reviewers').val().contains(auth.email)", + "projects": { + // Allow write access to projects if the authenticated user's email is listed as a reviewer in the permissions for the region. + ".write": "root.child('admin').child($regionAdmin).child('permissions').child('reviewers').val().contains(auth.email)", + // Allow read access to any authenticated user. + ".read": "auth.uid != null" + }, + "permissions": { // Allow read access to any authenticated user. - ".read": "auth.uid != null" + ".read": "auth.uid != null", + "admins": { + // Allow write access for admins if the authenticated user's email is listed as an admin in the permissions for the region. + ".write": "root.child('admin').child($regionAdmin).child('permissions').child('admins').val().contains(auth.email)", + // Allow read access to any authenticated user. + ".read": "auth.uid != null", + }, + "reviewers": { + // Allow write access for reviewers if the authenticated user's email is listed as an admin in the permissions for the region. + ".write": "root.child('admin').child($regionAdmin).child('permissions').child('admins').val().contains(auth.email)", + // Allow read access to any authenticated user. + ".read": "auth.uid != null", + }, + "dataciteCredentials": { + // Allow write access to DataCite credentials if the authenticated user's email is listed as an admin in the permissions for the region. + ".write": "root.child('admin').child($regionAdmin).child('permissions').child('admins').val().contains(auth.email)", + } }, - "reviewers": { - // Allow write access for reviewers if the authenticated user's email is listed as an admin in the permissions for the region. + "dataciteCredentials": { + // Allow write access to DataCite credentials if the authenticated user's email is listed as an admin in the permissions for the region. ".write": "root.child('admin').child($regionAdmin).child('permissions').child('admins').val().contains(auth.email)", - // Allow read access to any authenticated user. - ".read": "auth.uid != null" } - }, - "dataciteCredentials": { - // Allow write access to DataCite credentials if the authenticated user's email is listed as an admin in the permissions for the region. - ".write": "root.child('admin').child($regionAdmin).child('permissions').child('admins').val().contains(auth.email)", - } } } } diff --git a/firebase-functions/functions/datacite.js b/firebase-functions/functions/datacite.js index e6bec884..48ec7e15 100644 --- a/firebase-functions/functions/datacite.js +++ b/firebase-functions/functions/datacite.js @@ -1,11 +1,21 @@ +const admin = require("firebase-admin"); + const baseUrl = "https://api.datacite.org/dois/"; const functions = require("firebase-functions"); -const { defineString } = require('firebase-functions/params'); const axios = require("axios"); exports.createDraftDoi = functions.https.onCall(async (data) => { - const { record, authHash } = data; + const { record, region } = data; + + let authHash + + try { + authHash = (await admin.database().ref('admin').child(region).child("dataciteCredentials").child("dataciteHash").once("value")).val(); + } catch (error) { + console.error(`Error fetching Datacite Auth Hash for region ${region}:`, error); + return null; + } functions.logger.log(authHash); @@ -51,15 +61,21 @@ exports.createDraftDoi = functions.https.onCall(async (data) => { } }); -exports.updateDraftDoi = functions.https.onCall(async (data) => { - - const dataciteCred = process.env.DATACITE_AUTH_HASH || dataciteAuthHash.value() +exports.updateDraftDoi = functions.https.onCall(async (dataObj) => { + const { doi, region, data } = dataObj; + let authHash + try { + authHash = (await admin.database().ref('admin').child(region).child("dataciteCredentials").child("dataciteHash").once("value")).val(); + } catch (error) { + console.error(`Error fetching Datacite Auth Hash for region ${region}:`, error); + return null; + } try { - const url = `${baseUrl}${data.doi}/`; - const response = await axios.put(url, data.data, { + const url = `${baseUrl}${doi}/`; + const response = await axios.put(url, data, { headers: { - 'Authorization': `Basic ${data.dataciteAuthHash}`, + 'Authorization': `Basic ${authHash}`, 'Content-Type': "application/json", }, }); @@ -102,11 +118,20 @@ exports.updateDraftDoi = functions.https.onCall(async (data) => { exports.deleteDraftDoi = functions.https.onCall(async (data) => { - const { doi, dataciteAuthHash } = data; + const { doi, region } = data; + let authHash + + try { + authHash = (await admin.database().ref('admin').child(region).child("dataciteCredentials").child("dataciteHash").once("value")).val(); + } catch (error) { + console.error(`Error fetching Datacite Auth Hash for region ${region}:`, error); + return null; + } + try { const url = `${baseUrl}${doi}/`; const response = await axios.delete(url, { - headers: { 'Authorization': `Basic ${dataciteAuthHash}` }, + headers: { 'Authorization': `Basic ${authHash}` }, }); return response.status; } catch (err) { @@ -142,14 +167,31 @@ exports.deleteDraftDoi = functions.https.onCall(async (data) => { exports.getDoiStatus = functions.https.onCall(async (data) => { - const dataciteCred = process.env.DATACITE_AUTH_HASH || dataciteAuthHash.value() + let prefix; + let authHash + + functions.logger.log(data); + + try { + prefix = (await admin.database().ref('admin').child(data.region).child("dataciteCredentials").child("prefix").once("value")).val(); + } catch (error) { + console.error(`Error fetching Datacite Prefix for region ${data.region}:`, error); + return null; + } + + try { + authHash = (await admin.database().ref('admin').child(data.region).child("dataciteCredentials").child("dataciteHash").once("value")).val(); + } catch (error) { + console.error(`Error fetching Datacite Auth Hash for region ${data.region}:`, error); + return null; + } try { const url = `${baseUrl}${data.doi}/`; // TODO: limit response to just the state field. elasticsearch query syntax? const response = await axios.get(url, { headers: { - 'Authorization': `Basic ${data.authHash}` + 'Authorization': `Basic ${authHash}` }, }); return response.data.data.attributes.state; @@ -163,7 +205,7 @@ exports.getDoiStatus = functions.https.onCall(async (data) => { } // if the error is a 404, throw a HttpsError with the code 'not-found' if (err.response && err.response.status === 404) { - if (data.doi.startsWith(`${data.prefix}/`)) { + if (data.doi.startsWith(`${prefix}/`)) { return 'not found' } return 'unknown' @@ -184,3 +226,29 @@ exports.getDoiStatus = functions.https.onCall(async (data) => { } }); + +exports.getCredentialsStored = functions.https.onCall(async (data) => { + try { + const credentialsRef = admin.database().ref('admin').child(data).child("dataciteCredentials"); + const authHashSnapshot = await credentialsRef.child("dataciteHash").once("value"); + const prefixSnapshot = await credentialsRef.child("prefix").once("value"); + + const authHash = authHashSnapshot.val(); + const prefix = prefixSnapshot.val(); + + // Check for non-null and non-empty + return authHash && authHash !== "" && prefix && prefix !== ""; + } catch (error) { + console.error("Error checking Datacite credentials:", error); + return false; + } +}); + +exports.getDatacitePrefix = functions.https.onCall(async (region) => { + try { + const prefix = (await admin.database().ref('admin').child(region).child("dataciteCredentials").child("prefix").once("value")).val(); + return prefix; + } catch (error) { + throw new Error(`Error fetching Datacite Prefix for region ${region}: ${error}`); + } +}); \ No newline at end of file diff --git a/firebase-functions/functions/index.js b/firebase-functions/functions/index.js index d94af8f7..8dc90c6f 100644 --- a/firebase-functions/functions/index.js +++ b/firebase-functions/functions/index.js @@ -1,7 +1,7 @@ const admin = require("firebase-admin"); const { translate } = require("./translate"); const { checkURLActive } = require("./serverUtils"); -const { createDraftDoi, updateDraftDoi, deleteDraftDoi, getDoiStatus } = require("./datacite"); +const { createDraftDoi, updateDraftDoi, deleteDraftDoi, getDoiStatus, getCredentialsStored, getDatacitePrefix } = require("./datacite"); const { notifyReviewer, notifyUser } = require("./notify"); const { updatesRecordCreate, @@ -26,3 +26,5 @@ exports.deleteDraftDoi = deleteDraftDoi; exports.updateDraftDoi = updateDraftDoi; exports.getDoiStatus = getDoiStatus; exports.checkURLActive = checkURLActive; +exports.getCredentialsStored = getCredentialsStored; +exports.getDatacitePrefix = getDatacitePrefix; diff --git a/package-lock.json b/package-lock.json index fca90a82..c3080d1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@testing-library/user-event": "^7.1.2", "array-move": "^3.0.1", "axios": "^1.6.7", + "buffer": "^6.0.3", "citation-js": "^0.5.0", "clsx": "^1.1.1", "crypto-browserify": "^3.12.0", @@ -38,7 +39,6 @@ "path-browserify": "^1.0.1", "querystring-es3": "^0.2.1", "react": "^16.13.1", - "react-app-rewired": "^2.2.1", "react-dom": "^16.13.1", "react-helmet": "^6.1.0", "react-leaflet": "^2.7.0", @@ -78,6 +78,7 @@ "gh-pages": "^3.1.0", "lint-staged": "^10.4.0", "prettier": "^2.1.2", + "react-app-rewired": "^2.2.1", "react-hot-loader": "^4.12.21" } }, @@ -8641,9 +8642,9 @@ } }, "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", "funding": [ { "type": "github", @@ -8660,7 +8661,7 @@ ], "dependencies": { "base64-js": "^1.3.1", - "ieee754": "^1.1.13" + "ieee754": "^1.2.1" } }, "node_modules/buffer-equal-constant-time": { @@ -23149,6 +23150,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/react-app-rewired/-/react-app-rewired-2.2.1.tgz", "integrity": "sha512-uFQWTErXeLDrMzOJHKp0h8P1z0LV9HzPGsJ6adOtGlA/B9WfT6Shh4j2tLTTGlXOfiVx6w6iWpp7SOC5pvk+gA==", + "dev": true, "dependencies": { "semver": "^5.6.0" }, @@ -23163,6 +23165,7 @@ "version": "5.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, "bin": { "semver": "bin/semver" } @@ -27524,6 +27527,29 @@ "node": ">=8" } }, + "node_modules/sync-fetch/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/table": { "version": "6.8.1", "resolved": "https://registry.npmjs.org/table/-/table-6.8.1.tgz", diff --git a/package.json b/package.json index 48327fe4..47ad6f3a 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@testing-library/user-event": "^7.1.2", "array-move": "^3.0.1", "axios": "^1.6.7", + "buffer": "^6.0.3", "citation-js": "^0.5.0", "clsx": "^1.1.1", "crypto-browserify": "^3.12.0", @@ -39,7 +40,6 @@ "path-browserify": "^1.0.1", "querystring-es3": "^0.2.1", "react": "^16.13.1", - "react-app-rewired": "^2.2.1", "react-dom": "^16.13.1", "react-helmet": "^6.1.0", "react-leaflet": "^2.7.0", @@ -79,6 +79,7 @@ "gh-pages": "^3.1.0", "lint-staged": "^10.4.0", "prettier": "^2.1.2", + "react-app-rewired": "^2.2.1", "react-hot-loader": "^4.12.21" }, "scripts": { diff --git a/src/__tests__/firebaseEnableDoiCreation.test.js b/src/__tests__/firebaseEnableDoiCreation.test.js index adfad92d..11585cad 100644 --- a/src/__tests__/firebaseEnableDoiCreation.test.js +++ b/src/__tests__/firebaseEnableDoiCreation.test.js @@ -80,57 +80,4 @@ describe('Datacite Credentials Management', () => { // Verify that mockRemove was called, indicating the delete operation was attempted expect(mockRemove).toHaveBeenCalled(); }); - - it('should fetch the Datacite prefix for a region', async () => { - const region = 'hakai'; - const prefix = '10.1234'; - const authHash = 'abcd1234hash'; - - const snapshot = { val: () => prefix, exportVal: () => prefix, exists: jest.fn(() => true) }; - jest.spyOn(db, 'get').mockImplementationOnce(() => (snapshot)); - - // Simulate setting data before fetch attempt - await dataciteFunctions.newDataciteAccount(region, prefix, authHash); - - const fetchedPrefix = await dataciteFunctions.getDatacitePrefix(region); - expect(fetchedPrefix).toEqual(prefix); - - // Verify that mockOnce was called, indicating the read operation was simulated - expect(mockGet).toHaveBeenCalled(); - }); - - it('should fetch the Datacite authHash for a region', async () => { - const region = 'hakai'; - const prefix = '10.1234'; - const authHash = 'abcd1234hash'; - - const snapshot = { val: () => authHash, exportVal: () => authHash, exists: jest.fn(() => true) }; - jest.spyOn(db, 'get').mockImplementationOnce(() => (snapshot)); - - // Simulate setting data before fetch attempt - await dataciteFunctions.newDataciteAccount(region, prefix, authHash); - - const fetchedAuthHash = await dataciteFunctions.getAuthHash(region); - expect(fetchedAuthHash).toEqual(authHash); - - // Verify that mockOnce was called, indicating the read operation was simulated - expect(mockGet).toHaveBeenCalled(); - }); - - it('should check if credentials are stored for a region', async () => { - const region = 'hakai'; - const prefix = '10.1234'; - const dataciteHash = 'abcd1234hash'; - - const snapshot1 = { val: () => dataciteHash, exportVal: () => dataciteHash, exists: jest.fn(() => true) }; - jest.spyOn(db, 'get').mockImplementationOnce(() => (snapshot1)); - const snapshot2 = { val: () => prefix, exportVal: () => prefix, exists: jest.fn(() => true) }; - jest.spyOn(db, 'get').mockImplementationOnce(() => (snapshot2)); - - // Simulate setting data before fetch attempt - await dataciteFunctions.newDataciteAccount(region, prefix, dataciteHash); - - const credentialsStored = await dataciteFunctions.getCredentialsStored(region); - expect(credentialsStored).toBe(true); - }); }); diff --git a/src/__tests__/recordToDataCite.test.js b/src/__tests__/recordToDataCite.test.js deleted file mode 100644 index d3148d22..00000000 --- a/src/__tests__/recordToDataCite.test.js +++ /dev/null @@ -1,20 +0,0 @@ -import recordToDataCite from './../utils/recordToDataCite' -import licenses from './../utils/licenses'; -import regions from './../regions'; -import mockMetadataRecord from '../__testData__/mockMetadataRecord'; -import expectedDataCiteStructure from '../__testData__/expectedDataCiteStructure'; - -const language = 'en'; -const region = 'hakai'; -const datacitePrefix = "10.21966" - -describe('recordToDataCite', () => { - it('should correctly map metadata record to DataCite format', () => { - - const testResult = recordToDataCite(mockMetadataRecord, language, region, datacitePrefix); - - // Assert that the output matches the expected structure - expect(testResult).toEqual(expectedDataCiteStructure); - - }); -}) \ No newline at end of file diff --git a/src/components/BaseLayout.jsx b/src/components/BaseLayout.jsx index ce645010..652cab8a 100644 --- a/src/components/BaseLayout.jsx +++ b/src/components/BaseLayout.jsx @@ -6,6 +6,7 @@ import { createTheme, ThemeProvider } from "@material-ui/core/styles"; import Submissions from "./Pages/Submissions"; import Published from "./Pages/Published"; import Contacts from "./Pages/ContactsSaved"; +import Shared from "./Pages/Shared" import Login from "./Pages/Login"; import NavDrawer from "./NavDrawer"; import MetadataForm from "./Pages/MetadataForm"; @@ -55,6 +56,7 @@ const Pages = ({ match }) => { component={EditContact} /> + { - const { createDraftDoi, deleteDraftDoi, getDoiStatus, datacitePrefix, dataciteAuthHash } = useContext(UserContext); + const { createDraftDoi, deleteDraftDoi, getDoiStatus, datacitePrefix } = useContext(UserContext); const { language, region, userID } = useParams(); const doiIsValid = validateDOI(record.datasetIdentifier) const [doiGenerated, setDoiGenerated] = useState(false); @@ -51,40 +51,40 @@ const DOIInput = ({ record, name, handleUpdateDatasetIdentifier, handleUpdateDoi try { const mappedDataCiteObject = recordToDataCite(record, language, region, datacitePrefix); - if (dataciteAuthHash) { - await createDraftDoi({ - record: mappedDataCiteObject, - authHash: dataciteAuthHash, + await createDraftDoi({ + record: mappedDataCiteObject, + region, + }) + .then((response) => { + return response.data.data.attributes; }) - .then((response) => { - return response.data.data.attributes; - }) - .then(async (attributes) => { - // Update the record object with datasetIdentifier and doiCreationStatus - handleUpdateDatasetIdentifier({ target: { name, value: `https://doi.org/${attributes.doi}` }}); - handleUpdateDoiCreationStatus({ target: { name, value: "draft" }}); - - // Create a new object with updated properties - const updatedRecord = { - ...record, - datasetIdentifier: `https://doi.org/${attributes.doi}`, - doiCreationStatus: "draft", - }; - - // Save the updated record to the Firebase database - const recordsRef = ref(database, `${region}/users/${userID}/records`); - - if (record.recordID) { - await update(child(recordsRef, record.recordID), { datasetIdentifier: updatedRecord.datasetIdentifier, doiCreationStatus: updatedRecord.doiCreationStatus }); - } + .then(async (attributes) => { + // Update the record object (local state) with datasetIdentifier and doiCreationStatus + handleUpdateDatasetIdentifier({ target: { value: `https://doi.org/${attributes.doi}` }}); + handleUpdateDoiCreationStatus({ target: { value: "draft" }}); + + // Save doi values to database now without waiting for the user to press save + // Create a new object with updated properties + const updatedRecord = { + ...record, + datasetIdentifier: `https://doi.org/${attributes.doi}`, + doiCreationStatus: "draft", + }; + + // Save the updated record to the Firebase database + const recordsRef = ref(database, `${region}/users/${userID}/records`); + + if (record.recordID) { + await update(child(recordsRef, record.recordID), { datasetIdentifier: updatedRecord.datasetIdentifier, doiCreationStatus: updatedRecord.doiCreationStatus }); + } - setDoiGenerated(true); - }) - .finally(() => { - setLoadingDoi(false); - }); - } + setDoiGenerated(true); + }) + .finally(() => { + setLoadingDoi(false); + }); + } catch (err) { setDoiErrorFlag(true); throw new Error(`Error in handleGenerateDOI: ${err}`); @@ -93,9 +93,8 @@ const DOIInput = ({ record, name, handleUpdateDatasetIdentifier, handleUpdateDoi async function handleUpdateDraftDOI() { setLoadingDoiUpdate(true); - try { - const statusCode = await performUpdateDraftDoi(record, region, language, datacitePrefix, dataciteAuthHash) + const statusCode = await performUpdateDraftDoi(record, region, language, datacitePrefix) if (statusCode === 200) { setDoiUpdateFlag(true); @@ -123,7 +122,7 @@ const DOIInput = ({ record, name, handleUpdateDatasetIdentifier, handleUpdateDoi // Extract DOI from the full URL const doi = record.datasetIdentifier.replace('https://doi.org/', ''); - deleteDraftDoi({ doi, dataciteAuthHash }) + deleteDraftDoi({ doi, region }) .then((response) => response.data) .then(async (statusCode) => { if (statusCode === 204) { @@ -166,12 +165,12 @@ const DOIInput = ({ record, name, handleUpdateDatasetIdentifier, handleUpdateDoi if (debouncedDoiIdValue === '') { handleUpdateDoiCreationStatus({ target: { name, value: "" } }); } - else if (debouncedDoiIdValue && datacitePrefix && doiIsValid && dataciteAuthHash) { + else if (debouncedDoiIdValue && datacitePrefix && doiIsValid) { let id = debouncedDoiIdValue if (debouncedDoiIdValue.includes('doi.org/')) { id = debouncedDoiIdValue.split('doi.org/').pop(); } - getDoiStatus({ doi: id, prefix: datacitePrefix, authHash: dataciteAuthHash }) + getDoiStatus({ doi: id, region }) .then(response => { if (mounted.current) handleUpdateDoiCreationStatus({ target: { name, value: response.data } }); @@ -185,7 +184,7 @@ const DOIInput = ({ record, name, handleUpdateDatasetIdentifier, handleUpdateDoi return () => { mounted.current = false; }; - }, [debouncedDoiIdValue, getDoiStatus, doiIsValid, datacitePrefix, dataciteAuthHash]) + }, [debouncedDoiIdValue, getDoiStatus, doiIsValid]) @@ -218,7 +217,7 @@ const DOIInput = ({ record, name, handleUpdateDatasetIdentifier, handleUpdateDoi + + + + + {Object.keys(sharedWithUsers).length > 0 && ( + + Users this record is shared with: + + Utilisateurs avec lesquels cet enregistrement est + partagé : + + + )} + + + {Object.entries(sharedWithUsers).map( + ([userID, userDetails], index) => ( + + {userDetails.name}} + /> + + removeUserFromSharedWith(userID)} + > + + + + + ) + )} + + + + + + + + ); +}; + +export default SharedUsersList; diff --git a/src/components/NavDrawer.jsx b/src/components/NavDrawer.jsx index ac8c8c2c..ed962f7d 100644 --- a/src/components/NavDrawer.jsx +++ b/src/components/NavDrawer.jsx @@ -15,6 +15,7 @@ import { SupervisorAccount, Menu, AssignmentTurnedIn, + FolderShared, } from "@material-ui/icons"; import { @@ -128,6 +129,7 @@ export default function MiniDrawer({ children }) { isReviewer: userIsReviewer, isAdmin: userIsAdmin, authIsLoading, + hasSharedRecords, } = useContext(UserContext); let { language = "en", region = "region-select" } = useParams(); @@ -168,6 +170,7 @@ export default function MiniDrawer({ children }) { admin: , signIn: , logout: , + sharedWithMe: , }; const topBarBackgroundColor = region ? regions[region].colors.primary @@ -351,6 +354,24 @@ export default function MiniDrawer({ children }) { + {hasSharedRecords && ( + + history.push(`${baseURL}/shared`)} + > + + + + + + + )} + {userIsReviewer && ( { + this.unsubscribe = onAuthStateChanged(getAuth(firebase), async (user) => { if (user) { // Reference to the regionAdmin in the database const adminRef = ref(database, "admin"); @@ -66,8 +73,16 @@ class Admin extends FormClassTemplate { const permissionsRef = child(regionAdminRef, "permissions"); const projects = await getRegionProjects(region); - const datacitePrefix = await getDatacitePrefix(region); - const credentialsStored = await getCredentialsStored(region); + const datacitePrefix = await getDatacitePrefix(region).then( + (response) => { + return response.data; + } + ); + const credentialsStored = await getCredentialsStored(region).then( + (response) => { + return response.data; + } + ); onValue(permissionsRef, (permissionsFirebase) => { const permissions = permissionsFirebase.toJSON(); @@ -125,7 +140,7 @@ class Admin extends FormClassTemplate { handleDisableDoiCreation = async () => { const { region } = this.props.match.params; - + try { await deleteAllDataciteCredentials(region); this.setState({ @@ -145,22 +160,33 @@ class Admin extends FormClassTemplate { const { match } = this.props; const { region } = match.params; + const { reviewers, admins, projects } = this.state; + const database = getDatabase(firebase); + + if (auth.currentUser) { + const regionAdminRef = ref(database, `admin/${region}`); + const permissionsRef = child(regionAdminRef, "permissions"); + const projectsRef = child(regionAdminRef, "projects"); + + set(child(permissionsRef, "admins"), cleanArr(admins).join()); + + set(projectsRef, cleanArr(projects)); + set(child(permissionsRef, "reviewers"), cleanArr(reviewers).join()); + } + } + + saveDoiCredentials() { + const { match } = this.props; + const { region } = match.params; + const { - reviewers, - admins, - projects, datacitePrefix, dataciteAccountId, datacitePass, isDoiCreationEnabled, } = this.state; - const database = getDatabase(firebase); - // Check if DOI creation is enabled but credentials are not stored - if (isDoiCreationEnabled && (!datacitePrefix || !dataciteAccountId || !datacitePass)) { - this.setState({ showCredentialsMissingDialog: true }); - return; - } + const database = getDatabase(firebase); const bufferObj = Buffer.from( `${dataciteAccountId}:${datacitePass}`, @@ -168,16 +194,19 @@ class Admin extends FormClassTemplate { ); const base64String = bufferObj.toString("base64"); + // Check if DOI creation is enabled but credentials are not stored + if ( + isDoiCreationEnabled && + (!datacitePrefix || !dataciteAccountId || !datacitePass) + ) { + this.setState({ showCredentialsMissingDialog: true }); + return; + } + if (auth.currentUser) { const regionAdminRef = ref(database, `admin/${region}`); - const permissionsRef = child(regionAdminRef, "permissions"); - const projectsRef = child(regionAdminRef, "projects"); const dataciteRef = child(regionAdminRef, "dataciteCredentials"); - set(child(permissionsRef,"admins"), cleanArr(admins).join()); - - set(projectsRef, cleanArr(projects)); - set(child(permissionsRef, "reviewers"), cleanArr(reviewers).join()); set(child(dataciteRef, "prefix"), datacitePrefix); set(child(dataciteRef, "dataciteHash"), base64String); @@ -196,17 +225,27 @@ class Admin extends FormClassTemplate { aria-labelledby="alert-dialog-title" aria-describedby="alert-dialog-description" > - Delete Datacite Credentials? + + Delete Datacite Credentials? + - Disabling DOI creation will delete the stored credentials. Are you sure you want to proceed? + Disabling DOI creation will delete the stored credentials. Are you + sure you want to proceed? - - @@ -222,14 +261,22 @@ class Admin extends FormClassTemplate { aria-labelledby="credentials-missing-dialog-title" aria-describedby="credentials-=missing-dialog-description" > - Missing DataCite Credentials + + Missing DataCite Credentials + Please add DataCite credentials before saving. - @@ -247,9 +294,9 @@ class Admin extends FormClassTemplate { }; validateDatacitePrefix = (prefix) => { - const isValid = /^10\.\d+/.test(prefix); - this.setState({ datacitePrefixValid: isValid }); -}; + const isValid = /^10\.\d+/.test(prefix); + this.setState({ datacitePrefixValid: isValid }); + }; render() { const { @@ -289,68 +336,82 @@ class Admin extends FormClassTemplate { ) : ( <> - - - - - Projects - Projets - - - - - - this.setState({ projects: e.target.value.split("\n") }) - } - /> - + + + + + Projects + Projets + + + + + + this.setState({ projects: e.target.value.split("\n") }) + } + /> + - - - - Admins - Administrateurs - - - - - - this.setState({ admins: e.target.value.split("\n") }) - } - /> - + + + + Admins + Administrateurs + + + + + + this.setState({ admins: e.target.value.split("\n") }) + } + /> + + + + + Reviewers + Réviseurs + + + + + + this.setState({ + reviewers: e.target.value.split("\n"), + }) + } + /> + + - + - @@ -388,7 +449,13 @@ class Admin extends FormClassTemplate { - + Credentials Stored Identifiants Enregistrés @@ -401,7 +468,13 @@ class Admin extends FormClassTemplate { - + Please Add DataCite Credentials Identifiants Enregistrés @@ -427,7 +500,10 @@ class Admin extends FormClassTemplate { onChange={this.handleChange} fullWidth error={!this.state.datacitePrefixValid} - helperText={!this.state.datacitePrefixValid && "Prefix must start with '10.' followed by numbers."} + helperText={ + !this.state.datacitePrefixValid && + "Prefix must start with '10.' followed by numbers." + } /> @@ -476,28 +552,30 @@ class Admin extends FormClassTemplate { )} + {this.state.isDoiCreationEnabled && ( + + )} - - - )} - {this.renderDeletionDialog()} + {this.renderDeletionDialog()} {this.renderCredentialsMissingDialog()} ); } } +Admin.contextType = UserContext; export default Admin; diff --git a/src/components/Pages/MetadataForm.jsx b/src/components/Pages/MetadataForm.jsx index ee822ce6..e0aaba3e 100644 --- a/src/components/Pages/MetadataForm.jsx +++ b/src/components/Pages/MetadataForm.jsx @@ -144,7 +144,7 @@ class MetadataForm extends FormClassTemplate { const loggedInUserOwnsRecord = loggedInUserID === recordUserID; const { isReviewer } = this.context; - this.setState({ projects: await getRegionProjects(region) }); + this.setState({ projects: await getRegionProjects(region), loggedInUserID: user.uid }); let editorInfo; // get info of the person openeing the record const editorDataRef = child(ref(database, `${region}/users`), loggedInUserID); @@ -187,8 +187,10 @@ class MetadataForm extends FormClassTemplate { } const record = firebaseToJSObject(recordFireBaseObj); + const loggedInUserIsSharedWith = record.sharedWith && record.sharedWith[loggedInUserID] === true; + const loggedInUserCanEditRecord = - isReviewer || loggedInUserOwnsRecord; + isReviewer || loggedInUserOwnsRecord || loggedInUserIsSharedWith; this.setState({ record: standardizeRecord(record, null, null, recordID), @@ -254,11 +256,11 @@ class MetadataForm extends FormClassTemplate { const { match } = this.props; const { region, language } = match.params; const { record} = this.state; - const { datacitePrefix, dataciteAuthHash } = this.context; + const { datacitePrefix } = this.context; try { - if (datacitePrefix && dataciteAuthHash){ - const statusCode = await performUpdateDraftDoi(record, region, language, datacitePrefix, dataciteAuthHash); + if (datacitePrefix){ + const statusCode = await performUpdateDraftDoi(record, region, language, datacitePrefix); if (statusCode === 200) { this.state.doiUpdated = true @@ -382,6 +384,7 @@ class MetadataForm extends FormClassTemplate { loggedInUserCanEditRecord, saveIncompleteRecordModalOpen, projects, + loggedInUserID, } = this.state; if (!record) { @@ -397,8 +400,10 @@ class MetadataForm extends FormClassTemplate { record, handleUpdateRecord: this.handleUpdateRecord, updateRecord: this.updateRecord, + userID:loggedInUserID, }; const percentValidInt = Math.round(percentValid(record) * 100); + return loading ? ( ) : ( diff --git a/src/components/Pages/Shared.jsx b/src/components/Pages/Shared.jsx new file mode 100644 index 00000000..f048b788 --- /dev/null +++ b/src/components/Pages/Shared.jsx @@ -0,0 +1,202 @@ +import React from "react"; + +import { Typography, CircularProgress, List } from "@material-ui/core"; +import { getDatabase, ref, onValue, get, off } from "firebase/database"; +import { I18n, En, Fr } from "../I18n"; +import FormClassTemplate from "./FormClassTemplate"; +import firebase from "../../firebase"; +import { + multipleFirebaseToJSObject, + cloneRecord, +} from "../../utils/firebaseRecordFunctions"; + +import { getAuth, onAuthStateChanged } from "../../auth"; + +import MetadataRecordListItem from "../FormComponents/MetadataRecordListItem"; + +class Shared extends FormClassTemplate { + constructor(props) { + super(props); + this.state = { + sharedRecords: {}, + loading: false, + }; + this.unsubscribe = null; + this.listenerRefs = []; + this.handleCloneRecord = this.handleCloneRecord.bind(this); + } + + async loadSharedRecords() { + this.setState({ loading: true }); + const { match } = this.props; + const { region } = match.params; + const database = getDatabase(); + + // Set up a listener for changes in authentication state + this.unsubscribe = onAuthStateChanged(getAuth(firebase), async (user) => { + if (user) { + // Reference to the 'shares' node for the current user in the specified region + const sharesRef = ref(database, `${region}/shares/${user.uid}`); + this.listenerRefs.push(sharesRef); + + // Listen for changes in the shares data + onValue(sharesRef, async (snapshot) => { + // Snapshot of the user's shares + const sharesSnapshot = snapshot.val(); + + // Initialize an array to hold promises for fetching each shared record + const recordsPromises = []; + + // Iterate over each shared record, organized by authorID under the user's ID + Object.entries(sharesSnapshot || {}).forEach(([authorID, recordsByAuthor]) => { + // Iterate over each recordID shared by this author + Object.keys(recordsByAuthor || {}).forEach((recordID) => { + // Construct the path to the actual record data based on its authorID and recordID + const recordPath = `${region}/users/${authorID}/records/${recordID}`; + const recordRef = ref(database, recordPath); + // Fetch the record's details and store the promise in the array + const recordPromise = get(recordRef).then((recordSnapshot) => { + const recordDetails = recordSnapshot.val(); + if (recordDetails) { + // Return the complete record details, ensuring all data fields are included + return { + ...recordDetails, + recordID, + }; + } + throw new Error(`No details found for record ${recordID} by author ${authorID}`); + }); + recordsPromises.push(recordPromise); // Collect the promise + }); + }); + + // Await the resolution of all record detail promises + const records = await Promise.all(recordsPromises); + // Accumulate the records into an object mapping record IDs to record details + const sharedRecords = records.reduce((acc, record) => { + acc[record.recordID] = record; // Map each record by its ID + return acc; + }, {}); + + // Update the component state with the fetched shared records and set loading to false + this.setState({ + sharedRecords: multipleFirebaseToJSObject(sharedRecords), + loading: false, + }); + }); + + // Keep a reference to the listener to remove it when the component unmounts + this.listenerRefs.push(sharesRef); + } + }); + } + + componentWillUnmount() { + // fixes error Can't perform a React state update on an unmounted component + this.unsubscribeAndCloseListeners(); + } + + unsubscribeAndCloseListeners() { + if (this.unsubscribe) this.unsubscribe(); + if (this.listenerRefs.length) { + this.listenerRefs.forEach((refListener) => off(refListener)); + } + } + + async componentDidMount() { + this.loadSharedRecords(); + } + + editRecord(key, authorID) { + const { match, history } = this.props; + const { language, region } = match.params; + history.push(`/${language}/${region}/${authorID}/${key}`); + } + + handleCloneRecord(recordID, authorID) { + const { region } = this.props.match.params; + + const { currentUser } = getAuth(firebase); + if (currentUser) { + cloneRecord(recordID, authorID, currentUser.uid, region) + } + } + + render() { + const { sharedRecords, loading } = this.state; + + const recordDateSort = (a, b) => + new Date(b[1].created) - new Date(a[1].created); + + return ( +
+ + + Shared with me + Partagé avec moi + + + + {loading ? ( + + ) : ( + +
+ + + + The following records have been shared with you for editing. + + + Les enregistrements suivants ont été partagés avec vous pour + modification. + + + + + + You can edit them, but you cannot submit or delete. + + Vous pouvez les modifier, mais vous ne pouvez pas les + soumettre ou les supprimer. + + + + + {Object.entries(sharedRecords || {}) + .sort(recordDateSort) + .map(([key, record]) => { + const { title } = record; + + if (!(title?.en || !title?.fr)) return null; + + return ( + this.handleCloneRecord(key, record.userID)} + showEditAction + showPercentComplete + onViewEditClick={() => this.editRecord(key, record.userID)} + /> + ); + })} + +
+ {!sharedRecords && ( + + + You don't have any records shared with you. + Vous n'avez aucun enregistrement partagé avec vous. + + + )} +
+ )} +
+ ); + } +} + +export default Shared; diff --git a/src/components/Tabs/StartTab.jsx b/src/components/Tabs/StartTab.jsx index 5f759dc8..e1de8d4f 100644 --- a/src/components/Tabs/StartTab.jsx +++ b/src/components/Tabs/StartTab.jsx @@ -1,4 +1,4 @@ -import React, { useRef, useEffect } from "react"; +import React, { useRef, useEffect, useState } from "react"; import { Save } from "@material-ui/icons"; import { @@ -21,14 +21,16 @@ import { paperClass, QuestionText, SupplementalText } from "../FormComponents/Qu import { validateField } from "../../utils/validate"; import { metadataScopeCodes } from "../../isoCodeLists"; import CheckBoxList from "../FormComponents/CheckBoxList"; +import SharedUsersList from "../FormComponents/SharedUsersList"; import SelectInput from "../FormComponents/SelectInput"; const {DataCollectionSampling, ...filtereMetadataScopeCodes} = metadataScopeCodes; -const StartTab = ({ disabled, record, updateRecord, handleUpdateRecord }) => { +const StartTab = ({ disabled, record, updateRecord, handleUpdateRecord, userID }) => { const { language, region } = useParams(); const regionInfo = regions[region]; + const [showShareRecord, setShowShareRecord] = useState(false) const mounted = useRef(false); const updateResourceType = (value) => { @@ -70,11 +72,20 @@ const StartTab = ({ disabled, record, updateRecord, handleUpdateRecord }) => { }; }, [language]); + useEffect(() => { + + const isNewRecord = !record.recordID; + + if (userID === record.userID || isNewRecord) { + setShowShareRecord(true); + } + }, [userID, record.userID, record.recordID]); + return ( {disabled && ( - + @@ -89,7 +100,7 @@ const StartTab = ({ disabled, record, updateRecord, handleUpdateRecord }) => { - + )} @@ -181,7 +192,7 @@ const StartTab = ({ disabled, record, updateRecord, handleUpdateRecord }) => { - + @@ -299,6 +310,13 @@ const StartTab = ({ disabled, record, updateRecord, handleUpdateRecord }) => { disabled={disabled} /> + {showShareRecord && ( + + )} ); }; diff --git a/src/components/Tabs/SubmitTab.jsx b/src/components/Tabs/SubmitTab.jsx index 6269ac48..e931e527 100644 --- a/src/components/Tabs/SubmitTab.jsx +++ b/src/components/Tabs/SubmitTab.jsx @@ -26,10 +26,11 @@ import tabs from "../../utils/tabs"; import GetRegionInfo from "../FormComponents/Regions"; -const SubmitTab = ({ record, submitRecord, doiUpdated, doiError }) => { +const SubmitTab = ({ record, submitRecord, userID, doiUpdated, doiError }) => { const mounted = useRef(false); const [isSubmitting, setSubmitting] = useState(false); const [validationWarnings, setValidationWarnings] = useState(false); + const [showSubmitButton, setShowSubmitButton] = useState(false); const { language } = useParams(); @@ -38,8 +39,11 @@ const SubmitTab = ({ record, submitRecord, doiUpdated, doiError }) => { const regionInfo = GetRegionInfo(); useEffect(() => { + mounted.current = true; - mounted.current = true + if (userID === record.userID) { + setShowSubmitButton(true); + } const getUrlWarningsByTab = async (recordObj) => { const fields = Object.keys(warnings); @@ -71,8 +75,7 @@ const SubmitTab = ({ record, submitRecord, doiUpdated, doiError }) => { }, {} ); - if (mounted.current) - setValidationWarnings(fieldWarningInfoReduced); + if (mounted.current) setValidationWarnings(fieldWarningInfoReduced); }; getUrlWarningsByTab(record); @@ -80,8 +83,7 @@ const SubmitTab = ({ record, submitRecord, doiUpdated, doiError }) => { return () => { mounted.current = false; }; - - }, [record]); + }, [record, userID]); return ( @@ -177,9 +179,8 @@ const SubmitTab = ({ record, submitRecord, doiUpdated, doiError }) => { - {isSubmitting ? ( - - ) : ( + {isSubmitting && } + {!isSubmitting && showSubmitButton && (