diff --git a/.github/workflows/pull.yaml b/.github/workflows/pull.yaml index 94c5f0a1..4c76ca61 100644 --- a/.github/workflows/pull.yaml +++ b/.github/workflows/pull.yaml @@ -12,7 +12,7 @@ jobs: uses: styfle/cancel-workflow-action@0.11.0 with: access_token: ${{ secrets.GITHUB_TOKEN }} - + - uses: actions/checkout@v4 - uses: actions/setup-python@v4 with: @@ -30,6 +30,8 @@ jobs: - uses: psf/black@stable test_api: + env: + SECRET_KEY: ${{ secrets.SECRET_KEY }} name: Test API runs-on: ubuntu-latest steps: @@ -127,4 +129,4 @@ jobs: cache-dependency-path: 'client/package-lock.json' - run: npm ci - name: Build app - run: npm run build \ No newline at end of file + run: npm run build diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9f53de2e..ced7b6e1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,5 +19,4 @@ jobs: - name: Run tests env: VUE_APP_PDAP_API_KEY: ${{ secrets.VUE_APP_PDAP_API_KEY }} - run: python regular_api_checks.py diff --git a/README.md b/README.md index 0c297112..1e9d852a 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ pip install -r requirements.txt ### 5. Add environment secrets -Either add a `.env` file to your local root directory or manually export these secrets: `DO_DATABASE_URL` and `VITE_VUE_APP_BASE_URL`. +Either add a `.env` file to your local root directory or manually export these secrets: `DO_DATABASE_URL` and `VITE_VUE_API_BASE_URL`. Reach out to contact@pdap.io or make noise in Discord if you'd like access to these keys. @@ -55,14 +55,16 @@ Reach out to contact@pdap.io or make noise in Discord if you'd like access to th # .env DO_DATABASE_URL="postgres://data_sources_app:@db-postgresql-nyc3-38355-do-user-8463429-0.c.db.ondigitalocean.com:25060/defaultdb" -VITE_VUE_APP_BASE_URL="http://localhost:5000" +VITE_VUE_API_BASE_URL="http://localhost:5000" +VITE_VUE_APP_BASE_URL="http://localhost:8888" ``` ``` # shell export DO_DATABASE_URL=postgres://data_sources_app:@db-postgresql-nyc3-38355-do-user-8463429-0.c.db.ondigitalocean.com:25060/defaultdb -export VITE_VUE_APP_BASE_URL="http://localhost:5000" +export VITE_VUE_API_BASE_URL="http://localhost:5000" +export VITE_VUE_APP_BASE_URL="http://localhost:8888" ``` ### 6. Allow your IP address @@ -108,14 +110,18 @@ pip install pytest pytest ``` - ## Linting Linting is enforced with black on PR creation. You can use black to automatically reformat your files before commiting them, this will allow your PR to pass this check. Any files that require reformatting will be listed on any failed checks on the PR. ``` black app_test.py ``` -## Other helpful commands for the client app +## Client App + +A few things to know: + +- We use Vue3. This allows for using either the options or composition APIs. Feel free to use whichever you are most fluent in. +- We use `pinia` for state management. This works much better with the composition API than with options, so it is recommended to use the composition API if you need data from one of the `pinia` stores. ### Compiles and minifies for production ``` diff --git a/app.py b/app.py index 8e0684e7..c4fa29e7 100644 --- a/app.py +++ b/app.py @@ -2,6 +2,11 @@ from flask_restful import Api from flask_cors import CORS from resources.User import User +from resources.Login import Login +from resources.RefreshSession import RefreshSession +from resources.ApiKey import ApiKey +from resources.RequestResetPassword import RequestResetPassword +from resources.ResetPassword import ResetPassword from resources.QuickSearch import QuickSearch from resources.DataSources import DataSources from resources.DataSources import DataSourceById @@ -19,6 +24,29 @@ api.add_resource( User, "/user", resource_class_kwargs={"psycopg2_connection": psycopg2_connection} ) +api.add_resource( + Login, "/login", resource_class_kwargs={"psycopg2_connection": psycopg2_connection} +) +api.add_resource( + RefreshSession, + "/refresh-session", + resource_class_kwargs={"psycopg2_connection": psycopg2_connection}, +) +api.add_resource( + ApiKey, + "/api_key", + resource_class_kwargs={"psycopg2_connection": psycopg2_connection}, +) +api.add_resource( + RequestResetPassword, + "/request-reset-password", + resource_class_kwargs={"psycopg2_connection": psycopg2_connection}, +) +api.add_resource( + ResetPassword, + "/reset-password", + resource_class_kwargs={"psycopg2_connection": psycopg2_connection}, +) api.add_resource( QuickSearch, "/quick-search//", @@ -50,5 +78,6 @@ resource_class_kwargs={"psycopg2_connection": psycopg2_connection}, ) + if __name__ == "__main__": app.run(debug=True, host="0.0.0.0") diff --git a/app_test.py b/app_test.py index 86e34c9c..589693d3 100644 --- a/app_test.py +++ b/app_test.py @@ -14,7 +14,16 @@ data_source_by_id_results, DATA_SOURCES_APPROVED_COLUMNS, ) - +from middleware.user_queries import ( + user_post_results, + user_check_email, +) +from middleware.login_queries import ( + login_results, + create_session_token, + token_results, + is_admin, +) from middleware.archives_queries import ( archives_get_results, archives_get_query, @@ -22,6 +31,11 @@ archives_put_last_cached_results, ARCHIVES_GET_COLUMNS, ) +from middleware.reset_token_queries import ( + check_reset_token, + add_reset_token, + delete_reset_token, +) from app_test_data import ( DATA_SOURCES_ROWS, DATA_SOURCE_QUERY_RESULTS, @@ -125,6 +139,86 @@ def test_data_source_by_id_approved(session): assert not response +def test_user_post_query(session): + curs = session.cursor() + user_post_results(curs, "unit_test", "unit_test") + + email_check = curs.execute( + f"SELECT email FROM users WHERE email = 'unit_test'" + ).fetchone()[0] + + assert email_check == "unit_test" + + +def test_login_query(session): + curs = session.cursor() + user_data = login_results(curs, "test") + + assert user_data["password_digest"] + + +def test_create_session_token_results(session): + curs = session.cursor() + token = create_session_token(curs, 1, "test") + + curs = session.cursor() + new_token = token_results(curs, token) + + assert new_token["email"] + + +def test_is_admin(session): + curs = session.cursor() + admin = is_admin(curs, "mbodenator@gmail.com") + + assert admin + + +def test_not_admin(session): + curs = session.cursor() + admin = is_admin(curs, "test") + + assert not admin + + +def test_user_check_email(session): + curs = session.cursor() + user_data = user_check_email(curs, "test") + print(user_data) + + assert user_data["id"] + + +def test_check_reset_token(session): + curs = session.cursor() + reset_token = check_reset_token(curs, "test") + print(reset_token) + + assert reset_token["id"] + + +def test_add_reset_token(session): + curs = session.cursor() + add_reset_token(curs, "unit_test", "unit_test") + + email_check = curs.execute( + f"SELECT email FROM reset_tokens WHERE email = 'unit_test'" + ).fetchone()[0] + + assert email_check == "unit_test" + + +def test_delete_reset_token(session): + curs = session.cursor() + delete_reset_token(curs, "test", "test") + + email_check = curs.execute( + f"SELECT email FROM reset_tokens WHERE email = 'test'" + ).fetchone() + + assert not email_check + + def test_archives_get_results(session): response = archives_get_results(conn=session) diff --git a/client/package-lock.json b/client/package-lock.json index 28ceba6c..0b55a357 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -9,11 +9,16 @@ "version": "0.1.0", "dependencies": { "axios": "^1.6.0", - "vue": "^3.3.10", + "jwt-decode": "^4.0.0", + "lodash": "^4.17.21", + "pinia": "^2.1.7", + "pinia-plugin-persistedstate": "^3.2.1", + "vue": "^3.4.19", "vue-router": "^4.2.4" }, "devDependencies": { "@pdap-design-system/eslint-config": "^1.0.1", + "@pinia/testing": "^0.1.3", "@vitejs/plugin-vue": "^4.2.3", "@vitest/coverage-v8": "^1.2.1", "@vue/eslint-config-prettier": "^8.0.0", @@ -733,6 +738,47 @@ "eslint": ">= 8" } }, + "node_modules/@pinia/testing": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@pinia/testing/-/testing-0.1.3.tgz", + "integrity": "sha512-D2Ds2s69kKFaRf2KCcP1NhNZEg5+we59aRyQalwRm7ygWfLM25nDH66267U3hNvRUOTx8ofL24GzodZkOmB5xw==", + "dev": true, + "dependencies": { + "vue-demi": ">=0.14.5" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "pinia": ">=2.1.5" + } + }, + "node_modules/@pinia/testing/node_modules/vue-demi": { + "version": "0.14.7", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.7.tgz", + "integrity": "sha512-EOG8KXDQNwkJILkx/gPcoL/7vH+hORoBaKgGe+6W7VFMvCYJfmF2dGbvgDroVnI8LU7/kTu8mbjRZGBU1z9NTA==", + "dev": true, + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1127,12 +1173,12 @@ } }, "node_modules/@vue/compiler-core": { - "version": "3.4.15", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.15.tgz", - "integrity": "sha512-XcJQVOaxTKCnth1vCxEChteGuwG6wqnUHxAm1DO3gCz0+uXKaJNx8/digSz4dLALCy8n2lKq24jSUs8segoqIw==", + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.19.tgz", + "integrity": "sha512-gj81785z0JNzRcU0Mq98E56e4ltO1yf8k5PQ+tV/7YHnbZkrM0fyFyuttnN8ngJZjbpofWE/m4qjKBiLl8Ju4w==", "dependencies": { - "@babel/parser": "^7.23.6", - "@vue/shared": "3.4.15", + "@babel/parser": "^7.23.9", + "@vue/shared": "3.4.19", "entities": "^4.5.0", "estree-walker": "^2.0.2", "source-map-js": "^1.0.2" @@ -1144,26 +1190,26 @@ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" }, "node_modules/@vue/compiler-dom": { - "version": "3.4.15", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.15.tgz", - "integrity": "sha512-wox0aasVV74zoXyblarOM3AZQz/Z+OunYcIHe1OsGclCHt8RsRm04DObjefaI82u6XDzv+qGWZ24tIsRAIi5MQ==", + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.19.tgz", + "integrity": "sha512-vm6+cogWrshjqEHTzIDCp72DKtea8Ry/QVpQRYoyTIg9k7QZDX6D8+HGURjtmatfgM8xgCFtJJaOlCaRYRK3QA==", "dependencies": { - "@vue/compiler-core": "3.4.15", - "@vue/shared": "3.4.15" + "@vue/compiler-core": "3.4.19", + "@vue/shared": "3.4.19" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.4.15", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.15.tgz", - "integrity": "sha512-LCn5M6QpkpFsh3GQvs2mJUOAlBQcCco8D60Bcqmf3O3w5a+KWS5GvYbrrJBkgvL1BDnTp+e8q0lXCLgHhKguBA==", - "dependencies": { - "@babel/parser": "^7.23.6", - "@vue/compiler-core": "3.4.15", - "@vue/compiler-dom": "3.4.15", - "@vue/compiler-ssr": "3.4.15", - "@vue/shared": "3.4.15", + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.19.tgz", + "integrity": "sha512-LQ3U4SN0DlvV0xhr1lUsgLCYlwQfUfetyPxkKYu7dkfvx7g3ojrGAkw0AERLOKYXuAGnqFsEuytkdcComei3Yg==", + "dependencies": { + "@babel/parser": "^7.23.9", + "@vue/compiler-core": "3.4.19", + "@vue/compiler-dom": "3.4.19", + "@vue/compiler-ssr": "3.4.19", + "@vue/shared": "3.4.19", "estree-walker": "^2.0.2", - "magic-string": "^0.30.5", + "magic-string": "^0.30.6", "postcss": "^8.4.33", "source-map-js": "^1.0.2" } @@ -1174,12 +1220,12 @@ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" }, "node_modules/@vue/compiler-ssr": { - "version": "3.4.15", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.15.tgz", - "integrity": "sha512-1jdeQyiGznr8gjFDadVmOJqZiLNSsMa5ZgqavkPZ8O2wjHv0tVuAEsw5hTdUoUW4232vpBbL/wJhzVW/JwY1Uw==", + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.19.tgz", + "integrity": "sha512-P0PLKC4+u4OMJ8sinba/5Z/iDT84uMRRlrWzadgLA69opCpI1gG4N55qDSC+dedwq2fJtzmGald05LWR5TFfLw==", "dependencies": { - "@vue/compiler-dom": "3.4.15", - "@vue/shared": "3.4.15" + "@vue/compiler-dom": "3.4.19", + "@vue/shared": "3.4.19" } }, "node_modules/@vue/devtools-api": { @@ -1202,48 +1248,48 @@ } }, "node_modules/@vue/reactivity": { - "version": "3.4.15", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.15.tgz", - "integrity": "sha512-55yJh2bsff20K5O84MxSvXKPHHt17I2EomHznvFiJCAZpJTNW8IuLj1xZWMLELRhBK3kkFV/1ErZGHJfah7i7w==", + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.19.tgz", + "integrity": "sha512-+VcwrQvLZgEclGZRHx4O2XhyEEcKaBi50WbxdVItEezUf4fqRh838Ix6amWTdX0CNb/b6t3Gkz3eOebfcSt+UA==", "dependencies": { - "@vue/shared": "3.4.15" + "@vue/shared": "3.4.19" } }, "node_modules/@vue/runtime-core": { - "version": "3.4.15", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.15.tgz", - "integrity": "sha512-6E3by5m6v1AkW0McCeAyhHTw+3y17YCOKG0U0HDKDscV4Hs0kgNT5G+GCHak16jKgcCDHpI9xe5NKb8sdLCLdw==", + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.19.tgz", + "integrity": "sha512-/Z3tFwOrerJB/oyutmJGoYbuoadphDcJAd5jOuJE86THNZji9pYjZroQ2NFsZkTxOq0GJbb+s2kxTYToDiyZzw==", "dependencies": { - "@vue/reactivity": "3.4.15", - "@vue/shared": "3.4.15" + "@vue/reactivity": "3.4.19", + "@vue/shared": "3.4.19" } }, "node_modules/@vue/runtime-dom": { - "version": "3.4.15", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.15.tgz", - "integrity": "sha512-EVW8D6vfFVq3V/yDKNPBFkZKGMFSvZrUQmx196o/v2tHKdwWdiZjYUBS+0Ez3+ohRyF8Njwy/6FH5gYJ75liUw==", + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.19.tgz", + "integrity": "sha512-IyZzIDqfNCF0OyZOauL+F4yzjMPN2rPd8nhqPP2N1lBn3kYqJpPHHru+83Rkvo2lHz5mW+rEeIMEF9qY3PB94g==", "dependencies": { - "@vue/runtime-core": "3.4.15", - "@vue/shared": "3.4.15", + "@vue/runtime-core": "3.4.19", + "@vue/shared": "3.4.19", "csstype": "^3.1.3" } }, "node_modules/@vue/server-renderer": { - "version": "3.4.15", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.15.tgz", - "integrity": "sha512-3HYzaidu9cHjrT+qGUuDhFYvF/j643bHC6uUN9BgM11DVy+pM6ATsG6uPBLnkwOgs7BpJABReLmpL3ZPAsUaqw==", + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.19.tgz", + "integrity": "sha512-eAj2p0c429RZyyhtMRnttjcSToch+kTWxFPHlzGMkR28ZbF1PDlTcmGmlDxccBuqNd9iOQ7xPRPAGgPVj+YpQw==", "dependencies": { - "@vue/compiler-ssr": "3.4.15", - "@vue/shared": "3.4.15" + "@vue/compiler-ssr": "3.4.19", + "@vue/shared": "3.4.19" }, "peerDependencies": { - "vue": "3.4.15" + "vue": "3.4.19" } }, "node_modules/@vue/shared": { - "version": "3.4.15", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.15.tgz", - "integrity": "sha512-KzfPTxVaWfB+eGcGdbSf4CWdaXcGDqckoeXUh7SB3fZdEtzPCK2Vq9B/lRRL3yutax/LWITz+SwvgyOxz5V75g==" + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.19.tgz", + "integrity": "sha512-/KliRRHMF6LoiThEy+4c1Z4KB/gbPrGjWwJR+crg2otgrf/egKzRaCPvJ51S5oetgsgXLfc4Rm5ZgrKHZrtMSw==" }, "node_modules/@vue/test-utils": { "version": "2.4.4", @@ -1264,94 +1310,6 @@ } } }, - "node_modules/@vuelidate/core": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@vuelidate/core/-/core-2.0.3.tgz", - "integrity": "sha512-AN6l7KF7+mEfyWG0doT96z+47ljwPpZfi9/JrNMkOGLFv27XVZvKzRLXlmDPQjPl/wOB1GNnHuc54jlCLRNqGA==", - "dev": true, - "dependencies": { - "vue-demi": "^0.13.11" - }, - "peerDependencies": { - "@vue/composition-api": "^1.0.0-rc.1", - "vue": "^2.0.0 || >=3.0.0" - }, - "peerDependenciesMeta": { - "@vue/composition-api": { - "optional": true - } - } - }, - "node_modules/@vuelidate/core/node_modules/vue-demi": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.11.tgz", - "integrity": "sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==", - "dev": true, - "hasInstallScript": true, - "bin": { - "vue-demi-fix": "bin/vue-demi-fix.js", - "vue-demi-switch": "bin/vue-demi-switch.js" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "@vue/composition-api": "^1.0.0-rc.1", - "vue": "^3.0.0-0 || ^2.6.0" - }, - "peerDependenciesMeta": { - "@vue/composition-api": { - "optional": true - } - } - }, - "node_modules/@vuelidate/validators": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@vuelidate/validators/-/validators-2.0.4.tgz", - "integrity": "sha512-odTxtUZ2JpwwiQ10t0QWYJkkYrfd0SyFYhdHH44QQ1jDatlZgTh/KRzrWVmn/ib9Gq7H4hFD4e8ahoo5YlUlDw==", - "dev": true, - "dependencies": { - "vue-demi": "^0.13.11" - }, - "peerDependencies": { - "@vue/composition-api": "^1.0.0-rc.1", - "vue": "^2.0.0 || >=3.0.0" - }, - "peerDependenciesMeta": { - "@vue/composition-api": { - "optional": true - } - } - }, - "node_modules/@vuelidate/validators/node_modules/vue-demi": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.11.tgz", - "integrity": "sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==", - "dev": true, - "hasInstallScript": true, - "bin": { - "vue-demi-fix": "bin/vue-demi-fix.js", - "vue-demi-switch": "bin/vue-demi-switch.js" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "@vue/composition-api": "^1.0.0-rc.1", - "vue": "^3.0.0-0 || ^2.6.0" - }, - "peerDependenciesMeta": { - "@vue/composition-api": { - "optional": true - } - } - }, "node_modules/abbrev": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", @@ -1620,14 +1578,18 @@ } }, "node_modules/call-bind": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", - "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.6.tgz", + "integrity": "sha512-Mj50FLHtlsoVfRfnHaZvyrooHcrlceNZdL/QBvJJVd9Ta55qCQK0gs4ss2oZDeV9zFCs6ewzYgVE5yfVmfFpVg==", "dev": true, "dependencies": { + "es-errors": "^1.3.0", "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.1", - "set-function-length": "^1.1.1" + "get-intrinsic": "^1.2.3", + "set-function-length": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -2047,14 +2009,15 @@ "dev": true }, "node_modules/define-data-property": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", - "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.2.tgz", + "integrity": "sha512-SRtsSqsDbgpJBbW3pABMCOt6rQyeM8s8RiyeSN8jYG8sYmt/kGJejbydttUsnDs1tadr19tvhT4ShwMyoqAm4g==", "dev": true, "dependencies": { - "get-intrinsic": "^1.2.1", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.2", "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.0" + "has-property-descriptors": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -2227,6 +2190,15 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", @@ -2757,16 +2729,20 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", - "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", "dev": true, "dependencies": { + "es-errors": "^1.3.0", "function-bind": "^1.1.2", "has-proto": "^1.0.1", "has-symbols": "^1.0.3", "hasown": "^2.0.0" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -3358,6 +3334,14 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "engines": { + "node": ">=18" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -3429,8 +3413,7 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "node_modules/lodash.clonedeep": { "version": "4.5.0", @@ -3463,9 +3446,9 @@ } }, "node_modules/magic-string": { - "version": "0.30.5", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz", - "integrity": "sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==", + "version": "0.30.7", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.7.tgz", + "integrity": "sha512-8vBuFF/I/+OSLRmdf2wwFCJCz+nSn0m6DPvGH1fS/KiQoSaR+sETbov0eIk9KhEKy8CYqIkIAnbohxT/4H0kuA==", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, @@ -3971,6 +3954,94 @@ "npm": ">=8.19.3" } }, + "node_modules/pdap-design-system/node_modules/@vuelidate/core": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@vuelidate/core/-/core-2.0.3.tgz", + "integrity": "sha512-AN6l7KF7+mEfyWG0doT96z+47ljwPpZfi9/JrNMkOGLFv27XVZvKzRLXlmDPQjPl/wOB1GNnHuc54jlCLRNqGA==", + "dev": true, + "dependencies": { + "vue-demi": "^0.13.11" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^2.0.0 || >=3.0.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/pdap-design-system/node_modules/@vuelidate/core/node_modules/vue-demi": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.11.tgz", + "integrity": "sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==", + "dev": true, + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/pdap-design-system/node_modules/@vuelidate/validators": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@vuelidate/validators/-/validators-2.0.4.tgz", + "integrity": "sha512-odTxtUZ2JpwwiQ10t0QWYJkkYrfd0SyFYhdHH44QQ1jDatlZgTh/KRzrWVmn/ib9Gq7H4hFD4e8ahoo5YlUlDw==", + "dev": true, + "dependencies": { + "vue-demi": "^0.13.11" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^2.0.0 || >=3.0.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/pdap-design-system/node_modules/@vuelidate/validators/node_modules/vue-demi": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.11.tgz", + "integrity": "sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==", + "dev": true, + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, "node_modules/pdap-design-system/node_modules/happy-dom": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-6.0.4.tgz", @@ -4012,6 +4083,64 @@ "node": ">=0.10.0" } }, + "node_modules/pinia": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.1.7.tgz", + "integrity": "sha512-+C2AHFtcFqjPih0zpYuvof37SFxMQ7OEG2zV9jRI12i9BOy3YQVAHwdKtyyc8pDcDyIc33WCIsZaCFWU7WWxGQ==", + "dependencies": { + "@vue/devtools-api": "^6.5.0", + "vue-demi": ">=0.14.5" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "@vue/composition-api": "^1.4.0", + "typescript": ">=4.4.4", + "vue": "^2.6.14 || ^3.3.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/pinia-plugin-persistedstate": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/pinia-plugin-persistedstate/-/pinia-plugin-persistedstate-3.2.1.tgz", + "integrity": "sha512-MK++8LRUsGF7r45PjBFES82ISnPzyO6IZx3CH5vyPseFLZCk1g2kgx6l/nW8pEBKxxd4do0P6bJw+mUSZIEZUQ==", + "peerDependencies": { + "pinia": "^2.0.0" + } + }, + "node_modules/pinia/node_modules/vue-demi": { + "version": "0.14.7", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.7.tgz", + "integrity": "sha512-EOG8KXDQNwkJILkx/gPcoL/7vH+hORoBaKgGe+6W7VFMvCYJfmF2dGbvgDroVnI8LU7/kTu8mbjRZGBU1z9NTA==", + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, "node_modules/pirates": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", @@ -4502,14 +4631,15 @@ } }, "node_modules/set-function-length": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.0.tgz", - "integrity": "sha512-4DBHDoyHlM1IRPGYcoxexgh67y4ueR53FKV1yyxwFMY7aCqcN/38M1+SwZ/qJQ8iLv7+ck385ot4CcisOAPT9w==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.1.tgz", + "integrity": "sha512-j4t6ccc+VsKwYHso+kElc5neZpjtq9EnRICFZtWyBsLojhmeF/ZBd/elqm22WJh/BziDe/SBiOeAt0m2mfLD0g==", "dev": true, "dependencies": { - "define-data-property": "^1.1.1", + "define-data-property": "^1.1.2", + "es-errors": "^1.3.0", "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.2", + "get-intrinsic": "^1.2.3", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.1" }, @@ -4539,14 +4669,18 @@ } }, "node_modules/side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.5.tgz", + "integrity": "sha512-QcgiIWV4WV7qWExbN5llt6frQB/lBven9pqliLXfGPB+K9ZYXxDozp0wLkHS24kWCm+6YXH/f0HhnObZnZOBnQ==", "dev": true, "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -6294,15 +6428,15 @@ } }, "node_modules/vue": { - "version": "3.4.15", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.4.15.tgz", - "integrity": "sha512-jC0GH4KkWLWJOEQjOpkqU1bQsBwf4R1rsFtw5GQJbjHVKWDzO6P0nWWBTmjp1xSemAioDFj1jdaK1qa3DnMQoQ==", + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.4.19.tgz", + "integrity": "sha512-W/7Fc9KUkajFU8dBeDluM4sRGc/aa4YJnOYck8dkjgZoXtVsn3OeTGni66FV1l3+nvPA7VBFYtPioaGKUmEADw==", "dependencies": { - "@vue/compiler-dom": "3.4.15", - "@vue/compiler-sfc": "3.4.15", - "@vue/runtime-dom": "3.4.15", - "@vue/server-renderer": "3.4.15", - "@vue/shared": "3.4.15" + "@vue/compiler-dom": "3.4.19", + "@vue/compiler-sfc": "3.4.19", + "@vue/runtime-dom": "3.4.19", + "@vue/server-renderer": "3.4.19", + "@vue/shared": "3.4.19" }, "peerDependencies": { "typescript": "*" @@ -6571,4 +6705,4 @@ } } } -} +} \ No newline at end of file diff --git a/client/package.json b/client/package.json index 597846eb..32e85725 100644 --- a/client/package.json +++ b/client/package.json @@ -5,7 +5,7 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview", + "preview": "vite preview --port 8888", "lint": "eslint src --ext .js .", "lint:fix": "npm run lint -- --fix", "test": "vitest --dom --run", @@ -15,11 +15,16 @@ }, "dependencies": { "axios": "^1.6.0", - "vue": "^3.3.10", + "jwt-decode": "^4.0.0", + "lodash": "^4.17.21", + "pinia": "^2.1.7", + "pinia-plugin-persistedstate": "^3.2.1", + "vue": "^3.4.19", "vue-router": "^4.2.4" }, "devDependencies": { "@pdap-design-system/eslint-config": "^1.0.1", + "@pinia/testing": "^0.1.3", "@vitejs/plugin-vue": "^4.2.3", "@vitest/coverage-v8": "^1.2.1", "@vue/eslint-config-prettier": "^8.0.0", diff --git a/client/src/App.vue b/client/src/App.vue index f1593ea8..8852f31b 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -1,19 +1,23 @@ diff --git a/client/src/components/__tests__/__snapshots__/authWrapper.test.js.snap b/client/src/components/__tests__/__snapshots__/authWrapper.test.js.snap new file mode 100644 index 00000000..e1ccdd97 --- /dev/null +++ b/client/src/components/__tests__/__snapshots__/authWrapper.test.js.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`AuthWrapper > renders auth wrapper 1`] = `
`; diff --git a/client/src/components/__tests__/authWrapper.test.js b/client/src/components/__tests__/authWrapper.test.js new file mode 100644 index 00000000..c561cd20 --- /dev/null +++ b/client/src/components/__tests__/authWrapper.test.js @@ -0,0 +1,58 @@ +import { mount } from '@vue/test-utils'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import AuthWrapper from '../AuthWrapper.vue'; +import { createTestingPinia } from '@pinia/testing'; +import { useAuthStore } from '../../stores/auth'; +import { nextTick } from 'vue'; + +let wrapper; + +const NOW = Date.now(); +const NOW_MINUS_THIRTY = NOW - 30 * 1000; +const NOW_PLUS_THIRTY = NOW + 30 * 1000; + +describe('AuthWrapper', () => { + beforeEach(() => { + wrapper = mount(AuthWrapper, { + // props: { dataSource }, + global: { + plugins: [createTestingPinia()], + }, + }); + + vi.unstubAllGlobals(); + }); + + it('renders auth wrapper', () => { + expect(wrapper.find('[id="wrapper"]').exists()).toBe(true); + expect(wrapper.html()).toMatchSnapshot(); + }); + + it('refreshes access token when less than 1 minute remaining before access token expiry on event', async () => { + const auth = useAuthStore(); + auth.$patch({ + userId: 42, + accessToken: { + expires: NOW_PLUS_THIRTY, + }, + }); + + await wrapper.trigger('click'); + await nextTick(); + expect(auth.refreshAccessToken).toHaveBeenCalled(); + }); + + it('logs user out when access token is expired on all expected events', async () => { + const auth = useAuthStore(); + auth.$patch({ + userId: 42, + accessToken: { + expires: NOW_MINUS_THIRTY, + }, + }); + + await wrapper.trigger('click'); + await nextTick(); + expect(auth.logout).toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/__tests__/searchResultCard.test.js b/client/src/components/__tests__/searchResultCard.test.js index 51ccfef2..99ddd99a 100644 --- a/client/src/components/__tests__/searchResultCard.test.js +++ b/client/src/components/__tests__/searchResultCard.test.js @@ -1,6 +1,6 @@ import SearchResultCard from '../SearchResultCard.vue'; import { mount } from '@vue/test-utils'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { nextTick } from 'vue'; let wrapper; @@ -175,6 +175,10 @@ describe('SearchResultCard with missing data', () => { }); }); + afterEach(() => { + vi.resetAllMocks(); + }); + it('search result card exists with missing data', () => { expect(wrapper.find('[data-test="search-result-card"]').exists()).toBe( true, @@ -211,6 +215,16 @@ describe('SearchResultCard with missing data', () => { wrapper.find('[data-test="search-result-format-unknown"]').exists(), ).toBe(true); }); + + it('Does not call openSource when data is missing', async () => { + const button = wrapper.find( + '[data-test="search-result-visit-source-button"]', + ); + + await button.trigger('click'); + + expect(push).not.toHaveBeenCalled(); + }); }); // describe("SearchResultCard with municipality but not state", () => { diff --git a/client/src/main.js b/client/src/main.js index affa8e3d..7320b8d0 100644 --- a/client/src/main.js +++ b/client/src/main.js @@ -1,13 +1,19 @@ import './main.css'; import { createApp } from 'vue'; +import { createPinia } from 'pinia'; +import piniaPersistState from 'pinia-plugin-persistedstate'; + import App from './App.vue'; +import { FlexContainer } from 'pdap-design-system'; import router from './router'; import 'pdap-design-system/styles'; -import { FlexContainer } from 'pdap-design-system'; +const pinia = createPinia(); +pinia.use(piniaPersistState); const app = createApp(App); +app.use(pinia); app.use(router); // Register 'FlexContainer' so it can be passed as a grid item diff --git a/client/src/pages/ChangePassword.vue b/client/src/pages/ChangePassword.vue new file mode 100644 index 00000000..e4761703 --- /dev/null +++ b/client/src/pages/ChangePassword.vue @@ -0,0 +1,117 @@ + + + diff --git a/client/src/pages/DataSourceStaticView.vue b/client/src/pages/DataSourceStaticView.vue index cfa94b5f..737c0e84 100644 --- a/client/src/pages/DataSourceStaticView.vue +++ b/client/src/pages/DataSourceStaticView.vue @@ -62,7 +62,7 @@ v-for="item in dataSource[record.key]" :key="item" :class="record?.classNames" - :data-test="record['data-test']" + :data-test="record['data-test'] ?? 'data-source-item'" :href="dataSource[record.key]" :intent="record?.attributes?.intent" :target="record?.attributes?.target" @@ -87,7 +87,7 @@ " v-else-if="dataSource[record.key]" :class="record?.classNames" - :data-test="record['data-test']" + :data-test="record['data-test'] ?? 'data-source-item'" :href="dataSource[record.key]" :intent="record?.attributes?.intent" :target="record?.attributes?.target" @@ -106,7 +106,7 @@ :is="record.component ? record.component : 'p'" v-else-if="dataSource[record.renderIf]" :class="record?.classNames" - :data-test="record['data-test']" + :data-test="record['data-test'] ?? 'data-source-item'" :href="record?.href" :intent="record?.attributes?.intent" :target="record?.attributes?.target" @@ -135,7 +135,7 @@ import { STATIC_VIEW_UI_SHAPE } from '../util/pageData.js'; export default { name: 'DataSourceStaticView', components: { - Button + Button, }, data: function () { return { @@ -168,14 +168,14 @@ export default { `https://web.archive.org/web/*/${this.dataSource.source_url}`, ); default: - return () => undefined; + return undefined; } }, async getDataSourceDetails() { try { const res = await axios.get( `${ - import.meta.env.VITE_VUE_APP_BASE_URL + import.meta.env.VITE_VUE_API_BASE_URL }/search-tokens?endpoint=data-sources-by-id&arg1=${this.id}`, ); this.dataSource = res.data; diff --git a/client/src/pages/LogIn.vue b/client/src/pages/LogIn.vue new file mode 100644 index 00000000..047361be --- /dev/null +++ b/client/src/pages/LogIn.vue @@ -0,0 +1,244 @@ + + + diff --git a/client/src/pages/ResetPassword.vue b/client/src/pages/ResetPassword.vue new file mode 100644 index 00000000..6131e8f1 --- /dev/null +++ b/client/src/pages/ResetPassword.vue @@ -0,0 +1,213 @@ + + + diff --git a/client/src/pages/SearchResultPage.vue b/client/src/pages/SearchResultPage.vue index 33c6ed2d..c4dc7ed2 100644 --- a/client/src/pages/SearchResultPage.vue +++ b/client/src/pages/SearchResultPage.vue @@ -100,7 +100,7 @@ export default { }, async search() { const url = `${ - import.meta.env.VITE_VUE_APP_BASE_URL + import.meta.env.VITE_VUE_API_BASE_URL }/search-tokens?endpoint=quick-search&arg1=${this.searchTerm}&arg2=${ this.location }`; diff --git a/client/src/pages/__tests__/__snapshots__/app.test.js.snap b/client/src/pages/__tests__/__snapshots__/app.test.js.snap index 6fd5a6cc..30ab1216 100644 --- a/client/src/pages/__tests__/__snapshots__/app.test.js.snap +++ b/client/src/pages/__tests__/__snapshots__/app.test.js.snap @@ -1,87 +1,90 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`App > renders the App 1`] = ` -
- -
- - - -
- -
- + + `; diff --git a/client/src/pages/__tests__/__snapshots__/changePassword.test.js.snap b/client/src/pages/__tests__/__snapshots__/changePassword.test.js.snap new file mode 100644 index 00000000..2247b3b9 --- /dev/null +++ b/client/src/pages/__tests__/__snapshots__/changePassword.test.js.snap @@ -0,0 +1,45 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Change password page > Calls the change password method with valid data 1`] = ` +
+

Change your password

+
+ +
+ + + + +
+
+ + + + +
+ +
+
+`; + +exports[`Change password page > Renders error message with mismatched passwords when trying to sign up and re-validates form 1`] = ` +
+

Change your password

+
+
Passwords do not match, please try again.
+
+ + + + +
+
+ + + + +
+ +
+
+`; diff --git a/client/src/pages/__tests__/__snapshots__/login.test.js.snap b/client/src/pages/__tests__/__snapshots__/login.test.js.snap new file mode 100644 index 00000000..5fae2649 --- /dev/null +++ b/client/src/pages/__tests__/__snapshots__/login.test.js.snap @@ -0,0 +1,108 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Login page > Login/Logout > Calls the login method with valid form data 1`] = ` + + +
+

