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 };
+ })
+ }
+ />
+ );
+ })}
+
+
+ }
+ disabled={!allAccepted}
+ onClick={() =>
+ onAccept(
+ targetPolicies.map((policy) => {
+ return [policy.id, policy.version].join('.');
+ })
+ )
+ }
+ >
+ Agree and Continue
+
+
+
+
+ );
+};
+
+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 (
-
-
-
-
-
- A collaborative, open environment for systems biology of plants,
- microbes and their communities.
-
-
-
-
- Log 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 (
+
+
+ }
+ data-testid="loginORCID"
+ >
+ {text('ORCID')}
+
+
+
+
+ }
+ >
+ {text('Google')}
+
+
+ }
+ >
+ {text('Globus')}
+
+
+
+ );
+};
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 (
+
+
+
+
+
+
+ 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.
+
+
+
+
+ }
+ >
+ Log out from ORCID
+
+
+ }
+ >
+ Log out from Google
+
+
+ }
+ >
+ Log out from Globus
+
+
+
+
+
+
+ );
+};
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.
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
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}
+
+ ) : (
+ <>>
+ )}
+
+
+
+ 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 };
+ })
+ }
+ />
+ );
+ })}
+
+
+
+
+
+
+ }
+ onClick={() => navigate('/signup/2')}
+ >
+ Back to account information
+
+
+
+ );
+};
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',
+ },
+ },
+ },
},
});