diff --git a/package-lock.json b/package-lock.json index c545faec..86230408 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,10 @@ "d3-zoom": "^3.0.0", "dompurify": "^3.0.1", "downsample-lttb-ts": "^0.0.6", + "front-matter": "^4.0.2", "jest-fetch-mock": "^3.0.3", + "js-md5": "^0.8.3", + "kbase-policies": "github:kbase/policies", "leaflet": "^1.9.4", "marked": "^4.2.12", "node-sass": "^9.0.0", @@ -4886,6 +4889,161 @@ "rollup": "^1.20.0 || ^2.0.0" } }, + "node_modules/@rollup/plugin-typescript": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-12.1.1.tgz", + "integrity": "sha512-t7O653DpfB5MbFrqPe/VcKFFkvRuFNp9qId3xq4Eth5xlyymzxNpye2z8Hrl0RIMuXTSr5GGcFpkdlMeacUiFQ==", + "dependencies": { + "@rollup/pluginutils": "^5.1.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.14.0||^3.0.0||^4.0.0", + "tslib": "*", + "typescript": ">=3.7.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + }, + "tslib": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-typescript/node_modules/@rollup/pluginutils": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.3.tgz", + "integrity": "sha512-Pnsb6f32CD2W3uCaLZIzDmeFyQ2b8UWMFI7xtwUezpcGBDVDW6y9XgAWIlARiGAo6eNF5FK5aQTr0LFyNyqq5A==", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-typescript/node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==" + }, + "node_modules/@rollup/plugin-typescript/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "node_modules/@rollup/plugin-typescript/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@rollup/plugin-url": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-url/-/plugin-url-8.0.2.tgz", + "integrity": "sha512-5yW2LP5NBEgkvIRSSEdJkmxe5cUNZKG3eenKtfJvSkxVm/xTTu7w+ayBtNwhozl1ZnTUCU0xFaRQR+cBl2H7TQ==", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "make-dir": "^3.1.0", + "mime": "^3.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-url/node_modules/@rollup/pluginutils": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.3.tgz", + "integrity": "sha512-Pnsb6f32CD2W3uCaLZIzDmeFyQ2b8UWMFI7xtwUezpcGBDVDW6y9XgAWIlARiGAo6eNF5FK5aQTr0LFyNyqq5A==", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-url/node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==" + }, + "node_modules/@rollup/plugin-url/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "node_modules/@rollup/plugin-url/node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@rollup/plugin-url/node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@rollup/plugin-url/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@rollup/pluginutils": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", @@ -4907,6 +5065,222 @@ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==" }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.28.0.tgz", + "integrity": "sha512-wLJuPLT6grGZsy34g4N1yRfYeouklTgPhH1gWXCYspenKYD0s3cR99ZevOGw5BexMNywkbV3UkjADisozBmpPQ==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.28.0.tgz", + "integrity": "sha512-eiNkznlo0dLmVG/6wf+Ifi/v78G4d4QxRhuUl+s8EWZpDewgk7PX3ZyECUXU0Zq/Ca+8nU8cQpNC4Xgn2gFNDA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.28.0.tgz", + "integrity": "sha512-lmKx9yHsppblnLQZOGxdO66gT77bvdBtr/0P+TPOseowE7D9AJoBw8ZDULRasXRWf1Z86/gcOdpBrV6VDUY36Q==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.28.0.tgz", + "integrity": "sha512-8hxgfReVs7k9Js1uAIhS6zq3I+wKQETInnWQtgzt8JfGx51R1N6DRVy3F4o0lQwumbErRz52YqwjfvuwRxGv1w==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.28.0.tgz", + "integrity": "sha512-lA1zZB3bFx5oxu9fYud4+g1mt+lYXCoch0M0V/xhqLoGatbzVse0wlSQ1UYOWKpuSu3gyN4qEc0Dxf/DII1bhQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.28.0.tgz", + "integrity": "sha512-aI2plavbUDjCQB/sRbeUZWX9qp12GfYkYSJOrdYTL/C5D53bsE2/nBPuoiJKoWp5SN78v2Vr8ZPnB+/VbQ2pFA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.28.0.tgz", + "integrity": "sha512-WXveUPKtfqtaNvpf0iOb0M6xC64GzUX/OowbqfiCSXTdi/jLlOmH0Ba94/OkiY2yTGTwteo4/dsHRfh5bDCZ+w==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.28.0.tgz", + "integrity": "sha512-yLc3O2NtOQR67lI79zsSc7lk31xjwcaocvdD1twL64PK1yNaIqCeWI9L5B4MFPAVGEVjH5k1oWSGuYX1Wutxpg==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.28.0.tgz", + "integrity": "sha512-+P9G9hjEpHucHRXqesY+3X9hD2wh0iNnJXX/QhS/J5vTdG6VhNYMxJ2rJkQOxRUd17u5mbMLHM7yWGZdAASfcg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.28.0.tgz", + "integrity": "sha512-1xsm2rCKSTpKzi5/ypT5wfc+4bOGa/9yI/eaOLW0oMs7qpC542APWhl4A37AENGZ6St6GBMWhCCMM6tXgTIplw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.28.0.tgz", + "integrity": "sha512-zgWxMq8neVQeXL+ouSf6S7DoNeo6EPgi1eeqHXVKQxqPy1B2NvTbaOUWPn/7CfMKL7xvhV0/+fq/Z/J69g1WAQ==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.28.0.tgz", + "integrity": "sha512-VEdVYacLniRxbRJLNtzwGt5vwS0ycYshofI7cWAfj7Vg5asqj+pt+Q6x4n+AONSZW/kVm+5nklde0qs2EUwU2g==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.28.0.tgz", + "integrity": "sha512-LQlP5t2hcDJh8HV8RELD9/xlYtEzJkm/aWGsauvdO2ulfl3QYRjqrKW+mGAIWP5kdNCBheqqqYIGElSRCaXfpw==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.28.0.tgz", + "integrity": "sha512-Nl4KIzteVEKE9BdAvYoTkW19pa7LR/RBrT6F1dJCV/3pbjwDcaOq+edkP0LXuJ9kflW/xOK414X78r+K84+msw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.28.0.tgz", + "integrity": "sha512-eKpJr4vBDOi4goT75MvW+0dXcNUqisK4jvibY9vDdlgLx+yekxSm55StsHbxUsRxSTt3JEQvlr3cGDkzcSP8bw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.28.0.tgz", + "integrity": "sha512-Vi+WR62xWGsE/Oj+mD0FNAPY2MEox3cfyG0zLpotZdehPFXwz6lypkGs5y38Jd/NVSbOD02aVad6q6QYF7i8Bg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.28.0.tgz", + "integrity": "sha512-kN/Vpip8emMLn/eOza+4JwqDZBL6MPNpkdaEsgUtW1NYN3DZvZqSQrbKzJcTL6hd8YNmFTn7XGWMwccOcJBL0A==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.28.0.tgz", + "integrity": "sha512-Bvno2/aZT6usSa7lRDL2+hMjVAGjuqaymF1ApZm31JXzniR/hvr14jpU+/z4X6Gt5BPlzosscyJZGUvguXIqeQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rushstack/eslint-patch": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz", @@ -13988,17 +14362,6 @@ "node-int64": "^0.4.0" } }, - "node_modules/buffer": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz", - "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==", - "dev": true, - "dependencies": { - "base64-js": "^1.0.2", - "ieee754": "^1.1.4", - "isarray": "^1.0.0" - } - }, "node_modules/buffer-from": { "version": "1.1.2", "license": "MIT" @@ -14009,12 +14372,6 @@ "integrity": "sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==", "dev": true }, - "node_modules/buffer/node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true - }, "node_modules/builtin-modules": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", @@ -19807,6 +20164,14 @@ "readable-stream": "^2.0.0" } }, + "node_modules/front-matter": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/front-matter/-/front-matter-4.0.2.tgz", + "integrity": "sha512-I8ZuJ/qG92NWX8i5x1Y8qyj3vizhXS31OxjKDu3LKP+7/qBgfIKValiZIEwoVoJKUHlhWtYrktkxV1XsX+pPlg==", + "dependencies": { + "js-yaml": "^3.13.1" + } + }, "node_modules/fs-extra": { "version": "9.1.0", "license": "MIT", @@ -25375,6 +25740,11 @@ "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.6.4.tgz", "integrity": "sha512-pZe//GGmwJndub7ZghVHz7vjb2LgC1m8B07Au3eYqeqv9emhESByMXxaEgkUkEqJe87oBbSniGYoQNIBklc7IQ==" }, + "node_modules/js-md5": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz", + "integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==" + }, "node_modules/js-sdsl": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.2.0.tgz", @@ -25617,6 +25987,71 @@ "node": ">=8" } }, + "node_modules/kbase-policies": { + "version": "1.0.0", + "resolved": "git+ssh://git@github.com/kbase/policies.git#e53d734ca832543f32738c3cab40e809a6847270", + "hasInstallScript": true, + "dependencies": { + "@rollup/plugin-typescript": "^12.1.1", + "@rollup/plugin-url": "^8.0.2", + "rollup": "^4.27.4", + "tslib": "^2.8.1", + "typescript": "^5.7.2" + } + }, + "node_modules/kbase-policies/node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==" + }, + "node_modules/kbase-policies/node_modules/rollup": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.28.0.tgz", + "integrity": "sha512-G9GOrmgWHBma4YfCcX8PjH0qhXSdH8B4HDE2o4/jaxj93S4DPCIDoLcXz99eWMji4hB29UFCEd7B2gwGJDR9cQ==", + "dependencies": { + "@types/estree": "1.0.6" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.28.0", + "@rollup/rollup-android-arm64": "4.28.0", + "@rollup/rollup-darwin-arm64": "4.28.0", + "@rollup/rollup-darwin-x64": "4.28.0", + "@rollup/rollup-freebsd-arm64": "4.28.0", + "@rollup/rollup-freebsd-x64": "4.28.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.28.0", + "@rollup/rollup-linux-arm-musleabihf": "4.28.0", + "@rollup/rollup-linux-arm64-gnu": "4.28.0", + "@rollup/rollup-linux-arm64-musl": "4.28.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.28.0", + "@rollup/rollup-linux-riscv64-gnu": "4.28.0", + "@rollup/rollup-linux-s390x-gnu": "4.28.0", + "@rollup/rollup-linux-x64-gnu": "4.28.0", + "@rollup/rollup-linux-x64-musl": "4.28.0", + "@rollup/rollup-win32-arm64-msvc": "4.28.0", + "@rollup/rollup-win32-ia32-msvc": "4.28.0", + "@rollup/rollup-win32-x64-msvc": "4.28.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/kbase-policies/node_modules/typescript": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", + "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/kdbush": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-3.0.0.tgz", @@ -28013,6 +28448,23 @@ "vm-browserify": "^1.0.1" } }, + "node_modules/node-libs-browser/node_modules/buffer": { + "version": "4.9.2", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz", + "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==", + "dev": true, + "dependencies": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4", + "isarray": "^1.0.0" + } + }, + "node_modules/node-libs-browser/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true + }, "node_modules/node-libs-browser/node_modules/path-browserify": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-0.0.1.tgz", @@ -38389,9 +38841,9 @@ } }, "node_modules/tslib": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz", - "integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==" + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/tsutils": { "version": "3.21.0", diff --git a/package.json b/package.json index 2d3cc508..ab84b34e 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,10 @@ "d3-zoom": "^3.0.0", "dompurify": "^3.0.1", "downsample-lttb-ts": "^0.0.6", + "front-matter": "^4.0.2", "jest-fetch-mock": "^3.0.3", + "js-md5": "^0.8.3", + "kbase-policies": "github:kbase/policies", "leaflet": "^1.9.4", "marked": "^4.2.12", "node-sass": "^9.0.0", diff --git a/src/app/App.module.scss b/src/app/App.module.scss index 1fc115d8..4d5a5181 100644 --- a/src/app/App.module.scss +++ b/src/app/App.module.scss @@ -39,6 +39,7 @@ .left_navbar { background-color: use-color("white"); + border-right: 1px solid use-color("silver"); flex-grow: 0; flex-shrink: 0; max-height: 100%; @@ -53,6 +54,8 @@ flex-shrink: 1; max-height: 100%; overflow-y: auto; + padding-bottom: 1rem; + padding-top: 1rem; position: relative; } } diff --git a/src/app/App.tsx b/src/app/App.tsx index c2679a36..279fa40a 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -60,7 +60,7 @@ const App: FC = () => {
-
+
@@ -68,7 +68,7 @@ const App: FC = () => { -
+ ); diff --git a/src/app/Routes.tsx b/src/app/Routes.tsx index b53d339d..8bde81e3 100644 --- a/src/app/Routes.tsx +++ b/src/app/Routes.tsx @@ -1,5 +1,6 @@ import { FC, ReactElement } from 'react'; import { + createSearchParams, Navigate, Route, Routes as RRRoutes, @@ -27,9 +28,13 @@ import { } from '../common/hooks'; import ORCIDLinkFeature from '../features/orcidlink'; import { LogIn } from '../features/login/LogIn'; +import { LogInContinue } from '../features/login/LogInContinue'; +import { LoggedOut } from '../features/login/LoggedOut'; +import { SignUp } from '../features/signup/SignUp'; import ORCIDLinkCreateLink from '../features/orcidlink/CreateLink'; -export const LOGIN_ROUTE = '/legacy/login'; +export const LOGIN_ROUTE = '/login'; +export const SIGNUP_ROUTE = '/signup'; export const ROOT_REDIRECT_ROUTE = '/narratives'; const Routes: FC = () => { @@ -37,7 +42,9 @@ const Routes: FC = () => { usePageTracking(); return ( + {/* Legacy */} } /> + } /> { {/* Log In */} } /> + } /> + } /> + + {/* Sign Up */} + } /> {/* Navigator */} { export const Authed: FC<{ element: ReactElement }> = ({ element }) => { const token = useAppSelector((state) => state.auth.token); const location = useLocation(); - if (!token) + if (!token) { return ( ); + } return <>{element}; }; diff --git a/src/app/store.ts b/src/app/store.ts index 5c028d8f..82835d55 100644 --- a/src/app/store.ts +++ b/src/app/store.ts @@ -8,6 +8,7 @@ import navigator from '../features/navigator/navigatorSlice'; import orcidlink from '../features/orcidlink/orcidlinkSlice'; import params from '../features/params/paramsSlice'; import profile from '../features/profile/profileSlice'; +import signup from '../features/signup/SignupSlice'; const everyReducer = combineReducers({ auth, @@ -18,6 +19,7 @@ const everyReducer = combineReducers({ params, profile, orcidlink, + signup, [baseApi.reducerPath]: baseApi.reducer, }); diff --git a/src/common/api/authService.ts b/src/common/api/authService.ts index d7c3249a..d41a204e 100644 --- a/src/common/api/authService.ts +++ b/src/common/api/authService.ts @@ -30,12 +30,73 @@ interface AuthParams { search: string; token: string; }; + getLoginChoice: void; + postLoginPick: { id: string; policyids: string[] }; + loginUsernameSuggest: string; + loginCreate: { + id: string; + user: string; + display: string; + email: string; + linkall: false; + policyids: string[]; + }; } interface AuthResults { getMe: Me; getUsers: Record; searchUsers: Record; + getLoginChoice: { + // cancelurl: string; + create: { + availablename: string; + id: string; + provemail: string; + provfullname: string; + provusername: string; + }[]; + // createurl: string; + creationallowed: true; + expires: number; + login: { + adminonly: boolean; + disabled: boolean; + id: string; + loginallowed: true; + policyids: { + agreedon: number; + id: string; + }[]; + provusernames: string[]; + user: string; + }[]; + // pickurl: string; + provider: string; + // redirecturl: string | null; + // suggestnameurl: string; + }; + postLoginPick: { + redirecturl: null | string; + token: { + agent: string; + agentver: string; + created: number; + custom: unknown; + device: unknown; + expires: number; + id: string; + ip: string; + name: unknown; + os: unknown; + osver: unknown; + token: string; + type: string; + user: string; + }; + }; + loginUsernameSuggest: { availablename: string }; + loginCreate: AuthResults['postLoginPick']; } // Auth does not use JSONRpc, so we use queryFn to make custom queries @@ -102,8 +163,72 @@ export const authApi = baseApi.injectEndpoints({ method: 'DELETE', }), }), + getLoginChoice: builder.query< + AuthResults['getLoginChoice'], + AuthParams['getLoginChoice'] + >({ + query: () => + // MUST have an in-process-login-token cookie + authService({ + headers: { + accept: 'application/json', + }, + method: 'GET', + url: '/login/choice', + }), + }), + postLoginPick: builder.mutation< + AuthResults['postLoginPick'], + AuthParams['postLoginPick'] + >({ + query: (pickedChoice) => + authService({ + url: encode`/login/pick`, + body: pickedChoice, + method: 'POST', + }), + }), + loginUsernameSuggest: builder.query< + AuthResults['loginUsernameSuggest'], + AuthParams['loginUsernameSuggest'] + >({ + query: (username) => + // MUST have an in-process-login-token cookie + authService({ + headers: { + accept: 'application/json', + }, + method: 'GET', + url: `/login/suggestname/${encodeURIComponent(username)}`, + }), + }), + loginCreate: builder.mutation< + AuthResults['loginCreate'], + AuthParams['loginCreate'] + >({ + query: (params) => + // MUST have an in-process-login-token cookie + authService({ + headers: { + accept: 'application/json', + }, + method: 'POST', + body: params, + url: `/login/create/`, + }), + }), }), }); -export const { authFromToken, getMe, getUsers, searchUsers, revokeToken } = - authApi.endpoints; +export const { + authFromToken, + getMe, + getUsers, + searchUsers, + revokeToken, + getLoginChoice, + postLoginPick, + loginUsernameSuggest, + loginCreate, +} = authApi.endpoints; +export type GetLoginChoiceResult = AuthResults['getLoginChoice']; diff --git a/src/common/api/userProfileApi.ts b/src/common/api/userProfileApi.ts index a2f8b2cb..4b62bd23 100644 --- a/src/common/api/userProfileApi.ts +++ b/src/common/api/userProfileApi.ts @@ -8,12 +8,15 @@ const userProfile = jsonRpcService({ interface UserProfileParams { status: void; get_user_profile: { usernames: string[] }; - set_user_profile: { + set_user_profile: [ profile: { - user: { username: string; realname: string }; - profile: unknown; - }; - }; + profile: { + user: { username: string; realname: string }; + profile: unknown; + }; + }, + optionalToken: string | undefined + ]; } interface UserProfileResults { @@ -64,13 +67,16 @@ export const userProfileApi = baseApi UserProfileResults['set_user_profile'], UserProfileParams['set_user_profile'] >({ - query: ({ profile }) => + query: ([profile, optionalToken]) => userProfile({ + fetchArgs: optionalToken + ? { headers: { Authorization: optionalToken } } + : {}, method: 'UserProfile.set_user_profile', - params: [{ profile }], + params: [profile], }), // Invalidates the cache for any queries with a matching tag - invalidatesTags: (result, error, { profile }) => [ + invalidatesTags: (result, error, [{ profile }]) => [ { type: 'Profile', id: profile.user.username }, ], }), diff --git a/src/common/api/utils/kbaseBaseQuery.ts b/src/common/api/utils/kbaseBaseQuery.ts index eff973d4..f77fce14 100644 --- a/src/common/api/utils/kbaseBaseQuery.ts +++ b/src/common/api/utils/kbaseBaseQuery.ts @@ -28,7 +28,7 @@ export interface JsonRpcQueryArgs { service: StaticService | DynamicService; method: string; params?: unknown; - fetchArgs?: FetchArgs; + fetchArgs?: Partial; } export interface JSONRPC11Body { diff --git a/src/common/types/auth.ts b/src/common/types/auth.ts index bb25fc85..508377b3 100644 --- a/src/common/types/auth.ts +++ b/src/common/types/auth.ts @@ -8,7 +8,7 @@ export interface Me { idents: Record[]; lastlogin: number; local: boolean; - policyids: Record[]; + policyids: { id: string; agreedon: number }[]; roles: Record[]; user: string; } diff --git a/src/features/collections/Collections.module.scss b/src/features/collections/Collections.module.scss index 3fa010b7..00ac28d7 100644 --- a/src/features/collections/Collections.module.scss +++ b/src/features/collections/Collections.module.scss @@ -3,8 +3,6 @@ $border: 1px solid use-color("base-lighter"); .collections-main { - background-color: use-color("base-lightest"); - border-left: 1px solid use-color("silver"); min-height: 100%; } @@ -22,7 +20,7 @@ $border: 1px solid use-color("base-lighter"); font-size: 1.25rem; font-weight: 500; margin: 0; - padding: 2rem 1rem 1rem; + padding: 1rem; } .collection-card { diff --git a/src/features/layout/TopBar.tsx b/src/features/layout/TopBar.tsx index 49df4584..b5512c79 100644 --- a/src/features/layout/TopBar.tsx +++ b/src/features/layout/TopBar.tsx @@ -20,17 +20,15 @@ import { FontAwesomeIcon as FAIcon } from '@fortawesome/react-fontawesome'; import { FC, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; -import { toast } from 'react-hot-toast'; import { Link } from 'react-router-dom'; -import { resetStateAction } from '../../app/store'; -import { revokeToken } from '../../common/api/authService'; import { getUserProfile } from '../../common/api/userProfileApi'; import logo from '../../common/assets/logo/46_square.png'; import { Dropdown } from '../../common/components'; -import { useAppDispatch, useAppSelector } from '../../common/hooks'; -import { authUsername, setAuth } from '../auth/authSlice'; -import { noOp } from '../common'; +import { useAppSelector } from '../../common/hooks'; +import { authUsername } from '../auth/authSlice'; import classes from './TopBar.module.scss'; +import { LOGIN_ROUTE } from '../../app/Routes'; +import { useLogout } from '../login/LogIn'; export default function TopBar() { const username = useAppSelector(authUsername); @@ -57,7 +55,7 @@ export default function TopBar() { } const LoginPrompt: FC = () => ( - + Sign In @@ -142,30 +140,6 @@ const UserMenu: FC = () => { ); }; -const useLogout = () => { - const tokenId = useAppSelector(({ auth }) => auth.tokenInfo?.id); - const dispatch = useAppDispatch(); - const [revoke] = revokeToken.useMutation(); - const navigate = useNavigate(); - - if (!tokenId) return noOp; - - return () => { - revoke(tokenId) - .unwrap() - .then(() => { - dispatch(resetStateAction()); - // setAuth(null) follow the state reset to initialize the page as un-Authed - dispatch(setAuth(null)); - toast('You have been signed out'); - navigate('/legacy/auth2/signedout'); - }) - .catch(() => { - toast('Error, could not log out.'); - }); - }; -}; - const HamburgerMenu: FC = () => { const navigate = useNavigate(); return ( diff --git a/src/features/legacy/Legacy.tsx b/src/features/legacy/Legacy.tsx index aae94569..030cc3cf 100644 --- a/src/features/legacy/Legacy.tsx +++ b/src/features/legacy/Legacy.tsx @@ -1,11 +1,9 @@ import { RefObject, useEffect, useRef, useState } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; +import { createSearchParams, useLocation, useNavigate } from 'react-router-dom'; import { usePageTitle } from '../layout/layoutSlice'; import { useTryAuthFromToken } from '../auth/hooks'; -import { useAppDispatch } from '../../common/hooks'; -import { resetStateAction } from '../../app/store'; -import { setAuth } from '../auth/authSlice'; -import { toast } from 'react-hot-toast'; +import { LOGIN_ROUTE } from '../../app/Routes'; +import { useLogout } from '../login/LogIn'; export const LEGACY_BASE_ROUTE = '/legacy'; @@ -17,7 +15,7 @@ export default function Legacy() { const location = useLocation(); const navigate = useNavigate(); - const dispatch = useAppDispatch(); + const logout = useLogout(); const legacyContentRef = useRef(null); const [legacyTitle, setLegacyTitle] = useState(''); @@ -43,8 +41,17 @@ export default function Legacy() { let path = d.payload.request.original; if (path[0] === '/') path = path.slice(1); if (legacyPath !== path) { - setLegacyPath(path); - navigate(`./${path}`); + if (path === 'login') { + navigate({ + pathname: LOGIN_ROUTE, + search: createSearchParams({ + nextRequest: JSON.stringify(location), + }).toString(), + }); + } else { + setLegacyPath(path); + navigate(`./${path}`); + } } } else if (isTitleMessage(d)) { setLegacyTitle(d.payload); @@ -53,10 +60,7 @@ export default function Legacy() { setReceivedToken(d.payload.token); } } else if (isLogoutMessage(d)) { - dispatch(resetStateAction()); - dispatch(setAuth(null)); - toast('You have been signed out'); - navigate('/legacy/auth2/signedout'); + logout(); } }); diff --git a/src/features/login/EnforcePolicies.test.tsx b/src/features/login/EnforcePolicies.test.tsx new file mode 100644 index 00000000..e4d2c059 --- /dev/null +++ b/src/features/login/EnforcePolicies.test.tsx @@ -0,0 +1,111 @@ +import { ThemeProvider } from '@emotion/react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { createTestStore } from '../../app/store'; +import { theme } from '../../theme'; +import { noOp } from '../common'; +import { EnforcePolicies } from './EnforcePolicies'; + +jest.mock('./Policies', () => ({ + ...jest.requireActual('./Policies'), + kbasePolicies: { + 'kbase-user': { + raw: '---\ntitle: KBase Terms and Conditions\nid: kbase-user\nversion: 1\nequivalentVersions: []\n---\nsome content', + markdown: 'some content', + title: 'KBase Terms and Conditions', + id: 'kbase-user', + version: '2', + equivalentVersions: [], + }, + 'test-policy': { + raw: '---\ntitle: Test Policy\nid: test-policy\nversion: 1\nequivalentVersions: []\n---\ntest content', + markdown: 'test content', + title: 'Test Policy', + id: 'test-policy', + version: '1', + equivalentVersions: [], + }, + }, +})); + +const renderWithProviders = ( + ui: React.ReactElement, + { store = createTestStore() } = {} +) => { + return render( + + + +
{ui}
+
+
+
+ ); +}; + +describe('EnforcePolicies', () => { + it('renders default message', () => { + renderWithProviders( + + ); + expect( + screen.getByText( + 'To continue to your account, you must agree to the following KBase use policies.' + ) + ).toBeInTheDocument(); + }); + + it('renders special v2 policy message', () => { + renderWithProviders( + + ); + expect( + screen.getByText( + "KBase's recent renewal (Oct '2024) has prompted an update and version 2 release to our Terms and Conditions. Please review and agree to these policies changes to continue using this free resource." + ) + ).toBeInTheDocument(); + }); + + it('disables accept button until all policies are accepted', async () => { + const mockAccept = jest.fn(); + renderWithProviders( + + ); + + const acceptButton = screen.getByRole('button', { + name: /agree and continue/i, + }); + expect(acceptButton).toBeDisabled(); + + const checkbox = screen.getByTestId('policy-checkbox'); + await userEvent.click(checkbox); + + expect(acceptButton).toBeEnabled(); + }); + + it('calls onAccept when accept button clicked', async () => { + const mockAccept = jest.fn(); + renderWithProviders( + + ); + + const checkbox = screen.getByTestId('policy-checkbox'); + await userEvent.click(checkbox); + + const acceptButton = screen.getByRole('button', { + name: /agree and continue/i, + }); + await userEvent.click(acceptButton); + + expect(mockAccept).toHaveBeenCalledWith(['kbase-user.2']); + }); + it('throws error when policy does not exist', () => { + expect(() => + renderWithProviders( + + ) + ).toThrow('Required policy "non-existent-policy" cannot be loaded'); + }); +}); diff --git a/src/features/login/EnforcePolicies.tsx b/src/features/login/EnforcePolicies.tsx new file mode 100644 index 00000000..2c779b88 --- /dev/null +++ b/src/features/login/EnforcePolicies.tsx @@ -0,0 +1,139 @@ +import { faArrowRight } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + Alert, + Button, + Container, + Paper, + Box, + Checkbox, + FormControl, + FormControlLabel, + Typography, +} from '@mui/material'; +import { Stack } from '@mui/system'; +import { useState } from 'react'; +import classes from '../signup/SignUp.module.scss'; +import { kbasePolicies } from './Policies'; +import createDOMPurify from 'dompurify'; +import { marked } from 'marked'; + +export const EnforcePolicies = ({ + policyIds, + onAccept, +}: { + policyIds: string[]; + onAccept: (versionedPolicyIds: string[]) => void; +}) => { + // Get policy information + const targetPolicies = policyIds.map((id) => { + if (!kbasePolicies[id]) + throw new Error(`Required policy "${id}" cannot be loaded`); + return kbasePolicies[id]; + }); + const [accepted, setAccepted] = useState<{ + [k in typeof targetPolicies[number]['id']]?: boolean; + }>({}); + const allAccepted = targetPolicies.every((policy) => { + return accepted[policy.id] === true; + }); + + // Message to user, uses a special message when agreeing to kbase-user.2 + let message = + 'To continue to your account, you must agree to the following KBase use policies.'; // Default message + if ( + targetPolicies.find( + (p) => p.id === 'kbase-user' && String(p.version) === '2' + ) + ) { + message = + "KBase's recent renewal (Oct '2024) has prompted an update and version 2 release to our Terms and Conditions. Please review and agree to these policies changes to continue using this free resource."; + } + + return ( + + + + + {message} + + {targetPolicies.map((policy) => { + return ( + + setAccepted((current) => { + return { ...current, [policy.id]: val }; + }) + } + /> + ); + })} + + + + + + + ); +}; + +const purify = createDOMPurify(window); + +export const PolicyViewer = ({ + policyId, + setAccept, + accepted = false, +}: { + policyId: string; + setAccept: (accepted: boolean) => void; + accepted?: boolean; +}) => { + const policy = kbasePolicies[policyId]; + if (!policy) + throw new Error(`Required policy "${policyId}" cannot be loaded`); + return ( + + {policy.title} + +
+ +
+ + { + setAccept(e.currentTarget.checked); + }} + /> + } + label="I have read and agree to this policy" + /> + +
+ + ); +}; diff --git a/src/features/login/LogIn.test.tsx b/src/features/login/LogIn.test.tsx new file mode 100644 index 00000000..9f9dcd29 --- /dev/null +++ b/src/features/login/LogIn.test.tsx @@ -0,0 +1,180 @@ +import { ThemeProvider } from '@mui/material'; +import { fireEvent, render, within } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { LOGIN_ROUTE } from '../../app/Routes'; +import { createTestStore } from '../../app/store'; +import { useFilteredParams } from '../../common/hooks'; +import { theme } from '../../theme'; +import { LogIn } from './LogIn'; + +describe('Login', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('renders', () => { + const { baseElement } = render( + + + + + } /> + + + + + ); + expect(baseElement).toHaveTextContent('Log in'); + expect(baseElement).toHaveTextContent('New to KBase? Sign up'); + }); + + it('create redirectUrl without nextRequest', () => { + const { baseElement } = render( + + + + + } /> + + + + + ); + const redirectInput = within(baseElement).getByTestId( + 'redirecturl' + ) as HTMLInputElement; + expect(redirectInput.value).toBe( + 'http://localhost/login/continue?state=%7B%22origin%22%3A%22http%3A%2F%2Flocalhost%22%7D' + ); + }); + + it('create redirectUrl with nextRequest', () => { + const loginParams = new URLSearchParams(); + loginParams.set( + 'nextRequest', + JSON.stringify({ + pathname: '/someRedirect', + }) + ); + const RoutesWithParams = () => { + useFilteredParams(); + return ( + + } /> + + ); + }; + const { baseElement } = render( + + + + + + + + ); + const redirectInput = within(baseElement).getByTestId( + 'redirecturl' + ) as HTMLInputElement; + expect(redirectInput.value).toBe( + 'http://localhost/login/continue?state=%7B%22nextRequest%22%3A%22%7B%5C%22pathname%5C%22%3A%5C%22%2FsomeRedirect%5C%22%7D%22%2C%22origin%22%3A%22http%3A%2F%2Flocalhost%22%7D' + ); + }); + + it('Login button click submits form', () => { + const { baseElement } = render( + + + + + } /> + + + + + ); + const form = within(baseElement).getByTestId( + 'loginForm' + ) as HTMLFormElement; + const button = within(baseElement).getByTestId( + 'loginORCID' + ) as HTMLButtonElement; + const submit = jest.fn((e: SubmitEvent) => { + e.preventDefault(); + }); + form.onsubmit = submit; + fireEvent.click(button); + expect(submit).toBeCalled(); + expect(form.action).toBe('http://localhost/services/auth/login/start/'); + }); + + it('redirect if logged in', () => { + const Narratives = jest.fn(() => <>); + render( + + + + + } /> + + + + + + ); + expect(Narratives).toBeCalled(); + }); + + it('redirect if logged in with nextRequest', () => { + const loginParams = new URLSearchParams(); + loginParams.set( + 'nextRequest', + JSON.stringify({ + pathname: '/someRedirect', + }) + ); + const redirectSpy = jest.fn(); + const Redirect = () => { + redirectSpy(); + return <>; + }; + const RoutesWithParams = () => { + useFilteredParams(); + return ( + + } /> + } /> + + ); + }; + render( + + + + + + + + ); + expect(redirectSpy).toBeCalled(); + }); +}); diff --git a/src/features/login/LogIn.tsx b/src/features/login/LogIn.tsx index 5bcab436..a01eba07 100644 --- a/src/features/login/LogIn.tsx +++ b/src/features/login/LogIn.tsx @@ -1,4 +1,5 @@ import { + Alert, Box, Button, Container, @@ -7,105 +8,229 @@ import { Stack, Typography, } from '@mui/material'; -import { FC } from 'react'; +import { FC, useEffect } from 'react'; import logoRectangle from '../../common/assets/logo/rectangle.png'; import orcidLogo from '../../common/assets/orcid.png'; import globusLogo from '../../common/assets/globus.png'; import googleLogo from '../../common/assets/google.webp'; import classes from './LogIn.module.scss'; +import { useAppDispatch, useAppSelector } from '../../common/hooks'; +import { useAppParam } from '../params/hooks'; +import { To, useNavigate } from 'react-router-dom'; +import { resetStateAction } from '../../app/store'; +import { setAuth } from '../auth/authSlice'; +import { toast } from 'react-hot-toast'; +import { revokeToken } from '../../common/api/authService'; +import { noOp } from '../common'; +import { useCookie } from '../../common/cookie'; + +export const useCheckLoggedIn = (nextRequest: string | undefined) => { + const { initialized, token } = useAppSelector((state) => state.auth); + + const navigate = useNavigate(); + useEffect(() => { + if (token && initialized) { + if (nextRequest) { + try { + const next = JSON.parse(nextRequest) as To; + navigate(next); + } catch { + throw TypeError('nextRequest param cannot be parsed'); + } + } else { + navigate('/narratives'); + } + } + }, [initialized, navigate, nextRequest, token]); +}; + +export const useLogout = () => { + const tokenId = useAppSelector(({ auth }) => auth.tokenInfo?.id); + const dispatch = useAppDispatch(); + const [revoke] = revokeToken.useMutation(); + const navigate = useNavigate(); + + const clearNarrativeSession = useCookie('narrative_session')[2]; + + if (!tokenId) return noOp; + + return () => { + revoke(tokenId) + .unwrap() + .then(() => { + dispatch(resetStateAction()); + // setAuth(null) follow the state reset to initialize the page as un-Authed + dispatch(setAuth(null)); + clearNarrativeSession(); + toast('You have been signed out'); + navigate('/loggedout'); + }) + .catch(() => { + toast('Error, could not log out.'); + }); + }; +}; export const LogIn: FC = () => { + const nextRequest = useAppParam('nextRequest'); + useCheckLoggedIn(nextRequest); + const { loginActionUrl, loginRedirectUrl, loginOrigin } = + makeLoginURLs(nextRequest); + return ( - - - KBase circles logo - - - A collaborative, open environment for systems biology of plants, - microbes and their communities. - - - - - Log in - +
+ + + + KBase circles logo + + + A collaborative, open environment for systems biology of plants, + microbes and their communities. + + - + + Log in + + {process.env.NODE_ENV === 'development' ? ( + + DEV MODE: Login will occur on {loginOrigin} + + ) : ( + <> + )} + `Continue with ${provider}`} /> - - - - + Need help logging in? + + - - - New to KBase? Sign up - - - - Need help logging in? - - - - - + + +
); }; + +export const makeLoginURLs = (nextRequest?: string) => { + // OAuth Login wont work in dev mode, but send dev users to CI so they can grab their token + const loginOrigin = + process.env.NODE_ENV === 'development' + ? 'https://ci.kbase.us' + : document.location.origin; + + // Triggering login requires a form POST submission + const loginActionUrl = new URL('/services/auth/login/start/', loginOrigin); + + // Redirect URL is used to pass state to login/continue + const loginRedirectUrl = new URL(`${loginOrigin}/login/continue`); + loginRedirectUrl.searchParams.set( + 'state', + JSON.stringify({ + nextRequest: nextRequest, + origin: loginOrigin, + }) + ); + + return { loginOrigin, loginActionUrl, loginRedirectUrl }; +}; + +export const LoginButtons = ({ + text, +}: { + text: (provider: string) => string; +}) => { + return ( + + + + + + + + + ); +}; diff --git a/src/features/login/LogInContinue.test.tsx b/src/features/login/LogInContinue.test.tsx new file mode 100644 index 00000000..7a09ae38 --- /dev/null +++ b/src/features/login/LogInContinue.test.tsx @@ -0,0 +1,296 @@ +import { ThemeProvider } from '@mui/material'; +import { render, waitFor } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { createTestStore } from '../../app/store'; +import { theme } from '../../theme'; +import { LogInContinue } from './LogInContinue'; +import fetchMock from 'jest-fetch-mock'; +import { toast } from 'react-hot-toast'; +import { noOp } from '../common'; +import { kbasePolicies } from './Policies'; + +jest.mock('react-hot-toast', () => ({ + toast: jest.fn(), +})); + +describe('Login Continue', () => { + beforeEach(() => { + fetchMock.enableMocks(); + fetchMock.resetMocks(); + }); + + it('renders and logs in', async () => { + // getLoginChoice + fetchMock.mockResponseOnce( + JSON.stringify({ + login: [ + { + id: 'foouserid', + policyids: Object.values(kbasePolicies).map((p) => ({ + agreedon: 0, + id: [p.id, p.version].join('.'), + })), + }, + ], + }) + ); + // postLoginPick + fetchMock.mockResponseOnce( + JSON.stringify({ + token: { + token: 'foobartoken', + }, + }) + ); + // authFromToken + fetchMock.mockResponseOnce( + JSON.stringify({ + user: 'someusername', + }) + ); + + const store = createTestStore(); + const Narratives = jest.fn(() => <>); + const { container } = render( + + + + + } /> + + + + + + ); + await waitFor(() => expect(container).toHaveTextContent('Logging in')); + await waitFor(() => { + expect(store.getState().auth.initialized).toBe(true); + expect(store.getState().auth.token).toBe('FOOBARTOKEN'); + expect(store.getState().auth.username).toBe('someusername'); + expect(Narratives).toHaveBeenCalled(); + }); + }); + + it('renders and logs in with redirect (nextRequest)', async () => { + // getLoginChoice + fetchMock.mockResponseOnce( + JSON.stringify({ + login: [ + { + id: 'foouserid', + policyids: Object.values(kbasePolicies).map((p) => ({ + agreedon: 0, + id: [p.id, p.version].join('.'), + })), + }, + ], + }) + ); + // postLoginPick + fetchMock.mockResponseOnce( + JSON.stringify({ + redirecturl: + 'http://localhost/login/continue?state=%7B%22nextRequest%22%3A%22%7B%5C%22pathname%5C%22%3A%5C%22%2FsomeRedirect%5C%22%7D%22%2C%22origin%22%3A%22http%3A%2F%2Flocalhost%22%7D', + token: { + token: 'foobartoken', + }, + }) + ); + // authFromToken + fetchMock.mockResponseOnce( + JSON.stringify({ + user: 'someusername', + }) + ); + + const store = createTestStore(); + const SomeRedirect = jest.fn(() => <>); + const { container } = render( + + + + + } /> + + + + + + ); + await waitFor(() => expect(container).toHaveTextContent('Logging in')); + await waitFor(() => { + expect(store.getState().auth.initialized).toBe(true); + expect(store.getState().auth.token).toBe('FOOBARTOKEN'); + expect(store.getState().auth.username).toBe('someusername'); + expect(SomeRedirect).toHaveBeenCalled(); + }); + }); + + it('getLoginChoice fails gracefully', async () => { + const consoleError = jest.spyOn(console, 'error'); + consoleError.mockImplementation(noOp); + // getLoginChoice + fetchMock.mockResponseOnce('', { status: 500 }); + const Login = jest.fn(() => <>); + const store = createTestStore(); + render( + + + + + } /> + + + + + + ); + await waitFor(() => { + expect(store.getState().auth.initialized).toBe(false); + expect(toast).toHaveBeenCalled(); + expect(consoleError).toHaveBeenCalled(); + expect(Login).toHaveBeenCalled(); + }); + }); + + it('postLoginPick fails gracefully', async () => { + const consoleError = jest.spyOn(console, 'error'); + consoleError.mockImplementation(noOp); + // getLoginChoice + fetchMock.mockResponseOnce( + JSON.stringify({ + login: [ + { + id: 'foouserid', + policyids: Object.values(kbasePolicies).map((p) => ({ + agreedon: 0, + id: [p.id, p.version].join('.'), + })), + }, + ], + }) + ); + // postLoginPick + fetchMock.mockResponseOnce('', { status: 500 }); + + const Login = jest.fn(() => <>); + const store = createTestStore(); + render( + + + + + } /> + + + + + + ); + await waitFor(() => { + expect(store.getState().auth.initialized).toBe(false); + expect(toast).toHaveBeenCalled(); + expect(consoleError).toHaveBeenCalled(); + expect(Login).toHaveBeenCalled(); + }); + }); + + it('authFromToken fails gracefully', async () => { + const consoleError = jest.spyOn(console, 'error'); + consoleError.mockImplementation(noOp); + // getLoginChoice + fetchMock.mockResponseOnce( + JSON.stringify({ + login: [ + { + id: 'foouserid', + policyids: Object.values(kbasePolicies).map((p) => ({ + agreedon: 0, + id: [p.id, p.version].join('.'), + })), + }, + ], + }) + ); + // postLoginPick + fetchMock.mockResponseOnce( + JSON.stringify({ + token: { + token: 'foobartoken', + }, + }) + ); + // authFromToken + fetchMock.mockResponseOnce('', { status: 500 }); + const Login = jest.fn(() => <>); + const store = createTestStore(); + render( + + + + + } /> + + + + + + ); + await waitFor(() => { + expect(store.getState().auth.initialized).toBe(false); + expect(toast).toHaveBeenCalled(); + expect(consoleError).toHaveBeenCalled(); + expect(Login).toHaveBeenCalled(); + }); + }); + + it('handles new user signup flow', async () => { + // getLoginChoice - return create data instead of login data + fetchMock.mockResponseOnce( + JSON.stringify({ + login: [], + create: [ + { + id: 'newuserid', + provider: 'google', + username: 'newuser@google.com', + }, + ], + }) + ); + + const Signup = jest.fn(() => <>); + const store = createTestStore(); + render( + + + + + } /> + + + + + + ); + + await waitFor(() => { + // Check that login data was set in store + expect(store.getState().signup.loginData).toEqual({ + login: [], + create: [ + { + id: 'newuserid', + provider: 'google', + username: 'newuser@google.com', + }, + ], + }); + }); + await waitFor(() => { + expect(window.location.pathname === '/signup/2'); + }); + }); +}); diff --git a/src/features/login/LogInContinue.tsx b/src/features/login/LogInContinue.tsx new file mode 100644 index 00000000..2b58d3e6 --- /dev/null +++ b/src/features/login/LogInContinue.tsx @@ -0,0 +1,176 @@ +import { Container, Paper, Stack, Typography } from '@mui/material'; +import { FC, useEffect, useMemo, useState } from 'react'; +import logoRectangle from '../../common/assets/logo/rectangle.png'; +import classes from './LogIn.module.scss'; +import { Loader } from '../../common/components'; +import { getLoginChoice, postLoginPick } from '../../common/api/authService'; +import { useTryAuthFromToken } from '../auth/hooks'; +import { useCheckLoggedIn } from './LogIn'; +import { toast } from 'react-hot-toast'; +import { useNavigate } from 'react-router-dom'; +import { LOGIN_ROUTE } from '../../app/Routes'; +import { useAppDispatch } from '../../common/hooks'; +import { setLoginData } from '../signup/SignupSlice'; +import { kbasePolicies } from './Policies'; +import { EnforcePolicies } from './EnforcePolicies'; + +export const LogInContinue: FC = () => { + const [triggerPick, pickResult] = postLoginPick.useMutation(); + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + + // Redirect logic is somewhat odd due to how state must be passed with the Auth service. + // Instead of redirecting to redirecturl, we extract the state param and from that, which + // contains the stored nextRequest value. + + let nextRequest: string | undefined = undefined; + const redirecturl = pickResult.data?.redirecturl; + if (redirecturl) { + const stateParam = new URL(redirecturl).searchParams.get('state'); + if (stateParam) { + const stateObj = JSON.parse(stateParam); + if ( + stateObj && + 'nextRequest' in stateObj && + typeof stateObj.nextRequest === 'string' + ) { + nextRequest = stateObj.nextRequest; + } + } + } + + // redirect if/when login is completed + useCheckLoggedIn(nextRequest); + + const choiceResult = getLoginChoice.useQuery(); + const choiceData = choiceResult.currentData; + + const policyids = choiceData?.login[0]?.policyids; + // Check for missing policies + const missingPolicies = useMemo( + () => + Object.values(kbasePolicies).filter((policy) => { + const policyVersionsOk = [ + policy.version, + ...policy.equivalentVersions, + ].map((version) => `${policy.id}.${version}`); + return !policyids?.find((policy) => + policyVersionsOk.find((policyVersion) => policyVersion === policy.id) + ); + }), + [policyids] + ); + const [agreedPolicyIds, setAgreedPolicyIds] = useState([]); + const allNewPolicyAgreed = missingPolicies.every((p) => + agreedPolicyIds.includes([p.id, p.version].join('.')) + ); + + // if/when postLoginPick has a result, update app auth state using that token + const tokenResult = useTryAuthFromToken(pickResult.data?.token.token); + + const accountExists = (choiceData?.login?.length || 0) > 0; + + // wrap choiceData handling in an effect so we only triggerPick the pick call once + useEffect(() => { + if (choiceData) { + if (accountExists) { + if (choiceData.login.length > 1) { + // needs to be implemented if we have multiple KBase accounts linked to one provider account + } else { + if (allNewPolicyAgreed) { + const existingPolicyIds = choiceData.login[0]?.policyids.map( + ({ id }) => id + ); + triggerPick({ + id: choiceData.login[0]?.id, + policyids: [...agreedPolicyIds, ...existingPolicyIds], + }); + } + } + } else if (choiceData.create.length > 0) { + dispatch(setLoginData(choiceData)); + navigate('/signup/2'); + } + } + }, [ + choiceData, + triggerPick, + dispatch, + navigate, + allNewPolicyAgreed, + agreedPolicyIds, + accountExists, + ]); + + useEffect(() => { + // Monitor error state, return to login + if (!pickResult.isError && !choiceResult.isError && !tokenResult.isError) { + return; + } else { + // eslint-disable-next-line no-console + console.error({ + 'login error(s)': { + pick: pickResult.error, + choice: choiceResult.error, + token: tokenResult.error, + }, + }); + toast('An error occured during login, please try again.'); + navigate(LOGIN_ROUTE); + } + }, [ + choiceResult.error, + choiceResult.isError, + navigate, + pickResult.error, + pickResult.isError, + tokenResult.error, + tokenResult.isError, + ]); + + if (!allNewPolicyAgreed && accountExists) { + return ( + p.id)} + onAccept={(accepted) => { + setAgreedPolicyIds([...accepted]); + }} + /> + ); + } + + return ( + + + + KBase circles logo + + + A collaborative, open environment for systems biology of plants, + microbes and their communities. + + + + + Logging in + + + + + + ); +}; diff --git a/src/features/login/LoggedOut.tsx b/src/features/login/LoggedOut.tsx new file mode 100644 index 00000000..203f21ba --- /dev/null +++ b/src/features/login/LoggedOut.tsx @@ -0,0 +1,93 @@ +import { + Box, + Button, + Container, + Paper, + Stack, + Typography, +} from '@mui/material'; +import { useCheckLoggedIn } from './LogIn'; +import orcidLogo from '../../common/assets/orcid.png'; +import globusLogo from '../../common/assets/globus.png'; +import googleLogo from '../../common/assets/google.webp'; +import classes from './LogIn.module.scss'; + +export const LoggedOut = () => { + useCheckLoggedIn(undefined); + + return ( + + + + + + You are signed out of KBase + + + You may still be logged in to your identity provider. If you wish + to ensure that your KBase account is inaccessible from this + browser, you should sign out of any provider accounts you have + used to access KBase. + + + + + + + + + + + + ); +}; diff --git a/src/features/login/Policies.tsx b/src/features/login/Policies.tsx new file mode 100644 index 00000000..07196036 --- /dev/null +++ b/src/features/login/Policies.tsx @@ -0,0 +1,35 @@ +import policyStrings from 'kbase-policies'; +import frontmatter from 'front-matter'; + +export const ENFORCED_POLICIES = ['kbase-user']; + +interface PolicyMeta { + title: string; + id: string; + version: string; + equivalentVersions: string[]; +} + +export const kbasePolicies = policyStrings.reduce( + (policies, str) => { + const parsed = frontmatter(str); + const attr = parsed.attributes as PolicyMeta; + const policy = { + raw: str, + markdown: parsed.body, + title: String(attr.title) ?? '', + id: String(attr.id) ?? '', + version: String(attr.version) ?? '', + equivalentVersions: (attr.equivalentVersions ?? []) as string[], + }; + if (ENFORCED_POLICIES.includes(policy.id)) policies[policy.id] = policy; + return policies; + }, + {} as Record< + string, + PolicyMeta & { + raw: string; + markdown: string; + } + > +); diff --git a/src/features/navigator/Navigator.module.scss b/src/features/navigator/Navigator.module.scss index 3d07e8b5..e7f1fb8d 100644 --- a/src/features/navigator/Navigator.module.scss +++ b/src/features/navigator/Navigator.module.scss @@ -5,10 +5,8 @@ pre { } .navigator { - background-color: use-color("base-lightest"); - border-left: 1px solid use-color("silver"); min-height: 100%; - padding: 1rem; + padding: 0 1rem; } .navigator a { diff --git a/src/features/params/hooks.ts b/src/features/params/hooks.ts index 1457c995..934b5556 100644 --- a/src/features/params/hooks.ts +++ b/src/features/params/hooks.ts @@ -26,7 +26,9 @@ export const useUpdateAppParams = () => { ); }; -export const useAppParam = (key: Key) => { +export const useAppParam = ( + key: Key +): NonNullable | undefined => { const val = useAppSelector((state) => state.params[key]); if (val === undefined || val === null) return undefined; return val as NonNullable; diff --git a/src/features/params/paramsSlice.ts b/src/features/params/paramsSlice.ts index 610d1385..f5972f98 100644 --- a/src/features/params/paramsSlice.ts +++ b/src/features/params/paramsSlice.ts @@ -5,6 +5,8 @@ import type { RootState } from '../../app/store'; // Define a type for the slice state class ParamsClass { constructor( + // For Auth/Login/Linking + readonly nextRequest: string | null = null, // Search readonly limit: string | null = '20', readonly search: string | null = null, @@ -28,7 +30,7 @@ class ParamsClass { // For narrative opening readonly n: string | null = null, readonly check: string | null = null, - // For kbase-ui navigation via auth + // For kbase-ui navigation via auth in an iframe readonly nextrequest: string | null = null, readonly source: string | null = null, // for account management ui diff --git a/src/features/signup/AccountInformation.test.tsx b/src/features/signup/AccountInformation.test.tsx new file mode 100644 index 00000000..d491669b --- /dev/null +++ b/src/features/signup/AccountInformation.test.tsx @@ -0,0 +1,138 @@ +import { fireEvent, screen } from '@testing-library/react'; +import { createTestStore } from '../../app/store'; +import { Provider } from 'react-redux'; +import { ThemeProvider } from '@mui/material'; +import { BrowserRouter, useNavigate } from 'react-router-dom'; +import { render } from '@testing-library/react'; +import { AccountInformation } from './AccountInformation'; +import { setLoginData } from './SignupSlice'; +import { theme } from '../../theme'; +import { act } from 'react-dom/test-utils'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: jest.fn(), +})); +jest.mock('../../common/api/authService', () => ({ + ...jest.requireActual('../../common/api/authService'), + loginUsernameSuggest: { + useQuery: jest.fn().mockReturnValue({ + currentData: { availablename: 'testuser' }, + isFetching: false, + }), + }, +})); + +const renderWithProviders = ( + ui: React.ReactElement, + { store = createTestStore() } = {} +) => { + return render( + + + +
{ui}
+
+
+
+ ); +}; + +describe('AccountInformation', () => { + const mockNavigate = jest.fn(); + + beforeEach(() => { + (useNavigate as jest.Mock).mockImplementation(() => mockNavigate); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('redirects to step 1 if no login data', () => { + const store = createTestStore(); + renderWithProviders(, { store }); + expect(mockNavigate).toHaveBeenCalledWith('/signup/1'); + }); + + test('displays login data from provider', () => { + const store = createTestStore(); + store.dispatch( + setLoginData({ + creationallowed: true, + expires: 0, + login: [], + provider: 'Google', + create: [ + { + provemail: 'test@test.com', + provfullname: 'Test User', + availablename: 'testuser', + id: '123', + provusername: 'testuser', + }, + ], + }) + ); + renderWithProviders(, { store }); + expect(screen.getAllByText(/Google/)[0]).toBeInTheDocument(); + expect(screen.getAllByText(/test@test.com/)[0]).toBeInTheDocument(); + }); + + test('form submission with valid data', async () => { + const store = createTestStore(); + store.dispatch( + setLoginData({ + creationallowed: true, + expires: 0, + login: [], + provider: 'Google', + create: [ + { + provemail: 'test@test.com', + provfullname: 'Test User', + availablename: 'testuser', + id: '123', + provusername: 'testuser', + }, + ], + }) + ); + renderWithProviders(, { store }); + + await act(() => { + fireEvent.change(screen.getByRole('textbox', { name: /Full Name/i }), { + target: { value: 'Test User' }, + }); + }); + await act(() => { + fireEvent.change(screen.getByRole('textbox', { name: /Email/i }), { + target: { value: 'test@test.com' }, + }); + }); + await act(() => { + fireEvent.change( + screen.getByRole('textbox', { name: /KBase Username/i }), + { + target: { value: 'testuser' }, + } + ); + }); + await act(() => { + fireEvent.change(screen.getByRole('textbox', { name: /Organization/i }), { + target: { value: 'Test Org' }, + }); + }); + await act(() => { + fireEvent.change(screen.getByRole('textbox', { name: /Department/i }), { + target: { value: 'Test Dept' }, + }); + }); + + await act(() => { + fireEvent.submit(screen.getByTestId('accountinfoform')); + }); + + expect(mockNavigate).toHaveBeenCalledWith('/signup/3'); + }); +}); diff --git a/src/features/signup/AccountInformation.tsx b/src/features/signup/AccountInformation.tsx new file mode 100644 index 00000000..955ba960 --- /dev/null +++ b/src/features/signup/AccountInformation.tsx @@ -0,0 +1,321 @@ +import { faAngleRight } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Alert, + Box, + Button, + Checkbox, + FormControl, + FormControlLabel, + FormGroup, + FormLabel, + Paper, + Stack, + TextField, + Typography, +} from '@mui/material'; +import { FC, useEffect, useState, Fragment } from 'react'; +import { toast } from 'react-hot-toast'; +import { useNavigate } from 'react-router-dom'; +import { useAppDispatch, useAppSelector } from '../../common/hooks'; +import classes from './SignUp.module.scss'; +import ReferalSources from './ReferralSources.json'; +import { loginUsernameSuggest } from '../../common/api/authService'; +import { useForm } from 'react-hook-form'; +import { resetSignup, setAccount, setProfile } from './SignupSlice'; + +export const useCheckLoginDataOk = () => { + const navigate = useNavigate(); + const loginData = useAppSelector((state) => state.signup.loginData); + useEffect(() => { + if (!loginData) { + toast('You must login using a provider first to sign up!'); + navigate('/signup/1'); + } + }, [loginData, navigate]); +}; + +/** + * Account information form for sign up flow + */ +export const AccountInformation: FC<{}> = () => { + const navigate = useNavigate(); + const dispatch = useAppDispatch(); + + useCheckLoginDataOk(); + + // Login Data + const loginData = useAppSelector((state) => state.signup.loginData); + + // Account data + const account = useAppSelector((state) => state.signup.account); + + //username availibility + const [username, setUsername] = useState(account.username ?? ''); + const userAvail = loginUsernameSuggest.useQuery(username); + const nameShort = username.length < 3; + const nameAvail = + userAvail.currentData?.availablename === username.toLowerCase(); + + const surveyQuestion = 'How did you hear about us? (select all that apply)'; + const [optionalText, setOptionalText] = useState>({}); + + // Form state + const { register, handleSubmit } = useForm({ + defaultValues: { + account: account, + profile: { + userdata: { + organization: '', + department: '', + }, + surveydata: { + referralSources: { + question: surveyQuestion, + response: {} as Record, + }, + }, + }, + }, + }); + + // Form submission + const onSubmit = handleSubmit(async (fieldValues, event) => { + event?.preventDefault(); + // Add in survey text content from form + ReferalSources.forEach((src) => { + if ( + src.customText && + fieldValues.profile.surveydata.referralSources.response[src.value] === + true + ) + fieldValues.profile.surveydata.referralSources.response[src.value] = + optionalText[src.value]; + }); + // dispatch form data to signup state + dispatch(setAccount(fieldValues.account)); + dispatch( + setProfile({ + userdata: { + ...fieldValues.profile.userdata, + avatarOption: 'gravatar', + gravatarDefault: 'identicon', + }, + surveydata: fieldValues.profile.surveydata, + }) + ); + // next step! + navigate('/signup/3'); + }); + + return ( + + + + + You have signed in with your {loginData?.provider}{' '} + account {loginData?.create[0].provemail}. This will + be the account linked to your KBase account. + + + } + aria-controls="panel1-content" + id="panel1-header" + > + Not the account you were expecting? + + + + + If the account you see above is not the one you want, use the + link below to log out of {loginData?.provider}, and then try + again. + + + + + + If you are trying to sign up with a {loginData?.provider}{' '} + account that is already linked to a KBase account, you will be + unable to create a new KBase account using that{' '} + {loginData?.provider} account. + + + After signing out from {loginData?.provider} you will need to + restart the sign up process. + + + + + + + + + +
+ + + Create a new KBase Account + + Some field values have been pre-populated from your{' '} + {loginData?.provider} account. + All fields are required. + + + Full Name + + + + Email + ()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/, + message: 'Invalid email address', + }, + })} + defaultValue={account.email} + helperText="KBase may occasionally use this email address to communicate important information about KBase or your account. KBase will not share your email address with anyone, and other KBase users will not be able to see it." + /> + + + KBase Username + setUsername(e.currentTarget.value), + validate: () => + !nameShort && !userAvail.isFetching && nameAvail, + })} + defaultValue={account.username} + helperText={ + <> + {nameShort ? ( + + Username is too short. +
+
+ ) : !nameAvail && !userAvail.isFetching ? ( + + Username is not available. Suggested: " + {userAvail.currentData?.availablename}". +
+
+ ) : undefined} + + Your KBase username is the primary identifier associated + with all of your work and assets within KBase.Your + username is permanent and may not be changed later, so + please choose wisely. + + + } + error={nameShort || (!userAvail.isFetching && !nameAvail)} + /> +
+ + Organization + + + + Department + + + + {surveyQuestion} + + + {ReferalSources.map((source) => { + if (source.customText) { + return ( + + + } + label={source.label} + /> + + { + setOptionalText((s) => ({ + ...s, + [source.value]: e.target.value, + })); + }} + /> + + + ); + } else { + return ( + + } + label={source.label} + /> + ); + } + })} + + +
+
+ + + + +
+
+ ); +}; diff --git a/src/features/signup/ProviderSelect.tsx b/src/features/signup/ProviderSelect.tsx new file mode 100644 index 00000000..6c7f1409 --- /dev/null +++ b/src/features/signup/ProviderSelect.tsx @@ -0,0 +1,61 @@ +import { + Alert, + Box, + Container, + Link, + Paper, + Stack, + Typography, +} from '@mui/material'; +import { FC } from 'react'; +import { LOGIN_ROUTE } from '../../app/Routes'; +import { LoginButtons, makeLoginURLs } from '../login/LogIn'; +import classes from './SignUp.module.scss'; + +/** + * Provider selection screen for sign up flow + */ +export const ProviderSelect: FC = () => { + const { loginActionUrl, loginRedirectUrl, loginOrigin } = makeLoginURLs(); + + return ( + + + + + Choose a provider + {process.env.NODE_ENV === 'development' ? ( + + DEV MODE: Signup will occur on {loginOrigin} + + ) : ( + <> + )} +
+ `Sign up with ${provider}`} /> + + + + + Already have an account? Log in + + + + Need help signing up? + + +
+
+
+
+ ); +}; diff --git a/src/features/signup/ReferralSources.json b/src/features/signup/ReferralSources.json new file mode 100644 index 00000000..e4a4213a --- /dev/null +++ b/src/features/signup/ReferralSources.json @@ -0,0 +1,47 @@ +[ + { + "label": "Journal Publication", + "value": "journal-publication" + }, + { + "label": "Conference Presentation", + "value": "conference-presentation" + }, + { + "label": "Workshop/Webinar", + "value": "workshop-webinar" + }, + { + "label": "Colleague", + "value": "colleague" + }, + { + "label": "Course/Instructor", + "value": "course" + }, + { + "label": "Newsletter/Email", + "value": "newsletter-email" + }, + { + "label": "YouTube", + "value": "youtube" + }, + { + "label": "Twitter", + "value": "twitter" + }, + { + "label": "Search Engine", + "value": "search-engine" + }, + { + "label": "Online Advertisement", + "value": "online-ad" + }, + { + "label": "Other...", + "value": "other", + "customText": true + } +] diff --git a/src/features/signup/SignUp.module.scss b/src/features/signup/SignUp.module.scss new file mode 100644 index 00000000..9314d91d --- /dev/null +++ b/src/features/signup/SignUp.module.scss @@ -0,0 +1,71 @@ +/* stylelint-disable selector-class-pattern */ + +@import "../../common/colors"; + +.signup-panel { + padding: 1rem; + text-align: center; +} + +.sso-logo { + height: 2.5rem; + width: auto; +} + +.separator { + align-self: center; + background-color: use-color("base-lighter"); + height: 1px; + width: 80%; +} + +.collapsible-message { + &:global(.MuiAccordion-root) { + background: none; + box-shadow: none; + color: inherit; + min-height: 0; + } + + :global(.MuiAccordionSummary-root) { + flex-direction: row-reverse; + min-height: 0; + padding-left: 0.25rem; + padding-right: 0; + } + + :global(.MuiAccordionSummary-content) { + margin-bottom: 0.25rem; + margin-left: 0.5rem; + margin-top: 0.25rem; + } + + :global(.MuiAccordionSummary-expandIconWrapper.Mui-expanded) { + transform: rotate(90deg); + } + + :global(.MuiAccordionDetails-root) { + padding-bottom: 0; + } +} + +.account-information-panel, +.use-policies-panel { + padding: 1rem; + + :global(.MuiFormLabel-root) { + color: #000; + } +} + +.policy-panel { + background-color: use-color("base-lightest") !important; + max-height: 500px; + overflow: auto; + padding: 1rem; + + blockquote { + border-left: 4px solid use-color("base"); + padding-left: 1rem; + } +} diff --git a/src/features/signup/SignUp.test.tsx b/src/features/signup/SignUp.test.tsx new file mode 100644 index 00000000..336b6b5c --- /dev/null +++ b/src/features/signup/SignUp.test.tsx @@ -0,0 +1,183 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { act } from 'react-dom/test-utils'; +import { gravatarHash, SignUp, useDoSignup } from './SignUp'; +import { loginCreate } from '../../common/api/authService'; +import { setUserProfile } from '../../common/api/userProfileApi'; +import { BrowserRouter, useNavigate, useParams } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { createTestStore } from '../../app/store'; +import { ThemeProvider } from '@mui/material'; +import { theme } from '../../theme'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: jest.fn(), + useParams: jest.fn(), +})); + +const mockNavigate = jest.fn(); +const mockScrollTo = jest.fn(); + +const renderWithProviders = ( + ui: React.ReactElement, + { store = createTestStore() } = {} +) => { + return render( + + + +
{ui}
+
+
+
+ ); +}; + +describe('SignUp', () => { + beforeEach(() => { + (useNavigate as jest.Mock).mockReturnValue(mockNavigate); + (useParams as jest.Mock).mockReturnValue({ step: '1' }); + Element.prototype.scrollTo = mockScrollTo; + }); + + it('renders signup steps', () => { + renderWithProviders(); + expect(screen.getByText('Sign up for KBase')).toBeInTheDocument(); + expect( + screen.getByText('Sign up with a supported provider') + ).toBeInTheDocument(); + expect(screen.getByText('Account information')).toBeInTheDocument(); + expect(screen.getByText('KBase use policies')).toBeInTheDocument(); + }); + + it('navigates between steps when clicking previous steps', async () => { + (useParams as jest.Mock).mockReturnValue({ step: '3' }); + renderWithProviders(); + + const step1 = screen.getByText('Sign up with a supported provider'); + await userEvent.click(step1); + expect(mockNavigate).toHaveBeenCalledWith('/signup/1'); + expect(mockScrollTo).toHaveBeenCalledWith(0, 0); + }); +}); + +describe('useDoSignup', () => { + const mockLoginCreateMutation = jest.fn(); + const mockSetUserProfileMutation = jest.fn(); + + beforeEach(() => { + jest.spyOn(loginCreate, 'useMutation').mockReturnValue([ + mockLoginCreateMutation, + { + isUninitialized: false, + isSuccess: true, + data: { token: { token: 'someToken' } }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ]); + jest.spyOn(setUserProfile, 'useMutation').mockReturnValue([ + mockSetUserProfileMutation, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { isUninitialized: true } as any, + ]); + (useNavigate as jest.Mock).mockReturnValue(mockNavigate); + }); + + it('calls login create and set user profile mutations', async () => { + const mockStore = createTestStore({ + signup: { + loginData: { + create: [ + { + id: '123', + availablename: '', + provemail: '', + provfullname: '', + provusername: '', + }, + ], + creationallowed: true, + expires: 0, + login: [], + provider: 'Google', + }, + account: { + username: 'testuser', + display: 'Test User', + email: 'test@test.com', + policyids: [], + }, + profile: { + userdata: { + avatarOption: 'gravatar', + department: '', + gravatarDefault: 'identicon', + organization: '', + }, + surveydata: { + referralSources: { + question: '', + response: {}, + }, + }, + }, + }, + }); + + let doSignup: (policyIds: string[]) => void; + act(() => { + const TestComponent = () => { + [doSignup] = useDoSignup(); + return null; + }; + renderWithProviders(, { + store: mockStore, + }); + }); + + await act(async () => { + doSignup(['policy1']); + }); + + expect(mockLoginCreateMutation).toHaveBeenCalledWith({ + id: '123', + user: 'testuser', + display: 'Test User', + email: 'test@test.com', + policyids: ['policy1'], + linkall: false, + }); + + expect(mockSetUserProfileMutation).toHaveBeenCalledWith([ + { + profile: { + user: { + username: 'testuser', + realname: 'Test User', + }, + profile: { + metadata: expect.any(Object), + preferences: {}, + synced: { + gravatarHash: gravatarHash('test@test.com'), + }, + userdata: { + avatarOption: 'gravatar', + department: '', + gravatarDefault: 'identicon', + organization: '', + }, + surveydata: { + referralSources: { + question: '', + response: {}, + }, + }, + }, + }, + }, + 'someToken', + ]); + }); +}); diff --git a/src/features/signup/SignUp.tsx b/src/features/signup/SignUp.tsx new file mode 100644 index 00000000..c3b5ec3d --- /dev/null +++ b/src/features/signup/SignUp.tsx @@ -0,0 +1,153 @@ +import { + Container, + Stack, + Step, + StepLabel, + Stepper, + Typography, +} from '@mui/material'; +import { FC, useEffect } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { loginCreate } from '../../common/api/authService'; +import { setUserProfile } from '../../common/api/userProfileApi'; +import { useAppSelector } from '../../common/hooks'; +import { useTryAuthFromToken } from '../auth/hooks'; +import { AccountInformation } from './AccountInformation'; +import { ProviderSelect } from './ProviderSelect'; +import { KBasePolicies } from './SignupPolicies'; +import { md5 } from 'js-md5'; +import { ROOT_REDIRECT_ROUTE } from '../../app/Routes'; + +const signUpSteps = [ + 'Sign up with a supported provider', + 'Account information', + 'KBase use policies', +]; + +/** + * Sign up flow that handles choosing a provider, populating account information, + * and accepting the KBase use policies. + */ +export const SignUp: FC = () => { + const navigate = useNavigate(); + + const { step = '1' } = useParams(); + const activeStep = Number.parseInt(step) - 1; + + const setActiveStep = (step: number) => { + navigate(`/signup/${step + 1}`); + }; + + useEffect(() => { + document.querySelector('main')?.scrollTo?.(0, 0); + }, [activeStep]); + + return ( + + + Sign up for KBase + + {signUpSteps.map((step, i) => ( + { + if (i < activeStep) setActiveStep(i); + }} + > + {step} + + ))} + + {activeStep === 0 && } + {activeStep === 1 && } + {activeStep === 2 && } + + + ); +}; + +export const useDoSignup = () => { + const signupData = useAppSelector((state) => state.signup); + const navigate = useNavigate(); + + // Queries for creating an account and a profile for the user. + const [triggerCreateAccount, accountResult] = loginCreate.useMutation(); + const [triggerCreateProfile, profileResult] = setUserProfile.useMutation(); + const error = accountResult.error || profileResult.error; + + // Callback to trigger the first call. Consumer should check signup data is present before calling! + const doSignup = (policyIds: string[]) => { + triggerCreateAccount({ + id: String(signupData.loginData?.create[0].id), + user: String(signupData.account.username), + display: String(signupData.account.display), + email: String(signupData.account.email), + policyids: policyIds, + linkall: false, + }); + }; + + // Once the account is created, use the account token to set the account profile. + useEffect(() => { + if (!accountResult.data?.token.token) return; + triggerCreateProfile([ + { + profile: { + user: { + realname: String(signupData.account.display), + username: String(signupData.account.username), + }, + profile: { + metadata: { + createdBy: 'ui_europa', + created: new Date().toISOString(), + }, + // was globus info, no longer used + preferences: {}, + synced: { + gravatarHash: gravatarHash(signupData.account.email || ''), + }, + ...signupData.profile, + }, + }, + }, + accountResult.data?.token.token ?? '', + ]); + }, [ + accountResult, + signupData.account.display, + signupData.account.email, + signupData.account.username, + signupData.profile, + triggerCreateProfile, + ]); + + const createLoading = + !accountResult.isUninitialized && + (accountResult.isLoading || profileResult.isLoading); + + const createComplete = + !createLoading && + !accountResult.isUninitialized && + !profileResult.isUninitialized && + accountResult.isSuccess && + profileResult.isSuccess; + + // Once create completes, try auth from token. + const tryToken = createComplete ? accountResult.data.token.token : undefined; + const tokenQuery = useTryAuthFromToken(tryToken); + + const complete = createComplete && tokenQuery.isSuccess; + const loading = createLoading || tokenQuery.isLoading; + + // once everything completes and we're authed from the token, redirect to root. + useEffect(() => { + if (complete) navigate(ROOT_REDIRECT_ROUTE); + }, [complete, navigate]); + + return [doSignup, loading, complete, error] as const; +}; + +export const gravatarHash = (email: string) => { + return md5.create().update(email.trim().toLowerCase()).hex(); +}; diff --git a/src/features/signup/SignupPolicies.test.tsx b/src/features/signup/SignupPolicies.test.tsx new file mode 100644 index 00000000..34801157 --- /dev/null +++ b/src/features/signup/SignupPolicies.test.tsx @@ -0,0 +1,154 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import { configureStore } from '@reduxjs/toolkit'; +import { KBasePolicies } from './SignupPolicies'; +import { toast } from 'react-hot-toast'; +import * as SignUp from './SignUp'; + +jest.mock('react-hot-toast'); +jest.mock('./AccountInformation', () => ({ + useCheckLoginDataOk: jest.fn(), +})); +jest.mock('./SignUp', () => ({ + useDoSignup: jest.fn(), +})); + +const mockNavigate = jest.fn(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, +})); + +jest.mock('../login/EnforcePolicies', () => ({ + PolicyViewer: ({ + policyId, + accepted, + setAccept, + }: { + policyId: string; + accepted: boolean; + setAccept: (checked: boolean) => void; + }) => ( +
+ setAccept(e.target.checked)} + data-testid={`checkbox-${policyId}`} + /> +
+ ), +})); + +jest.mock('../login/Policies', () => ({ + kbasePolicies: { + termsOfService: { + id: 'termsOfService', + version: '1', + title: 'Terms of Service', + markdown: 'Terms of Service content', + }, + privacyPolicy: { + id: 'privacyPolicy', + version: '1', + title: 'Privacy Policy', + markdown: 'Privacy Policy content', + }, + }, +})); + +const mockScrollTo = jest.fn(); +Element.prototype.scrollTo = mockScrollTo; + +const createMockStore = (initialState = {}) => { + return configureStore({ + reducer: { + signup: (state = { account: {} }, action) => state, + }, + preloadedState: { + signup: { + account: { + username: 'testuser', + email: 'test@test.com', + ...initialState, + }, + }, + }, + }); +}; + +describe('Signup Policies', () => { + const mockDoSignup = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (SignUp.useDoSignup as jest.Mock).mockReturnValue([mockDoSignup, false]); + }); + + const renderComponent = (store = createMockStore()) => { + return render( + + + + + + ); + }; + + it('should render all policies', () => { + renderComponent(); + expect(screen.getByTestId('policy-termsOfService')).toBeInTheDocument(); + expect(screen.getByTestId('policy-privacyPolicy')).toBeInTheDocument(); + }); + + it('should not call doSignup when policies are not accepted', () => { + renderComponent(); + const submitButton = screen.getByText('Create KBase account'); + Object.defineProperty(submitButton, 'disabled', { value: false }); + fireEvent.click(submitButton); + expect(mockDoSignup).not.toHaveBeenCalled(); + }); + + it('should handle policy acceptance', () => { + renderComponent(); + const tosCheckbox = screen.getByTestId('checkbox-termsOfService'); + const privacyCheckbox = screen.getByTestId('checkbox-privacyPolicy'); + const submitButton = screen.getByText('Create KBase account'); + expect(submitButton).toBeDisabled(); + fireEvent.click(tosCheckbox); + fireEvent.click(privacyCheckbox); + expect(submitButton).not.toBeDisabled(); + }); + + it('should call doSignup when all policies are accepted and form is submitted', () => { + renderComponent(); + fireEvent.click(screen.getByTestId('checkbox-termsOfService')); + fireEvent.click(screen.getByTestId('checkbox-privacyPolicy')); + fireEvent.click(screen.getByText('Create KBase account')); + expect(mockDoSignup).toHaveBeenCalledWith([ + 'termsOfService.1', + 'privacyPolicy.1', + ]); + }); + + it('should show warning toast if account information is missing', () => { + const store = createMockStore({ username: undefined }); + renderComponent(store); + expect(toast).toHaveBeenCalledWith( + 'You must fill out your account information to sign up!' + ); + }); + + it('should navigate when cancel button is clicked', () => { + renderComponent(); + fireEvent.click(screen.getByText('Cancel sign up')); + expect(mockNavigate).toHaveBeenCalledWith('/signup/1'); + }); + + it('should navigate when back button is clicked', () => { + renderComponent(); + fireEvent.click(screen.getByText('Back to account information')); + expect(mockNavigate).toHaveBeenCalledWith('/signup/2'); + }); +}); diff --git a/src/features/signup/SignupPolicies.tsx b/src/features/signup/SignupPolicies.tsx new file mode 100644 index 00000000..5a5496fc --- /dev/null +++ b/src/features/signup/SignupPolicies.tsx @@ -0,0 +1,117 @@ +import { faArrowLeft } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Button, Paper, Stack, Typography } from '@mui/material'; +import { FC, useEffect, useState } from 'react'; +import { toast } from 'react-hot-toast'; +import { useNavigate } from 'react-router-dom'; +import { Loader } from '../../common/components'; +import { useAppDispatch, useAppSelector } from '../../common/hooks'; +import { PolicyViewer } from '../login/EnforcePolicies'; +import { kbasePolicies } from '../login/Policies'; +import { useCheckLoginDataOk } from './AccountInformation'; +import { useDoSignup } from './SignUp'; +import classes from './SignUp.module.scss'; +import { resetSignup, setAccount } from './SignupSlice'; + +/** + * KBase policy agreements step for sign up flow. + */ +export const KBasePolicies: FC<{}> = () => { + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + + // Check prev steps data is filled out. + useCheckLoginDataOk(); + const account = useAppSelector((state) => state.signup.account); + useEffect(() => { + if (Object.values(account).some((v) => v === undefined)) { + toast('You must fill out your account information to sign up!'); + navigate('/signup/2'); + } + }, [account, navigate]); + + // The policies the user needs to accept. + const signupPolicies = Object.values(kbasePolicies).map((p) => p.id); + const versionedPolicyIds = signupPolicies.map((policyId) => { + return [kbasePolicies[policyId].id, kbasePolicies[policyId].version].join( + '.' + ); + }); + const [accepted, setAccepted] = useState<{ + [k in typeof signupPolicies[number]]?: boolean; + }>({}); + const allAccepted = signupPolicies.every( + (policyId) => accepted[policyId] === true + ); + + // Performs signup (if all policies have been accepted) + const [doSignup, loading] = useDoSignup(); + const onSubmit = () => { + if (!allAccepted) return; + dispatch( + setAccount({ + policyids: versionedPolicyIds, + }) + ); + doSignup(versionedPolicyIds); + }; + + return ( + + + + KBase Use Policies + + To finish signing up and create your account, you must agree to the + following KBase use policies. + + {Object.values(kbasePolicies).map((policy) => { + return ( + + setAccepted((current) => { + return { ...current, [policy.id]: val }; + }) + } + /> + ); + })} + + + + + + + + + + ); +}; diff --git a/src/features/signup/SignupSlice.tsx b/src/features/signup/SignupSlice.tsx new file mode 100644 index 00000000..7c398b45 --- /dev/null +++ b/src/features/signup/SignupSlice.tsx @@ -0,0 +1,70 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { GetLoginChoiceResult } from '../../common/api/authService'; + +export interface SignupState { + loginData?: GetLoginChoiceResult; + account: { + display?: string; + email?: string; + username?: string; + policyids: string[]; + }; + profile?: { + userdata: { + organization: string; + department: string; + avatarOption: 'gravatar'; + gravatarDefault: 'identicon'; + }; + surveydata: { + referralSources: { + question: string; + response: Record; + }; + }; + }; +} + +const initialState: SignupState = { + loginData: undefined, + account: { + display: 'someName', + email: 'someEmail@email.co', + username: 'someUser', + policyids: [], + }, + profile: undefined, +}; + +export const signupSlice = createSlice({ + name: 'signup', + initialState, + reducers: { + setLoginData: (state, action: PayloadAction) => { + // Set provider creeation data + state.loginData = action.payload; + // Set account defaults from provider + state.account.display = action.payload?.create[0].provfullname; + state.account.email = action.payload?.create[0].provemail; + state.account.username = action.payload?.create[0].availablename; + }, + setAccount: ( + state, + action: PayloadAction> + ) => { + state.account = { ...state.account, ...action.payload }; + }, + setProfile: (state, action: PayloadAction) => { + state.profile = action.payload; + }, + resetSignup: (state) => { + state.account = initialState.account; + state.loginData = initialState.loginData; + state.profile = initialState.profile; + }, + }, +}); + +export default signupSlice.reducer; +export const { setLoginData, setAccount, setProfile, resetSignup } = + signupSlice.actions; diff --git a/src/theme.tsx b/src/theme.tsx index b8797303..5b19cf81 100644 --- a/src/theme.tsx +++ b/src/theme.tsx @@ -18,6 +18,14 @@ const baseColor = 'rgb(62, 56, 50)'; export const theme = createTheme({ palette: { + primary: { + // TODO: import from single source of truth + main: 'rgb(2, 109, 170)', + }, + warning: { + // TODO: import from single source of truth + main: 'rgb(255, 210, 0)', + }, base: { main: baseColor, contrastText: getContrastRatio(baseColor, '#fff') > 4.5 ? '#fff' : '#111', @@ -64,6 +72,28 @@ export const theme = createTheme({ }, }, }, + MuiTypography: { + styleOverrides: { + h1: { + fontSize: '2.5rem', + }, + h2: { + fontSize: '2rem', + }, + h3: { + fontSize: '1.75rem', + }, + h4: { + fontSize: '1.5rem', + }, + h5: { + fontSize: '1.25rem', + }, + h6: { + fontSize: '1rem', + }, + }, + }, }, });