Sign In

+
+ +
+ + + + +
+
+ + + + +
+ + +
+
+ + Reset Password +
+
+`; + +exports[`Login page > Signup > Calls the signup method with valid data 1`] = ` + + +
+

Sign In

+
+ +
+ + + + +
+
+ + + + +
+
+ + + + +
+
    Passwords must be at least 8 characters and include:
  • 1 uppercase letter
  • +
  • 1 lowercase letter
  • +
  • 1 number
  • +
  • 1 special character
  • +
+ +
+
+ + Reset Password +
+
+`; + +exports[`Login page > Signup > Renders error message with mismatched passwords when trying to sign up and re-validates form 1`] = ` + + +
+

Sign In

+
+
Passwords do not match, please try again.
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
    Passwords must be at least 8 characters and include:
  • 1 uppercase letter
  • +
  • 1 lowercase letter
  • +
  • 1 number
  • +
  • 1 special character
  • +
+ +
+
+ + Reset Password +
+
+`; diff --git a/client/src/pages/__tests__/__snapshots__/resetPassword.test.js.snap b/client/src/pages/__tests__/__snapshots__/resetPassword.test.js.snap new file mode 100644 index 00000000..e84397fa --- /dev/null +++ b/client/src/pages/__tests__/__snapshots__/resetPassword.test.js.snap @@ -0,0 +1,77 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Reset password page > No token (request PW reset) > No token, request reset password link > Calls the request reset password method with valid data 1`] = ` +
+

Request a link to reset your password

+
+ +
+ + + + +
+ +
+
+`; + +exports[`Reset password page > No token (request PW reset) > No token, success > Renders success message for request reset link 1`] = ` +
+

Success

+

We sent you an email with a link to reset your password. Please follow the link in the email to proceed

+ +
+`; + +exports[`Reset password page > With token (reset password) > With token, reset password > Calls the reset password method with valid data 1`] = ` +
+

Change your password

+
+ +
+ + + + +
+
+ + + + +
+ +
+
+`; + +exports[`Reset password page > With token (reset password) > With token, reset password > Renders error message with mismatched passwords when trying to sign up and re-validates form 1`] = ` +
+

Change your password

+
+
Passwords do not match, please try again.
+
+ + + + +
+
+ + + + +
+ +
+
+`; + +exports[`Reset password page > With token (reset password) > With token, success > Renders success message for reset password 1`] = ` +
+

Success

+

Your password has been successfully updated

+ Click here to log in +
+`; diff --git a/client/src/pages/__tests__/app.test.js b/client/src/pages/__tests__/app.test.js index dadf5fad..fb0e0b5c 100644 --- a/client/src/pages/__tests__/app.test.js +++ b/client/src/pages/__tests__/app.test.js @@ -2,6 +2,7 @@ import { mount, RouterLinkStub, RouterViewStub } from '@vue/test-utils'; import App from '../../App.vue'; import { beforeEach, describe, expect, it } from 'vitest'; import { links } from '../../util/links'; +import { createTestingPinia } from '@pinia/testing'; let wrapper; @@ -9,6 +10,7 @@ describe('App', () => { beforeEach(() => { wrapper = mount(App, { global: { + plugins: [createTestingPinia()], provide: { navLinks: links, footerLinks: links, diff --git a/client/src/pages/__tests__/changePassword.test.js b/client/src/pages/__tests__/changePassword.test.js new file mode 100644 index 00000000..4c8bb921 --- /dev/null +++ b/client/src/pages/__tests__/changePassword.test.js @@ -0,0 +1,88 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import { describe, expect, beforeEach, it, vi } from 'vitest'; +import { nextTick } from 'vue'; +import { createTestingPinia } from '@pinia/testing'; +import { useUserStore } from '../../stores/user'; + +import ChangePassword from '../ChangePassword.vue'; + +let wrapper; +let user; + +describe('Change password page', () => { + beforeEach(() => { + wrapper = mount(ChangePassword, { + global: { + plugins: [createTestingPinia()], + }, + }); + user = useUserStore(); + }); + + it('Calls the change password method with valid data', async () => { + const password = wrapper.find('[data-test="password"] input'); + const confirmPassword = wrapper.find( + '[data-test="confirm-password"] input', + ); + const form = wrapper.find('[data-test="change-password-form"]'); + + expect(wrapper.html()).toMatchSnapshot(); + + await password.setValue('Password1!'); + await confirmPassword.setValue('Password1!'); + + form.trigger('submit'); + await flushPromises(); + + expect(user.changePassword).toHaveBeenCalledOnce(); + }); + + it('Renders error message with mismatched passwords when trying to sign up and re-validates form', async () => { + const password = wrapper.find('[data-test="password"] input'); + const confirmPassword = wrapper.find( + '[data-test="confirm-password"] input', + ); + const form = wrapper.find('[data-test="change-password-form"]'); + + await password.setValue('Password1!'); + await confirmPassword.setValue('Password41234!'); + + await form.trigger('submit'); + await nextTick(); + await flushPromises(); + + const error = form.find('.pdap-form-error-message'); + + expect(error.exists()).toBe(true); + expect(error.text()).toBe('Passwords do not match, please try again.'); + + await nextTick(); + + await confirmPassword.setValue('Pasdasdfasdf'); + await nextTick(); + expect(error.exists()).toBe(true); + + expect(wrapper.html()).toMatchSnapshot(); + }); + + it('Handles API error', async () => { + const mockError = new Error('foo'); + vi.mocked(user.changePassword).mockRejectedValueOnce(mockError); + + const password = wrapper.find('[data-test="password"] input'); + const confirmPassword = wrapper.find( + '[data-test="confirm-password"] input', + ); + const form = wrapper.find('[data-test="change-password-form"]'); + + await password.setValue('Password1!'); + await confirmPassword.setValue('Password1!'); + + form.trigger('submit'); + await flushPromises(); + + const error = form.find('.pdap-form-error-message'); + expect(error.exists()).toBe(true); + expect(error.text()).toBe('foo'); + }); +}); diff --git a/client/src/pages/__tests__/dataSourceStaticView.test.js b/client/src/pages/__tests__/dataSourceStaticView.test.js index 562fc47b..521ee523 100644 --- a/client/src/pages/__tests__/dataSourceStaticView.test.js +++ b/client/src/pages/__tests__/dataSourceStaticView.test.js @@ -19,7 +19,7 @@ const $routeMock = { vi.mock('axios'); beforeAll(() => { - import.meta.env.VITE_VUE_APP_BASE_URL = 'https://data-sources.pdap.io'; + import.meta.env.VITE_VUE_API_BASE_URL = 'https://data-sources.pdap.io'; }); describe('DataSourceStaticView', () => { @@ -76,9 +76,10 @@ describe('DataSourceStaticView', () => { }); it('Routes back to /search on Agency Name button click with correct parameters', async () => { - const button = wrapper.find('[data-test="agency-name-button"]'); + const button = wrapper.findComponent('[data-test="agency-name-button"]'); expect(button.exists()).toBe(true); + expect(button.props('intent')).toBe('tertiary'); const name = button.text(); @@ -105,6 +106,15 @@ describe('DataSourceStaticView', () => { ); }); + it('Does nothing on click of non-button item.', async () => { + const spy = vi.spyOn(window, 'open'); + const item = wrapper.find('[data-test="data-source-item"]'); + + item.trigger('click'); + + expect(spy).not.toHaveBeenLastCalledWith(); + }); + // it("renders correctly when there is no data", () => { // const wrapper = mount(DataSourceStaticView, { // data() { diff --git a/client/src/pages/__tests__/login.test.js b/client/src/pages/__tests__/login.test.js new file mode 100644 index 00000000..64ed7feb --- /dev/null +++ b/client/src/pages/__tests__/login.test.js @@ -0,0 +1,195 @@ +import { RouterLinkStub, flushPromises, mount } from '@vue/test-utils'; +import { describe, expect, vi, beforeEach, it } from 'vitest'; +import { nextTick } from 'vue'; +import LogIn from '../LogIn.vue'; +import { createTestingPinia } from '@pinia/testing'; +import { useAuthStore } from '../../stores/auth'; +import { useUserStore } from '../../stores/user'; + +const push = vi.fn(); +const $routerMock = { + push, +}; + +let wrapper; + +describe('Login page', () => { + beforeEach(() => { + wrapper = mount(LogIn, { + global: { + mocks: { + router: $routerMock, + }, + plugins: [createTestingPinia()], + stubs: { + RouterLink: RouterLinkStub, + }, + }, + }); + }); + + describe('Login/Logout', () => { + let auth; + + beforeEach(() => { + auth = useAuthStore(); + }); + + it('Calls the login method with valid form data', async () => { + const email = wrapper.find('[data-test="email"] input'); + const password = wrapper.find('[data-test="password"] input'); + const form = wrapper.find('[data-test="login-form"]'); + + await email.setValue('hello@hello.com'); + await password.setValue('Password1!'); + + form.trigger('submit'); + await flushPromises(); + + expect(auth.login).toHaveBeenCalledWith('hello@hello.com', 'Password1!'); + expect(wrapper.html()).toMatchSnapshot(); + }); + + describe('Success and already logged in states', async () => { + beforeEach(() => { + auth.userId = 42; + }); + + it('Displays success copy', async () => { + wrapper.vm.success = "You're now logged in!"; + await nextTick(); + + const heading = await wrapper.find('[data-test="success-heading"]'); + const subheading = await wrapper.find( + '[data-test="success-subheading"]', + ); + + expect(heading.text()).toBe('Success'); + expect(subheading.text()).toBe("You're now logged in!"); + }); + + it('Logs user out', async () => { + const logout = await wrapper.find('[data-test="logout-button"]'); + + expect(logout.exists()).toBe(true); + + logout.trigger('click'); + await flushPromises(); + + expect(auth.logout).toHaveBeenCalledOnce(); + }); + }); + + it('Handles API error', async () => { + const mockError = new Error('foo'); + vi.mocked(auth.login).mockRejectedValueOnce(mockError); + + const email = wrapper.find('[data-test="email"] input'); + const password = wrapper.find('[data-test="password"] input'); + const form = wrapper.find('[data-test="login-form"]'); + + await email.setValue('hello@hello.com'); + await password.setValue('Password1!'); + + form.trigger('submit'); + await flushPromises(); + + const error = form.find('.pdap-form-error-message'); + expect(error.exists()).toBe(true); + expect(error.text()).toBe('foo'); + }); + }); + + describe('Signup', () => { + let toggle; + let user; + + beforeEach(() => { + toggle = wrapper.find('[data-test="toggle-button"]'); + user = useUserStore(); + }); + + it('Calls the signup method with valid data', async () => { + expect(toggle.exists()).toBe(true); + toggle.trigger('click'); + + await nextTick(); + + const email = wrapper.find('[data-test="email"] input'); + const password = wrapper.find('[data-test="password"] input'); + const confirmPassword = wrapper.find( + '[data-test="confirm-password"] input', + ); + const form = wrapper.find('[data-test="login-form"]'); + + await email.setValue('hello@hello.com'); + await password.setValue('Password1!'); + await confirmPassword.setValue('Password1!'); + + form.trigger('submit'); + await flushPromises(); + await nextTick(); + + expect(user.signup).toHaveBeenCalledOnce(); + expect(wrapper.html()).toMatchSnapshot(); + }); + + it('Renders error message with mismatched passwords when trying to sign up and re-validates form', async () => { + expect(toggle.exists()).toBe(true); + toggle.trigger('click'); + + await nextTick(); + + const email = wrapper.find('[data-test="email"] input'); + const password = wrapper.find('[data-test="password"] input'); + const confirmPassword = wrapper.find( + '[data-test="confirm-password"] input', + ); + const form = wrapper.find('[data-test="login-form"]'); + + await email.setValue('hello@hello.com'); + await password.setValue('Password1!'); + await confirmPassword.setValue('Password'); + + form.trigger('submit'); + await flushPromises(); + await nextTick(); + + const error = form.find('.pdap-form-error-message'); + + expect(error.exists()).toBe(true); + expect(error.text()).toBe('Passwords do not match, please try again.'); + expect(wrapper.html()).toMatchSnapshot(); + + await nextTick(); + + confirmPassword.setValue('Pasdasdfasdf'); + await nextTick(); + expect(error.exists()).toBe(true); + }); + }); + + describe('Miscellaneous states', () => { + it('Toggles form type', async () => { + const toggle = wrapper.find('[data-test="toggle-button"]'); + const submit = wrapper.find('[data-test="submit-button"]'); + + toggle.trigger('click'); + await nextTick(); + + expect(submit.text()).toBe('Create account'); + + toggle.trigger('click'); + await nextTick(); + + expect(submit.text()).toBe('Login'); + }); + + it('Renders button loading copy', async () => { + wrapper.vm.loading = true; + wrapper.vm.$forceUpdate(); + + expect(wrapper.vm.getSubmitButtonCopy()).toBe('Loading...'); + }); + }); +}); diff --git a/client/src/pages/__tests__/resetPassword.test.js b/client/src/pages/__tests__/resetPassword.test.js new file mode 100644 index 00000000..ea0df7b1 --- /dev/null +++ b/client/src/pages/__tests__/resetPassword.test.js @@ -0,0 +1,197 @@ +import { RouterLinkStub, flushPromises, mount } from '@vue/test-utils'; +import { describe, expect, beforeEach, it, vi, beforeAll } from 'vitest'; +import { nextTick } from 'vue'; +import { createTestingPinia } from '@pinia/testing'; +import { useUserStore } from '../../stores/user'; +import ResetPassword from '../ResetPassword.vue'; + +import { useRoute } from 'vue-router'; + +vi.mock('vue-router', async () => ({ + useRoute: vi.fn(), + createRouter: vi.fn(() => ({ + beforeEach: vi.fn(), + })), + createWebHistory: vi.fn(), + RouterLink: RouterLinkStub, +})); + +let wrapper; +let user; + +describe('Reset password page', () => { + beforeEach(() => { + wrapper = mount(ResetPassword, { + global: { + plugins: [createTestingPinia()], + }, + stubs: { + RouterLink: RouterLinkStub, + }, + }); + + user = useUserStore(); + }); + + describe('No token (request PW reset)', () => { + beforeAll(() => { + useRoute.mockImplementation(() => ({ + params: { + token: undefined, + }, + })); + }); + + describe('No token, success', () => { + it('Renders success message for request reset link', async () => { + wrapper.vm.success = true; + await nextTick(); + + const subheading = await wrapper.find( + '[data-test="success-subheading"]', + ); + + expect(subheading.text()).toBe( + 'We sent you an email with a link to reset your password. Please follow the link in the email to proceed', + ); + + expect(wrapper.html()).toMatchSnapshot(); + }); + }); + + describe('No token, request reset password link', () => { + let email; + let form; + + beforeEach(() => { + email = wrapper.find('[data-test="email"] input'); + form = wrapper.find('[data-test="reset-password-form"]'); + }); + + it('Calls the request reset password method with valid data', async () => { + await email.setValue('hello@hello.com'); + + expect(wrapper.html()).toMatchSnapshot(); + + form.trigger('submit'); + await flushPromises(); + + expect(user.requestPasswordReset).toHaveBeenCalledWith( + 'hello@hello.com', + ); + }); + + it('Handles API error', async () => { + const mockError = new Error('foo'); + vi.mocked(user.requestPasswordReset).mockRejectedValueOnce(mockError); + + form.trigger('submit'); + await flushPromises(); + + const error = form.find('.pdap-form-error-message'); + expect(error.exists()).toBe(true); + expect(error.text()).toBe('foo'); + }); + }); + }); + + describe('With token (reset password)', () => { + beforeAll(() => { + useRoute.mockImplementation(() => ({ + params: { + token: '123abc', + }, + })); + }); + + describe('With token, success', () => { + it('Renders success message for reset password', async () => { + wrapper.vm.success = true; + await nextTick(); + + const subheading = await wrapper.find( + '[data-test="success-subheading"]', + ); + + expect(subheading.text()).toBe( + 'Your password has been successfully updated', + ); + + expect(wrapper.html()).toMatchSnapshot(); + }); + }); + + describe('With token, token expired', () => { + it('Renders token expired UI', async () => { + wrapper.vm.isExpiredToken = true; + await nextTick(); + + const expired = await wrapper.find('[data-test="token-expired"]'); + const reRequest = await wrapper.find('[data-test="re-request-link"]'); + + expect(expired.exists()).toBe(true); + expect(reRequest.exists()).toBe(true); + }); + }); + + describe('With token, reset password', () => { + let password; + let confirmPassword; + let form; + + beforeEach(() => { + password = wrapper.find('[data-test="password"] input'); + confirmPassword = wrapper.find('[data-test="confirm-password"] input'); + form = wrapper.find('[data-test="reset-password-form"]'); + }); + + it('Calls the reset password method with valid data', async () => { + expect(wrapper.html()).toMatchSnapshot(); + + await password.setValue('Password1!'); + await confirmPassword.setValue('Password1!'); + + form.trigger('submit'); + await flushPromises(); + + expect(user.resetPassword).toHaveBeenCalledOnce(); + }); + + it('Renders error message with mismatched passwords when trying to sign up and re-validates form', async () => { + await password.setValue('Password1!'); + await confirmPassword.setValue('Password41234!'); + + await form.trigger('submit'); + await nextTick(); + await flushPromises(); + + const error = form.find('.pdap-form-error-message'); + + expect(error.exists()).toBe(true); + expect(error.text()).toBe('Passwords do not match, please try again.'); + + await nextTick(); + + await confirmPassword.setValue('Pasdasdfasdf'); + await nextTick(); + expect(error.exists()).toBe(true); + + expect(wrapper.html()).toMatchSnapshot(); + }); + + it('Handles API error with invalid token', async () => { + const mockError = new Error('The submitted token is invalid'); + vi.mocked(user.resetPassword).mockRejectedValueOnce(mockError); + + form.trigger('submit'); + await flushPromises(); + + const expired = await wrapper.find('[data-test="token-expired"]'); + const reRequest = await wrapper.find('[data-test="re-request-link"]'); + + expect(expired.exists()).toBe(true); + expect(reRequest.exists()).toBe(true); + }); + }); + }); +}); diff --git a/client/src/pages/__tests__/searchResultPage.test.js b/client/src/pages/__tests__/searchResultPage.test.js index c24ac9e6..e7357b58 100644 --- a/client/src/pages/__tests__/searchResultPage.test.js +++ b/client/src/pages/__tests__/searchResultPage.test.js @@ -18,7 +18,7 @@ const $routeMock = { let wrapper; beforeAll(() => { - import.meta.env.VITE_VUE_APP_BASE_URL = 'https://data-sources.pdap.io'; + import.meta.env.VITE_VUE_API_BASE_URL = 'https://data-sources.pdap.io'; }); describe('SearchResultPage renders with data', () => { diff --git a/client/src/router.js b/client/src/router.js index d4487660..0408e0a0 100644 --- a/client/src/router.js +++ b/client/src/router.js @@ -1,7 +1,14 @@ import { createWebHistory, createRouter } from 'vue-router'; +import { useAuthStore } from './stores/auth'; + +import ChangePassword from './pages/ChangePassword.vue'; +import DataSourceStaticView from '../src/pages/DataSourceStaticView.vue'; +import LogIn from './pages/LogIn.vue'; import QuickSearchPage from '../src/pages/QuickSearchPage.vue'; +import ResetPassword from './pages/ResetPassword.vue'; import SearchResultPage from '../src/pages/SearchResultPage.vue'; -import DataSourceStaticView from '../src/pages/DataSourceStaticView.vue'; + +export const PRIVATE_ROUTES = ['/change-password']; const routes = [ { path: '/', component: QuickSearchPage, name: 'QuickSearchPage' }, @@ -15,6 +22,21 @@ const routes = [ component: DataSourceStaticView, name: 'DataSourceStaticView', }, + { + path: '/login', + component: LogIn, + name: 'LogIn', + }, + { + path: '/change-password', + component: ChangePassword, + name: 'ChangePassword', + }, + { + path: '/reset-password/:token?', + component: ResetPassword, + name: 'ResetPassword', + }, ]; const router = createRouter({ @@ -22,4 +44,14 @@ const router = createRouter({ routes, }); +router.beforeEach(async (to) => { + // redirect to login page if not logged in and trying to access a restricted page + const auth = useAuthStore(); + + if (PRIVATE_ROUTES.includes(to.fullPath) && !auth.userId) { + auth.returnUrl = to.path; + router.push('/login'); + } +}); + export default router; diff --git a/client/src/stores/auth.js b/client/src/stores/auth.js new file mode 100644 index 00000000..8a022732 --- /dev/null +++ b/client/src/stores/auth.js @@ -0,0 +1,86 @@ +import axios from 'axios'; +import { defineStore } from 'pinia'; +import parseJwt from '../util/parseJwt'; +import router from '../router'; +import { useUserStore } from './user'; + +const HEADERS = { + headers: { 'Content-Type': 'application/json' }, +}; +const LOGIN_URL = `${import.meta.env.VITE_VUE_API_BASE_URL}/login`; +const REFRESH_SESSION_URL = `${import.meta.env.VITE_VUE_API_BASE_URL}/refresh-session`; + +export const useAuthStore = defineStore('auth', { + state: () => ({ + userId: null, + accessToken: { + value: null, + expires: Date.now(), + }, + returnUrl: null, + }), + persist: true, + actions: { + async login(email, password) { + const user = useUserStore(); + + try { + const response = await axios.post( + LOGIN_URL, + { email, password }, + HEADERS, + ); + + // Update user store with email + user.$patch({ email }); + + this.parseTokenAndSetData(response); + if (this.returnUrl) router.push(this.returnUrl); + } catch (error) { + throw new Error(error.response?.data?.message); + } + }, + + logout(isAuthRoute) { + const user = useUserStore(); + + this.$patch({ + userId: null, + accessToken: { value: null, expires: Date.now() }, + returnUrl: null, + }); + + user.$patch({ + email: '', + }); + if (isAuthRoute) router.push('/login'); + }, + + async refreshAccessToken() { + if (!this.$state.userId) return; + try { + const response = await axios.post( + REFRESH_SESSION_URL, + { session_token: this.$state.accessToken.value }, + HEADERS, + ); + return this.parseTokenAndSetData(response); + } catch (error) { + throw new Error(error.response?.data?.message); + } + }, + + parseTokenAndSetData(response) { + const token = response.data.data; + const tokenParsed = parseJwt(token); + + this.$patch({ + userId: tokenParsed.sub, + accessToken: { + value: token, + expires: new Date(tokenParsed.exp * 1000).getTime(), + }, + }); + }, + }, +}); diff --git a/client/src/stores/user.js b/client/src/stores/user.js new file mode 100644 index 00000000..1fdabdc9 --- /dev/null +++ b/client/src/stores/user.js @@ -0,0 +1,68 @@ +import axios from 'axios'; +import { defineStore } from 'pinia'; +import { useAuthStore } from './auth'; + +const HEADERS = { + headers: { 'Content-Type': 'application/json' }, +}; +const SIGNUP_URL = `${import.meta.env.VITE_VUE_API_BASE_URL}/user`; +const CHANGE_PASSWORD_URL = `${import.meta.env.VITE_VUE_API_BASE_URL}/user`; +const REQUEST_PASSWORD_RESET_URL = `${import.meta.env.VITE_VUE_API_BASE_URL}/request-reset-password`; +const PASSWORD_RESET_URL = `${import.meta.env.VITE_VUE_API_BASE_URL}/reset-password`; + +export const useUserStore = defineStore('user', { + state: () => ({ + email: '', + }), + persist: true, + actions: { + async signup(email, password) { + const auth = useAuthStore(); + + try { + await axios.post(SIGNUP_URL, { email, password }, HEADERS); + // Update store with email + this.$patch({ email }); + // Log users in after signup and return that response + return await auth.login(email, password); + } catch (error) { + throw new Error(error.response?.data?.message); + } + }, + + async changePassword(email, password) { + const auth = useAuthStore(); + try { + await axios.put( + CHANGE_PASSWORD_URL, + { email, password }, + { + headers: { + ...HEADERS.headers, + Authorization: `Bearer ${auth.accessToken.value}`, + }, + }, + ); + return await auth.login(email, password); + } catch (error) { + throw new Error(error.response?.data?.message); + } + }, + + async requestPasswordReset(email) { + try { + await axios.post(REQUEST_PASSWORD_RESET_URL, { email }, HEADERS); + } catch (error) { + throw new Error(error.response?.data?.message); + } + }, + + async resetPassword(password, token) { + try { + await axios.post(`${PASSWORD_RESET_URL}`, { password, token }, HEADERS); + } catch (error) { + throw new Error(error.response?.data?.message); + } + }, + }, +}); diff --git a/client/src/util/__tests__/parseJwt.test.js b/client/src/util/__tests__/parseJwt.test.js new file mode 100644 index 00000000..5c47829e --- /dev/null +++ b/client/src/util/__tests__/parseJwt.test.js @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import parseJwt from '../parseJwt'; + +const token = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MDg3MTc3NjAsImlhdCI6MTcwODcxNzQ2MCwic3ViIjo2OX0.vX3JKqlUb-L_IWEYyG7R0zlMnY-kj5py5XsUviyAJN4'; + +describe('parseJwt', () => { + it('should parse a valid Jwt', () => { + expect(parseJwt(token)).toEqual({ + exp: 1708717760, + iat: 1708717460, + sub: 69, + }); + }); +}); diff --git a/client/src/util/parseJwt.js b/client/src/util/parseJwt.js new file mode 100644 index 00000000..afe44dd3 --- /dev/null +++ b/client/src/util/parseJwt.js @@ -0,0 +1,10 @@ +import { jwtDecode } from 'jwt-decode'; + +/** + * Util for parsing JSON Web Tokens + * @param {string} token JWT to be decoded + * @returns {{ expiration: number, iat: number, sub: string }} Decoded JWT + */ +export default function parseJwt(token) { + return jwtDecode(token); +} diff --git a/do_db_ddl_clean.sql b/do_db_ddl_clean.sql index 714215ce..2a7c4b03 100644 --- a/do_db_ddl_clean.sql +++ b/do_db_ddl_clean.sql @@ -135,12 +135,20 @@ CREATE TABLE if not exists state_names ( ); CREATE TABLE if not exists users ( - id bigint NOT NULL, + id serial primary key, created_at timestamp with time zone, updated_at timestamp with time zone, email text NOT NULL, password_digest text, - api_key character varying + api_key character varying, + role text +); + +CREATE TABLE if not exists reset_tokens ( + id serial primary key, + email text NOT NULL, + token text varying NOT NULL, + create_date timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE if not exists volunteers ( @@ -158,9 +166,18 @@ CREATE TABLE if not exists volunteers ( created timestamp without time zone NOT NULL ); +CREATE TABLE if not exists session_tokens ( + id serial primary key, + token text NOT NULL, + email text NOT NULL, + expiration_date timestamp with time zone NOT NULL +); + INSERT INTO agency_source_link (link_id, airtable_uid, agency_described_linked_uid) VALUES (1, 'rec00T2YLS2jU7Tbn', 'recv9fMNEQTbVarj2'); INSERT INTO agency_source_link (link_id, airtable_uid, agency_described_linked_uid) VALUES (2, 'rec8zJuEOvhAZCfAD', 'recxUlLdt3Wwov6P1'); INSERT INTO agency_source_link (link_id, airtable_uid, agency_described_linked_uid) VALUES (3, 'recUGIoPQbJ6laBmr', 'recv9fMNEQTbVarj2'); INSERT INTO agency_source_link (link_id, airtable_uid, agency_described_linked_uid) VALUES (4, 'rec8gO2K86yk9mQIU', 'recRvBpZqXM8mjddz'); INSERT INTO state_names VALUES (1, 'IL', 'Illinois'); -INSERT INTO state_names VALUES (2, 'PA', 'Pennsylvania'); \ No newline at end of file +INSERT INTO state_names VALUES (2, 'PA', 'Pennsylvania'); +INSERT INTO users (id, email, password_digest) VALUES (1, "test", "test"); +INSERT INTO reset_tokens (id, email, token) VALUES (1, "test", "test"); diff --git a/middleware/data_source_queries.py b/middleware/data_source_queries.py index 3738bde7..40d75765 100644 --- a/middleware/data_source_queries.py +++ b/middleware/data_source_queries.py @@ -164,7 +164,7 @@ def data_sources_results(conn): def data_sources_query(conn={}, test_query_results=[]): - results = data_sources_results(conn, "", "") if conn else test_query_results + results = data_sources_results(conn) if conn else test_query_results data_source_output_columns = DATA_SOURCES_APPROVED_COLUMNS + ["agency_name"] diff --git a/middleware/login_queries.py b/middleware/login_queries.py new file mode 100644 index 00000000..5be52340 --- /dev/null +++ b/middleware/login_queries.py @@ -0,0 +1,60 @@ +import jwt +import os +import datetime + + +def login_results(cursor, email): + cursor.execute( + f"select id, password_digest, api_key from users where email = '{email}'" + ) + results = cursor.fetchall() + if len(results) > 0: + user_data = { + "id": results[0][0], + "password_digest": results[0][1], + "api_key": results[0][2], + } + return user_data + else: + return {"error": "no match"} + + +def is_admin(cursor, email): + cursor.execute(f"select role from users where email = '{email}'") + results = cursor.fetchall() + if len(results) > 0: + role = results[0][0] + if role == "admin": + return True + return False + + else: + return {"error": "no match"} + + +def create_session_token(cursor, id, email): + expiration = datetime.datetime.utcnow() + datetime.timedelta(days=0, seconds=300) + payload = { + "exp": expiration, + "iat": datetime.datetime.utcnow(), + "sub": id, + } + session_token = jwt.encode(payload, os.getenv("SECRET_KEY"), algorithm="HS256") + cursor.execute( + f"insert into session_tokens (token, email, expiration_date) values ('{session_token}', '{email}', '{expiration}')" + ) + + return session_token + + +def token_results(cursor, token): + cursor.execute(f"select id, email from session_tokens where token = '{token}'") + results = cursor.fetchall() + if len(results) > 0: + user_data = { + "id": results[0][0], + "email": results[0][1], + } + return user_data + else: + return {"error": "no match"} diff --git a/middleware/quick_search_query.py b/middleware/quick_search_query.py index 26d42f96..5e29d20f 100644 --- a/middleware/quick_search_query.py +++ b/middleware/quick_search_query.py @@ -80,7 +80,9 @@ def spacy_search_query(cursor, search, location): return results -def quick_search_query(search="", location="", test_query_results=[], conn={}): +def quick_search_query( + search="", location="", test_query_results=[], conn={}, test=False +): data_sources = {"count": 0, "data": []} if type(conn) == dict and "data" in conn: return data_sources @@ -122,7 +124,7 @@ def quick_search_query(search="", location="", test_query_results=[], conn={}): "data": data_source_matches_converted, } - if not test_query_results: + if not test_query_results and not test: current_datetime = datetime.datetime.now() datetime_string = current_datetime.strftime("%Y-%m-%d %H:%M:%S") diff --git a/middleware/reset_token_queries.py b/middleware/reset_token_queries.py new file mode 100644 index 00000000..dc4d934e --- /dev/null +++ b/middleware/reset_token_queries.py @@ -0,0 +1,30 @@ +def check_reset_token(cursor, token): + cursor.execute( + f"select id, create_date, email from reset_tokens where token = '{token}'" + ) + results = cursor.fetchall() + if len(results) > 0: + user_data = { + "id": results[0][0], + "create_date": results[0][1], + "email": results[0][2], + } + return user_data + else: + return {"error": "no match"} + + +def add_reset_token(cursor, email, token): + cursor.execute( + f"insert into reset_tokens (email, token) values ('{email}', '{token}')" + ) + + return + + +def delete_reset_token(cursor, email, token): + cursor.execute( + f"delete from reset_tokens where email = '{email}' and token = '{token}'" + ) + + return diff --git a/middleware/security.py b/middleware/security.py index 6734c90b..c7b32173 100644 --- a/middleware/security.py +++ b/middleware/security.py @@ -3,38 +3,70 @@ from flask import request, jsonify from middleware.initialize_psycopg2_connection import initialize_psycopg2_connection from datetime import datetime as dt +from middleware.login_queries import is_admin import os -def is_valid(api_key): +def is_valid(api_key, endpoint, method): + """ + Get the user data that matches the API key from the request + """ + if not api_key: + return False, False + psycopg2_connection = initialize_psycopg2_connection() - # Get the user data that matches the API key from the request cursor = psycopg2_connection.cursor() - cursor.execute(f"select id, api_key from users where api_key = '{api_key}'") + cursor.execute(f"select id, api_key, role from users where api_key = '{api_key}'") results = cursor.fetchall() - user_data = {} + if len(results) > 0: + role = results[0][2] + if not results: cursor.execute( - f"delete from access_tokens where expiration_date < '{dt.now()}'" + f"select email, expiration_date from session_tokens where token = '{api_key}'" ) - psycopg2_connection.commit() + results = cursor.fetchall() + if len(results) > 0: + email = results[0][0] + expiration_date = results[0][1] + print(expiration_date, dt.utcnow()) + + if expiration_date < dt.utcnow(): + return False, True + + if is_admin(cursor, email): + role = "admin" + + if not results: cursor.execute(f"select id, token from access_tokens where token = '{api_key}'") results = cursor.fetchall() + cursor.execute( + f"delete from access_tokens where expiration_date < '{dt.utcnow()}'" + ) + psycopg2_connection.commit() + role = "user" if not results: - return False + return False, False + + if endpoint in ("datasources", "datasourcebyid") and method in ("PUT", "POST"): + if role != "admin": + return False, False - user_data = dict(zip(("id", "api_key"), results[0])) - # Compare the API key in the user table to the API in the request header and proceed through the protected route if it's valid. Otherwise, compare_digest will return False and api_required will send an error message to provide a valid API key - if compare_digest(user_data.get("api_key"), api_key): - return True + # Compare the API key in the user table to the API in the request header and proceed + # through the protected route if it's valid. Otherwise, compare_digest will return False + # and api_required will send an error message to provide a valid API key + return True, False -# The api_required decorator can be added to protect a route so that only authenticated users can access the information -# To protect a route with this decorator, add @api_required on the line above a given route -# The request header for a protected route must include an "Authorization" key with the value formatted as "Bearer [api_key]" -# A user can get an API key by signing up and logging in (see User.py) def api_required(func): + """ + The api_required decorator can be added to protect a route so that only authenticated users can access the information + To protect a route with this decorator, add @api_required on the line above a given route + The request header for a protected route must include an "Authorization" key with the value formatted as "Bearer [api_key]" + A user can get an API key by signing up and logging in (see User.py) + """ + @functools.wraps(func) def decorator(*args, **kwargs): api_key = None @@ -53,9 +85,12 @@ def decorator(*args, **kwargs): "message": "Please provide an 'Authorization' key in the request header" }, 400 # Check if API key is correct and valid - if is_valid(api_key): + valid, expired = is_valid(api_key, request.endpoint, request.method) + if valid: return func(*args, **kwargs) else: + if expired: + return {"message": "The provided API key has expired"}, 401 return {"message": "The provided API key is not valid"}, 403 return decorator diff --git a/middleware/user_queries.py b/middleware/user_queries.py new file mode 100644 index 00000000..ab9753a4 --- /dev/null +++ b/middleware/user_queries.py @@ -0,0 +1,20 @@ +from werkzeug.security import generate_password_hash + + +def user_check_email(cursor, email): + cursor.execute(f"select id from users where email = '{email}'") + results = cursor.fetchall() + if len(results) > 0: + user_data = {"id": results[0][0]} + return user_data + else: + return {"error": "no match"} + + +def user_post_results(cursor, email, password): + password_digest = generate_password_hash(password) + cursor.execute( + f"insert into users (email, password_digest) values ('{email}', '{password_digest}')" + ) + + return diff --git a/regular_api_checks.py b/regular_api_checks.py index aaf049d1..6f28cee2 100644 --- a/regular_api_checks.py +++ b/regular_api_checks.py @@ -3,15 +3,17 @@ import json import requests -api_key = os.getenv("VUE_APP_PDAP_API_KEY") -HEADERS = {"Authorization": f"Bearer {api_key}"} +API_KEY = os.getenv("VUE_APP_PDAP_API_KEY") +BASE_URL = os.getenv("VITE_VUE_API_BASE_URL") +HEADERS = {"Authorization": f"Bearer {API_KEY}"} # quick-search def test_quicksearch_officer_involved_shootings_philadelphia_results(): response = requests.get( - "https://data-sources.pdap.io/api/quick-search/Officer Involved Shootings/philadelphia", + f"{BASE_URL}/quick-search/Officer Involved Shootings/philadelphia", headers=HEADERS, + json={"test_flag": True}, ) return len(response.json()["data"]) > 0 @@ -19,8 +21,9 @@ def test_quicksearch_officer_involved_shootings_philadelphia_results(): def test_quicksearch_officer_involved_shootings_lowercase_philadelphia_results(): response = requests.get( - "https://data-sources.pdap.io/api/quick-search/officer involved shootings/Philadelphia", + f"{BASE_URL}/quick-search/officer involved shootings/Philadelphia", headers=HEADERS, + json={"test_flag": True}, ) return len(response.json()["data"]) > 0 @@ -28,8 +31,9 @@ def test_quicksearch_officer_involved_shootings_lowercase_philadelphia_results() def test_quicksearch_officer_involved_shootings_philadelphia_county_results(): response = requests.get( - "https://data-sources.pdap.io/api/quick-search/Officer Involved Shootings/philadelphia county", + f"{BASE_URL}/quick-search/Officer Involved Shootings/philadelphia county", headers=HEADERS, + json={"test_flag": True}, ) return len(response.json()["data"]) > 0 @@ -37,7 +41,9 @@ def test_quicksearch_officer_involved_shootings_philadelphia_county_results(): def test_quicksearch_all_allgeheny_results(): response = requests.get( - "https://data-sources.pdap.io/api/quick-search/all/allegheny", headers=HEADERS + f"{BASE_URL}/quick-search/all/allegheny", + headers=HEADERS, + json={"test_flag": True}, ) return len(response.json()["data"]) > 0 @@ -45,7 +51,9 @@ def test_quicksearch_all_allgeheny_results(): def test_quicksearch_complaints_all_results(): response = requests.get( - "https://data-sources.pdap.io/api/quick-search/complaints/all", headers=HEADERS + f"{BASE_URL}/quick-search/complaints/all", + headers=HEADERS, + json={"test_flag": True}, ) return len(response.json()["data"]) > 0 @@ -53,8 +61,9 @@ def test_quicksearch_complaints_all_results(): def test_quicksearch_media_bulletin_pennsylvania_results(): response = requests.get( - "https://data-sources.pdap.io/api/quick-search/media bulletin/pennsylvania", + f"{BASE_URL}/quick-search/media bulletin/pennsylvania", headers=HEADERS, + json={"test_flag": True}, ) return len(response.json()["data"]) > 0 @@ -63,24 +72,24 @@ def test_quicksearch_media_bulletin_pennsylvania_results(): # data-sources def test_data_source_by_id(): response = requests.get( - "https://data-sources.pdap.io/api/data-sources-by-id/reczwxaH31Wf9gRjS", + f"{BASE_URL}/data-sources-by-id/reczwxaH31Wf9gRjS", headers=HEADERS, ) - return response.json()["data_source_id"] == "reczwxaH31Wf9gRjS" + return len(response.json()["data"]) > 0 def test_data_sources(): - response = requests.get( - "https://data-sources.pdap.io/api/data-sources", headers=HEADERS - ) + response = requests.get(f"{BASE_URL}/data-sources", headers=HEADERS) return len(response.json()["data"]) > 0 def test_create_data_source(): response = requests.post( - "/data-sources", headers=HEADERS, json={"name": "test", "record_type": "test"} + f"{BASE_URL}/data-sources", + headers=HEADERS, + json={"name": "test", "record_type": "test"}, ) assert response.json() == True @@ -88,18 +97,16 @@ def test_create_data_source(): def test_update_data_source(): response = requests.put( - "/data-sources-by-id/45a4cd5d-26da-473a-a98e-a39fbcf4a96c", + f"{BASE_URL}/data-sources-by-id/45a4cd5d-26da-473a-a98e-a39fbcf4a96c", headers=HEADERS, json={"description": "test"}, ) - assert response.json()["status"] == "success" + assert response.json()["message"] == "Data source updated successfully." def test_data_sources_approved(): - response = requests.get( - "https://data-sources.pdap.io/api/data-sources", headers=HEADERS - ) + response = requests.get(f"{BASE_URL}/data-sources", headers=HEADERS) unapproved_url = "https://joinstatepolice.ny.gov/15-mile-run" return ( @@ -110,7 +117,7 @@ def test_data_sources_approved(): def test_data_source_by_id_approved(): response = requests.get( - "https://data-sources.pdap.io/api/data-sources-by-id/rec013MFNfBnrTpZj", + f"{BASE_URL}/data-sources-by-id/rec013MFNfBnrTpZj", headers=HEADERS, ) @@ -119,16 +126,14 @@ def test_data_source_by_id_approved(): # search-tokens def test_search_tokens_data_sources(): - response = requests.get( - "https://data-sources.pdap.io/api/search-tokens?endpoint=data-sources" - ) + response = requests.get(f"{BASE_URL}/search-tokens?endpoint=data-sources") return len(response.json()["data"]) > 0 def test_search_tokens_data_source_by_id(): response = requests.get( - "https://data-sources.pdap.io/api/search-tokens?endpoint=data-sources-by-id&arg1=reczwxaH31Wf9gRjS" + f"{BASE_URL}/search-tokens?endpoint=data-sources-by-id&arg1=reczwxaH31Wf9gRjS" ) return response.json()["data_source_id"] == "reczwxaH31Wf9gRjS" @@ -136,28 +141,79 @@ def test_search_tokens_data_source_by_id(): def test_search_tokens_quick_search_complaints_allegheny_results(): response = requests.get( - "https://data-sources.pdap.io/api/search-tokens?endpoint=quick-search&arg1=complaints&arg2=allegheny" + f"{BASE_URL}/search-tokens?endpoint=quick-search&arg1=complaints&arg2=allegheny" ) return len(response.json()["data"]) > 0 # user -def test_get_user(): +def test_put_user(): + response = requests.put( + f"{BASE_URL}/user", + headers=HEADERS, + json={"email": "test2", "password": "test"}, + ) + + return response.json()["message"] == "Successfully updated password" + + +# login +def test_login(): + response = requests.post( + f"{BASE_URL}/login", + json={"email": "test2", "password": "test"}, + ) + + return response.json()["message"] == "Successfully logged in" + + +# refresh-session +def test_refresh_session(): + response = requests.post( + f"{BASE_URL}/login", + json={"email": "test2", "password": "test"}, + ) + token = response.json()["data"] + + response = requests.post( + f"{BASE_URL}/refresh-session", json={"session_token": token} + ) + + return response.json()["message"] == "Successfully refreshed session token" + + +# reset-password +def test_request_reset_password(): + reset_token = requests.post( + f"{BASE_URL}/request-reset-password", + headers=HEADERS, + json={"email": "test"}, + ) + + response = requests.post( + f"{BASE_URL}/reset-password", + headers=HEADERS, + json={"token": reset_token.json()["token"], "password": "test"}, + ) + + return response.json()["message"] == "Successfully updated password" + + +# api-key +def test_get_api_key(): response = requests.get( - "https://data-sources.pdap.io/api/user", + f"{BASE_URL}/api_key", headers=HEADERS, json={"email": "test2", "password": "test"}, ) - return response + return len(response.json()["api_key"]) > 0 # archives def test_get_archives(): - response = requests.get( - "https://data-sources.pdap.io/api/archives", headers=HEADERS - ) + response = requests.get(f"{BASE_URL}/archives", headers=HEADERS) return len(response.json()[0]) > 0 @@ -166,7 +222,7 @@ def test_put_archives(): current_datetime = datetime.datetime.now() datetime_string = current_datetime.strftime("%Y-%m-%d %H:%M:%S") response = requests.put( - "https://data-sources.pdap.io/api/archives", + f"{BASE_URL}/archives", headers=HEADERS, json=json.dumps( { @@ -184,7 +240,7 @@ def test_put_archives_brokenasof(): current_datetime = datetime.datetime.now() datetime_string = current_datetime.strftime("%Y-%m-%d") response = requests.put( - "https://data-sources.pdap.io/api/archives", + f"{BASE_URL}/archives", headers=HEADERS, json=json.dumps( { @@ -200,20 +256,14 @@ def test_put_archives_brokenasof(): # agencies def test_agencies(): - response = requests.get( - "https://data-sources.pdap.io/api/agencies/1", headers=HEADERS - ) + response = requests.get(f"{BASE_URL}/agencies/1", headers=HEADERS) return len(response.json()["data"]) > 0 def test_agencies_pagination(): - response1 = requests.get( - "https://data-sources.pdap.io/api/agencies/1", headers=HEADERS - ) - response2 = requests.get( - "https://data-sources.pdap.io/api/agencies/2", headers=HEADERS - ) + response1 = requests.get(f"{BASE_URL}/agencies/1", headers=HEADERS) + response2 = requests.get(f"{BASE_URL}/agencies/2", headers=HEADERS) return response1 != response2 @@ -228,12 +278,16 @@ def main(): "test_quicksearch_media_bulletin_pennsylvania_results", "test_data_source_by_id", "test_data_sources", + "test_update_data_source", "test_data_sources_approved", "test_data_source_by_id_approved", "test_search_tokens_data_sources", "test_search_tokens_data_source_by_id", "test_search_tokens_quick_search_complaints_allegheny_results", - # "test_get_user", + "test_put_user", + "test_login", + "test_request_reset_password", + "test_get_api_key", "test_get_archives", "test_put_archives", "test_put_archives_brokenasof", diff --git a/resources/ApiKey.py b/resources/ApiKey.py index ccec6b6d..77081f5f 100644 --- a/resources/ApiKey.py +++ b/resources/ApiKey.py @@ -1,7 +1,7 @@ from werkzeug.security import check_password_hash from flask_restful import Resource from flask import request -from middleware.user_queries import user_get_results +from middleware.login_queries import login_results import uuid @@ -18,7 +18,7 @@ def get(self): email = data.get("email") password = data.get("password") cursor = self.psycopg2_connection.cursor() - user_data = user_get_results(cursor, email) + user_data = login_results(cursor, email) if check_password_hash(user_data["password_digest"], password): api_key = uuid.uuid4().hex @@ -26,14 +26,11 @@ def get(self): cursor.execute( "UPDATE users SET api_key = %s WHERE id = %s", (api_key, user_id) ) - payload = { - "message": "API key successfully created", - "api_key": api_key, - } + payload = {"api_key": api_key} self.psycopg2_connection.commit() return payload except Exception as e: self.psycopg2_connection.rollback() print(str(e)) - return {"message": str(e)}, 500 + return {"message": str(e)} diff --git a/resources/DataSources.py b/resources/DataSources.py index 7d9e2f78..70c5f80a 100644 --- a/resources/DataSources.py +++ b/resources/DataSources.py @@ -19,7 +19,10 @@ def get(self, data_source_id): conn=self.psycopg2_connection, data_source_id=data_source_id ) if data_source_details: - return data_source_details + return { + "message": "Successfully found data source", + "data": data_source_details, + } else: return {"message": "Data source not found."}, 404 @@ -60,8 +63,6 @@ def put(self, data_source_id): WHERE airtable_uid = '{data_source_id}' """ - print(sql_query) - cursor.execute(sql_query) self.psycopg2_connection.commit() return {"message": "Data source updated successfully."} diff --git a/resources/Login.py b/resources/Login.py new file mode 100644 index 00000000..af2715c8 --- /dev/null +++ b/resources/Login.py @@ -0,0 +1,40 @@ +from werkzeug.security import check_password_hash +from flask_restful import Resource +from flask import request +from middleware.login_queries import login_results, create_session_token + + +class Login(Resource): + def __init__(self, **kwargs): + self.psycopg2_connection = kwargs["psycopg2_connection"] + + def post(self): + """ + Login function: allows a user to login using their email and password as credentials + The password is compared to the hashed password stored in the users table + Once the password is verified, an API key is generated, which is stored in the users table and sent to the verified user + """ + try: + data = request.get_json() + email = data.get("email") + password = data.get("password") + cursor = self.psycopg2_connection.cursor() + + user_data = login_results(cursor, email) + + if "password_digest" in user_data and check_password_hash( + user_data["password_digest"], password + ): + token = create_session_token(cursor, user_data["id"], email) + self.psycopg2_connection.commit() + return { + "message": "Successfully logged in", + "data": token, + } + + return {"message": "Invalid email or password"}, 401 + + except Exception as e: + self.psycopg2_connection.rollback() + print(str(e)) + return {"message": str(e)}, 500 diff --git a/resources/QuickSearch.py b/resources/QuickSearch.py index a782b3b6..645cbd15 100644 --- a/resources/QuickSearch.py +++ b/resources/QuickSearch.py @@ -5,6 +5,7 @@ import json import os from middleware.initialize_psycopg2_connection import initialize_psycopg2_connection +from flask import request class QuickSearch(Resource): @@ -15,9 +16,15 @@ def __init__(self, **kwargs): # A user can get an API key by signing up and logging in (see User.py) @api_required def get(self, search, location): + try: + data = request.get_json() + test = data.get("test_flag") + except: + test = False + try: data_sources = quick_search_query( - search, location, [], self.psycopg2_connection + search, location, [], self.psycopg2_connection, test ) if data_sources["count"] == 0: @@ -29,10 +36,13 @@ def get(self, search, location): if data_sources["count"] == 0: return { "count": 0, - message: "No results found. Please considering requesting a new data source.", + "message": "No results found. Please considering requesting a new data source.", }, 404 - return data_sources + return { + "message": "Results for search successfully retrieved", + "data": data_sources, + } except Exception as e: self.psycopg2_connection.rollback() diff --git a/resources/RefreshSession.py b/resources/RefreshSession.py new file mode 100644 index 00000000..b13a82f4 --- /dev/null +++ b/resources/RefreshSession.py @@ -0,0 +1,42 @@ +from flask_restful import Resource +from flask import request +from middleware.login_queries import token_results, create_session_token +from datetime import datetime as dt + + +class RefreshSession(Resource): + def __init__(self, **kwargs): + self.psycopg2_connection = kwargs["psycopg2_connection"] + + def post(self): + """ + Login function: allows a user to login using their email and password as credentials + The password is compared to the hashed password stored in the users table + Once the password is verified, an API key is generated, which is stored in the users table and sent to the verified user + """ + try: + data = request.get_json() + old_token = data.get("session_token") + cursor = self.psycopg2_connection.cursor() + user_data = token_results(cursor, old_token) + cursor.execute( + f"delete from session_tokens where token = '{old_token}' and expiration_date < '{dt.utcnow()}'" + ) + self.psycopg2_connection.commit() + + if "id" in user_data: + token = create_session_token( + cursor, user_data["id"], user_data["email"] + ) + self.psycopg2_connection.commit() + return { + "message": "Successfully refreshed session token", + "data": token, + } + + return {"message": "Invalid session token"}, 403 + + except Exception as e: + self.psycopg2_connection.rollback() + print(str(e)) + return {"message": str(e)}, 500 diff --git a/resources/RequestResetPassword.py b/resources/RequestResetPassword.py new file mode 100644 index 00000000..d411a5de --- /dev/null +++ b/resources/RequestResetPassword.py @@ -0,0 +1,46 @@ +from werkzeug.security import generate_password_hash, check_password_hash +from flask_restful import Resource +from flask import request +from middleware.user_queries import user_check_email +from middleware.reset_token_queries import add_reset_token +import os +import uuid +import requests + + +class RequestResetPassword(Resource): + def __init__(self, **kwargs): + self.psycopg2_connection = kwargs["psycopg2_connection"] + + def post(self): + try: + data = request.get_json() + email = data.get("email") + cursor = self.psycopg2_connection.cursor() + user_data = user_check_email(cursor, email) + id = user_data["id"] + token = uuid.uuid4().hex + add_reset_token(cursor, email, token) + self.psycopg2_connection.commit() + + body = f"To reset your password, click the following link: {os.getenv('VITE_VUE_APP_BASE_URL')}/reset-password/{token}" + r = requests.post( + "https://api.mailgun.net/v3/mail.pdap.io/messages", + auth=("api", os.getenv("MAILGUN_KEY")), + data={ + "from": "mail@pdap.io", + "to": [email], + "subject": "PDAP Data Sources Reset Password", + "text": body, + }, + ) + + return { + "message": "An email has been sent to your email address with a link to reset your password.", + "token": token, + } + + except Exception as e: + self.psycopg2_connection.rollback() + print(str(e)) + return {"error": str(e)}, 500 diff --git a/resources/ResetPassword.py b/resources/ResetPassword.py new file mode 100644 index 00000000..28d6623a --- /dev/null +++ b/resources/ResetPassword.py @@ -0,0 +1,45 @@ +from werkzeug.security import generate_password_hash +from flask_restful import Resource +from flask import request +from middleware.reset_token_queries import ( + check_reset_token, + add_reset_token, + delete_reset_token, +) +from datetime import datetime as dt + + +class ResetPassword(Resource): + def __init__(self, **kwargs): + self.psycopg2_connection = kwargs["psycopg2_connection"] + + def post(self): + try: + data = request.get_json() + token = data.get("token") + password = data.get("password") + cursor = self.psycopg2_connection.cursor() + token_data = check_reset_token(cursor, token) + email = token_data.get("email") + if "create_date" not in token_data: + return {"message": "The submitted token is invalid"}, 400 + + token_create_date = token_data["create_date"] + token_expired = (dt.utcnow() - token_create_date).total_seconds() > 300 + delete_reset_token(cursor, token_data["email"], token) + if token_expired: + return {"message": "The submitted token is invalid"}, 400 + + password_digest = generate_password_hash(password) + cursor = self.psycopg2_connection.cursor() + cursor.execute( + f"update users set password_digest = '{password_digest}' where email = '{email}'" + ) + self.psycopg2_connection.commit() + + return {"message": "Successfully updated password"} + + except Exception as e: + self.psycopg2_connection.rollback() + print(str(e)) + return {"message": str(e)}, 500 diff --git a/resources/SearchTokens.py b/resources/SearchTokens.py index 5ee44390..b65d8c95 100644 --- a/resources/SearchTokens.py +++ b/resources/SearchTokens.py @@ -11,7 +11,7 @@ sys.path.append("..") -BASE_URL = os.getenv("VITE_VUE_APP_BASE_URL") +BASE_URL = os.getenv("VITE_VUE_API_BASE_URL") class SearchTokens(Resource): @@ -32,8 +32,11 @@ def get(self): cursor = self.psycopg2_connection.cursor() token = uuid.uuid4().hex expiration = datetime.datetime.now() + datetime.timedelta(minutes=5) - # cursor.execute(f"insert into access_tokens (token, expiration_date) values (%s, %s)", (token, expiration)) - # self.psycopg2_connection.commit() + cursor.execute( + f"insert into access_tokens (token, expiration_date) values (%s, %s)", + (token, expiration), + ) + self.psycopg2_connection.commit() if endpoint == "quick-search": try: diff --git a/resources/User.py b/resources/User.py index f0a17a0a..d1ee4a51 100644 --- a/resources/User.py +++ b/resources/User.py @@ -1,56 +1,37 @@ -from werkzeug.security import generate_password_hash, check_password_hash +from werkzeug.security import generate_password_hash from flask_restful import Resource -from flask import request, jsonify -import uuid -import os -import jwt +from flask import request +from middleware.user_queries import user_post_results +from middleware.security import api_required class User(Resource): def __init__(self, **kwargs): self.psycopg2_connection = kwargs["psycopg2_connection"] - # Login function: allows a user to login using their email and password as credentials - # The password is compared to the hashed password stored in the users table - # Once the password is verified, an API key is generated, which is stored in the users table and sent to the verified user - def get(self): + def post(self): + """ + Sign up function: allows a user to sign up by submitting an email and password. + The email and a hashed password are stored in the users table and this data is returned to the user upon completion + """ try: data = request.get_json() email = data.get("email") password = data.get("password") cursor = self.psycopg2_connection.cursor() - cursor.execute( - f"select id, password_digest from users where email = '{email}'" - ) - results = cursor.fetchall() - user_data = {} - if len(results) > 0: - user_data = {"id": results[0][0], "password_digest": results[0][1]} - else: - return { - "message": "There username or password is incorrect. Please try again." - }, 400 + user_post_results(cursor, email, password) + self.psycopg2_connection.commit() - if check_password_hash(user_data["password_digest"], password): - api_key = uuid.uuid4().hex - user_id = str(user_data["id"]) - cursor.execute( - "UPDATE users SET api_key = %s WHERE id = %s", (api_key, user_id) - ) - payload = { - "message": "API key successfully created", - "api_key": api_key, - } - self.psycopg2_connection.commit() - return payload + return {"message": "Successfully added user"} except Exception as e: self.psycopg2_connection.rollback() print(str(e)) - return {"message": str(e)}, 500 + return {"message": e}, 500 - # Sign up function: allows a user to sign up by submitting an email and password. The email and a hashed password are stored in the users table and this data is returned to the user upon completion - def post(self): + # Endpoint for updating a user's password + @api_required + def put(self): try: data = request.get_json() email = data.get("email") @@ -58,14 +39,13 @@ def post(self): password_digest = generate_password_hash(password) cursor = self.psycopg2_connection.cursor() cursor.execute( - f"insert into users (email, password_digest) values (%s, %s)", - (email, password_digest), + f"update users set password_digest = '{password_digest}' where email = '{email}'" ) self.psycopg2_connection.commit() - - return {"message": "Successfully added user"} + return {"message": "Successfully updated password"} except Exception as e: self.psycopg2_connection.rollback() print(str(e)) return {"message": e}, 500 + return {"message": e}, 500