diff --git a/backend/app.js b/backend/app.js index 5bb71635..6d1d71ca 100644 --- a/backend/app.js +++ b/backend/app.js @@ -3,14 +3,17 @@ require('dotenv').config({ path: `${process.env.NODE_ENV}.env` }); require('./utils/aws/awsSetup'); const path = require('path'); +const passport = require('passport'); const log = require('loglevel'); const express = require('express'); const fileUpload = require('express-fileupload'); const cors = require('cors'); const bodyParser = require('body-parser'); +const session = require('express-session'); +const MongoStore = require('connect-mongo'); +const cookieParser = require('cookie-parser'); const { errorHandler } = require('./utils'); -const { requireAuthentication } = require('./middleware/authentication'); const { initDB } = require('./utils/initDb'); const { setResponseHeaders, @@ -24,7 +27,7 @@ const app = express(); app.use(configureHelment()); app.use(setResponseHeaders); app.use(express.static(path.join(__dirname, '../frontend/build'))); -app.use(cors()); +app.use(cors({ credentials: true, origin: 'http://localhost:3000', methods: ['GET', 'POST'] })); app.use( fileUpload({ createParentPath: true, @@ -48,7 +51,40 @@ app.get('/*', (req, res, next) => { } }); -app.use(requireAuthentication); +/** + * This is the secret used to sign the session ID cookie. + * This can be either a string for a single secret, or an array of multiple secrets. + * If an array of secrets is provided, only the first element will be used to sign the session + * ID cookie, while all the elements will be considered when verifying the signature in requests. + * The secret itself should be not easily parsed by a human and would best be a random set of + * characters. + * + * Patients will be logged in a session for 5 minutes, unless they refresh to extend this period. + * maxAge can also be set to null, which keeps a user logged in until the BROWSER is closed. + */ +const sess = { + secret: '3DP4ME', + cookie: { + domain: 'localhost', path: '/', httpOnly: true, secure: false, maxAge: 150000, + }, + resave: false, + saveUninitialized: false, + store: MongoStore.create({ mongoUrl: process.env.DB_URI }), +}; + +if (app.get('env') === 'production') { + app.set('trust proxy', 1); // trust first proxy + sess.cookie.secure = true; // serve secure cookies +} + +app.use(cookieParser()); + +app.use(session(sess)); + +app.use(bodyParser.urlencoded({ extended: false })); +app.use(passport.initialize()); +app.use(passport.session()); + app.use(logRequest); app.use(require('./routes')); diff --git a/backend/middleware/conditionalAuthentication.js b/backend/middleware/conditionalAuthentication.js new file mode 100644 index 00000000..08090810 --- /dev/null +++ b/backend/middleware/conditionalAuthentication.js @@ -0,0 +1,23 @@ +const log = require('loglevel'); + +const { + ERR_AUTH_FAILED, +} = require('../utils/constants'); +const { sendResponse } = require('../utils/response'); + +const { requireAuthentication } = require('./authentication'); +const { requirePatientAuthentication } = require('./verifyPatient'); + +module.exports.requireConditionalAuthentication = async (req, res, next) => { + try { + const user = req?.session?.passport?.user; + if (!user) { + requireAuthentication(req, res, next); + } else { + requirePatientAuthentication(req, res); + } + } catch (error) { + log.error(error); + sendResponse(res, 401, ERR_AUTH_FAILED); + } +}; diff --git a/backend/middleware/patientAuthentication.js b/backend/middleware/patientAuthentication.js new file mode 100644 index 00000000..2ad5a1fa --- /dev/null +++ b/backend/middleware/patientAuthentication.js @@ -0,0 +1,41 @@ +const passport = require('passport'); +const LocalStrategy = require('passport-local').Strategy; +const twofactor = require('node-2fa'); + +const { models } = require('../models'); +const { TWO_FACTOR_WINDOW_MINS } = require('../utils/constants'); + +const verifyPatientToken = (patient, token) => { + const patientSecret = patient.secret; + + if (patient.secret) { + return twofactor.verifyToken(patientSecret, token, TWO_FACTOR_WINDOW_MINS); + } + + return false; +}; + +passport.use('passport-local', new LocalStrategy( + (_id, token, done) => { + models.Patient.findById(_id, (err, user) => { + if (err) { return done(err); } + if (!user) { + return done(null, false, { message: 'No such user exists.' }); + } + if (!(verifyPatientToken(user, token))) { + return done(null, false, { message: 'Incorrect token.' }); + } + return done(null, user); + }); + }, +)); + +passport.serializeUser((user, done) => { + done(null, user._id); +}); + +passport.deserializeUser((_id, done) => { + models.Patient.findById(_id, (err, user) => { + done(err, user); + }); +}); diff --git a/backend/middleware/verifyPatient.js b/backend/middleware/verifyPatient.js new file mode 100644 index 00000000..4ab46af9 --- /dev/null +++ b/backend/middleware/verifyPatient.js @@ -0,0 +1,26 @@ +const log = require('loglevel'); + +const { + ERR_AUTH_FAILED, + ERR_NOT_APPROVED, +} = require('../utils/constants'); +const { sendResponse } = require('../utils/response'); + +/** + * Middleware requires the incoming request to be authenticated. If not authenticated, a response + * is sent back to the client, and the middleware chain is stopped. Authentication is done by + * checking the request for a user, which is automatically attached when Passport logs a user in. + */ +module.exports.requirePatientAuthentication = async (req, res) => { + try { + const user = req?.session?.passport?.user; + if (!user) { + sendResponse(res, 401, ERR_NOT_APPROVED); + } else { + sendResponse(res, 200); + } + } catch (error) { + log.error(error); + sendResponse(res, 401, ERR_AUTH_FAILED); + } +}; diff --git a/backend/package.json b/backend/package.json index 2ce31bed..ff21ac0f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,6 +11,8 @@ "axios": "^0.21.2", "babel-eslint": "^10.1.0", "body-parser": "^1.19.0", + "connect-mongo": "^4.6.0", + "cookie-parser": "^1.4.6", "cors": "^2.8.5", "cross-env": "^7.0.3", "del": "^6.0.0", @@ -23,6 +25,7 @@ "express": "^4.17.1", "express-async-errors": "^3.1.1", "express-fileupload": "^1.2.0", + "express-session": "^1.17.2", "faker": "^5.5.3", "fs": "^0.0.1-security", "gulp": "^4.0.2", @@ -40,6 +43,9 @@ "node": "^16.9.1", "node-2fa": "^2.0.2", "omit-deep-lodash": "^1.1.5", + "passport": "^0.5.0", + "passport-local": "^1.0.0", + "passport-session": "^1.0.2", "prettier": "^2.4.1", "superagent-defaults": "^0.1.14", "supertest": "^6.1.3", diff --git a/backend/routes/api/authentication.js b/backend/routes/api/authentication.js index a14637c4..cb75a0b2 100644 --- a/backend/routes/api/authentication.js +++ b/backend/routes/api/authentication.js @@ -1,6 +1,16 @@ const express = require('express'); const twofactor = require('node-2fa'); +const passport = require('passport'); +const accountSid = process.env.ACCOUNT_SID; +const authToken = process.env.TWILIO_AUTH_TOKEN; + +const client = require('twilio')(accountSid, authToken); + +const { + TWILIO_SENDING_NUMBER, + TWILIO_WHATSAPP_PREFIX, +} = require('../../utils/constants'); const { errorWrap } = require('../../utils'); const { models } = require('../../models'); const { sendResponse } = require('../../utils/response'); @@ -8,15 +18,35 @@ const { TWO_FACTOR_WINDOW_MINS } = require('../../utils/constants'); const router = express.Router(); +require('../../middleware/patientAuthentication'); + +router.post('/authenticated/:patientId', passport.authenticate('passport-local'), async (req, res) => { + const { patientId } = req.params; + console.log('ya'); + if (!req.user) { return res.redirect(`/${patientId}`); } + console.log('nah'); + req.logIn(req.user, (err) => { + if (err) { return err; } + console.log(req.session); + // do i need this line? + req.session.save(); + return sendResponse( + res, + 200, + 'Successfully authenticated patient', + ); + }); +}); + /** * Get secret, generate the token, then return the token * If a patient's secret does not already exist, generate a new secret, then also return the token + * Send the patient token through Twilio to Whatsapp */ router.get( '/:patientId', errorWrap(async (req, res) => { const { patientId } = req.params; - const patient = await models.Patient.findById(patientId); if (!patient) { @@ -39,12 +69,14 @@ router.get( const newToken = twofactor.generateToken(patient.secret); - await sendResponse( - res, - 200, - 'New authentication key generated', - newToken, - ); + client.messages + .create({ + body: `Your one time token is ${newToken.token}`, + to: `${TWILIO_WHATSAPP_PREFIX}${patient.phoneNumber}`, + from: TWILIO_SENDING_NUMBER, + }) + .then((message) => console.log(message.sid)) + .catch((err) => console.log(err)); }), ); @@ -76,17 +108,58 @@ router.post( res, 200, 'Patient verified', - isAuthenticated, ); } else { await sendResponse( res, 404, 'Invalid token entered', - isAuthenticated, ); } }), ); +router.get( + '/patient-portal/:patientId', + errorWrap(async (req, res) => { + const { patientId } = req.params; + let patient; + + try { + patient = await models.Patient.findById(patientId); + } catch { + await sendResponse( + res, + 404, + 'An error occurred while checking for patient authentication', + ); + return; + } + + if (!patient) { + await sendResponse( + res, + 404, + 'Invalid patient ID', + ); + return; + } + + if (!req.user) { + await sendResponse( + res, + 404, + 'Session expired', + ); + return; + } + + await sendResponse( + res, + 200, + 'Patient verified', + ); + }), +); + module.exports = router; diff --git a/backend/routes/api/index.js b/backend/routes/api/index.js index 2197a81b..065ff700 100644 --- a/backend/routes/api/index.js +++ b/backend/routes/api/index.js @@ -9,6 +9,6 @@ router.use('/metadata', require('./metadata')); router.use('/users', require('./users')); router.use('/roles', require('./roles')); router.use('/messages', require('./messages')); -router.use('/authentication', require('./authentication')); +router.use('/patient-2fa', require('./authentication')); module.exports = router; diff --git a/backend/routes/api/messages.js b/backend/routes/api/messages.js index ed947e1c..318848c1 100644 --- a/backend/routes/api/messages.js +++ b/backend/routes/api/messages.js @@ -12,6 +12,9 @@ const { TWILIO_RECEIVING_NUMBER, TWILIO_SENDING_NUMBER, } = require('../../utils/constants'); +const { requireAuthentication } = require('../../middleware/authentication'); + +router.use(requireAuthentication); router.post('/sms', async (req, res) => { const phone = req?.body?.WaId; diff --git a/backend/routes/api/metadata.js b/backend/routes/api/metadata.js index f8d1bf03..55bb8901 100644 --- a/backend/routes/api/metadata.js +++ b/backend/routes/api/metadata.js @@ -12,6 +12,8 @@ const { updateStepsInTransaction, getReadableSteps, } = require('../../utils/stepUtils'); +const { requireAuthentication } = require('../../middleware/authentication'); +const { requireConditionalAuthentication } = require('../../middleware/conditionalAuthentication'); /** * Gets the metadata for a step. This describes the fields contained in the steps. @@ -19,6 +21,7 @@ const { */ router.get( '/steps', + requireConditionalAuthentication, errorWrap(async (req, res) => { const metaData = await getReadableSteps(req); @@ -36,7 +39,7 @@ router.get( */ router.post( '/steps', - requireAdmin, + requireAuthentication, requireAdmin, errorWrap(async (req, res) => { try { const stepToCreate = req.body; @@ -62,7 +65,7 @@ router.post( */ router.put( '/steps/', - requireAdmin, + requireAuthentication, requireAdmin, errorWrap(async (req, res) => { try { let stepData = []; @@ -101,7 +104,7 @@ router.put( */ router.delete( '/steps/:stepkey', - requireAdmin, + requireAuthentication, requireAdmin, errorWrap(async (req, res) => { const { stepkey } = req.params; const step = await models.Step.deleteOne({ key: stepkey }); diff --git a/backend/routes/api/patients.js b/backend/routes/api/patients.js index 8438591c..faad3613 100644 --- a/backend/routes/api/patients.js +++ b/backend/routes/api/patients.js @@ -22,12 +22,15 @@ const { isFieldWritable, getWritableFields, } = require('../../utils/fieldUtils'); +const { requireAuthentication } = require('../../middleware/authentication'); +const { requireConditionalAuthentication } = require('../../middleware/conditionalAuthentication'); /** * Returns everything in the patients collection (basic patient info) */ router.get( '/', + requireAuthentication, errorWrap(async (req, res) => { const patientData = await getDataFromModelWithPaginationAndSearch(req, models.Patient); await sendResponse(res, 200, '', patientData); @@ -40,6 +43,7 @@ router.get( router.get( '/count', + requireAuthentication, errorWrap(async (req, res) => { const patientCount = await models.Patient.count(); return sendResponse(res, 200, 'success', patientCount); @@ -52,6 +56,7 @@ router.get( * */ router.get( '/:id', + requireConditionalAuthentication, errorWrap(async (req, res) => { const { id } = req.params; @@ -99,6 +104,7 @@ router.get( */ router.post( '/', + requireAuthentication, removeRequestAttributes(PATIENT_IMMUTABLE_ATTRIBUTES), errorWrap(async (req, res) => { const patient = req.body; @@ -122,6 +128,7 @@ router.post( */ router.put( '/:id', + requireAuthentication, removeRequestAttributes(PATIENT_IMMUTABLE_ATTRIBUTES), errorWrap(async (req, res) => { const { id } = req.params; @@ -146,6 +153,7 @@ router.put( */ router.get( '/:id/files/:stepKey/:fieldKey/:fileName', + requireConditionalAuthentication, errorWrap(async (req, res) => { const { id, stepKey, fieldKey, fileName, @@ -183,6 +191,7 @@ router.get( */ router.delete( '/:id/files/:stepKey/:fieldKey/:fileName', + requireConditionalAuthentication, errorWrap(async (req, res) => { const { id, stepKey, fieldKey, fileName, @@ -245,6 +254,7 @@ router.delete( */ router.post( '/:id/files/:stepKey/:fieldKey/:fileName', + requireConditionalAuthentication, errorWrap(async (req, res) => { // TODO during refactoring: We upload file name in form data, is this even needed??? const { @@ -318,6 +328,7 @@ router.post( */ router.post( '/:id/:stepKey', + requireConditionalAuthentication, removeRequestAttributes(STEP_IMMUTABLE_ATTRIBUTES), errorWrap(async (req, res) => { const { id, stepKey } = req.params; diff --git a/backend/routes/api/roles.js b/backend/routes/api/roles.js index 350c6c7a..68b6db37 100644 --- a/backend/routes/api/roles.js +++ b/backend/routes/api/roles.js @@ -5,6 +5,9 @@ const { errorWrap } = require('../../utils'); const { models } = require('../../models/index'); const { requireAdmin } = require('../../middleware/authentication'); const { sendResponse } = require('../../utils/response'); +const { requireAuthentication } = require('../../middleware/authentication'); + +router.use(requireAuthentication); /** * Returns all roles in the DB. diff --git a/backend/routes/api/steps.js b/backend/routes/api/steps.js index a34812ae..fc972f78 100644 --- a/backend/routes/api/steps.js +++ b/backend/routes/api/steps.js @@ -10,6 +10,9 @@ const { sendResponse, getDataFromModelWithPaginationAndSearch, } = require('../../utils/response'); +const { requireAuthentication } = require('../../middleware/authentication'); + +router.use(requireAuthentication); /** * Returns basic information for all patients that are active in diff --git a/backend/routes/api/users.js b/backend/routes/api/users.js index a84f844b..721aab60 100644 --- a/backend/routes/api/users.js +++ b/backend/routes/api/users.js @@ -17,16 +17,16 @@ const { isRoleValid, } = require('../../utils/roleUtils'); const { requireAdmin } = require('../../middleware/authentication'); -const { - ADMIN_ID, - DEFAULT_USERS_ON_GET_REQUEST, -} = require('../../utils/constants'); +const { ADMIN_ID, DEFAULT_USERS_ON_GET_REQUEST } = require('../../utils/constants'); +const { requireAuthentication } = require('../../middleware/authentication'); +const { requireConditionalAuthentication } = require('../../middleware/conditionalAuthentication'); /** * Gets information about the user making this request. */ router.get( '/self', + requireConditionalAuthentication, errorWrap(async (req, res) => { const isAdmin = req?.user?.roles?.includes(ADMIN_ID) || false; @@ -43,7 +43,7 @@ router.get( */ router.get( '/', - requireAdmin, + requireAuthentication, requireAdmin, errorWrap(async (req, res) => { const { token } = req.query; let nPerPage = req.query.nPerPage ?? DEFAULT_USERS_ON_GET_REQUEST; @@ -78,7 +78,7 @@ router.get( */ router.put( '/:username/roles/:roleId', - requireAdmin, + requireAuthentication, requireAdmin, errorWrap(async (req, res) => { const { username, roleId } = req.params; @@ -109,7 +109,7 @@ router.put( */ router.delete( '/:username/roles/:roleId', - requireAdmin, + requireAuthentication, requireAdmin, errorWrap(async (req, res) => { const { username, roleId } = req.params; @@ -137,7 +137,7 @@ router.delete( */ router.put( '/:username/access/:accessLevel', - requireAdmin, + requireAuthentication, requireAdmin, errorWrap(async (req, res) => { const { username, accessLevel } = req.params; diff --git a/backend/utils/constants.js b/backend/utils/constants.js index eca80140..98b941b3 100644 --- a/backend/utils/constants.js +++ b/backend/utils/constants.js @@ -64,4 +64,6 @@ module.exports.DEFAULT_PATIENTS_ON_GET_REQUEST = 1; module.exports.TWILIO_SENDING_NUMBER = 'whatsapp:+14155238886'; module.exports.TWILIO_RECEIVING_NUMBER = 'whatsapp:+13098319210'; +module.exports.TWILIO_WHATSAPP_PREFIX = 'whatsapp:+'; + module.exports.TWO_FACTOR_WINDOW_MINS = 5; diff --git a/backend/yarn.lock b/backend/yarn.lock index 901df4ac..c0ea8132 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -2606,6 +2606,16 @@ asap@^2.0.0: resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" integrity sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY= +asn1.js@^5.4.1: + version "5.4.1" + resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-5.4.1.tgz#11a980b84ebb91781ce35b0fdc2ee294e3783f07" + integrity sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA== + dependencies: + bn.js "^4.0.0" + inherits "^2.0.1" + minimalistic-assert "^1.0.0" + safer-buffer "^2.1.0" + assign-symbols@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367" @@ -3269,6 +3279,11 @@ bl@^4.0.3: inherits "^2.0.4" readable-stream "^3.4.0" +bn.js@^4.0.0: + version "4.12.0" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.0.tgz#775b3f278efbb9718eec7361f483fb36fbbfea88" + integrity sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA== + body-parser@1.19.0, body-parser@^1.19.0: version "1.19.0" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a" @@ -3778,6 +3793,14 @@ confusing-browser-globals@^1.0.10: resolved "https://registry.yarnpkg.com/confusing-browser-globals/-/confusing-browser-globals-1.0.10.tgz#30d1e7f3d1b882b25ec4933d1d1adac353d20a59" integrity sha512-gNld/3lySHwuhaVluJUKLePYirM3QNCKzVxqAdhJII9/WXKVX5PURzMVJspS1jTslSqjeuG4KMVTSouit5YPHA== +connect-mongo@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/connect-mongo/-/connect-mongo-4.6.0.tgz#1bf62868efc9f28ecf1459ae9a9d6caaf90ae8a6" + integrity sha512-8new4Z7NLP3CGP65Aw6ls3xDBeKVvHRSh39CXuDZTQsvpeeU9oNMzfFgvqmHqZ6gWpxIl663RyoVEmCAGf1yOg== + dependencies: + debug "^4.3.1" + kruptein "^3.0.0" + content-disposition@0.5.3: version "0.5.3" resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.3.tgz#e130caf7e7279087c5616c2007d0485698984fbd" @@ -3797,6 +3820,14 @@ convert-source-map@^1.4.0, convert-source-map@^1.5.0, convert-source-map@^1.6.0, dependencies: safe-buffer "~5.1.1" +cookie-parser@^1.4.6: + version "1.4.6" + resolved "https://registry.yarnpkg.com/cookie-parser/-/cookie-parser-1.4.6.tgz#3ac3a7d35a7a03bbc7e365073a26074824214594" + integrity sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA== + dependencies: + cookie "0.4.1" + cookie-signature "1.0.6" + cookie-signature@1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" @@ -3807,7 +3838,7 @@ cookie@0.4.0: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba" integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg== -cookie@^0.4.0: +cookie@0.4.1, cookie@^0.4.0: version "0.4.1" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1" integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA== @@ -4061,6 +4092,11 @@ depd@~1.1.2: resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak= +depd@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" + integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== + destroy@~1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80" @@ -4668,6 +4704,20 @@ express-fileupload@^1.2.0: dependencies: busboy "^0.3.1" +express-session@^1.17.2: + version "1.17.2" + resolved "https://registry.yarnpkg.com/express-session/-/express-session-1.17.2.tgz#397020374f9bf7997f891b85ea338767b30d0efd" + integrity sha512-mPcYcLA0lvh7D4Oqr5aNJFMtBMKPLl++OKKxkHzZ0U0oDq1rpKBnkR5f5vCHR26VeArlTOEF9td4x5IjICksRQ== + dependencies: + cookie "0.4.1" + cookie-signature "1.0.6" + debug "2.6.9" + depd "~2.0.0" + on-headers "~1.0.2" + parseurl "~1.3.3" + safe-buffer "5.2.1" + uid-safe "~2.1.5" + express@^4.17.1: version "4.17.1" resolved "https://registry.yarnpkg.com/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134" @@ -6667,6 +6717,13 @@ kleur@^3.0.3: resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== +kruptein@^3.0.0: + version "3.0.3" + resolved "https://registry.yarnpkg.com/kruptein/-/kruptein-3.0.3.tgz#2073eba5cccbe5ad510b03416950f8a3e2e394c4" + integrity sha512-v5mqSHKS2M1xWUo5V7Q6TMcj1vjTgKWvfspizn6Z939Cmv8NNn5E+Z4LeGBEKDL3yT4pMXaRTjh98oksGTDntA== + dependencies: + asn1.js "^5.4.1" + language-subtag-registry@~0.3.2: version "0.3.21" resolved "https://registry.yarnpkg.com/language-subtag-registry/-/language-subtag-registry-0.3.21.tgz#04ac218bea46f04cb039084602c6da9e788dd45a" @@ -7034,6 +7091,11 @@ mimic-response@^1.0.0, mimic-response@^1.0.1: resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b" integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ== +minimalistic-assert@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" + integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== + minimatch@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" @@ -7497,6 +7559,11 @@ on-finished@~2.3.0: dependencies: ee-first "1.1.1" +on-headers@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f" + integrity sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA== + once@^1.3.0, once@^1.3.1, once@^1.3.2, once@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" @@ -7689,6 +7756,33 @@ pascalcase@^0.1.1: resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14" integrity sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ= +passport-local@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/passport-local/-/passport-local-1.0.0.tgz#1fe63268c92e75606626437e3b906662c15ba6ee" + integrity sha1-H+YyaMkudWBmJkN+O5BmYsFbpu4= + dependencies: + passport-strategy "1.x.x" + +passport-session@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/passport-session/-/passport-session-1.0.2.tgz#28abf357b0958e7c704164a3f539bd08cb9851db" + integrity sha1-KKvzV7CVjnxwQWSj9Tm9CMuYUds= + dependencies: + passport-strategy "1.x.x" + +passport-strategy@1.x.x: + version "1.0.0" + resolved "https://registry.yarnpkg.com/passport-strategy/-/passport-strategy-1.0.0.tgz#b5539aa8fc225a3d1ad179476ddf236b440f52e4" + integrity sha1-tVOaqPwiWj0a0XlHbd8ja0QPUuQ= + +passport@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/passport/-/passport-0.5.0.tgz#7914aaa55844f9dce8c3aa28f7d6b73647ee0169" + integrity sha512-ln+ue5YaNDS+fes6O5PCzXKSseY5u8MYhX9H5Co4s+HfYI5oqvnHKoOORLYDUPh+8tHvrxugF2GFcUA1Q1Gqfg== + dependencies: + passport-strategy "1.x.x" + pause "0.0.1" + path-dirname@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/path-dirname/-/path-dirname-1.0.2.tgz#cc33d24d525e099a5388c0336c6e32b9160609e0" @@ -7771,6 +7865,11 @@ path-type@^4.0.0: resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== +pause@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/pause/-/pause-0.0.1.tgz#1d408b3fdb76923b9543d96fb4c9dfd535d9cb5d" + integrity sha1-HUCLP9t2kjuVQ9lvtMnf1TXZy10= + pend@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" @@ -8014,6 +8113,11 @@ queue-microtask@^1.2.2: resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== +random-bytes@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/random-bytes/-/random-bytes-1.0.0.tgz#4f68a1dc0ae58bd3fb95848c30324db75d64360b" + integrity sha1-T2ih3Arli9P7lYSMMDJNt11kNgs= + randombytes@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" @@ -8399,7 +8503,7 @@ safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== -safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@~5.2.0: +safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -8411,7 +8515,7 @@ safe-regex@^1.1.0: dependencies: ret "~0.1.10" -"safer-buffer@>= 2.1.2 < 3": +"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== @@ -9350,6 +9454,13 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= +uid-safe@~2.1.5: + version "2.1.5" + resolved "https://registry.yarnpkg.com/uid-safe/-/uid-safe-2.1.5.tgz#2b3d5c7240e8fc2e58f8aa269e5ee49c0857bd3a" + integrity sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA== + dependencies: + random-bytes "~1.0.0" + ulid@2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/ulid/-/ulid-2.3.0.tgz#93063522771a9774121a84d126ecd3eb9804071f" diff --git a/frontend/package.json b/frontend/package.json index 6f5f3959..463f8997 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,7 @@ "@sweetalert/with-react": "^0.1.1", "@types/date-fns": "^2.6.0", "aws-amplify": "^4.2.9", - "axios": "^0.21.1", + "axios": "^0.24.0", "babel-eslint": "^10.1.0", "compose": "^0.1.2", "cra-template": "1.1.2", diff --git a/frontend/src/App.js b/frontend/src/App.js index b4aa713f..72b11905 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -1,20 +1,12 @@ -import React, { useEffect, useState } from 'react'; -import { Amplify, Auth } from 'aws-amplify'; +import React from 'react'; +import { Amplify } from 'aws-amplify'; import { registerLocale } from 'react-datepicker'; import { enUS, arSA } from 'date-fns/locale'; import Store from './store/Store'; import AppContent from './AppContent'; -import Login from './pages/Login/Login'; import { awsconfig } from './aws/aws-exports'; import { LANGUAGES } from './utils/constants'; -import { getCurrentUserInfo } from './aws/aws-helper'; -import { - UNDEFINED_AUTH, - AUTHENTICATED, - UNAUTHENTICATED, - setAuthListener, -} from './aws/aws-auth'; // Configure amplify Amplify.configure(awsconfig); @@ -24,56 +16,11 @@ registerLocale(LANGUAGES.EN, enUS); registerLocale(LANGUAGES.AR, arSA); function App() { - const [authLevel, setAuthLevel] = useState(UNDEFINED_AUTH); - const [username, setUsername] = useState(''); - const [userEmail, setUserEmail] = useState(''); - - /** - * Attempts to authenticate the user and get their name/email - */ - useEffect(() => { - const getUserInfo = async () => { - const userInfo = await getCurrentUserInfo(); - setUsername(userInfo?.attributes?.name); - setUserEmail(userInfo?.attributes?.email); - }; - - updateAuthLevel(); - getUserInfo(); - }, []); - - /** - * Checks if the current user is authenticated and updates the auth - * level accordingly - */ - const updateAuthLevel = async () => { - try { - await Auth.currentAuthenticatedUser(); - setAuthLevel(AUTHENTICATED); - } catch (error) { - setAuthLevel(UNAUTHENTICATED); - } - }; - - // We get the auth level at startup, then set a listener to get notified when it changes. - setAuthListener((newAuthLevel) => setAuthLevel(newAuthLevel)); - - // If we're not sure of the user's status, say we're authenticating - if (authLevel === UNDEFINED_AUTH) return

Authenticating User

; - - // If the user is unauthenticated, show login screen - if (authLevel === UNAUTHENTICATED) return ; - - // If the user is authenticated, show the app - if (authLevel === AUTHENTICATED) - return ( - - - - ); - - // This should never get executed - return

Something went wrong

; + return ( + + + + ); } export default App; diff --git a/frontend/src/AppContent.js b/frontend/src/AppContent.js index ef17dbb4..5a0552fe 100644 --- a/frontend/src/AppContent.js +++ b/frontend/src/AppContent.js @@ -1,76 +1,20 @@ -import PropTypes from 'prop-types'; -import React, { useContext, useEffect } from 'react'; +import React, { useContext } from 'react'; import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'; import { QueryParamProvider } from 'use-query-params'; -import { trackPromise } from 'react-promise-tracker'; -import { getSelf } from './api/api'; -import { getCurrentUserInfo } from './aws/aws-helper'; import ErrorModal from './components/ErrorModal/ErrorModal'; -import Navbar from './components/Navbar/Navbar'; -import { useErrorWrap } from './hooks/useErrorWrap'; import { useTranslations } from './hooks/useTranslations'; -import AccountManagement from './pages/AccountManagement/AccountManagment'; -import Dashboard from './pages/Dashboard/Dashboard'; -import DashboardManagement from './pages/DashboardManagement/DashboardManagement'; -import PatientDetail from './pages/PatientDetail/PatientDetail'; -import Patients from './pages/Patients/Patients'; -import Patient2FA from './pages/Patient2FALogin/Patient2FALogin'; import { Context } from './store/Store'; -import { - COGNITO_ATTRIBUTES, - LANGUAGES, - REDUCER_ACTIONS, - ROUTES, -} from './utils/constants'; +import { LANGUAGES, REDUCER_ACTIONS } from './utils/constants'; +import AllRoutes from './Routes/AllRoutes'; import LoadingIndicator from './components/LoadingIndicator/LoadingIndicator'; -const AppContent = ({ username, userEmail }) => { - const errorWrap = useErrorWrap(); +const AppContent = () => { const [state, dispatch] = useContext(Context); const selectedLang = useTranslations()[1]; const contentClassNames = selectedLang === LANGUAGES.AR ? 'flip content' : 'content'; - /** - * Gets the user's preferred language and sets it in the store - * Also checks if the user is an admin and updates store - */ - useEffect(() => { - const setLanguage = async () => { - const userInfo = await getCurrentUserInfo(); - if (!userInfo?.attributes) return; - - const language = userInfo.attributes[COGNITO_ATTRIBUTES.LANGUAGE]; - if (isLanguageValid(language)) { - dispatch({ - type: REDUCER_ACTIONS.SET_LANGUAGE, - language, - }); - } else { - console.error(`Language is invalid: ${language}`); - } - }; - - const setAdminStatus = async () => { - const selfRes = await trackPromise(getSelf()); - dispatch({ - type: REDUCER_ACTIONS.SET_ADMIN_STATUS, - isAdmin: selfRes?.result?.isAdmin, - }); - }; - - setLanguage(); - errorWrap(setAdminStatus); - }, [dispatch, errorWrap]); - - /** - * Returns true if the given string is a valid language identifier - */ - const isLanguageValid = (language) => { - return Object.values(LANGUAGES).includes(language); - }; - /** * Sets store when the global error modal should be closed */ @@ -84,8 +28,6 @@ const AppContent = ({ username, userEmail }) => { - - {/* Global error popup */} { {/* Routes */}
- - - - - - - - - - - - - - - - - - +
@@ -128,9 +47,4 @@ const AppContent = ({ username, userEmail }) => { ); }; -AppContent.propTypes = { - username: PropTypes.string.isRequired, - userEmail: PropTypes.string.isRequired, -}; - export default AppContent; diff --git a/frontend/src/Routes/AWSRoutes.js b/frontend/src/Routes/AWSRoutes.js new file mode 100644 index 00000000..2c122c18 --- /dev/null +++ b/frontend/src/Routes/AWSRoutes.js @@ -0,0 +1,148 @@ +import React, { useContext, useEffect, useState } from 'react'; +import { Route, Switch } from 'react-router-dom'; +import { Auth } from 'aws-amplify'; + +import { Context } from '../store/Store'; +import Navbar from '../components/Navbar/Navbar'; +import AccountManagement from '../pages/AccountManagement/AccountManagment'; +import Dashboard from '../pages/Dashboard/Dashboard'; +import DashboardManagement from '../pages/DashboardManagement/DashboardManagement'; +import PatientDetail from '../pages/PatientDetail/PatientDetail'; +import Patients from '../pages/Patients/Patients'; +import { + REDUCER_ACTIONS, + ROUTES, + LANGUAGES, + COGNITO_ATTRIBUTES, +} from '../utils/constants'; +import Login from '../pages/Login/Login'; +import { useErrorWrap } from '../hooks/useErrorWrap'; +import { getCurrentUserInfo } from '../aws/aws-helper'; +import { + UNDEFINED_AUTH, + AUTHENTICATED, + UNAUTHENTICATED, + setAuthListener, +} from '../aws/aws-auth'; +import { getSelf } from '../api/api'; + +const AWSRoutes = () => { + const [authLevel, setAuthLevel] = useState(UNDEFINED_AUTH); + const [username, setUsername] = useState(''); + const [userEmail, setUserEmail] = useState(''); + const [state, dispatch] = useContext(Context); + const errorWrap = useErrorWrap(); + + /** + * Gets the user's preferred language and sets it in the store + * Also checks if the user is an admin and updates store + */ + useEffect(() => { + const setLanguage = async () => { + const userInfo = await getCurrentUserInfo(); + if (!userInfo?.attributes) return; + + const language = userInfo.attributes[COGNITO_ATTRIBUTES.LANGUAGE]; + if (isLanguageValid(language)) { + dispatch({ + type: REDUCER_ACTIONS.SET_LANGUAGE, + language, + }); + } else { + console.error(`Language is invalid: ${language}`); + } + }; + + const setAdminStatus = async () => { + const selfRes = await getSelf(); + dispatch({ + type: REDUCER_ACTIONS.SET_ADMIN_STATUS, + isAdmin: selfRes?.result?.isAdmin, + }); + }; + + setLanguage(); + if (authLevel === AUTHENTICATED) { + errorWrap(setAdminStatus); + } + }, [dispatch, errorWrap]); + + /** + * Returns true if the given string is a valid language identifier + */ + const isLanguageValid = (language) => { + return Object.values(LANGUAGES).includes(language); + }; + + /** + * Attempts to authenticate the user and get their name/email + */ + useEffect(() => { + const getUserInfo = async () => { + const userInfo = await getCurrentUserInfo(); + setUsername(userInfo?.attributes?.name); + setUserEmail(userInfo?.attributes?.email); + }; + + updateAuthLevel(); + getUserInfo(); + }, []); + + /** + * Checks if the current user is authenticated and updates the auth + * level accordingly + */ + const updateAuthLevel = async () => { + try { + await Auth.currentAuthenticatedUser(); + setAuthLevel(AUTHENTICATED); + } catch (error) { + setAuthLevel(UNAUTHENTICATED); + } + }; + + // We get the auth level at startup, then set a listener to get notified when it changes. + setAuthListener((newAuthLevel) => setAuthLevel(newAuthLevel)); + + // If we're not sure of the user's status, say we're authenticating + if (authLevel === UNDEFINED_AUTH) return

Authenticating User

; + + // If the user is unauthenticated, show login screen + if (authLevel === UNAUTHENTICATED) + return ( + + + + ); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default AWSRoutes; diff --git a/frontend/src/Routes/AllRoutes.js b/frontend/src/Routes/AllRoutes.js new file mode 100644 index 00000000..fe469f00 --- /dev/null +++ b/frontend/src/Routes/AllRoutes.js @@ -0,0 +1,28 @@ +import React from 'react'; +import { Route, Switch } from 'react-router-dom'; + +import { ROUTES } from '../utils/constants'; +import Patient2FA from '../pages/Patient2FALogin/Patient2FALogin'; +import PatientPortal from '../pages/PatientPortal/PatientPortal'; + +import AWSRoutes from './AWSRoutes'; + +const AllRoutes = () => { + return ( + + + + + , + + + + , + + + + + ); +}; + +export default AllRoutes; diff --git a/frontend/src/Routes/allRoutes.js b/frontend/src/Routes/allRoutes.js new file mode 100644 index 00000000..fe469f00 --- /dev/null +++ b/frontend/src/Routes/allRoutes.js @@ -0,0 +1,28 @@ +import React from 'react'; +import { Route, Switch } from 'react-router-dom'; + +import { ROUTES } from '../utils/constants'; +import Patient2FA from '../pages/Patient2FALogin/Patient2FALogin'; +import PatientPortal from '../pages/PatientPortal/PatientPortal'; + +import AWSRoutes from './AWSRoutes'; + +const AllRoutes = () => { + return ( + + + + + , + + + + , + + + + + ); +}; + +export default AllRoutes; diff --git a/frontend/src/Routes/awsRoutes.js b/frontend/src/Routes/awsRoutes.js new file mode 100644 index 00000000..2c122c18 --- /dev/null +++ b/frontend/src/Routes/awsRoutes.js @@ -0,0 +1,148 @@ +import React, { useContext, useEffect, useState } from 'react'; +import { Route, Switch } from 'react-router-dom'; +import { Auth } from 'aws-amplify'; + +import { Context } from '../store/Store'; +import Navbar from '../components/Navbar/Navbar'; +import AccountManagement from '../pages/AccountManagement/AccountManagment'; +import Dashboard from '../pages/Dashboard/Dashboard'; +import DashboardManagement from '../pages/DashboardManagement/DashboardManagement'; +import PatientDetail from '../pages/PatientDetail/PatientDetail'; +import Patients from '../pages/Patients/Patients'; +import { + REDUCER_ACTIONS, + ROUTES, + LANGUAGES, + COGNITO_ATTRIBUTES, +} from '../utils/constants'; +import Login from '../pages/Login/Login'; +import { useErrorWrap } from '../hooks/useErrorWrap'; +import { getCurrentUserInfo } from '../aws/aws-helper'; +import { + UNDEFINED_AUTH, + AUTHENTICATED, + UNAUTHENTICATED, + setAuthListener, +} from '../aws/aws-auth'; +import { getSelf } from '../api/api'; + +const AWSRoutes = () => { + const [authLevel, setAuthLevel] = useState(UNDEFINED_AUTH); + const [username, setUsername] = useState(''); + const [userEmail, setUserEmail] = useState(''); + const [state, dispatch] = useContext(Context); + const errorWrap = useErrorWrap(); + + /** + * Gets the user's preferred language and sets it in the store + * Also checks if the user is an admin and updates store + */ + useEffect(() => { + const setLanguage = async () => { + const userInfo = await getCurrentUserInfo(); + if (!userInfo?.attributes) return; + + const language = userInfo.attributes[COGNITO_ATTRIBUTES.LANGUAGE]; + if (isLanguageValid(language)) { + dispatch({ + type: REDUCER_ACTIONS.SET_LANGUAGE, + language, + }); + } else { + console.error(`Language is invalid: ${language}`); + } + }; + + const setAdminStatus = async () => { + const selfRes = await getSelf(); + dispatch({ + type: REDUCER_ACTIONS.SET_ADMIN_STATUS, + isAdmin: selfRes?.result?.isAdmin, + }); + }; + + setLanguage(); + if (authLevel === AUTHENTICATED) { + errorWrap(setAdminStatus); + } + }, [dispatch, errorWrap]); + + /** + * Returns true if the given string is a valid language identifier + */ + const isLanguageValid = (language) => { + return Object.values(LANGUAGES).includes(language); + }; + + /** + * Attempts to authenticate the user and get their name/email + */ + useEffect(() => { + const getUserInfo = async () => { + const userInfo = await getCurrentUserInfo(); + setUsername(userInfo?.attributes?.name); + setUserEmail(userInfo?.attributes?.email); + }; + + updateAuthLevel(); + getUserInfo(); + }, []); + + /** + * Checks if the current user is authenticated and updates the auth + * level accordingly + */ + const updateAuthLevel = async () => { + try { + await Auth.currentAuthenticatedUser(); + setAuthLevel(AUTHENTICATED); + } catch (error) { + setAuthLevel(UNAUTHENTICATED); + } + }; + + // We get the auth level at startup, then set a listener to get notified when it changes. + setAuthListener((newAuthLevel) => setAuthLevel(newAuthLevel)); + + // If we're not sure of the user's status, say we're authenticating + if (authLevel === UNDEFINED_AUTH) return

Authenticating User

; + + // If the user is unauthenticated, show login screen + if (authLevel === UNAUTHENTICATED) + return ( + + + + ); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default AWSRoutes; diff --git a/frontend/src/api/axios-patient-auth.js b/frontend/src/api/axios-patient-auth.js new file mode 100644 index 00000000..9d5df213 --- /dev/null +++ b/frontend/src/api/axios-patient-auth.js @@ -0,0 +1,58 @@ +import axios from 'axios'; + +const BASE_URL = process.env.REACT_APP_BACKEND_BASE_URL; + +// Generalized axios configuration +axios.defaults.headers.common['Content-Type'] = 'application/json'; +axios.defaults.withCredentials = true; + +// The configured axios instance to be exported +const instance = axios.create({ + baseURL: BASE_URL, + validateStatus: () => { + return true; + }, +}); + +export const send2FAPatientCode = async (_id) => { + const requestString = `/patient-2fa/${_id}`; + + const res = await instance.get(requestString); + + // Previously, without the redirect, the site would crash when an invalid patient id was entered + if (!res?.data?.success) { + window.location = `/patient-2fa/${_id}`; + } + + return res.data; +}; + +export const authenticatePatient = async (_id, token) => { + const bodyFormData = new FormData(); + bodyFormData.append('username', _id); + bodyFormData.append('password', token); + const res = await instance({ + method: 'post', + url: `/patient-2fa/authenticated/${_id}`, + data: bodyFormData, + headers: { 'Content-Type': 'multipart/form-data' }, + }); + + return res.data; +}; + +export const redirectAndAuthenticate = async (_id) => { + const res = await instance({ + method: 'get', + url: `/patient-2fa/patient-portal/${_id}`, + data: { _id }, + headers: { 'Content-Type': 'multipart/form-data' }, + }); + + if (!res?.data?.success) { + window.location = `/patient-2fa/${_id}`; + return false; + } + + return true; +}; diff --git a/frontend/src/pages/Patient2FALogin/Patient2FALogin.js b/frontend/src/pages/Patient2FALogin/Patient2FALogin.js index 90e4d76d..968e787c 100644 --- a/frontend/src/pages/Patient2FALogin/Patient2FALogin.js +++ b/frontend/src/pages/Patient2FALogin/Patient2FALogin.js @@ -2,13 +2,19 @@ import React, { useState } from 'react'; import ReactCodeInput from 'react-code-input'; import { useParams } from 'react-router-dom'; +import { + authenticatePatient, + send2FAPatientCode, +} from '../../api/axios-patient-auth'; import Logo from '../../assets/3dp4me_logo.png'; import { useTranslations } from '../../hooks/useTranslations'; +import { ROUTES } from '../../utils/constants'; import './Patient2FALogin.scss'; import './TokenInput.scss'; const Patient2FALogin = () => { + const [token, setToken] = useState(); const [isTokenSent, setIsTokenSent] = useState(); const translations = useTranslations()[0]; const params = useParams(); @@ -30,6 +36,19 @@ const Patient2FALogin = () => { backgroundColor: '#DEDFFB', }; + const onTokenSend = () => { + send2FAPatientCode(patientId); + setIsTokenSent(true); + }; + + const checkIsAuthenticated = async () => { + const res = await authenticatePatient(patientId, token); + + if (res.success) { + window.location.href = `${ROUTES.PATIENT_PORTAL}/${patientId}`; + } + }; + const displayAuthPage = () => { if (!isTokenSent) { return ( @@ -42,7 +61,7 @@ const Patient2FALogin = () => { @@ -64,9 +83,16 @@ const Patient2FALogin = () => {
- - -
{ + const params = useParams(); + const { patientId } = params; + const [shouldRender, setShouldRender] = useState(); + const translations = useTranslations()[0]; + + useEffect(async () => { + const isAuth = await redirectAndAuthenticate(patientId); + + setShouldRender(isAuth); + }, []); + + if (!shouldRender) { + return
{translations.patientPortal.authenticating}
; + } + + return
{shouldRender &&
Hi
}
; +}; + +export default PatientPortal; diff --git a/frontend/src/pages/PatientPortal/PatientPortal.scss b/frontend/src/pages/PatientPortal/PatientPortal.scss new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/translations.json b/frontend/src/translations.json index ecc03e95..ff2bd00c 100644 --- a/frontend/src/translations.json +++ b/frontend/src/translations.json @@ -9,6 +9,9 @@ "verify": "Verify", "resendCode": "Send code again" }, + "patientPortal": { + "authenticating": "Checking authentication status" + }, "errors": { "noMetadata": "You aren't allowed to view any steps. Contact an administrator to give you the proper roles." }, @@ -265,6 +268,9 @@ "verify": "التحقق", "resendCode": "ارسل الرمز مجددا" }, + "patientPortal": { + "authenticating": "التحقق من حالة المصادقة" + }, "errors": { "noMetadata": "لا يُسمح لك بمشاهدة أي خطوات. اتصل بالمسؤول لمنحك الأدوار المناسبة." }, diff --git a/frontend/src/utils/constants.js b/frontend/src/utils/constants.js index f97648f2..599f5a19 100644 --- a/frontend/src/utils/constants.js +++ b/frontend/src/utils/constants.js @@ -47,6 +47,7 @@ export const ROUTES = { DASHBOARD_MANAGEMENT: '/dashboard-management', PATIENT_DETAIL: '/patient-info', PATIENT_2FA: '/patient-2fa', + PATIENT_PORTAL: '/patient-portal', }; /** diff --git a/frontend/yarn.lock b/frontend/yarn.lock index d0616e39..c79d90e1 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -4338,13 +4338,20 @@ aws4@^1.8.0: resolved "https://registry.npmjs.org/aws4/-/aws4-1.11.0.tgz" integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA== -axios@0.21.4, axios@^0.21.1: +axios@0.21.4: version "0.21.4" resolved "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz" integrity sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg== dependencies: follow-redirects "^1.14.0" +axios@^0.24.0: + version "0.24.0" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.24.0.tgz#804e6fa1e4b9c5288501dd9dff56a7a0940d20d6" + integrity sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA== + dependencies: + follow-redirects "^1.14.4" + axobject-query@^2.0.2: version "2.2.0" resolved "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz" @@ -7286,6 +7293,11 @@ follow-redirects@^1.0.0, follow-redirects@^1.14.0: resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.4.tgz" integrity sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g== +follow-redirects@^1.14.4: + version "1.14.5" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.5.tgz#f09a5848981d3c772b5392309778523f8d85c381" + integrity sha512-wtphSXy7d4/OR+MvIFbCVBDzZ5520qV8XfPklSN5QtxuMUJZ+b0Wnst1e1lCDocfzuCkHqj8k0FpZqO+UIaKNA== + for-each@^0.3.3: version "0.3.3" resolved "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz"